fix(wikilink): navigate to real page URL and extend shared node schema

Resolve wikilinks to /s/<spaceSlug>/p/<slugId> via buildPageUrl instead of
the inexistent /page/<uuid> route. Persist slugId and spaceSlug as node
attributes so the URL can be rebuilt from the document alone, without an
extra lookup round-trip.

The client extension now .extend()s the shared WikilinkNode from
@docmost/editor-ext to keep the schema identical between client and the
Hocuspocus server.
This commit is contained in:
Corentin JOGUET 2026-05-11 12:28:23 +00:00
parent b802f1d647
commit a87e61e382
3 changed files with 40 additions and 158 deletions

View file

@ -47,14 +47,18 @@ describe('WikilinkExtension schema', () => {
editor.destroy(); editor.destroy();
}); });
it('has attrs: pageId, title, alias', () => { it('has attrs: pageId, slugId, spaceSlug, title, alias', () => {
const editor = makeEditor(); const editor = makeEditor();
const nodeSpec = editor.schema.nodes.wikilink; const nodeSpec = editor.schema.nodes.wikilink;
const attrs = nodeSpec.spec.attrs as Record<string, { default: any }>; const attrs = nodeSpec.spec.attrs as Record<string, { default: any }>;
expect(attrs).toHaveProperty('pageId'); expect(attrs).toHaveProperty('pageId');
expect(attrs).toHaveProperty('slugId');
expect(attrs).toHaveProperty('spaceSlug');
expect(attrs).toHaveProperty('title'); expect(attrs).toHaveProperty('title');
expect(attrs).toHaveProperty('alias'); expect(attrs).toHaveProperty('alias');
expect(attrs.pageId.default).toBeNull(); expect(attrs.pageId.default).toBeNull();
expect(attrs.slugId.default).toBeNull();
expect(attrs.spaceSlug.default).toBeNull();
expect(attrs.alias.default).toBeNull(); expect(attrs.alias.default).toBeNull();
editor.destroy(); editor.destroy();
}); });

View file

@ -1,147 +1,37 @@
import { import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react';
Node, import type { NodeViewProps } from '@tiptap/core';
nodeInputRule, import { PluginKey } from '@tiptap/pm/state';
type NodeViewRendererProps,
} from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Suggestion, type SuggestionOptions } from '@tiptap/suggestion'; import { Suggestion, type SuggestionOptions } from '@tiptap/suggestion';
import { useNavigate } from 'react-router-dom';
import { WikilinkNode, type WikilinkAttrs } from '@docmost/editor-ext';
import { buildPageUrl } from '@/features/page/page.utils.ts';
import { renderWikilinkSuggestion } from './wikilink-suggestion'; import { renderWikilinkSuggestion } from './wikilink-suggestion';
/** export type { WikilinkAttrs };
* Wikilink Tiptap extension (R3.2).
*
* Implements the Obsidian-style [[Page Title]] and [[Page Title|alias]] syntax.
*
* Node attrs:
* - pageId : resolved UUID of the target page (null when unresolved)
* - title : canonical title of the target page
* - alias : optional display alias (text shown in editor)
*
* Rendering:
* - React NodeView: a styled link chip.
* - Unresolved (pageId === null): applies 'broken-wikilink' class (red / italic).
*
* Input rule:
* - Typing [[ opens the suggestion popup (reuses Tiptap Suggestion).
* - Pressing Esc or selecting a page closes the popup and inserts the node.
*
* The suggestion popup searches pages via `GET /api/search/suggestions?q=...`
* (same endpoint used by the native @mention system).
*/
export interface WikilinkAttrs {
pageId: string | null;
title: string;
alias: string | null;
}
const WIKILINK_INPUT_REGEX = /\[\[$/;
const WIKILINK_PARSE_REGEX = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/;
declare module '@tiptap/core' {
interface Commands<ReturnType> {
wikilink: {
/**
* Insert a wikilink node at the current cursor position.
*/
insertWikilink: (attrs: WikilinkAttrs) => ReturnType;
};
}
}
/** /**
* The wikilink node itself. * Wikilink client extension.
* *
* It is `inline` and `atom` (cannot be entered the cursor moves around it). * Extends the shared @docmost/editor-ext WikilinkNode (registered on the
* This mirrors how Docmost handles mentions. * Hocuspocus server too without that, collab strips unknown nodes) to add:
* - a React NodeView (rendered chip + click navigation)
* - a Suggestion plugin triggered by [[
*
* Navigation uses buildPageUrl(spaceSlug, slugId, title) so links land on the
* real /s/<space>/p/<slug-id> route. spaceSlug/slugId are written into node
* attrs at insert time (resolved from the search suggestion).
*/ */
export const WikilinkExtension = Node.create<{ export const WikilinkExtension = WikilinkNode.extend<{
suggestion: Partial<SuggestionOptions>; suggestion: Partial<SuggestionOptions>;
}>({ }>({
name: 'wikilink', addOptions() {
return { suggestion: {} };
group: 'inline',
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
pageId: {
default: null,
parseHTML: (el) => el.getAttribute('data-page-id'),
renderHTML: (attrs) =>
attrs.pageId ? { 'data-page-id': attrs.pageId } : {},
},
title: {
default: '',
parseHTML: (el) => el.getAttribute('data-title') ?? el.textContent,
renderHTML: (attrs) => ({ 'data-title': attrs.title }),
},
alias: {
default: null,
parseHTML: (el) => el.getAttribute('data-alias') ?? null,
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}]]`,
];
}, },
addNodeView() { addNodeView() {
return ReactNodeViewRenderer(WikilinkNodeView); return ReactNodeViewRenderer(WikilinkNodeView);
}, },
addCommands() {
return {
insertWikilink:
(attrs: WikilinkAttrs) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs,
});
},
};
},
addInputRules() {
return [
// Input rule fires when the user types [[
// The rule itself doesn't insert a node — it triggers the suggestion popup.
// We use a nodeInputRule with a regex that won't consume anything so the
// Suggestion plugin can take over after detecting the [[ trigger.
// This is intentionally a no-op rule; the real work is in addProseMirrorPlugins.
nodeInputRule({
find: /\[\[Page Title\]\]$/,
type: this.type,
getAttributes: () => ({ pageId: null, title: 'Page Title', alias: null }),
}),
];
},
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [
Suggestion({ Suggestion({
@ -151,60 +41,42 @@ export const WikilinkExtension = Node.create<{
startOfLine: false, startOfLine: false,
pluginKey: new PluginKey('wikilink-suggestion'), pluginKey: new PluginKey('wikilink-suggestion'),
command: ({ editor, range, props }) => { command: ({ editor, range, props }) => {
// Delete the trigger text and insert the node
editor editor
.chain() .chain()
.focus() .focus()
.deleteRange(range) .deleteRange(range)
.insertWikilink({ .insertWikilink({
pageId: props.pageId ?? null, pageId: props.pageId ?? null,
slugId: props.slugId ?? null,
spaceSlug: props.spaceSlug ?? null,
title: props.title, title: props.title,
alias: props.alias ?? null, alias: props.alias ?? null,
}) })
.run(); .run();
}, },
allow: ({ editor, range }) => { allow: ({ editor }) => {
// Only trigger when not inside a code block / code mark
const { $from } = editor.state.selection; const { $from } = editor.state.selection;
const parent = $from.parent;
return ( return (
parent.type.name !== 'codeBlock' && $from.parent.type.name !== 'codeBlock' && !editor.isActive('code')
!editor.isActive('code')
); );
}, },
...this.options.suggestion, ...this.options.suggestion,
// The render function is provided by the suggestion module and
// rendered via renderWikilinkSuggestion below.
render: renderWikilinkSuggestion, render: renderWikilinkSuggestion,
}), }),
]; ];
}, },
}); });
// ---------------------------------------------------------------------------
// React NodeView component (defined in the same file to keep the module
// self-contained — it does NOT import from the React component world at
// parse time so SSR / unit tests remain safe).
// ---------------------------------------------------------------------------
import React from 'react';
import { NodeViewWrapper } from '@tiptap/react';
import { useNavigate } from 'react-router-dom';
import type { NodeViewProps } from '@tiptap/core';
function WikilinkNodeView({ node }: NodeViewProps) { function WikilinkNodeView({ node }: NodeViewProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { pageId, title, alias } = node.attrs as WikilinkAttrs; const { pageId, slugId, spaceSlug, title, alias } =
node.attrs as WikilinkAttrs;
const display = alias ?? title ?? '?'; const display = alias ?? title ?? '?';
const isBroken = !pageId; const isBroken = !pageId || !slugId || !spaceSlug;
const handleClick = () => { const handleClick = () => {
if (pageId) { if (!isBroken && slugId && spaceSlug) {
// Navigate using the same slug-based URL pattern Docmost uses. navigate(buildPageUrl(spaceSlug, slugId, title));
// The actual path is <spaceSlug>/page/<slugId> — since we only have
// the UUID here, we navigate to a lookup route that redirects.
// As a fallback, we navigate to a search URL.
navigate(`/page/${pageId}`);
} }
}; };
@ -214,13 +86,17 @@ function WikilinkNodeView({ node }: NodeViewProps) {
data-testid={`wikilink-${pageId ?? 'broken'}`} data-testid={`wikilink-${pageId ?? 'broken'}`}
data-wikilink="true" data-wikilink="true"
data-page-id={pageId ?? undefined} data-page-id={pageId ?? undefined}
data-slug-id={slugId ?? undefined}
data-space-slug={spaceSlug ?? undefined}
data-title={title} data-title={title}
data-alias={alias ?? undefined} data-alias={alias ?? undefined}
className={isBroken ? 'wikilink wikilink--broken' : 'wikilink'} className={isBroken ? 'wikilink wikilink--broken' : 'wikilink'}
onClick={handleClick} onClick={handleClick}
style={{ style={{
cursor: isBroken ? 'not-allowed' : 'pointer', cursor: isBroken ? 'not-allowed' : 'pointer',
color: isBroken ? 'var(--mantine-color-red-6)' : 'var(--mantine-color-blue-6)', color: isBroken
? 'var(--mantine-color-red-6)'
: 'var(--mantine-color-blue-6)',
fontStyle: isBroken ? 'italic' : 'normal', fontStyle: isBroken ? 'italic' : 'normal',
textDecoration: 'underline', textDecoration: 'underline',
userSelect: 'none', userSelect: 'none',

View file

@ -31,6 +31,7 @@ import classes from './wikilink-list.module.css';
export interface WikilinkSuggestionItem { export interface WikilinkSuggestionItem {
pageId: string; pageId: string;
slugId: string | null;
title: string; title: string;
icon: string | null; icon: string | null;
spaceName: string | null; spaceName: string | null;
@ -60,6 +61,7 @@ const WikilinkList = forwardRef<any, WikilinkListProps>((props, ref) => {
const items: WikilinkSuggestionItem[] = (suggestions?.pages ?? []).map((p: any) => ({ const items: WikilinkSuggestionItem[] = (suggestions?.pages ?? []).map((p: any) => ({
pageId: p.id, pageId: p.id,
slugId: p.slugId ?? null,
title: p.title ?? 'Untitled', title: p.title ?? 'Untitled',
icon: p.icon ?? null, icon: p.icon ?? null,
spaceName: p.space?.name ?? null, spaceName: p.space?.name ?? null,