diff --git a/apps/api/src/public/public.e2e.db.test.ts b/apps/api/src/public/public.e2e.db.test.ts index 86f8860..9cb5a39 100644 --- a/apps/api/src/public/public.e2e.db.test.ts +++ b/apps/api/src/public/public.e2e.db.test.ts @@ -139,6 +139,36 @@ describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => { await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/comments`).expect(404); }); + it('expands a page embed to the target’s 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', + `

Body of the embedded page.

` + + `
Host
`, + ); + await makePage( + pondId, + hostSlug, + 'Host', + `

Before.

` + + `
Embedded
` + + `
Missing
`, + ); + + 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 = /
[^<]*<\/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 ( + `` + ); + }); + } + /** * The page's comments for the anonymous public view (issue #133), read-only. * `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"', ); } + +/** 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 ( + `` + ); +} + +/** `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);