M17–M19: #133–#137 (Kommentare inline, Statuszeile, Transklusion, Excalidraw, Checkbox-Fix) #138
@ -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',
|
||||
`<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 () => {
|
||||
await prisma.roleGrant.delete({ where: { id: publicGrantId } });
|
||||
// The API route invalidates on its own mutations; this test deletes
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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<PublicPageContent> {
|
||||
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<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.
|
||||
* `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 (
|
||||
`<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);
|
||||
}
|
||||
|
||||
27
apps/api/src/public/read-content.controller.ts
Normal file
27
apps/api/src/public/read-content.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
88
apps/web/src/editor/nodes/transclusion.tsx
Normal file
88
apps/web/src/editor/nodes/transclusion.tsx
Normal 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);
|
||||
},
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}}”"
|
||||
|
||||
@ -132,6 +132,15 @@ function renderBlock(node: Node): string {
|
||||
` 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':
|
||||
return `<pre><code>${escapeHtml(node.textContent)}</code></pre>`;
|
||||
case 'horizontal_rule':
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<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: {} }),
|
||||
},
|
||||
|
||||
|
||||
50
packages/shared/src/editor-schema/transclusion.test.ts
Normal file
50
packages/shared/src/editor-schema/transclusion.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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<string>();
|
||||
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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user