`,
+ );
+
+ 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 () => {
await prisma.roleGrant.delete({ where: { id: publicGrantId } });
// The API route invalidates on its own mutations; this test deletes
diff --git a/apps/api/src/public/public.module.ts b/apps/api/src/public/public.module.ts
index 91c1604..c333f07 100644
--- a/apps/api/src/public/public.module.ts
+++ b/apps/api/src/public/public.module.ts
@@ -5,6 +5,7 @@ import { PluginsModule } from '../plugins/plugins.module';
import { PublicController } from './public.controller';
import { PublicService } from './public.service';
+import { ReadContentController } from './read-content.controller';
/**
* Public read access (issue #56): anonymous-reachable page endpoints on top of
@@ -14,7 +15,7 @@ import { PublicService } from './public.service';
*/
@Module({
imports: [PluginsModule, CommentsModule],
- controllers: [PublicController],
+ controllers: [PublicController, ReadContentController],
providers: [PublicService],
})
export class PublicModule {}
diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts
index ad6faa8..fa33498 100644
--- a/apps/api/src/public/public.service.ts
+++ b/apps/api/src/public/public.service.ts
@@ -61,25 +61,86 @@ export class PublicService {
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 {
const { pond, page } = await this.resolve(user, pondSlug, pageSlug);
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
- // Plugin blocks render their static form (#79), and the pond's active
- // section-style CSS travels inline — the public view loads no plugin
- // runtime, and the CSS passed the install gate's scoping rules.
- const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? '');
+ // The pond's active section-style CSS travels inline — the read view loads
+ // no plugin runtime, and the CSS passed the install gate's scoping rules.
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 {
pondName: pond.name,
pondSlug: pond.slug,
title: page.title,
slug: page.slug,
- html: styleTag + resolveMediaUrls(withFallbacks),
+ html: styleTag + resolveMediaUrls(body),
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,
+ ): Promise {
+ 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,
+ ): Promise {
+ const placeholder = /
`
+ );
+}
+
+/** `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,
+): Promise {
+ 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);
+}
diff --git a/apps/api/src/public/read-content.controller.ts b/apps/api/src/public/read-content.controller.ts
new file mode 100644
index 0000000..f827af6
--- /dev/null
+++ b/apps/api/src/public/read-content.controller.ts
@@ -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 {
+ return this.publicPages.content(request.user ?? null, pondSlug, pageSlug);
+ }
+}
diff --git a/apps/web/src/editor/WikilinkAutocomplete.tsx b/apps/web/src/editor/WikilinkAutocomplete.tsx
index 517d602..18f1a8e 100644
--- a/apps/web/src/editor/WikilinkAutocomplete.tsx
+++ b/apps/web/src/editor/WikilinkAutocomplete.tsx
@@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next';
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 {
query: string;
from: number;
+ embed: boolean;
coords: { left: number; bottom: number };
}
@@ -16,18 +18,21 @@ interface QueryState {
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 {
+/** Detects a `[[query` (link) or `![[query` (embed, #135) immediately before a
+ * 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;
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);
+ const match = /(!?)\[\[([^[\]\n]*)$/.exec(before);
if (!match) return null;
- const query = match[1] ?? '';
- return { query, from: selection.from - query.length - 2 };
+ const embed = match[1] === '!';
+ 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 {
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();
+ const range = { from: current.from, to: editor.state.selection.from };
+ if (current.embed) {
+ // A page embed is a block node (#135) — replace the typed `![[query` with
+ // the transclusion block; ProseMirror lifts it out of the paragraph.
+ editor
+ .chain()
+ .focus()
+ .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();
}
diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts
index 7408dd7..4a8730c 100644
--- a/apps/web/src/editor/document-extensions.ts
+++ b/apps/web/src/editor/document-extensions.ts
@@ -7,6 +7,7 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
import { PluginBlock } from './nodes/plugin-block';
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
import { TaskItem } from './nodes/task-item';
+import { Transclusion } from './nodes/transclusion';
import { Wikilink } from './nodes/wikilink';
import {
Blockquote,
@@ -43,6 +44,7 @@ export const documentExtensions: AnyExtension[] = [
Image,
PluginBlock,
Wikilink,
+ Transclusion,
Table,
TableRow,
TableCell,
diff --git a/apps/web/src/editor/nodes/transclusion.tsx b/apps/web/src/editor/nodes/transclusion.tsx
new file mode 100644
index 0000000..4cd0d6e
--- /dev/null
+++ b/apps/web/src/editor/nodes/transclusion.tsx
@@ -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(`/read/${pondSlug}/${slug}`),
+ enabled: !editable && exists,
+ retry: false,
+ });
+
+ if (editable || !exists || content.isError) {
+ return (
+
+
+ ⧉
+
+
+ {t('transclusion.embedded', { title: label })}
+
+
+ {t('transclusion.open')}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {content.data?.title ?? label}
+
+
+ {content.data ? (
+ // Server-sanitized read HTML (shared docToHtml pipeline) — safe by
+ // contract, same as the public view.
+
+ ) : (
+
+ )}
+
+ );
+}
+
+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);
+ },
+});
diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css
index a3135b9..43a6602 100644
--- a/apps/web/src/styles/base.css
+++ b/apps/web/src/styles/base.css
@@ -2383,6 +2383,58 @@ ul[data-type='task_list'] li > div > p:last-child {
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 {
list-style: none;
margin: 0;
diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json
index ebddc6a..379b840 100644
--- a/packages/shared/i18n/de/editor.json
+++ b/packages/shared/i18n/de/editor.json
@@ -173,6 +173,10 @@
"autocompleteLabel": "Auf eine Seite verlinken",
"createHint": "Seite „{{title}}“ anlegen"
},
+ "transclusion": {
+ "embedded": "Eingebettet: {{title}}",
+ "open": "Öffnen"
+ },
"notFound": {
"hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.",
"create": "Seite „{{slug}}“ anlegen"
diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json
index 765615f..9684ddf 100644
--- a/packages/shared/i18n/en/editor.json
+++ b/packages/shared/i18n/en/editor.json
@@ -173,6 +173,10 @@
"autocompleteLabel": "Link to a page",
"createHint": "Create page “{{title}}”"
},
+ "transclusion": {
+ "embedded": "Embedded: {{title}}",
+ "open": "Open"
+ },
"notFound": {
"hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.",
"create": "Create the page “{{slug}}”"
diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts
index 06d01b8..9f79ec3 100644
--- a/packages/shared/src/editor-schema/html.ts
+++ b/packages/shared/src/editor-schema/html.ts
@@ -132,6 +132,15 @@ function renderBlock(node: Node): string {
` data-plugin-data="${data}">[${pluginId}/${blockType}]
`
);
}
+ 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 `
${display}
`;
+ }
case 'code_block':
return `
${escapeHtml(node.textContent)}
`;
case 'horizontal_rule':
diff --git a/packages/shared/src/editor-schema/markdown.ts b/packages/shared/src/editor-schema/markdown.ts
index fecd92c..5bb7565 100644
--- a/packages/shared/src/editor-schema/markdown.ts
+++ b/packages/shared/src/editor-schema/markdown.ts
@@ -192,6 +192,35 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
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"}`. */
const SECTION_OPEN = /^:::+\s*\{\s*data-section-style="([^"/]+)\/([^"]+)"\s*\}\s*$/;
const SECTION_CLOSE = /^:::+\s*$/;
@@ -248,6 +277,9 @@ function createTokenizer(): MarkdownIt {
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);
+ // 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.
md.block.ruler.before('fence', 'section', sectionRule);
const rawParse = md.parse.bind(md);
@@ -312,6 +344,13 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
displayText: tok.attrGet('display') || null,
}),
},
+ transclusion: {
+ node: 'transclusion',
+ getAttrs: (tok) => ({
+ targetSlug: tok.attrGet('target') ?? '',
+ displayText: tok.attrGet('display') || null,
+ }),
+ },
em: { mark: 'italic' },
strong: { mark: 'bold' },
s: { mark: 'strikethrough' },
@@ -435,6 +474,12 @@ const markdownSerializer = new MarkdownSerializer(
const display = node.attrs.displayText as string | null;
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) {
for (let i = index + 1; i < parent.childCount; i += 1) {
if (parent.child(i).type !== node.type) {
diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts
index a6d3200..543cddd 100644
--- a/packages/shared/src/editor-schema/schema.ts
+++ b/packages/shared/src/editor-schema/schema.ts
@@ -7,8 +7,9 @@ import { tableNodes } from 'prosemirror-tables';
* validation (ADR 0008) all import this schema instead of defining their
* own, so "valid document" means the same thing everywhere.
*
- * Node names `wikilink` and `plugin_block` are reserved for these features
- * (wikilinks #46, plugin-defined block types #76) — do not repurpose them.
+ * Node names `wikilink`, `plugin_block`, and `transclusion` are reserved for
+ * these features (wikilinks #46, plugin-defined block types #76, page embeds
+ * #135) — do not repurpose them.
*/
export const editorSchema = new Schema({
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 = {
+ 'data-transclusion': slug,
+ class: 'dt-transclusion',
+ };
+ if (display) attrs['data-display'] = display;
+ return ['div', attrs, display ?? slug];
+ },
+ },
+
...tableNodes({ tableGroup: 'block', cellContent: 'block+', cellAttributes: {} }),
},
diff --git a/packages/shared/src/editor-schema/transclusion.test.ts b/packages/shared/src/editor-schema/transclusion.test.ts
new file mode 100644
index 0000000..a92e79b
--- /dev/null
+++ b/packages/shared/src/editor-schema/transclusion.test.ts
@@ -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();
+ });
+});
diff --git a/packages/shared/src/editor-schema/wikilinks.ts b/packages/shared/src/editor-schema/wikilinks.ts
index b296079..a5648c3 100644
--- a/packages/shared/src/editor-schema/wikilinks.ts
+++ b/packages/shared/src/editor-schema/wikilinks.ts
@@ -1,16 +1,18 @@
import { Node } from 'prosemirror-model';
/**
- * The distinct target slugs of every `[[wikilink]]` in a document (issue #47).
- * Used server-side to maintain the `page_links` index on every content change,
- * so backlinks and phantom (missing-target) links can be queried. Order of
- * first appearance is preserved; duplicates are collapsed.
+ * The distinct target slugs of every `[[wikilink]]` and every `![[embed]]`
+ * (transclusion, issue #135) in a document (issue #47). Used server-side to
+ * maintain the `page_links` index on every content change, so backlinks and
+ * 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[] {
const slugs: string[] = [];
const seen = new Set();
doc.descendants((node) => {
- if (node.type.name === 'wikilink') {
+ if (node.type.name === 'wikilink' || node.type.name === 'transclusion') {
const slug = node.attrs.targetSlug as string;
if (slug && !seen.has(slug)) {
seen.add(slug);