M17–M19: #133–#137 (Kommentare inline, Statuszeile, Transklusion, Excalidraw, Checkbox-Fix) #138

Merged
fable-5 merged 10 commits from m17-m19-issues into main 2026-07-19 09:38:38 +02:00
15 changed files with 489 additions and 28 deletions
Showing only changes of commit 15376d4ac2 - Show all commits

View File

@ -139,6 +139,36 @@ describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => {
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/comments`).expect(404); await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/comments`).expect(404);
}); });
it('expands a page embed to the targets content, cycle-safe (issue #135)', async () => {
const embeddedSlug = `embedded-${suffix}`;
const hostSlug = `host-${suffix}`;
// The embedded page embeds the host back — the expansion must terminate.
await makePage(
pondId,
embeddedSlug,
'Embedded',
`<p>Body of the embedded page.</p>` +
`<div class="dt-transclusion" data-transclusion="${hostSlug}">Host</div>`,
);
await makePage(
pondId,
hostSlug,
'Host',
`<p>Before.</p>` +
`<div class="dt-transclusion" data-transclusion="${embeddedSlug}">Embedded</div>` +
`<div class="dt-transclusion" data-transclusion="ghost-${suffix}">Missing</div>`,
);
const res = await api().get(`/api/v1/public/${pondSlug}/${hostSlug}/content`).expect(200);
const html = (res.body as { html: string }).html;
// The embedded page's body is spliced in, wrapped as an embed.
expect(html).toContain('Body of the embedded page.');
expect(html).toContain('class="dt-embed"');
// No raw placeholder survives; a missing target degrades to a link.
expect(html).not.toContain('dt-transclusion');
expect(html).toContain(`data-wikilink="ghost-${suffix}"`);
});
it('404s all public endpoints once the public grant is removed', async () => { it('404s all public endpoints once the public grant is removed', async () => {
await prisma.roleGrant.delete({ where: { id: publicGrantId } }); await prisma.roleGrant.delete({ where: { id: publicGrantId } });
// The API route invalidates on its own mutations; this test deletes // The API route invalidates on its own mutations; this test deletes

View File

@ -5,6 +5,7 @@ import { PluginsModule } from '../plugins/plugins.module';
import { PublicController } from './public.controller'; import { PublicController } from './public.controller';
import { PublicService } from './public.service'; import { PublicService } from './public.service';
import { ReadContentController } from './read-content.controller';
/** /**
* Public read access (issue #56): anonymous-reachable page endpoints on top of * Public read access (issue #56): anonymous-reachable page endpoints on top of
@ -14,7 +15,7 @@ import { PublicService } from './public.service';
*/ */
@Module({ @Module({
imports: [PluginsModule, CommentsModule], imports: [PluginsModule, CommentsModule],
controllers: [PublicController], controllers: [PublicController, ReadContentController],
providers: [PublicService], providers: [PublicService],
}) })
export class PublicModule {} export class PublicModule {}

View File

@ -61,25 +61,86 @@ export class PublicService {
return { pond, page }; return { pond, page };
} }
/** The page content for the SPA's read-only public view. */ /** The page content for the read view (public and authenticated, issue #56). */
async content(user: User | null, pondSlug: string, pageSlug: string): Promise<PublicPageContent> { async content(user: User | null, pondSlug: string, pageSlug: string): Promise<PublicPageContent> {
const { pond, page } = await this.resolve(user, pondSlug, pageSlug); const { pond, page } = await this.resolve(user, pondSlug, pageSlug);
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
// Plugin blocks render their static form (#79), and the pond's active // The pond's active section-style CSS travels inline — the read view loads
// section-style CSS travels inline — the public view loads no plugin // no plugin runtime, and the CSS passed the install gate's scoping rules.
// runtime, and the CSS passed the install gate's scoping rules.
const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? '');
const styleTag = await this.fallbacks.sectionStyleTag(pond.id); const styleTag = await this.fallbacks.sectionStyleTag(pond.id);
// Plugin blocks render their static form (#79) and page embeds expand to the
// target's rendered HTML (#135), then media is resolved once over the whole
// tree. `visited` seeds with this page so an embed of self is not expanded.
const body = await this.renderBody(user, pond.id, page, 0, new Set([page.slug]));
return { return {
pondName: pond.name, pondName: pond.name,
pondSlug: pond.slug, pondSlug: pond.slug,
title: page.title, title: page.title,
slug: page.slug, slug: page.slug,
html: styleTag + resolveMediaUrls(withFallbacks), html: styleTag + resolveMediaUrls(body),
updatedAt: (cache?.updatedAt ?? new Date()).toISOString(), updatedAt: (cache?.updatedAt ?? new Date()).toISOString(),
}; };
} }
/** Longest embed chain we follow before falling back to a link (issue #135). */
private static readonly MAX_EMBED_DEPTH = 2;
/**
* A page's body HTML: cached HTML + plugin fallbacks + expanded page embeds,
* but WITHOUT media resolution or the style tag those are applied once at
* the top of {@link content} so nested embeds are not double-processed.
*/
private async renderBody(
user: User | null,
pondId: string,
page: { id: string },
depth: number,
visited: Set<string>,
): Promise<string> {
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? '');
return this.expandEmbeds(withFallbacks, user, pondId, depth, visited);
}
/**
* Replaces each `dt-transclusion` placeholder (issue #135) with the target
* page's rendered body. Same-pond only, read-permission-checked; a missing,
* unreadable, cyclic, or too-deep target degrades to a plain link so the page
* never leaks existence and never loops.
*/
private async expandEmbeds(
html: string,
user: User | null,
pondId: string,
depth: number,
visited: Set<string>,
): Promise<string> {
const placeholder = /<div class="dt-transclusion" data-transclusion="([^"]+)">[^<]*<\/div>/g;
return replaceAsync(html, placeholder, async (_match, rawSlug) => {
const slug = rawSlug as string;
const target = await this.prisma.page.findFirst({
where: { pondId, slug, deletedAt: null },
select: { id: true, pondId: true, slug: true, title: true },
});
const readable = target && (await this.permissions.canAccessPage(user, target, 'read'));
if (!target || !readable || depth >= PublicService.MAX_EMBED_DEPTH || visited.has(slug)) {
return embedLink(slug, target?.title ?? slug);
}
const inner = await this.renderBody(
user,
pondId,
target,
depth + 1,
new Set(visited).add(slug),
);
return (
`<div class="dt-embed"><div class="dt-embed__title">` +
`<a class="wikilink" href="${escapeHtml(slug)}" data-wikilink="${escapeHtml(slug)}">` +
`${escapeHtml(target.title)}</a></div>${inner}</div>`
);
});
}
/** /**
* The page's comments for the anonymous public view (issue #133), read-only. * The page's comments for the anonymous public view (issue #133), read-only.
* `resolve()` enforces (possibly anonymous) read access a non-public page * `resolve()` enforces (possibly anonymous) read access a non-public page
@ -127,3 +188,35 @@ function resolveMediaUrls(html: string): string {
'src="/api/v1/media/$1" data-file-id="$1"', 'src="/api/v1/media/$1" data-file-id="$1"',
); );
} }
/** The fallback for an embed that cannot expand (missing/unreadable/cyclic/too
* deep, issue #135): a plain wikilink, so the page never loops or leaks. */
function embedLink(slug: string, label: string): string {
const safeSlug = escapeHtml(slug);
return (
`<div class="dt-embed dt-embed--link">` +
`<a class="wikilink" href="${safeSlug}" data-wikilink="${safeSlug}">${escapeHtml(label)}</a>` +
`</div>`
);
}
/** `String.replace` with an async replacer (issue #135): resolves every match's
* replacement in parallel, then splices them back in match order. */
async function replaceAsync(
input: string,
regex: RegExp,
replacer: (match: string, ...groups: string[]) => Promise<string>,
): Promise<string> {
const matches = [...input.matchAll(regex)];
if (matches.length === 0) return input;
const replacements = await Promise.all(
matches.map((match) => replacer(match[0], ...match.slice(1))),
);
let result = '';
let lastIndex = 0;
matches.forEach((match, i) => {
result += input.slice(lastIndex, match.index) + replacements[i];
lastIndex = (match.index ?? 0) + match[0].length;
});
return result + input.slice(lastIndex);
}

View File

@ -0,0 +1,27 @@
import { Controller, Get, Param, Req } from '@nestjs/common';
import { AuthedRequest } from '../auth/auth.guard';
import { PublicPageContent, PublicService } from './public.service';
/**
* Authenticated read-rendering (issue #135). Returns a page's rendered read
* HTML plugin fallbacks, expanded page embeds, resolved media for a
* signed-in viewer with read access. The transclusion node view fetches this to
* show an embedded page's content inline in the authenticated read view, which
* must also work for pages that are not public and so are out of reach of the
* `/public` endpoints. NOT `@Public`: the auth guard requires a session and the
* service enforces read permission (a non-readable page 404s, no leak).
*/
@Controller('read')
export class ReadContentController {
constructor(private readonly publicPages: PublicService) {}
@Get(':pondSlug/:pageSlug')
async content(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Req() request: AuthedRequest,
): Promise<PublicPageContent> {
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug);
}
}

View File

@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next';
import { useWikilinks } from './wikilink-context'; import { useWikilinks } from './wikilink-context';
/** An open `[[` context: the query typed so far and where its `[[` began. */ /** An open `[[` context: the query typed so far and where its `[[` began.
* `embed` is true when it was opened as `![[` a page embed (issue #135). */
interface QueryState { interface QueryState {
query: string; query: string;
from: number; from: number;
embed: boolean;
coords: { left: number; bottom: number }; coords: { left: number; bottom: number };
} }
@ -16,18 +18,21 @@ interface QueryState {
type Suggestion = type Suggestion =
{ kind: 'page'; slug: string; label: string } | { kind: 'create'; slug: string; label: string }; { kind: 'page'; slug: string; label: string } | { kind: 'create'; slug: string; label: string };
/** Detects a `[[query` immediately before a collapsed cursor (issue #46). */ /** Detects a `[[query` (link) or `![[query` (embed, #135) immediately before a
function detectQuery(editor: Editor): { query: string; from: number } | null { * collapsed cursor (issue #46). The optional leading `!` opens an embed. */
function detectQuery(editor: Editor): { query: string; from: number; embed: boolean } | null {
const { selection } = editor.state; const { selection } = editor.state;
if (!selection.empty) return null; if (!selection.empty) return null;
const $from = selection.$from; const $from = selection.$from;
if (!$from.parent.isTextblock) return null; if (!$from.parent.isTextblock) return null;
const start = Math.max(0, $from.parentOffset - 200); const start = Math.max(0, $from.parentOffset - 200);
const before = $from.parent.textBetween(start, $from.parentOffset, undefined, ''); const before = $from.parent.textBetween(start, $from.parentOffset, undefined, '');
const match = /\[\[([^[\]\n]*)$/.exec(before); const match = /(!?)\[\[([^[\]\n]*)$/.exec(before);
if (!match) return null; if (!match) return null;
const query = match[1] ?? ''; const embed = match[1] === '!';
return { query, from: selection.from - query.length - 2 }; const query = match[2] ?? '';
// `[[` is 2 chars; an embed's leading `!` is one more to swallow.
return { query, from: selection.from - query.length - 2 - (embed ? 1 : 0), embed };
} }
/** /**
@ -70,14 +75,28 @@ export function WikilinkAutocomplete({ editor }: { editor: Editor }): React.JSX.
function choose(item: Suggestion | undefined): void { function choose(item: Suggestion | undefined): void {
const current = live.current.state; const current = live.current.state;
if (!item || !current) return; if (!item || !current) return;
editor const range = { from: current.from, to: editor.state.selection.from };
.chain() if (current.embed) {
.focus() // A page embed is a block node (#135) — replace the typed `![[query` with
.insertContentAt({ from: current.from, to: editor.state.selection.from }, [ // the transclusion block; ProseMirror lifts it out of the paragraph.
{ type: 'wikilink', attrs: { targetSlug: item.slug, displayText: null } }, editor
{ type: 'text', text: ' ' }, .chain()
]) .focus()
.run(); .insertContentAt(range, {
type: 'transclusion',
attrs: { targetSlug: item.slug, displayText: null },
})
.run();
} else {
editor
.chain()
.focus()
.insertContentAt(range, [
{ type: 'wikilink', attrs: { targetSlug: item.slug, displayText: null } },
{ type: 'text', text: ' ' },
])
.run();
}
close(); close();
} }

View File

@ -7,6 +7,7 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
import { PluginBlock } from './nodes/plugin-block'; import { PluginBlock } from './nodes/plugin-block';
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 { Transclusion } from './nodes/transclusion';
import { Wikilink } from './nodes/wikilink'; import { Wikilink } from './nodes/wikilink';
import { import {
Blockquote, Blockquote,
@ -43,6 +44,7 @@ export const documentExtensions: AnyExtension[] = [
Image, Image,
PluginBlock, PluginBlock,
Wikilink, Wikilink,
Transclusion,
Table, Table,
TableRow, TableRow,
TableCell, TableCell,

View File

@ -0,0 +1,88 @@
import { useQuery } from '@tanstack/react-query';
import { Node } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { apiGet } from '../../lib/api';
import { attributesFromSpec, nodeSpec } from '../spec-utils';
import { useWikilinks } from '../wikilink-context';
interface RenderedContent {
title: string;
html: string;
}
/**
* Renders a `![[page embed]]` (issue #135). In edit mode and while loading or
* when the target is missing/unreadable it shows a compact placeholder card
* (title + open link) so writing stays fast. In read mode it fetches the
* target's server-rendered HTML (`/read/:pond/:slug`, permission-checked, with
* nested embeds already expanded) and shows it inline.
*/
function TransclusionView({ node, editor }: NodeViewProps): React.JSX.Element {
const { t } = useTranslation('editor');
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 label = display ?? title ?? slug;
const editable = editor.isEditable;
const content = useQuery({
queryKey: ['rendered', pondSlug, slug],
queryFn: () => apiGet<RenderedContent>(`/read/${pondSlug}/${slug}`),
enabled: !editable && exists,
retry: false,
});
if (editable || !exists || content.isError) {
return (
<NodeViewWrapper className="dt-transclusion-card" contentEditable={false}>
<span className="dt-transclusion-card__icon" aria-hidden>
</span>
<span className="dt-transclusion-card__label">
{t('transclusion.embedded', { title: label })}
</span>
<Link className="dt-transclusion-card__open" to={`/p/${pondSlug}/${slug}`}>
{t('transclusion.open')}
</Link>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper className="dt-embed" contentEditable={false}>
<div className="dt-embed__title">
<Link className="wikilink" to={`/p/${pondSlug}/${slug}`}>
{content.data?.title ?? label}
</Link>
</div>
{content.data ? (
// Server-sanitized read HTML (shared docToHtml pipeline) — safe by
// contract, same as the public view.
<div className="dt-embed__body" dangerouslySetInnerHTML={{ __html: content.data.html }} />
) : (
<div className="dt-embed__body" aria-busy="true" />
)}
</NodeViewWrapper>
);
}
const transclusionSpec = nodeSpec('transclusion');
export const Transclusion = Node.create({
name: 'transclusion',
group: transclusionSpec.group,
atom: transclusionSpec.atom,
addAttributes() {
return attributesFromSpec(transclusionSpec);
},
parseHTML: () => transclusionSpec.parseDOM,
renderHTML: ({ node }) => transclusionSpec.toDOM!(node),
addNodeView() {
return ReactNodeViewRenderer(TransclusionView);
},
});

View File

@ -2383,6 +2383,58 @@ ul[data-type='task_list'] li > div > p:last-child {
border-bottom: 1px dashed currentColor; border-bottom: 1px dashed currentColor;
} }
/* Page embed / transclusion (issue #135). Placeholder card shown in the editor
and while loading; the expanded embed wraps the target's rendered content. */
.dt-transclusion-card {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-3) 0;
padding: var(--space-2) var(--space-3);
border: 1px dashed var(--color-border);
border-radius: 8px;
background: var(--color-bg-subtle);
color: var(--color-text-muted);
font-size: 0.9375rem;
}
.dt-transclusion-card__icon {
font-size: 1.1em;
}
.dt-transclusion-card__label {
flex: 1;
min-width: 0;
}
.dt-transclusion-card__open {
color: var(--color-accent);
text-decoration: none;
white-space: nowrap;
}
.dt-embed {
margin: var(--space-3) 0;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-accent);
border-radius: 8px;
background: var(--color-surface, #fff);
}
.dt-embed__title {
margin-bottom: var(--space-2);
font-weight: var(--font-weight-heading);
}
.dt-embed__body > :first-child {
margin-top: 0;
}
.dt-embed__body > :last-child {
margin-bottom: 0;
}
.wikilink-suggest { .wikilink-suggest {
list-style: none; list-style: none;
margin: 0; margin: 0;

View File

@ -173,6 +173,10 @@
"autocompleteLabel": "Auf eine Seite verlinken", "autocompleteLabel": "Auf eine Seite verlinken",
"createHint": "Seite „{{title}}“ anlegen" "createHint": "Seite „{{title}}“ anlegen"
}, },
"transclusion": {
"embedded": "Eingebettet: {{title}}",
"open": "Öffnen"
},
"notFound": { "notFound": {
"hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.", "hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.",
"create": "Seite „{{slug}}“ anlegen" "create": "Seite „{{slug}}“ anlegen"

View File

@ -173,6 +173,10 @@
"autocompleteLabel": "Link to a page", "autocompleteLabel": "Link to a page",
"createHint": "Create page “{{title}}”" "createHint": "Create page “{{title}}”"
}, },
"transclusion": {
"embedded": "Embedded: {{title}}",
"open": "Open"
},
"notFound": { "notFound": {
"hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.", "hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.",
"create": "Create the page “{{slug}}”" "create": "Create the page “{{slug}}”"

View File

@ -132,6 +132,15 @@ function renderBlock(node: Node): string {
` data-plugin-data="${data}">[${pluginId}/${blockType}]</div>` ` data-plugin-data="${data}">[${pluginId}/${blockType}]</div>`
); );
} }
case 'transclusion': {
// A page embed (#135). The static HTML is a placeholder carrying the
// target slug; the read view / public renderer expands it server-side to
// the target page's rendered HTML (permission-checked, recursion limited).
// Left un-expanded (here) it degrades to a labelled block.
const slug = escapeHtml(node.attrs.targetSlug as string);
const display = node.attrs.displayText ? escapeHtml(node.attrs.displayText as string) : slug;
return `<div class="dt-transclusion" data-transclusion="${slug}">${display}</div>`;
}
case 'code_block': case 'code_block':
return `<pre><code>${escapeHtml(node.textContent)}</code></pre>`; return `<pre><code>${escapeHtml(node.textContent)}</code></pre>`;
case 'horizontal_rule': case 'horizontal_rule':

View File

@ -192,6 +192,35 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
return true; return true;
} }
/** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page (#135). */
const TRANSCLUSION_LINE = /^!\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/;
/**
* Block rule for page embeds (issue #135). A line consisting solely of
* `![[slug]]` becomes a `transclusion` block node; anything else (including
* `![[x]]` mid-paragraph) is left untouched. Registered before `paragraph` so
* the lone-embed line is not swallowed as ordinary text.
*/
function transclusionRule(
state: StateBlock,
startLine: number,
_endLine: number,
silent: boolean,
): boolean {
const start = state.bMarks[startLine]! + state.tShift[startLine]!;
const max = state.eMarks[startLine]!;
const match = TRANSCLUSION_LINE.exec(state.src.slice(start, max));
if (!match) return false;
if (silent) return true;
const token = state.push('transclusion', '', 0);
token.attrSet('target', match[1]!.trim());
const display = match[2]?.trim();
if (display) token.attrSet('display', display);
token.map = [startLine, startLine + 1];
state.line = startLine + 1;
return true;
}
/** Opening fence of a section-style container: `::: {data-section-style="p/s"}`. */ /** Opening fence of a section-style container: `::: {data-section-style="p/s"}`. */
const SECTION_OPEN = /^:::+\s*\{\s*data-section-style="([^"/]+)\/([^"]+)"\s*\}\s*$/; const SECTION_OPEN = /^:::+\s*\{\s*data-section-style="([^"/]+)\/([^"]+)"\s*\}\s*$/;
const SECTION_CLOSE = /^:::+\s*$/; const SECTION_CLOSE = /^:::+\s*$/;
@ -248,6 +277,9 @@ 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. // Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
md.inline.ruler.before('link', 'wikilink', wikilinkRule); md.inline.ruler.before('link', 'wikilink', wikilinkRule);
// Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads
// as plain text (issue #135).
md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
// Run before `fence` so `:::` is not read as a code fence. // Run before `fence` so `:::` is not read as a code fence.
md.block.ruler.before('fence', 'section', sectionRule); md.block.ruler.before('fence', 'section', sectionRule);
const rawParse = md.parse.bind(md); const rawParse = md.parse.bind(md);
@ -312,6 +344,13 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
displayText: tok.attrGet('display') || null, displayText: tok.attrGet('display') || null,
}), }),
}, },
transclusion: {
node: 'transclusion',
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' },
@ -435,6 +474,12 @@ const markdownSerializer = new MarkdownSerializer(
const display = node.attrs.displayText as string | null; const display = node.attrs.displayText as string | null;
state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`); state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`);
}, },
transclusion(state, node) {
const slug = node.attrs.targetSlug as string;
const display = node.attrs.displayText as string | null;
state.write(display ? `![[${slug}|${display}]]` : `![[${slug}]]`);
state.closeBlock(node);
},
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

@ -7,8 +7,9 @@ import { tableNodes } from 'prosemirror-tables';
* validation (ADR 0008) all import this schema instead of defining their * validation (ADR 0008) all import this schema instead of defining their
* own, so "valid document" means the same thing everywhere. * own, so "valid document" means the same thing everywhere.
* *
* Node names `wikilink` and `plugin_block` are reserved for these features * Node names `wikilink`, `plugin_block`, and `transclusion` are reserved for
* (wikilinks #46, plugin-defined block types #76) do not repurpose them. * these features (wikilinks #46, plugin-defined block types #76, page embeds
* #135) do not repurpose them.
*/ */
export const editorSchema = new Schema({ export const editorSchema = new Schema({
nodes: { nodes: {
@ -255,6 +256,40 @@ export const editorSchema = new Schema({
}, },
}, },
// Obsidian-style page embed `![[slug]]` (issue #135). A block atom that
// references another page by slug; the read view / public renderer expands
// it to the target page's rendered HTML (permission-checked, recursion
// limited), while the editor shows a placeholder card. Like `wikilink` it
// stores slug + optional display text only — live resolution happens where
// the pond's pages are known.
transclusion: {
group: 'block',
atom: true,
attrs: {
targetSlug: { validate: 'string' },
displayText: { default: null },
},
parseDOM: [
{
tag: 'div[data-transclusion]',
getAttrs: (dom) => ({
targetSlug: dom.getAttribute('data-transclusion'),
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-transclusion': slug,
class: 'dt-transclusion',
};
if (display) attrs['data-display'] = display;
return ['div', attrs, display ?? slug];
},
},
...tableNodes({ tableGroup: 'block', cellContent: 'block+', cellAttributes: {} }), ...tableNodes({ tableGroup: 'block', cellContent: 'block+', cellAttributes: {} }),
}, },

View File

@ -0,0 +1,50 @@
import { Node } from 'prosemirror-model';
import { describe, expect, it } from 'vitest';
import { docToHtml } from './html';
import { docToMarkdown, markdownToDoc } from './markdown';
import { extractWikilinkSlugs } from './wikilinks';
function firstTransclusion(doc: Node): Node | null {
let found: Node | null = null;
doc.descendants((node) => {
if (!found && node.type.name === 'transclusion') found = node;
});
return found;
}
describe('page embed / transclusion (issue #135)', () => {
it('parses a lone ![[slug]] line to a transclusion block and round-trips', () => {
const doc = markdownToDoc('Intro\n\n![[rennrad]]\n\nOutro');
const node = firstTransclusion(doc);
expect(node).not.toBeNull();
expect(node!.attrs.targetSlug).toBe('rennrad');
expect(node!.attrs.displayText).toBeNull();
expect(docToMarkdown(doc)).toContain('![[rennrad]]');
});
it('supports optional display text and round-trips it', () => {
const doc = markdownToDoc('![[rennrad|Mein Rad]]');
const node = firstTransclusion(doc);
expect(node!.attrs.targetSlug).toBe('rennrad');
expect(node!.attrs.displayText).toBe('Mein Rad');
expect(docToMarkdown(doc).trim()).toBe('![[rennrad|Mein Rad]]');
});
it('renders a placeholder div carrying the slug', () => {
const doc = markdownToDoc('![[rennrad]]');
const html = docToHtml(doc);
expect(html).toContain('class="dt-transclusion"');
expect(html).toContain('data-transclusion="rennrad"');
});
it('registers the embed target as an outgoing link (backlinks/graph)', () => {
const doc = markdownToDoc('![[rennrad]]\n\n[[dota]]');
expect(extractWikilinkSlugs(doc)).toEqual(['rennrad', 'dota']);
});
it('only embeds a lone line — mid-paragraph ![[x]] is not a transclusion', () => {
const doc = markdownToDoc('see ![[rennrad]] inline');
expect(firstTransclusion(doc)).toBeNull();
});
});

View File

@ -1,16 +1,18 @@
import { Node } from 'prosemirror-model'; import { Node } from 'prosemirror-model';
/** /**
* The distinct target slugs of every `[[wikilink]]` in a document (issue #47). * The distinct target slugs of every `[[wikilink]]` and every `![[embed]]`
* Used server-side to maintain the `page_links` index on every content change, * (transclusion, issue #135) in a document (issue #47). Used server-side to
* so backlinks and phantom (missing-target) links can be queried. Order of * maintain the `page_links` index on every content change, so backlinks and
* first appearance is preserved; duplicates are collapsed. * phantom (missing-target) links can be queried an embed is semantically a
* reference, so it registers as a link too. Order of first appearance is
* preserved; duplicates are collapsed.
*/ */
export function extractWikilinkSlugs(doc: Node): string[] { export function extractWikilinkSlugs(doc: Node): string[] {
const slugs: string[] = []; const slugs: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
doc.descendants((node) => { doc.descendants((node) => {
if (node.type.name === 'wikilink') { if (node.type.name === 'wikilink' || node.type.name === 'transclusion') {
const slug = node.attrs.targetSlug as string; const slug = node.attrs.targetSlug as string;
if (slug && !seen.has(slug)) { if (slug && !seen.has(slug)) {
seen.add(slug); seen.add(slug);