From a87e61e382c9b6bf2d3c748bfce8358a961a6136 Mon Sep 17 00:00:00 2001 From: Corentin Date: Mon, 11 May 2026 12:28:23 +0000 Subject: [PATCH] fix(wikilink): navigate to real page URL and extend shared node schema Resolve wikilinks to /s//p/ via buildPageUrl instead of the inexistent /page/ 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. --- .../__tests__/wikilink-extension.test.ts | 6 +- .../extension/wikilink-extension.tsx | 190 +++--------------- .../wikilinks/extension/wikilink-list.tsx | 2 + 3 files changed, 40 insertions(+), 158 deletions(-) diff --git a/apps/client/src/features/acadenice/wikilinks/__tests__/wikilink-extension.test.ts b/apps/client/src/features/acadenice/wikilinks/__tests__/wikilink-extension.test.ts index 38dfddd2..df49b52d 100644 --- a/apps/client/src/features/acadenice/wikilinks/__tests__/wikilink-extension.test.ts +++ b/apps/client/src/features/acadenice/wikilinks/__tests__/wikilink-extension.test.ts @@ -47,14 +47,18 @@ describe('WikilinkExtension schema', () => { editor.destroy(); }); - it('has attrs: pageId, title, alias', () => { + it('has attrs: pageId, slugId, spaceSlug, title, alias', () => { const editor = makeEditor(); const nodeSpec = editor.schema.nodes.wikilink; const attrs = nodeSpec.spec.attrs as Record; expect(attrs).toHaveProperty('pageId'); + expect(attrs).toHaveProperty('slugId'); + expect(attrs).toHaveProperty('spaceSlug'); expect(attrs).toHaveProperty('title'); expect(attrs).toHaveProperty('alias'); expect(attrs.pageId.default).toBeNull(); + expect(attrs.slugId.default).toBeNull(); + expect(attrs.spaceSlug.default).toBeNull(); expect(attrs.alias.default).toBeNull(); editor.destroy(); }); diff --git a/apps/client/src/features/acadenice/wikilinks/extension/wikilink-extension.tsx b/apps/client/src/features/acadenice/wikilinks/extension/wikilink-extension.tsx index fde1ac9d..8b2c66b8 100644 --- a/apps/client/src/features/acadenice/wikilinks/extension/wikilink-extension.tsx +++ b/apps/client/src/features/acadenice/wikilinks/extension/wikilink-extension.tsx @@ -1,147 +1,37 @@ -import { - Node, - nodeInputRule, - type NodeViewRendererProps, -} from '@tiptap/core'; -import { ReactNodeViewRenderer } from '@tiptap/react'; -import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'; +import type { NodeViewProps } from '@tiptap/core'; +import { PluginKey } from '@tiptap/pm/state'; 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'; -/** - * 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 { - wikilink: { - /** - * Insert a wikilink node at the current cursor position. - */ - insertWikilink: (attrs: WikilinkAttrs) => ReturnType; - }; - } -} +export type { WikilinkAttrs }; /** - * The wikilink node itself. + * Wikilink client extension. * - * It is `inline` and `atom` (cannot be entered — the cursor moves around it). - * This mirrors how Docmost handles mentions. + * Extends the shared @docmost/editor-ext WikilinkNode (registered on the + * 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//p/ 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; }>({ - name: 'wikilink', - - 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}]]`, - ]; + addOptions() { + return { suggestion: {} }; }, addNodeView() { 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() { return [ Suggestion({ @@ -151,60 +41,42 @@ export const WikilinkExtension = Node.create<{ startOfLine: false, pluginKey: new PluginKey('wikilink-suggestion'), command: ({ editor, range, props }) => { - // Delete the trigger text and insert the node editor .chain() .focus() .deleteRange(range) .insertWikilink({ pageId: props.pageId ?? null, + slugId: props.slugId ?? null, + spaceSlug: props.spaceSlug ?? null, title: props.title, alias: props.alias ?? null, }) .run(); }, - allow: ({ editor, range }) => { - // Only trigger when not inside a code block / code mark + allow: ({ editor }) => { const { $from } = editor.state.selection; - const parent = $from.parent; return ( - parent.type.name !== 'codeBlock' && - !editor.isActive('code') + $from.parent.type.name !== 'codeBlock' && !editor.isActive('code') ); }, ...this.options.suggestion, - // The render function is provided by the suggestion module and - // rendered via renderWikilinkSuggestion below. 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) { 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 isBroken = !pageId; + const isBroken = !pageId || !slugId || !spaceSlug; const handleClick = () => { - if (pageId) { - // Navigate using the same slug-based URL pattern Docmost uses. - // The actual path is /page/ — 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}`); + if (!isBroken && slugId && spaceSlug) { + navigate(buildPageUrl(spaceSlug, slugId, title)); } }; @@ -214,13 +86,17 @@ function WikilinkNodeView({ node }: NodeViewProps) { data-testid={`wikilink-${pageId ?? 'broken'}`} data-wikilink="true" data-page-id={pageId ?? undefined} + data-slug-id={slugId ?? undefined} + data-space-slug={spaceSlug ?? undefined} data-title={title} data-alias={alias ?? undefined} className={isBroken ? 'wikilink wikilink--broken' : 'wikilink'} onClick={handleClick} style={{ 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', textDecoration: 'underline', userSelect: 'none', diff --git a/apps/client/src/features/acadenice/wikilinks/extension/wikilink-list.tsx b/apps/client/src/features/acadenice/wikilinks/extension/wikilink-list.tsx index a1cddcd1..b3fbf6ad 100644 --- a/apps/client/src/features/acadenice/wikilinks/extension/wikilink-list.tsx +++ b/apps/client/src/features/acadenice/wikilinks/extension/wikilink-list.tsx @@ -31,6 +31,7 @@ import classes from './wikilink-list.module.css'; export interface WikilinkSuggestionItem { pageId: string; + slugId: string | null; title: string; icon: string | null; spaceName: string | null; @@ -60,6 +61,7 @@ const WikilinkList = forwardRef((props, ref) => { const items: WikilinkSuggestionItem[] = (suggestions?.pages ?? []).map((p: any) => ({ pageId: p.id, + slugId: p.slugId ?? null, title: p.title ?? 'Untitled', icon: p.icon ?? null, spaceName: p.space?.name ?? null,