Add wikilink node with autocomplete (#46)
All checks were successful
CD / Build and push images (push) Successful in 3m2s
CI / Lint, typecheck, test (push) Successful in 2m15s
CI / Auth e2e pack (push) Successful in 2m42s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s

Introduce Obsidian-style `[[page links]]` (ADR 0004).

- shared: reserved `wikilink` inline atom in the editor schema (attrs
  `targetSlug`, optional `displayText`); markdown mapping `[[slug]]` /
  `[[slug|text]]` via a markdown-it inline rule + serializer node; plain-text
  and HTML derivation include the shown text. Round-trip + parse unit tests.
- web:
  - `Wikilink` node extension with a React NodeView: shows the explicit
    display text or the target's current title (so a rename updates the link),
    renders a missing target as a dashed phantom with a tooltip, navigates on
    click in read mode.
  - `[[` autocomplete popup (`WikilinkAutocomplete`), dependency-free: filters
    the pond's pages as you type with a create-new-page hint for misses,
    Enter/click inserts the node and removes the typed `[[query`; ↑/↓/Enter/Esc
    intercepted in the capture phase so ProseMirror does not act on them.
  - `WikilinkContext` provides the pond's pages (slug→title) for live
    resolution and the autocomplete, populated by the page editor.
  - i18n `editor.wikilink.*` (de + en); wikilink + phantom + popup styles.
- e2e `wikilink.spec.ts` (new CI pack): type `[[`, autocomplete filters and
  inserts a working link that resolves the target title and persists across a
  reload. Phantom → live resolution on page creation is verified in #47.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
Claude Opus 4.8 2026-07-09 12:42:01 +02:00
parent 69b00fcf2f
commit 7244b89215
15 changed files with 574 additions and 31 deletions

View File

@ -184,6 +184,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/reorder.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/reorder.spec.ts
- name: Reset login rate limit before wikilink pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run wikilink pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/wikilink.spec.ts
- name: Dump server logs on failure - name: Dump server logs on failure
if: failure() if: failure()
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true

View File

@ -0,0 +1,63 @@
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Wikilink pack (issue #46). Types `[[` in the editor, verifies the
* autocomplete filters and inserts a working link node, and that the link
* survives a reload (persisted through the collab server). Language-independent
* selectors (CSS classes + page titles). Phantom live resolution on page
* creation is verified in #47.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
type Ctx = Awaited<ReturnType<typeof contextForUser>>;
async function personalPond(context: Ctx): Promise<{ id: string; slug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
return { id: pond.id, slug: pond.slug };
}
async function createPage(context: Ctx, pondId: string, title: string): Promise<{ slug: string }> {
const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title } });
return created.json();
}
test('typing [[ autocompletes and inserts a working wikilink', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const ts = Date.now();
const targetTitle = `Wiki Target ${ts}`;
await createPage(context, pond.id, targetTitle);
const source = await createPage(context, pond.id, `Wiki Source ${ts}`);
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${source.slug}`);
// Enter edit mode and focus the editor body.
await page.locator('.editor-page__mode-toggle').click();
const body = page.locator('.editor-content .ProseMirror');
await expect(body).toBeVisible();
await body.click();
// Type the trigger and part of the target title — the popup filters live.
await page.keyboard.type(`[[Wiki Target ${ts}`);
const suggest = page.locator('.wikilink-suggest');
await expect(suggest).toBeVisible();
await expect(suggest.getByText(targetTitle, { exact: true })).toBeVisible();
// Enter inserts the wikilink node, which renders the target's current title.
await page.keyboard.press('Enter');
const link = page.locator('.editor-content a.wikilink', { hasText: targetTitle });
await expect(link).toBeVisible();
// A resolved (existing) target is not phantom.
await expect(link).not.toHaveClass(/wikilink--phantom/);
// Persisted through collaboration: reload and the link is still there.
await page.reload();
await page.locator('.editor-page__mode-toggle').click();
await expect(page.locator('.editor-content a.wikilink', { hasText: targetTitle })).toBeVisible();
await context.close();
});

View File

@ -0,0 +1,155 @@
import { slugify } from '@dorfteich/shared';
import type { Editor } from '@tiptap/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useWikilinks } from './wikilink-context';
/** An open `[[` context: the query typed so far and where its `[[` began. */
interface QueryState {
query: string;
from: number;
coords: { left: number; bottom: number };
}
/** A suggestion row: an existing page, or a create-phantom hint for a miss. */
type Suggestion =
{ kind: 'page'; slug: string; label: string } | { kind: 'create'; slug: string; label: string };
/** Detects a `[[query` immediately before a collapsed cursor (issue #46). */
function detectQuery(editor: Editor): { query: string; from: number } | null {
const { selection } = editor.state;
if (!selection.empty) return null;
const $from = selection.$from;
if (!$from.parent.isTextblock) return null;
const start = Math.max(0, $from.parentOffset - 200);
const before = $from.parent.textBetween(start, $from.parentOffset, undefined, '');
const match = /\[\[([^[\]\n]*)$/.exec(before);
if (!match) return null;
const query = match[1] ?? '';
return { query, from: selection.from - query.length - 2 };
}
/**
* Autocomplete popup for `[[` wikilinks (issue #46). Typing `[[` opens a list
* of the current pond's pages filtered by the query (with a create-new-page
* hint for misses); Enter/click inserts the wikilink node and removes the typed
* `[[query`. Keyboard navigation (//Enter/Esc) is intercepted in the capture
* phase so ProseMirror does not act on those keys while the popup is open.
*/
export function WikilinkAutocomplete({ editor }: { editor: Editor }): React.JSX.Element | null {
const { t } = useTranslation('editor');
const { targets } = useWikilinks();
const [state, setState] = useState<QueryState | null>(null);
const [selected, setSelected] = useState(0);
const suggestions = useMemo<Suggestion[]>(() => {
if (!state) return [];
const q = state.query.trim().toLowerCase();
const pages: Suggestion[] = targets
.filter((page) => page.title.toLowerCase().includes(q) || page.slug.toLowerCase().includes(q))
.slice(0, 8)
.map((page) => ({ kind: 'page', slug: page.slug, label: page.title }));
const createSlug = slugify(state.query);
if (createSlug && !targets.some((page) => page.slug === createSlug)) {
pages.push({ kind: 'create', slug: createSlug, label: state.query.trim() });
}
return pages;
}, [state, targets]);
// Latest state/suggestions/selection, so the once-attached keydown handler
// (below) and click handler both act on current values, not a stale closure.
const live = useRef({ state, suggestions, selected });
live.current = { state, suggestions, selected };
function close(): void {
setState(null);
setSelected(0);
}
function choose(item: Suggestion | undefined): void {
const current = live.current.state;
if (!item || !current) return;
editor
.chain()
.focus()
.insertContentAt({ from: current.from, to: editor.state.selection.from }, [
{ type: 'wikilink', attrs: { targetSlug: item.slug, displayText: null } },
{ type: 'text', text: ' ' },
])
.run();
close();
}
// Recompute the open query on every doc/selection change.
useEffect(() => {
const update = (): void => {
const found = detectQuery(editor);
if (!found) {
setState(null);
return;
}
const coords = editor.view.coordsAtPos(editor.state.selection.from);
setState({ ...found, coords: { left: coords.left, bottom: coords.bottom } });
setSelected(0);
};
editor.on('transaction', update);
return () => {
editor.off('transaction', update);
};
}, [editor]);
// Keyboard navigation, intercepted before ProseMirror (capture phase).
useEffect(() => {
const dom = editor.view.dom;
const onKeyDown = (event: KeyboardEvent): void => {
const { state: s, suggestions: items, selected: sel } = live.current;
if (!s || items.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setSelected((i) => (i + 1) % items.length);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setSelected((i) => (i - 1 + items.length) % items.length);
} else if (event.key === 'Enter') {
event.preventDefault();
choose(items[sel]);
} else if (event.key === 'Escape') {
event.preventDefault();
close();
}
};
dom.addEventListener('keydown', onKeyDown, true);
return () => dom.removeEventListener('keydown', onKeyDown, true);
}, [editor]);
if (!state || suggestions.length === 0) return null;
return (
<ul
className="wikilink-suggest"
role="listbox"
aria-label={t('wikilink.autocompleteLabel')}
style={{ position: 'fixed', left: state.coords.left, top: state.coords.bottom + 4 }}
>
{suggestions.map((item, index) => (
<li key={`${item.kind}:${item.slug}`}>
<button
type="button"
role="option"
aria-selected={index === selected}
className={
index === selected ? 'wikilink-suggest__item is-active' : 'wikilink-suggest__item'
}
onMouseDown={(event) => {
event.preventDefault();
choose(item);
}}
>
{item.kind === 'create' ? t('wikilink.createHint', { title: item.label }) : item.label}
</button>
</li>
))}
</ul>
);
}

View File

@ -6,6 +6,7 @@ import { Image } from './nodes/image';
import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists'; import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
import { Table, TableCell, TableHeader, TableRow } from './nodes/table'; import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
import { TaskItem } from './nodes/task-item'; import { TaskItem } from './nodes/task-item';
import { Wikilink } from './nodes/wikilink';
import { import {
Blockquote, Blockquote,
CodeBlock, CodeBlock,
@ -37,6 +38,7 @@ export const documentExtensions: AnyExtension[] = [
TaskItem, TaskItem,
HardBreak, HardBreak,
Image, Image,
Wikilink,
Table, Table,
TableRow, TableRow,
TableCell, TableCell,

View File

@ -0,0 +1,62 @@
import { Node } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { attributesFromSpec, nodeSpec } from '../spec-utils';
import { useWikilinks } from '../wikilink-context';
/**
* Renders a `[[wikilink]]` (issue #46): its shown text is the explicit
* `displayText` or, when none is set, the target page's current title so
* renaming the target updates the link everywhere. A missing target renders as
* a dashed "phantom" with a tooltip and becomes live once the page exists
* (resolution is server-side in #47). Click navigates in read mode; in edit
* mode the atom just selects (no navigation).
*/
function WikilinkView({ node, editor }: NodeViewProps): React.JSX.Element {
const { t } = useTranslation('editor');
const navigate = useNavigate();
const { resolve, pondSlug } = useWikilinks();
const slug = node.attrs.targetSlug as string;
const display = node.attrs.displayText as string | null;
const { title, exists } = resolve(slug);
const text = display ?? title ?? slug;
const editable = editor.isEditable;
return (
<NodeViewWrapper as="span" className="wikilink-nodeview">
<a
className={exists ? 'wikilink' : 'wikilink wikilink--phantom'}
href={`/p/${pondSlug}/${slug}`}
title={exists ? undefined : t('wikilink.phantomTooltip')}
contentEditable={false}
onClick={(event) => {
event.preventDefault();
// In edit mode a click should not navigate away from the editor.
if (!editable) navigate(`/p/${pondSlug}/${slug}`);
}}
>
{text}
</a>
</NodeViewWrapper>
);
}
const wikilinkSpec = nodeSpec('wikilink');
export const Wikilink = Node.create({
name: 'wikilink',
group: wikilinkSpec.group,
inline: wikilinkSpec.inline,
atom: wikilinkSpec.atom,
addAttributes() {
return attributesFromSpec(wikilinkSpec);
},
parseHTML: () => wikilinkSpec.parseDOM,
renderHTML: ({ node }) => wikilinkSpec.toDOM!(node),
addNodeView() {
return ReactNodeViewRenderer(WikilinkView);
},
});

View File

@ -0,0 +1,53 @@
import { createContext, useContext } from 'react';
/** A page in the current pond, as the wikilink UI needs it (issue #46). */
export interface WikilinkTarget {
slug: string;
title: string;
}
/** How a wikilink node resolves its target for display and navigation. */
export interface WikilinkResolution {
/** Current title of the target page, or null when it does not exist (phantom). */
title: string | null;
exists: boolean;
}
export interface WikilinkContextValue {
/** Pages of the current pond, for the `[[` autocomplete. */
targets: WikilinkTarget[];
/** Resolve a slug to its live title and existence. */
resolve: (slug: string) => WikilinkResolution;
/** Pond slug, so a wikilink can build its navigation URL. */
pondSlug: string;
/** Whether the editor is in edit mode (click behaviour differs from read mode). */
editable: boolean;
}
/**
* Live pond context for wikilinks (issue #46). Provided by the page editor,
* consumed by the wikilink NodeView (title resolution, phantom styling) and the
* autocomplete popup. Defaults are inert so a wikilink still renders (as its
* slug) if it is ever mounted without a provider.
*/
export const WikilinkContext = createContext<WikilinkContextValue>({
targets: [],
resolve: () => ({ title: null, exists: false }),
pondSlug: '',
editable: false,
});
export function useWikilinks(): WikilinkContextValue {
return useContext(WikilinkContext);
}
/** Builds a slug→title lookup and a resolver from a pond's page list. */
export function makeWikilinkResolver(
targets: WikilinkTarget[],
): (slug: string) => WikilinkResolution {
const bySlug = new Map(targets.map((t) => [t.slug, t.title]));
return (slug) => {
const title = bySlug.get(slug);
return title !== undefined ? { title, exists: true } : { title: null, exists: false };
};
}

View File

@ -1,8 +1,8 @@
import type { PageStateView, PondView } from '@dorfteich/shared'; import type { PageListItemView, PageStateView, PondView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Collaboration } from '@tiptap/extension-collaboration'; import { Collaboration } from '@tiptap/extension-collaboration';
import { EditorContent, useEditor } from '@tiptap/react'; import { EditorContent, useEditor } from '@tiptap/react';
import { useEffect, useLayoutEffect, useState } from 'react'; import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import * as Y from 'yjs'; import * as Y from 'yjs';
@ -18,6 +18,8 @@ import { ImageUpload } from '../editor/image-upload';
import { PresenceStrip } from '../editor/PresenceStrip'; import { PresenceStrip } from '../editor/PresenceStrip';
import { Toolbar } from '../editor/Toolbar'; import { Toolbar } from '../editor/Toolbar';
import { useCollabProvider } from '../editor/use-collab-provider'; import { useCollabProvider } from '../editor/use-collab-provider';
import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
import { useForceSidebarHidden } from '../layout/sidebar-chrome'; import { useForceSidebarHidden } from '../layout/sidebar-chrome';
import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api'; import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api';
import { recallPage, rememberPage } from '../offline/page-cache'; import { recallPage, rememberPage } from '../offline/page-cache';
@ -101,35 +103,48 @@ function PageEditor({
editor?.setEditable(canEdit); editor?.setEditable(canEdit);
}, [editor, canEdit]); }, [editor, canEdit]);
// Pond pages power wikilink title resolution + the `[[` autocomplete (#46).
const pondPages = useQuery({
queryKey: ['pages', page.pondId],
queryFn: () => apiGet<PageListItemView[]>(`/ponds/${page.pondId}/pages`),
});
const wikilinks = useMemo(() => {
const targets = (pondPages.data ?? []).map((p) => ({ slug: p.slug, title: p.title }));
return { targets, resolve: makeWikilinkResolver(targets), pondSlug, editable: canEdit };
}, [pondPages.data, pondSlug, canEdit]);
if (!editor || !ydoc) return <></>; if (!editor || !ydoc) return <></>;
return ( return (
<div className="editor-shell"> <WikilinkContext.Provider value={wikilinks}>
{canEdit && <Toolbar editor={editor} />} <div className="editor-shell">
<div className="editor-connection" role="status" data-status={collab.status}> {canEdit && <Toolbar editor={editor} />}
{t(`connection.${collab.status}`)} <div className="editor-connection" role="status" data-status={collab.status}>
{t(`connection.${collab.status}`)}
</div>
<PresenceStrip provider={collab.provider} />
{collab.localOnly && (
<div className="editor-banner editor-banner--info" role="note">
{t('offline.localOnly')}
</div>
)}
{mode === 'edit' && readOnly && (
<div className="editor-banner editor-banner--info" role="note">
{t('readOnly.notice')}
</div>
)}
{collab.tooLarge && (
<div className="editor-banner editor-banner--error" role="alert">
{t('tooLarge.notice')}
</div>
)}
{collab.accessRevoked && (
<AccessRevokedDialog editor={editor} slug={page.slug} onDiscard={discardLocalAndLeave} />
)}
<EditorContent editor={editor} className="editor-content" />
{canEdit && <WikilinkAutocomplete editor={editor} />}
</div> </div>
<PresenceStrip provider={collab.provider} /> </WikilinkContext.Provider>
{collab.localOnly && (
<div className="editor-banner editor-banner--info" role="note">
{t('offline.localOnly')}
</div>
)}
{mode === 'edit' && readOnly && (
<div className="editor-banner editor-banner--info" role="note">
{t('readOnly.notice')}
</div>
)}
{collab.tooLarge && (
<div className="editor-banner editor-banner--error" role="alert">
{t('tooLarge.notice')}
</div>
)}
{collab.accessRevoked && (
<AccessRevokedDialog editor={editor} slug={page.slug} onDiscard={discardLocalAndLeave} />
)}
<EditorContent editor={editor} className="editor-content" />
</div>
); );
} }
@ -287,7 +302,7 @@ export function PageEditorPage(): React.JSX.Element {
/> />
<button <button
type="button" type="button"
className="button" className="button editor-page__mode-toggle"
onClick={() => setMode(mode === 'edit' ? 'view' : 'edit')} onClick={() => setMode(mode === 'edit' ? 'view' : 'edit')}
> >
{mode === 'edit' ? t('mode.view') : t('mode.edit')} {mode === 'edit' ? t('mode.view') : t('mode.edit')}

View File

@ -1174,3 +1174,59 @@ button {
background: var(--color-bg-subtle); background: var(--color-bg-subtle);
color: var(--color-text); color: var(--color-text);
} }
/* Wikilinks (issue #46) ------------------------------------------------- */
.wikilink-nodeview {
display: inline;
}
.wikilink {
color: var(--color-accent);
text-decoration: none;
border-bottom: 1px solid transparent;
cursor: pointer;
}
.wikilink:hover {
border-bottom-color: currentColor;
}
/* A link to a page that does not exist yet. */
.wikilink--phantom {
color: var(--color-text-muted);
border-bottom: 1px dashed currentColor;
}
.wikilink-suggest {
list-style: none;
margin: 0;
padding: var(--space-1);
min-width: 14rem;
max-width: 22rem;
max-height: 16rem;
overflow-y: auto;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
z-index: 30;
}
.wikilink-suggest__item {
display: block;
width: 100%;
text-align: left;
padding: var(--space-1) var(--space-2);
border: 0;
border-radius: var(--radius);
background: none;
color: var(--color-text);
cursor: pointer;
font-size: 0.95rem;
}
.wikilink-suggest__item.is-active,
.wikilink-suggest__item:hover {
background: var(--color-bg-subtle);
}

View File

@ -126,5 +126,10 @@
"purgeConfirm": "Diese Seite und ihre Dateien endgültig löschen? Das kann nicht rückgängig gemacht werden.", "purgeConfirm": "Diese Seite und ihre Dateien endgültig löschen? Das kann nicht rückgängig gemacht werden.",
"pageTrashedHint": "Diese Seite wurde in den Papierkorb verschoben.", "pageTrashedHint": "Diese Seite wurde in den Papierkorb verschoben.",
"restoreLink": "Im Papierkorb ansehen" "restoreLink": "Im Papierkorb ansehen"
},
"wikilink": {
"phantomTooltip": "Diese Seite existiert noch nicht",
"autocompleteLabel": "Auf eine Seite verlinken",
"createHint": "Seite „{{title}}“ anlegen"
} }
} }

View File

@ -126,5 +126,10 @@
"purgeConfirm": "Permanently delete this page and its files? This cannot be undone.", "purgeConfirm": "Permanently delete this page and its files? This cannot be undone.",
"pageTrashedHint": "This page has been moved to the trash.", "pageTrashedHint": "This page has been moved to the trash.",
"restoreLink": "View in trash" "restoreLink": "View in trash"
},
"wikilink": {
"phantomTooltip": "This page does not exist yet",
"autocompleteLabel": "Link to a page",
"createHint": "Create page “{{title}}”"
} }
} }

View File

@ -56,6 +56,15 @@ function renderInline(node: Node): string {
const width = child.attrs.width as number | null; const width = child.attrs.width as number | null;
const widthAttr = width ? ` width="${width}"` : ''; const widthAttr = width ? ` width="${width}"` : '';
out += `<img data-file-id="${escapeHtml(child.attrs.fileId as string)}" alt="${alt}"${widthAttr}>`; out += `<img data-file-id="${escapeHtml(child.attrs.fileId as string)}" alt="${alt}"${widthAttr}>`;
} else if (child.type.name === 'wikilink') {
// The slug and shown text; live title resolution and navigation happen
// where the pond's pages are known (issue #46) — this is the static
// representation used for the content cache and version history.
const slug = escapeHtml(child.attrs.targetSlug as string);
const display = child.attrs.displayText as string | null;
const text = escapeHtml(display ?? (child.attrs.targetSlug as string));
const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : '';
out += `<a class="wikilink" data-wikilink="${slug}"${displayAttr}>${text}</a>`;
} }
}); });
return out; return out;

View File

@ -30,6 +30,10 @@ const FIXTURES: Record<string, string> = {
image: '![A pond](file-123)', image: '![A pond](file-123)',
nestedBlockquoteAndList: '> - Item inside a quote\n> - Second item', nestedBlockquoteAndList: '> - Item inside a quote\n> - Second item',
wikilinkBare: 'See [[architecture]] for details.',
wikilinkWithDisplay: 'See [[architecture|the design docs]] for details.',
}; };
describe('markdown round-trip (issue #24)', () => { describe('markdown round-trip (issue #24)', () => {
@ -59,4 +63,24 @@ describe('markdown round-trip (issue #24)', () => {
const doc = markdownToDoc('- [ ] Task\n- Plain item'); const doc = markdownToDoc('- [ ] Task\n- Plain item');
expect(doc.firstChild?.type.name).toBe('bullet_list'); expect(doc.firstChild?.type.name).toBe('bullet_list');
}); });
it('parses a wikilink into a node with slug and optional display text (issue #46)', () => {
const withDisplay = markdownToDoc('[[my-page|My Page]]').firstChild?.firstChild;
expect(withDisplay?.type.name).toBe('wikilink');
expect(withDisplay?.attrs).toMatchObject({ targetSlug: 'my-page', displayText: 'My Page' });
const bare = markdownToDoc('[[my-page]]').firstChild?.firstChild;
expect(bare?.attrs).toMatchObject({ targetSlug: 'my-page', displayText: null });
});
it('leaves malformed brackets as plain text', () => {
// A single bracket pair is an ordinary (broken) markdown link, not a wikilink.
const doc = markdownToDoc('[[unclosed and [not a link]');
expect(doc.textContent).toContain('[[unclosed');
let hasWikilink = false;
doc.descendants((n) => {
if (n.type.name === 'wikilink') hasWikilink = true;
});
expect(hasWikilink).toBe(false);
});
}); });

View File

@ -1,4 +1,5 @@
import MarkdownIt from 'markdown-it'; import MarkdownIt from 'markdown-it';
import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs';
import Token from 'markdown-it/lib/token.mjs'; import Token from 'markdown-it/lib/token.mjs';
import { Mark, Node } from 'prosemirror-model'; import { Mark, Node } from 'prosemirror-model';
import { MarkdownParser, MarkdownSerializer, MarkdownSerializerState } from 'prosemirror-markdown'; import { MarkdownParser, MarkdownSerializer, MarkdownSerializerState } from 'prosemirror-markdown';
@ -131,8 +132,41 @@ function transformTokens(tokens: Token[]): Token[] {
return out; return out;
} }
/** markdown-it inline rule for `[[slug]]` / `[[slug|display]]` (issue #46). */
function wikilinkRule(state: StateInline, silent: boolean): boolean {
const start = state.pos;
// Two opening brackets.
if (state.src.charCodeAt(start) !== 0x5b || state.src.charCodeAt(start + 1) !== 0x5b) {
return false;
}
const close = state.src.indexOf(']]', start + 2);
if (close < 0) return false;
const inner = state.src.slice(start + 2, close);
// No nesting or line breaks inside a wikilink.
if (inner.includes('[') || inner.includes(']') || inner.includes('\n')) return false;
const pipe = inner.indexOf('|');
const slug = (pipe >= 0 ? inner.slice(0, pipe) : inner).trim();
const display = pipe >= 0 ? inner.slice(pipe + 1).trim() : '';
if (!slug) return false;
if (!silent) {
const token = state.push('wikilink', '', 0);
token.attrs = display
? [
['target', slug],
['display', display],
]
: [['target', slug]];
}
state.pos = close + 2;
return true;
}
function createTokenizer(): MarkdownIt { function createTokenizer(): MarkdownIt {
const md = new MarkdownIt('default', { html: false }); const md = new MarkdownIt('default', { html: false });
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
const rawParse = md.parse.bind(md); const rawParse = md.parse.bind(md);
md.parse = (src, env) => transformTokens(rawParse(src, env)); md.parse = (src, env) => transformTokens(rawParse(src, env));
return md; return md;
@ -173,6 +207,13 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
}), }),
}, },
hardbreak: { node: 'hard_break' }, hardbreak: { node: 'hard_break' },
wikilink: {
node: 'wikilink',
getAttrs: (tok) => ({
targetSlug: tok.attrGet('target') ?? '',
displayText: tok.attrGet('display') || null,
}),
},
em: { mark: 'italic' }, em: { mark: 'italic' },
strong: { mark: 'bold' }, strong: { mark: 'bold' },
s: { mark: 'strikethrough' }, s: { mark: 'strikethrough' },
@ -268,6 +309,11 @@ const markdownSerializer = new MarkdownSerializer(
const fileId = (node.attrs.fileId as string).replace(/[()]/g, '\\$&'); const fileId = (node.attrs.fileId as string).replace(/[()]/g, '\\$&');
state.write(`![${alt}](${fileId})`); state.write(`![${alt}](${fileId})`);
}, },
wikilink(state, node) {
const slug = node.attrs.targetSlug as string;
const display = node.attrs.displayText as string | null;
state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`);
},
hard_break(state, node, parent, index) { hard_break(state, node, parent, index) {
for (let i = index + 1; i < parent.childCount; i += 1) { for (let i = index + 1; i < parent.childCount; i += 1) {
if (parent.child(i).type !== node.type) { if (parent.child(i).type !== node.type) {

View File

@ -2,8 +2,14 @@ import { Node } from 'prosemirror-model';
/** Search/preview representation (data-model.md `page_content_cache`). */ /** Search/preview representation (data-model.md `page_content_cache`). */
export function docToPlainText(doc: Node): string { export function docToPlainText(doc: Node): string {
const text = doc.textBetween(0, doc.content.size, '\n\n', (leaf) => const text = doc.textBetween(0, doc.content.size, '\n\n', (leaf) => {
leaf.type.name === 'image' ? ((leaf.attrs.alt as string) ?? '') : '', if (leaf.type.name === 'image') return (leaf.attrs.alt as string) ?? '';
); // A wikilink contributes its shown text (explicit display or the slug) so
// search and previews include what the reader sees (issue #46).
if (leaf.type.name === 'wikilink') {
return (leaf.attrs.displayText as string | null) ?? (leaf.attrs.targetSlug as string);
}
return '';
});
return text.replace(/\n{3,}/g, '\n\n').trim(); return text.replace(/\n{3,}/g, '\n\n').trim();
} }

View File

@ -142,6 +142,38 @@ export const editorSchema = new Schema({
], ],
}, },
// Obsidian-style `[[page link]]` (ADR 0004, issue #46). An inline atom
// carrying the target page's `slug` and an optional explicit `displayText`;
// when `displayText` is null the editor/renderer shows the target's current
// title, so a rename updates every link. Live title resolution and phantom
// (missing target) styling happen where the pond's pages are known — the
// stored document only keeps slug + optional display text.
wikilink: {
group: 'inline',
inline: true,
atom: true,
attrs: {
targetSlug: { validate: 'string' },
displayText: { default: null },
},
parseDOM: [
{
tag: 'a[data-wikilink]',
getAttrs: (dom) => ({
targetSlug: dom.getAttribute('data-wikilink'),
displayText: dom.getAttribute('data-display') || null,
}),
},
],
toDOM: (node) => {
const slug = node.attrs.targetSlug as string;
const display = node.attrs.displayText as string | null;
const attrs: Record<string, string> = { 'data-wikilink': slug, class: 'wikilink' };
if (display) attrs['data-display'] = display;
return ['a', attrs, display ?? slug];
},
},
...tableNodes({ tableGroup: 'block', cellContent: 'block+', cellAttributes: {} }), ...tableNodes({ tableGroup: 'block', cellContent: 'block+', cellAttributes: {} }),
}, },