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
41 changed files with 2699 additions and 198 deletions

24
.claude/settings.json Normal file
View File

@ -0,0 +1,24 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Grep",
"hooks": [
{
"type": "command",
"command": "/Users/stwaidele/.local/bin/graphify hook-guard search"
}
]
},
{
"matcher": "Read|Glob",
"hooks": [
{
"type": "command",
"command": "/Users/stwaidele/.local/bin/graphify hook-guard read"
}
]
}
]
}
}

3
.gitignore vendored
View File

@ -14,3 +14,6 @@ apps/api/data/
# Font catalog WOFF2 + generated stylesheet — fetched at build time
# (ADR 0016, deploy/fonts/build-fonts.mjs), never committed.
apps/web/public/fonts/
# graphify knowledge graph (generated)
graphify-out/

View File

@ -15,3 +15,8 @@ fixtures/import/*.src.html
# as pandoc reads the exported document back — Prettier would break them.
fixtures/export/*.expected.md
packages/plugins/*/vendor/
# Agent/tool config generated by Claude Code + `graphify claude install`
# (regenerated on demand, not hand-formatted source) — keep out of Prettier so
# a re-install never breaks the lint gate.
CLAUDE.md
.claude/

9
CLAUDE.md Normal file
View File

@ -0,0 +1,9 @@
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).

View File

@ -277,10 +277,12 @@ describe.skipIf(!hasTestDb)('LinksService.pondGraph (db, issue #111)', () => {
{ from: a, to: secret },
].sort((x, y) => x.to.localeCompare(y.to)),
);
expect(graph.phantoms.map((p) => p.targetSlug).sort()).toEqual([
`ghost-${suffix}`,
`ghost-secret-${suffix}`,
]);
// Sort BOTH sides: phantom order is not guaranteed, and the two slugs share
// the random suffix so their sort order flips on ~1/5 of suffixes (the
// expected array was previously left unsorted — a latent flake).
expect(graph.phantoms.map((p) => p.targetSlug).sort()).toEqual(
[`ghost-${suffix}`, `ghost-secret-${suffix}`].sort(),
);
});
it('slices every collection to the reader — no hidden page leaks anywhere', async () => {

View File

@ -1,4 +1,5 @@
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
import type { PageCommentsView } from '@dorfteich/shared';
import type { Response } from 'express';
import { AuthedRequest, Public } from '../auth/auth.guard';
@ -24,6 +25,16 @@ export class PublicController {
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug);
}
@Get(':pondSlug/:pageSlug/comments')
@Public()
async comments(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Req() request: AuthedRequest,
): Promise<PageCommentsView> {
return this.publicPages.comments(request.user ?? null, pondSlug, pageSlug);
}
@Get(':pondSlug/:pageSlug')
@Public()
async html(

View File

@ -115,12 +115,67 @@ describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => {
await api().get(`/api/v1/public/${pondSlug}/does-not-exist`).expect(404);
});
it('404s both endpoints once the public grant is removed', async () => {
it('serves a pages comments read-only to an anonymous visitor (issue #133)', async () => {
const page = await prisma.page.findFirstOrThrow({ where: { pondId, slug: pageSlug } });
const root = await prisma.comment.create({
data: { pageId: page.id, authorId: ownerId, body: 'A public remark' },
});
await prisma.comment.create({
data: { pageId: page.id, parentId: root.id, authorId: ownerId, body: 'A public reply' },
});
const res = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/comments`).expect(200);
const body = res.body as {
threads: { root: { body: string }; replies: { body: string }[] }[];
openCount: number;
};
expect(body.openCount).toBe(1);
expect(body.threads).toHaveLength(1);
const [thread] = body.threads;
expect(thread!.root.body).toBe('A public remark');
expect(thread!.replies.map((r) => r.body)).toEqual(['A public reply']);
// A non-public page never reveals its comments either.
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 () => {
await prisma.roleGrant.delete({ where: { id: publicGrantId } });
// The API route invalidates on its own mutations; this test deletes
// directly, so drop the cached pond context to mirror that (issue #39).
app.get(PondPermissionCache).invalidate(pondId);
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(404);
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(404);
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/comments`).expect(404);
});
});

View File

@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { CommentsModule } from '../comments/comments.module';
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
@ -12,8 +14,8 @@ import { PublicService } from './public.service';
* marks `GET /media/:fileId` public too.
*/
@Module({
imports: [PluginsModule],
controllers: [PublicController],
imports: [PluginsModule, CommentsModule],
controllers: [PublicController, ReadContentController],
providers: [PublicService],
})
export class PublicModule {}

View File

@ -1,6 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { PageCommentsView } from '@dorfteich/shared';
import { Pond, User } from '@prisma/client';
import { CommentsService } from '../comments/comments.service';
import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PrismaService } from '../prisma/prisma.service';
@ -38,6 +40,7 @@ export class PublicService {
private readonly permissions: PermissionService,
private readonly fallbacks: PluginFallbackRenderer,
private readonly settings: InstanceSettingsService,
private readonly commentsService: CommentsService,
) {}
private async resolve(
@ -58,25 +61,98 @@ 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
* 404s here too, so comments never leak. `list` builds the same
* `PageCommentsView` the authenticated endpoint returns; the SPA renders it
* without any composer or action controls.
*/
async comments(user: User | null, pondSlug: string, pageSlug: string): Promise<PageCommentsView> {
const { page } = await this.resolve(user, pondSlug, pageSlug);
return this.commentsService.list(page.id, 'all');
}
/** A complete, self-contained HTML document for crawlers / PDF export. */
async html(
user: User | null,
@ -112,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);
}

View File

@ -0,0 +1,31 @@
import { Controller, Get, Param, Req } from '@nestjs/common';
import { AuthedRequest } from '../auth/auth.guard';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
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) {}
// Session required (explicit access rule, issue #52); per-page read
// permission is enforced in the service (resolve → canAccessPage → 404).
@Get(':pondSlug/:pageSlug')
@AuthenticatedOnly()
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,11 @@ import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/**
* Comments UI (issue #92): the full two-user lifecycle in the panel,
* resolve/unresolve with the collapsed section, the permission variant
* (composer hidden with a hint), and the localStorage unread badge.
* The pack provisions its own page and grant, so it is repeatable.
* Comments UI (issues #92, #133): the discussion is a fixed inline section in
* the read view (no toggle), between the backlinks and the local graph. The
* pack drives the full two-user lifecycle, resolve/unresolve with the collapsed
* section, and the permission variant (composer hidden with a hint). It
* provisions its own page and grant, so it is repeatable.
*/
let pondId: string;
@ -52,23 +53,21 @@ test.afterAll(async ({ browser }) => {
});
test('two users run the full comment lifecycle with resolve/unresolve', async ({ browser }) => {
// The reader starts the thread.
// The reader starts the thread — the section is inline in the read view.
const reader = await contextForUser(browser, BASE_URL, 'fixture-editor');
const readerPage = await reader.newPage();
await readerPage.goto(pageUrl);
await readerPage.locator('.editor-shell__comments-toggle').click();
const readerPanel = readerPage.locator('.comments-panel');
const readerPanel = readerPage.locator('.comments-section');
await readerPanel.locator('.comments-composer textarea').fill('Is this **final**?');
await readerPanel.locator('.comments-composer button[type="submit"]').click();
await expect(readerPanel.locator('.comment__body strong')).toHaveText('final');
// The owner sees the unread badge, replies, and resolves the thread.
// The owner opens the page and sees the thread inline, replies, resolves.
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const ownerPage = await owner.newPage();
await ownerPage.goto(pageUrl);
await expect(ownerPage.locator('.comments-unread-badge')).toBeVisible();
await ownerPage.locator('.editor-shell__comments-toggle').click();
const ownerPanel = ownerPage.locator('.comments-panel');
const ownerPanel = ownerPage.locator('.comments-section');
await expect(ownerPanel.locator('.comment__body strong')).toHaveText('final');
await ownerPanel
.locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i })
.click();
@ -83,7 +82,7 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({
.locator('.comment__actions .comments-link-button', { hasText: /resolve|erledig/i })
.first()
.click();
const resolvedSection = ownerPanel.locator('.comments-panel__resolved');
const resolvedSection = ownerPanel.locator('.comments-section__resolved');
await expect(resolvedSection).toBeVisible();
await expect(resolvedSection.locator('details, summary').first()).toBeVisible();
// Collapsed: the thread body is hidden until the section is opened.
@ -93,7 +92,7 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({
// Unresolve restores it to the open list.
await resolvedSection.locator('.comments-link-button', { hasText: /reopen|öffnen/i }).click();
await expect(ownerPanel.locator('.comments-panel__resolved')).toHaveCount(0);
await expect(ownerPanel.locator('.comments-section__resolved')).toHaveCount(0);
await expect(ownerPanel.locator('.comments-thread .comment__body strong')).toBeVisible();
// Author edits and deletes own reply.
@ -108,9 +107,8 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({
// Cleanup: the reader deletes their own (now reply-free) root.
await readerPage.reload();
await readerPage.locator('.editor-shell__comments-toggle').click();
await readerPanel.locator('.comments-link-button', { hasText: /delete|löschen/i }).click();
await expect(readerPanel.locator('.comments-panel__empty')).toBeVisible();
await expect(readerPanel.locator('.comments-section__empty')).toBeVisible();
await reader.close();
await owner.close();
@ -127,9 +125,8 @@ test('the composer hides with a hint when the policy bars readers', async ({ bro
const reader = await contextForUser(browser, BASE_URL, 'fixture-editor');
const page = await reader.newPage();
await page.goto(pageUrl);
await page.locator('.editor-shell__comments-toggle').click();
const panel = page.locator('.comments-panel');
await expect(panel.locator('.comments-panel__policy-hint')).toBeVisible();
const panel = page.locator('.comments-section');
await expect(panel.locator('.comments-section__policy-hint')).toBeVisible();
await expect(panel.locator('.comments-composer')).toHaveCount(0);
await reader.close();

View File

@ -1,35 +1,36 @@
import type { CommentThreadView, CommentView } from '@dorfteich/shared';
import type { PageCommentsView, CommentThreadView, CommentView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { apiDelete, apiPatch, apiPost } from '../lib/api';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
import { markCommentsSeen, useComments, useInvalidateComments } from './use-comments';
/**
* The page's discussion panel (issue #92): threaded comments with a
* Markdown composer, edit/delete for authors, resolve with a collapsed
* resolved section, and the permission-aware composer (hidden with a hint
* when the pond's policy bars the viewer).
* The page's discussion (issue #92), rendered inline in the read view between
* the backlinks and the local graph (issue #133): threaded comments with a
* Markdown composer, edit/delete for authors, and resolve with a collapsed
* resolved section. The composer is hidden with a hint when the pond's policy
* bars the viewer. Replaces the former slide-in panel.
*/
export function CommentsPanel({
export function CommentsSection({
pageId,
mayComment,
onClose,
}: {
pageId: string;
/** Resolved by the caller from the pond's commentPolicy + collab mode. */
/** Resolved by the caller from the pond's commentPolicy + write access. */
mayComment: boolean;
onClose: () => void;
}): React.JSX.Element {
const { t } = useTranslation('comments');
const comments = useComments(pageId);
const refresh = useInvalidateComments(pageId);
const [error, setError] = useState<unknown>(null);
// Opening the panel is "visiting" the discussion: the unread badge resets.
// Seeing the inline section is "visiting" the discussion: the unread notion
// (used by comment notifications) resets while it is on screen.
useEffect(() => {
markCommentsSeen(pageId);
return () => markCommentsSeen(pageId);
@ -45,22 +46,15 @@ export function CommentsPanel({
}
};
const view = comments.data;
const open = (view?.threads ?? []).filter((thread) => !thread.resolved);
const resolved = (view?.threads ?? []).filter((thread) => thread.resolved);
return (
<section className="comments-panel" aria-label={t('title')}>
<div className="comments-panel__header">
<section id="comments" className="comments-section" aria-label={t('title')}>
<div className="comments-section__header">
<h2>
{t('title')}
{view && view.openCount > 0 && (
<span className="comments-panel__count">{view.openCount}</span>
{comments.data && comments.data.openCount > 0 && (
<span className="comments-section__count">{comments.data.openCount}</span>
)}
</h2>
<button type="button" className="button" onClick={onClose}>
{t('close')}
</button>
</div>
<FormError error={error} />
@ -71,14 +65,82 @@ export function CommentsPanel({
onSubmit={(body) => run(() => apiPost(`/pages/${pageId}/comments`, { body }))}
/>
) : (
<p className="comments-panel__policy-hint">{t('composer.editorsOnly')}</p>
<p className="comments-section__policy-hint">{t('composer.editorsOnly')}</p>
)}
<CommentThreads
view={comments.data}
pageId={pageId}
mayComment={mayComment}
run={run}
readOnly={false}
/>
</section>
);
}
/**
* Read-only comments for the anonymous public view (issue #133): fetched from
* the public endpoint (no auth, no composer, no action controls). The section
* is omitted entirely when the page has no comments, so public pages stay clean.
*/
export function PublicComments({
pondSlug,
pageSlug,
}: {
pondSlug: string;
pageSlug: string;
}): React.JSX.Element | null {
const { t } = useTranslation('comments');
const query = useQuery({
queryKey: ['public-comments', pondSlug, pageSlug],
queryFn: () => apiGet<PageCommentsView>(`/public/${pondSlug}/${pageSlug}/comments`),
enabled: Boolean(pondSlug && pageSlug),
retry: false,
});
const view = query.data;
if (!view || view.threads.length === 0) return null;
return (
<section className="comments-section comments-section--readonly" aria-label={t('title')}>
<div className="comments-section__header">
<h2>
{t('title')}
{view.openCount > 0 && <span className="comments-section__count">{view.openCount}</span>}
</h2>
</div>
<CommentThreads view={view} pageId="" mayComment={false} run={noop} readOnly />
</section>
);
}
const noop = async (): Promise<void> => {};
function CommentThreads({
view,
pageId,
mayComment,
run,
readOnly,
}: {
view: PageCommentsView | undefined;
pageId: string;
mayComment: boolean;
run: (action: () => Promise<unknown>) => Promise<void>;
readOnly: boolean;
}): React.JSX.Element {
const { t } = useTranslation('comments');
const open = (view?.threads ?? []).filter((thread) => !thread.resolved);
const resolved = (view?.threads ?? []).filter((thread) => thread.resolved);
return (
<>
{view && open.length === 0 && resolved.length === 0 && (
<p className="comments-panel__empty">{t('empty')}</p>
<p className="comments-section__empty">{t('empty')}</p>
)}
<ul className="comments-panel__threads">
<ul className="comments-section__threads">
{open.map((thread) => (
<Thread
key={thread.root.id}
@ -86,14 +148,15 @@ export function CommentsPanel({
pageId={pageId}
mayComment={mayComment}
run={run}
readOnly={readOnly}
/>
))}
</ul>
{resolved.length > 0 && (
<details className="comments-panel__resolved">
<details className="comments-section__resolved">
<summary>{t('resolvedSection', { count: resolved.length })}</summary>
<ul className="comments-panel__threads">
<ul className="comments-section__threads">
{resolved.map((thread) => (
<Thread
key={thread.root.id}
@ -101,12 +164,13 @@ export function CommentsPanel({
pageId={pageId}
mayComment={mayComment}
run={run}
readOnly={readOnly}
/>
))}
</ul>
</details>
)}
</section>
</>
);
}
@ -115,26 +179,42 @@ function Thread({
pageId,
mayComment,
run,
readOnly,
}: {
thread: CommentThreadView;
pageId: string;
mayComment: boolean;
run: (action: () => Promise<unknown>) => Promise<void>;
readOnly: boolean;
}): React.JSX.Element {
const { t } = useTranslation('comments');
const [replying, setReplying] = useState(false);
return (
<li className={`comments-thread${thread.resolved ? ' comments-thread--resolved' : ''}`}>
<CommentItem comment={thread.root} run={run} isRoot resolved={thread.resolved} />
<CommentItem
comment={thread.root}
run={run}
isRoot
resolved={thread.resolved}
mayComment={mayComment}
readOnly={readOnly}
/>
<ul className="comments-thread__replies">
{thread.replies.map((reply) => (
<li key={reply.id}>
<CommentItem comment={reply} run={run} isRoot={false} resolved={thread.resolved} />
<CommentItem
comment={reply}
run={run}
isRoot={false}
resolved={thread.resolved}
mayComment={mayComment}
readOnly={readOnly}
/>
</li>
))}
</ul>
{mayComment && !thread.resolved && (
{!readOnly && mayComment && !thread.resolved && (
<div className="comments-thread__actions">
{replying ? (
<Composer
@ -169,11 +249,15 @@ function CommentItem({
run,
isRoot,
resolved,
mayComment,
readOnly,
}: {
comment: CommentView;
run: (action: () => Promise<unknown>) => Promise<void>;
isRoot: boolean;
resolved: boolean;
mayComment: boolean;
readOnly: boolean;
}): React.JSX.Element {
const { t, i18n } = useTranslation('comments');
const { user } = useAuth();
@ -205,40 +289,47 @@ function CommentItem({
// Server-sanitized render (shared pipeline, issue #91) — safe by contract.
<div className="comment__body" dangerouslySetInnerHTML={{ __html: comment.html }} />
)}
<footer className="comment__actions">
{own && !editing && (
<>
<button type="button" className="comments-link-button" onClick={() => setEditing(true)}>
{t('edit.open')}
</button>
<button
type="button"
className="comments-link-button"
onClick={() => void run(() => apiDelete(`/comments/${comment.id}`))}
>
{t('delete')}
</button>
</>
)}
{isRoot &&
(resolved ? (
<button
type="button"
className="comments-link-button"
onClick={() => void run(() => apiDelete(`/comments/${comment.id}/resolve`))}
>
{t('unresolve')}
</button>
) : (
<button
type="button"
className="comments-link-button"
onClick={() => void run(() => apiPost(`/comments/${comment.id}/resolve`, {}))}
>
{t('resolve')}
</button>
))}
</footer>
{!readOnly && (
<footer className="comment__actions">
{own && !editing && (
<>
<button
type="button"
className="comments-link-button"
onClick={() => setEditing(true)}
>
{t('edit.open')}
</button>
<button
type="button"
className="comments-link-button"
onClick={() => void run(() => apiDelete(`/comments/${comment.id}`))}
>
{t('delete')}
</button>
</>
)}
{isRoot &&
mayComment &&
(resolved ? (
<button
type="button"
className="comments-link-button"
onClick={() => void run(() => apiDelete(`/comments/${comment.id}/resolve`))}
>
{t('unresolve')}
</button>
) : (
<button
type="button"
className="comments-link-button"
onClick={() => void run(() => apiPost(`/comments/${comment.id}/resolve`, {}))}
>
{t('resolve')}
</button>
))}
</footer>
)}
</article>
);
}

View File

@ -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();
}

View File

@ -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,

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

@ -0,0 +1,22 @@
/**
* Approximate word count from plain text (issue #134). Splits on whitespace
* and keeps only tokens that contain a letter or digit, so markdown/HTML
* punctuation (`##`, `-`, `|`, `>`) never inflates the count.
*/
export function countWords(text: string): number {
if (!text) return 0;
return text
.trim()
.split(/\s+/)
.filter((token) => /[\p{L}\p{N}]/u.test(token)).length;
}
/**
* Strip HTML to its visible text using the browser's parser (issue #134). Used
* by the public read view to derive a word count from the server-rendered HTML
* without loading the editor bundle.
*/
export function htmlToText(html: string): string {
if (!html) return '';
return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? '';
}

View File

@ -7,7 +7,6 @@ import {
Ellipsis,
FolderInput,
History,
MessageSquare,
Paperclip,
Pencil,
Save,
@ -35,9 +34,6 @@ interface PageActionsProps {
pondSlug: string;
mode: 'view' | 'edit';
onToggleMode: () => void;
unread: number;
showComments: boolean;
onToggleComments: () => void;
showAttachments: boolean;
onToggleAttachments: () => void;
hasTools: boolean;
@ -68,20 +64,6 @@ export function PageActions(props: PageActionsProps): React.JSX.Element {
</IconButton>
{props.mode === 'edit' && <SaveVersionButton pageId={props.pageId} />}
<WatchToggle targetType="page" targetId={props.pageId} variant="icon" />
<IconButton
className="editor-shell__comments-toggle"
label={
props.unread > 0
? `${t('comments:toggle')}${t('comments:unread', { count: props.unread })}`
: t('comments:toggle')
}
active={props.showComments}
aria-expanded={props.showComments}
onClick={props.onToggleComments}
>
<MessageSquare aria-hidden />
{props.unread > 0 && <span className="comments-unread-badge">{props.unread}</span>}
</IconButton>
<IconButton
className="editor-shell__attachments-toggle"
label={t('files:title')}

View File

@ -11,8 +11,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
import * as Y from 'yjs';
import { useAuth } from '../auth/auth-context';
import { CommentsPanel } from '../comments/CommentsPanel';
import { unreadCount, useComments } from '../comments/use-comments';
import { CommentsSection } from '../comments/CommentsSection';
import { FormError } from '../components/forms';
import { useToast } from '../components/Toast';
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
@ -32,9 +31,11 @@ import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
import { usePageActionsSlot } from '../layout/page-actions';
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
import { ApiError, apiGet, apiPatch, apiPost } from '../lib/api';
import { ApiError, apiGet, apiGetText, apiPatch, apiPost } from '../lib/api';
import { hasPrimaryModifier, isTypingTarget } from '../lib/keyboard';
import { countWords } from '../lib/word-count';
import { PageActions } from './PageActions';
import { PageStatusBar } from './PageStatusBar';
import { recallPage, rememberPage } from '../offline/page-cache';
import { PluginBlockContext } from '../editor/plugin-block-context';
import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
@ -126,22 +127,20 @@ function PageEditor({
page,
mode,
pondSlug,
commentPolicy,
showAttachments,
showComments,
showPageTools,
onCloseAttachments,
onCloseComments,
onWriteAccess,
}: {
page: ResolvedPage;
mode: Mode;
pondSlug: string;
commentPolicy: 'readers' | 'editors';
showAttachments: boolean;
showComments: boolean;
showPageTools: boolean;
onCloseAttachments: () => void;
onCloseComments: () => void;
/** Reports write access up so the outer page can render the inline
* comments composer (issue #133); the collab mode lives with the provider. */
onWriteAccess: (canWrite: boolean) => void;
}): React.JSX.Element {
const { t } = useTranslation('editor');
const { user } = useAuth();
@ -168,10 +167,12 @@ function PageEditor({
const collab = useCollabProvider(ydoc, page.id);
const readOnly = collab.mode === 'ro';
// `readers` = everyone who can see the page; `editors` = the collab token
// explicitly granted rw (while it is still null, stay conservative — the
// composer appears once the mode resolves).
const mayComment = commentPolicy === 'readers' || collab.mode === 'rw';
// The collab token's rw grant is the authoritative write signal; report it up
// so the outer page can decide whether to show the inline comment composer
// (issue #133). `readers`-policy ponds let anyone comment regardless.
useEffect(() => {
onWriteAccess(collab.mode === 'rw');
}, [collab.mode, onWriteAccess]);
// A revoked page can no longer be edited (issue #39); the content stays
// visible for export via the dialog below.
const canEdit = mode === 'edit' && !readOnly && !collab.accessRevoked;
@ -274,9 +275,6 @@ function PageEditor({
onClose={onCloseAttachments}
/>
)}
{showComments && (
<CommentsPanel pageId={page.id} mayComment={mayComment} onClose={onCloseComments} />
)}
{/* The connection status renders as an icon in the content footer
(left half); the localized text stays for screen readers and as
the hover tooltip. */}
@ -338,12 +336,10 @@ export function PageEditorPage(): React.JSX.Element {
const [showHistory, setShowHistory] = useState(false);
const [showLabels, setShowLabels] = useState(false);
const [showAttachments, setShowAttachments] = useState(false);
// Deep link from a comment notification (issue #94): ?comments=1 opens
// the panel immediately.
const [showComments, setShowComments] = useState(
() => new URLSearchParams(window.location.search).get('comments') === '1',
);
const [showPageTools, setShowPageTools] = useState(false);
// Write access reported up by the editor's collab provider, so the outer page
// can decide whether to show the inline comment composer (issue #133).
const [canWrite, setCanWrite] = useState(false);
const actionsSlot = usePageActionsSlot();
const queryClient = useQueryClient();
const showToast = useToast();
@ -360,6 +356,16 @@ export function PageEditorPage(): React.JSX.Element {
enabled: Boolean(pond.data),
});
// Word count for the read-mode status line (#134). Derived from the same
// Markdown export the history panel uses (shared query key), fetched only in
// reading mode so the editor session pays nothing.
const pageMarkdown = useQuery({
queryKey: ['page-markdown', page.data?.id],
queryFn: () => apiGetText(`/pages/${page.data!.id}/export/markdown`),
enabled: mode === 'view' && Boolean(page.data?.id),
});
const wordCount = useMemo(() => countWords(pageMarkdown.data ?? ''), [pageMarkdown.data]);
// Remember this page's metadata while online so it can be opened offline (#38).
useEffect(() => {
if (pond.data && page.data) {
@ -448,12 +454,26 @@ export function PageEditorPage(): React.JSX.Element {
return () => window.removeEventListener('keydown', onKeyDown);
}, [pageId, user, mode, queryClient, t, showToast]);
// TopBar action data (issue #101): the comments badge and the plugin
// page-tools visibility live next to the icons, not inside the editor.
const comments = useComments(resolved?.id);
const unread = showComments || !resolved ? 0 : unreadCount(comments.data, resolved.id, user?.id);
// TopBar action data (issue #101): the plugin page-tools visibility lives
// next to the icons, not inside the editor.
const pagePlugins = usePondPlugins(resolved?.pondId);
// Comments are shown inline in the read view (issue #133). `readers`-policy
// ponds let any reader comment; `editors` require the collab rw grant the
// editor reports up via onWriteAccess.
const mayComment = (pond.data?.settings.commentPolicy ?? 'readers') === 'readers' || canWrite;
// Deep link from a comment notification (issue #94): ?comments=1 scrolls to
// the inline discussion once it has mounted in read mode.
useEffect(() => {
if (mode !== 'view') return;
if (new URLSearchParams(window.location.search).get('comments') !== '1') return;
const timer = window.setTimeout(() => {
document.getElementById('comments')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 200);
return () => window.clearTimeout(timer);
}, [mode, resolved?.id]);
async function saveTitle(): Promise<void> {
if (!resolved || title === resolved.title) return;
await apiPatch(`/pages/${resolved.id}`, { title });
@ -494,9 +514,6 @@ export function PageEditorPage(): React.JSX.Element {
pondSlug={pondSlug}
mode={mode}
onToggleMode={() => setMode(mode === 'edit' ? 'view' : 'edit')}
unread={unread}
showComments={showComments}
onToggleComments={() => setShowComments((open) => !open)}
showAttachments={showAttachments}
onToggleAttachments={() => setShowAttachments((open) => !open)}
hasTools={hasPageTools(pagePlugins.data)}
@ -521,17 +538,20 @@ export function PageEditorPage(): React.JSX.Element {
onBlur={() => void saveTitle()}
/>
</div>
{/* Status line between the header and the article (#134): last update,
word count, reading time reading mode only. */}
{mode === 'view' && page.data && (
<PageStatusBar updatedAt={page.data.updatedAt} wordCount={wordCount} />
)}
<div className="editor-page__body">
<PageEditor
page={resolved}
mode={mode}
pondSlug={pondSlug}
commentPolicy={pond.data?.settings.commentPolicy ?? 'readers'}
showAttachments={showAttachments}
showComments={showComments}
showPageTools={showPageTools}
onCloseAttachments={() => setShowAttachments(false)}
onCloseComments={() => setShowComments(false)}
onWriteAccess={setCanWrite}
/>
{/* Side panels stack vertically in one column (M10 follow-up). */}
{(showLabels || showHistory) && (
@ -551,8 +571,10 @@ export function PageEditorPage(): React.JSX.Element {
)}
</div>
{/* "Linked from" appears below the content in read mode (issue #48);
the local neighborhood graph joins it there (issue #113). */}
the inline discussion (issue #133) and the local neighborhood graph
(issue #113) follow it, in that order. */}
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
{mode === 'view' && <CommentsSection pageId={resolved.id} mayComment={mayComment} />}
{mode === 'view' && (
<LocalGraphPanel pageId={resolved.id} pondId={resolved.pondId} pondSlug={pondSlug} />
)}

View File

@ -0,0 +1,44 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
/** Average silent reading speed; reading time = ceil(words / WPM). */
const WORDS_PER_MINUTE = 200;
/**
* A slim status line shown between the page header and the article body
* (issue #134): last update, word count, and estimated reading time. Rendered
* both in the authenticated read view and the anonymous public view, so it
* takes already-derived values as props rather than reaching into the editor.
*/
export function PageStatusBar({
updatedAt,
wordCount,
}: {
updatedAt: string;
wordCount: number;
}): React.JSX.Element {
const { t, i18n } = useTranslation('common');
const updated = useMemo(() => {
const date = new Date(updatedAt);
if (Number.isNaN(date.getTime())) return null;
return new Intl.DateTimeFormat(i18n.language, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date);
}, [updatedAt, i18n.language]);
const minutes = Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE));
return (
<div className="page-statusbar" aria-label={t('statusbar.label')}>
{updated && (
<span className="page-statusbar__item">{t('statusbar.updated', { date: updated })}</span>
)}
<span className="page-statusbar__item">{t('statusbar.words', { count: wordCount })}</span>
{wordCount > 0 && (
<span className="page-statusbar__item">{t('statusbar.readingTime', { minutes })}</span>
)}
</div>
);
}

View File

@ -1,9 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { PublicComments } from '../comments/CommentsSection';
import { ApiError, apiGet } from '../lib/api';
import { countWords, htmlToText } from '../lib/word-count';
import { NotFoundPage } from './NotFoundPage';
import { PageStatusBar } from './PageStatusBar';
interface PublicPageContent {
pondName: string;
@ -31,6 +35,14 @@ export function PublicPageView(): React.JSX.Element {
retry: false,
});
// Word count for the status line (#134), derived from the server-rendered
// HTML — no editor bundle needed. Kept before the early returns so the hook
// order stays stable.
const wordCount = useMemo(
() => countWords(htmlToText(query.data?.html ?? '')),
[query.data?.html],
);
if (query.error instanceof ApiError && query.error.status === 404) return <NotFoundPage />;
if (query.isLoading || !query.data) return <div aria-busy="true" />;
@ -40,9 +52,12 @@ export function PublicPageView(): React.JSX.Element {
<p className="public-page__badge">{t('readOnlyBadge')}</p>
<p className="public-page__pond">{page.pondName}</p>
<h1 className="public-page__title">{page.title}</h1>
<PageStatusBar updatedAt={page.updatedAt} wordCount={wordCount} />
{/* The HTML comes from the server's content cache (issue #24), derived
from the sanitized editor schema safe to render. */}
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
{/* Existing comments, read-only for anonymous visitors (issue #133). */}
<PublicComments pondSlug={pondSlug} pageSlug={pageSlug} />
</article>
);
}

View File

@ -522,15 +522,6 @@ button {
gap: var(--space-1);
}
.icon-button .comments-unread-badge {
position: absolute;
top: -4px;
right: -6px;
margin-left: 0;
line-height: 1.1rem;
padding: 0 0.3rem;
}
.page-actions__more {
position: relative;
}
@ -979,6 +970,29 @@ button {
outline-offset: 2px;
}
/* Status line between page header and article (#134). Middot-separated,
muted; shared by the authenticated read view and the public view. */
.page-statusbar {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: calc(-1 * var(--space-2));
margin-bottom: var(--space-4);
color: var(--color-text-muted);
font-size: 0.8125rem;
}
.page-statusbar__item {
display: inline-flex;
align-items: center;
}
.page-statusbar__item:not(:first-child)::before {
content: '·';
margin-right: var(--space-2);
color: var(--color-border);
}
/* The export entries flow inline with the other overflow-menu items. */
.editor-page__export {
display: contents;
@ -1581,17 +1595,44 @@ button {
padding: 0;
}
.editor-content ul[data-type='task_list'] {
/* Task lists (checkbox lists). Styled globally via the unique
`data-type='task_list'` attribute produced only by docToHtml and the
editor nodeview so checkbox and text line up in the editor
(.editor-content) AND every read-mode container that injects docToHtml
output (.public-page__body, .comment__body, .legal-page__body, .home-page,
.history-panel__preview). Previously these rules were scoped to
.editor-content and never reached the read views. Issue #137. */
ul[data-type='task_list'] {
list-style: none;
padding-left: var(--space-2);
}
.editor-content ul[data-type='task_list'] li {
ul[data-type='task_list'] li {
display: flex;
align-items: flex-start;
gap: var(--space-2);
}
/* The checkbox is the top flex child; nudge it onto the first text line and
drop the leading/trailing margins of the item's paragraph so the text meets
the checkbox instead of dropping a line. Covers both DOM shapes: read-mode
`li > input` + `li > p`, and editor `li > label > input` + `li > div > p`. */
ul[data-type='task_list'] li > input[type='checkbox'],
ul[data-type='task_list'] li > label {
flex: none;
margin-top: 0.25em;
}
ul[data-type='task_list'] li > p:first-child,
ul[data-type='task_list'] li > div > p:first-child {
margin-top: 0;
}
ul[data-type='task_list'] li > p:last-child,
ul[data-type='task_list'] li > div > p:last-child {
margin-bottom: 0;
}
.editor-content table {
border-collapse: collapse;
margin: var(--space-4) 0;
@ -2342,6 +2383,58 @@ button {
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;
@ -3296,8 +3389,8 @@ button {
margin-top: var(--space-3);
}
/* Comments panel (issue #92) */
.comments-panel {
/* Inline comments section, read view (issues #92, #133) */
.comments-section {
border: 1px solid var(--color-border, #cbd5e1);
border-radius: 8px;
padding: var(--space-3);
@ -3305,19 +3398,19 @@ button {
background: var(--color-surface, #fff);
}
.comments-panel__header {
.comments-section__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
}
.comments-panel__header h2 {
.comments-section__header h2 {
margin: 0;
font-size: 1.1rem;
}
.comments-panel__count {
.comments-section__count {
margin-left: var(--space-2);
font-size: 0.8125rem;
background: var(--color-surface-muted, #e2e8f0);
@ -3325,12 +3418,12 @@ button {
padding: 0.05rem 0.5rem;
}
.comments-panel__policy-hint,
.comments-panel__empty {
.comments-section__policy-hint,
.comments-section__empty {
color: var(--color-text-muted);
}
.comments-panel__threads {
.comments-section__threads {
list-style: none;
margin: 0;
padding: 0;
@ -3339,16 +3432,16 @@ button {
gap: var(--space-3);
}
.comments-panel__resolved {
.comments-section__resolved {
margin-top: var(--space-3);
}
.comments-panel__resolved > summary {
.comments-section__resolved > summary {
cursor: pointer;
color: var(--color-text-muted);
}
.comments-panel__resolved > .comments-panel__threads {
.comments-section__resolved > .comments-section__threads {
margin-top: var(--space-2);
}
@ -3421,15 +3514,6 @@ button {
color: var(--color-text-muted);
}
.comments-unread-badge {
margin-left: var(--space-1);
font-size: 0.75rem;
background: var(--color-primary, #2f6f4f);
color: #fff;
border-radius: 999px;
padding: 0.05rem 0.45rem;
}
.comment-policy__label {
display: flex;
align-items: center;

View File

@ -0,0 +1,2 @@
vendor/
dist/

View File

@ -0,0 +1,115 @@
// Builds the installable Excalidraw plugin. Unlike draw.io (which vendors a
// standalone webapp) Excalidraw is an npm React component: esbuild bundles the
// controller + React + Excalidraw into plugin.js, and its font/worker assets
// are copied from node_modules into `excalidraw-assets/` so the runtime loads
// them from the plugin's own asset path (EXCALIDRAW_ASSET_PATH) — nothing ever
// talks to excalidraw.com, and the sandbox CSP pins every request to self.
import { createRequire } from 'node:module';
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
import { zipSync } from 'fflate';
const root = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
const require = createRequire(import.meta.url);
// --- 1. Locate Excalidraw's prebuilt font/worker assets --------------------
// The package's `exports` map hides ./package.json, so resolve the main entry
// and walk up to the package root instead.
function packageRootOf(specifier) {
let dir = dirname(require.resolve(specifier));
while (dir !== dirname(dir)) {
const pj = join(dir, 'package.json');
if (existsSync(pj)) {
try {
if (JSON.parse(readFileSync(pj, 'utf8')).name === specifier) return dir;
} catch {
// keep walking up
}
}
dir = dirname(dir);
}
throw new Error(`package root not found for ${specifier}`);
}
const excalidrawPkg = packageRootOf('@excalidraw/excalidraw');
const prodDir = join(excalidrawPkg, 'dist', 'prod');
if (!existsSync(prodDir)) {
throw new Error(`Excalidraw prod build not found at ${prodDir}. Check the dist layout.`);
}
// Runtime assets loaded relative to EXCALIDRAW_ASSET_PATH (set to the plugin's
// asset base in plugin.tsx): fonts render the sketch, locales translate the
// editor UI, data holds font metadata. Copied to the ZIP root so the sandbox
// CSP (self only) can serve them.
const ASSET_SUBDIRS = ['fonts', 'locales', 'data'];
/** Recursively collect files below `dir` (absolute), keyed by path relative to
* it, each prefixed with `prefix`. */
function collect(dir, prefix) {
const files = {};
for (const name of readdirSync(dir)) {
const abs = join(dir, name);
const rel = `${prefix}${name}`;
if (statSync(abs).isDirectory()) Object.assign(files, collect(abs, `${rel}/`));
else files[rel] = readFileSync(abs);
}
return files;
}
const assetFiles = {};
for (const sub of ASSET_SUBDIRS) {
const dir = join(prodDir, sub);
if (existsSync(dir)) Object.assign(assetFiles, collect(dir, `${sub}/`));
}
// --- 2. Bundle the plugin controller (React + Excalidraw) ------------------
mkdirSync(join(root, 'dist'), { recursive: true });
await build({
entryPoints: [join(root, 'src/plugin.tsx')],
bundle: true,
format: 'esm',
platform: 'browser',
// Excalidraw's `exports` gate index.js / index.css behind `production` /
// `development` conditions (no `default`); select the production build.
conditions: ['production'],
outfile: join(root, 'dist/plugin.js'),
minify: true,
jsx: 'automatic',
loader: { '.css': 'text', '.woff2': 'dataurl', '.ttf': 'dataurl', '.svg': 'text' },
define: {
'process.env.NODE_ENV': '"production"',
'process.env.IS_PREACT': '"false"',
},
});
// --- 3. Pack the ZIP --------------------------------------------------------
const files = {
'manifest.json': readFileSync(join(root, 'manifest.json')),
'plugin.js': readFileSync(join(root, 'dist/plugin.js')),
};
for (const name of readdirSync(join(root, 'i18n'))) {
files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name));
}
// Asset paths already carry their subdir prefix (fonts/…, locales/…, data/…)
// and sit at the ZIP root so they resolve under EXCALIDRAW_ASSET_PATH.
Object.assign(files, assetFiles);
const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`);
rmSync(target, { force: true });
writeFileSync(target, zipSync(files, { level: 6 }));
const unpacked = Object.values(files).reduce((sum, bytes) => sum + bytes.length, 0);
console.log(
`wrote ${relative(process.cwd(), target)} ` +
`(zip ${(statSync(target).size / 1024 / 1024).toFixed(1)} MiB, ` +
`unpacked ${(unpacked / 1024 / 1024).toFixed(1)} MiB, ` +
`${Object.keys(files).length} files)`,
);

View File

@ -0,0 +1,10 @@
{
"empty": "Noch keine Skizze.",
"editButton": "Skizze im Vollbild bearbeiten",
"createButton": "Skizze erstellen",
"loading": "Excalidraw wird geladen …",
"save": "Speichern & Beenden",
"cancel": "Abbrechen",
"saved": "Gespeichert — „Fertig“ schließt den Bearbeiten-Modus.",
"renderHint": "Zum Bearbeiten in den Bearbeiten-Modus der Seite wechseln."
}

View File

@ -0,0 +1,10 @@
{
"empty": "No sketch yet.",
"editButton": "Edit sketch in fullscreen",
"createButton": "Create sketch",
"loading": "Loading Excalidraw …",
"save": "Save & Exit",
"cancel": "Cancel",
"saved": "Saved — \"Done\" closes edit mode.",
"renderHint": "Switch the page to edit mode to change the sketch."
}

View File

@ -0,0 +1,19 @@
{
"id": "excalidraw",
"name": "Excalidraw Whiteboard",
"version": "1.0.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "block",
"id": "diagram",
"title": { "de": "Excalidraw-Skizze", "en": "Excalidraw sketch" }
}
],
"permissions": ["blockData", "ui"],
"fallback": { "type": "text", "value": "[Excalidraw]" },
"license": "MIT",
"homepage": "https://excalidraw.com",
"i18n": { "de": "i18n/de.json", "en": "i18n/en.json" }
}

View File

@ -0,0 +1,25 @@
{
"name": "@dorfteich/plugin-excalidraw",
"version": "0.0.0",
"private": true,
"description": "Reference block plugin: Excalidraw hand-drawn whiteboard sketches — fullscreen editing with the bundled Excalidraw editor, inline SVG rendering",
"license": "MIT",
"scripts": {
"build": "node build.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"@dorfteich/plugin-sdk": "workspace:*",
"@excalidraw/excalidraw": "0.18.1",
"@types/node": "^26.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"esbuild": "^0.24.0",
"fflate": "^0.8.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,4 @@
// The Excalidraw stylesheet is bundled as a string via esbuild's `.css` → text
// loader (see build.mjs) and injected at runtime; declare the module so the
// import type-checks.
declare module '@excalidraw/excalidraw/index.css';

View File

@ -0,0 +1,268 @@
import { createPlugin, windowTransport, type RenderContext } from '@dorfteich/plugin-sdk';
import { Excalidraw, exportToSvg, serializeAsJSON } from '@excalidraw/excalidraw';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
/** The subset of Excalidraw's imperative API this plugin uses kept local so
* the deep types-path export (which moves between versions) is not imported. */
interface ExcalidrawApi {
getSceneElements: () => readonly unknown[];
getAppState: () => Record<string, unknown>;
getFiles: () => Record<string, unknown>;
}
// Bundled as a string (esbuild `.css` → text loader) and injected once, since
// the sandbox frame document loads only `plugin.js` and the CSP forbids remote
// stylesheets.
import excalidrawCss from '@excalidraw/excalidraw/index.css';
import de from '../i18n/de.json';
import en from '../i18n/en.json';
/**
* Excalidraw reference plugin: hand-drawn whiteboard sketches edited in the
* REAL Excalidraw editor, which the package bundles (npm, esbuild) together
* with its font assets nothing is ever loaded from excalidraw.com; the
* sandbox CSP pins every request to the plugin's own version-pinned asset path
* (`EXCALIDRAW_ASSET_PATH`, set below).
*
* Block data: `{ scene, svg }` `scene` is the Excalidraw scene as a JSON
* string (document of record), `svg` the rendered snapshot as raw markup, so
* render mode, office/PDF exports (fallback renderer) and the public read view
* can all show the sketch without running any editor code.
*/
const STRINGS: Record<string, Record<string, string>> = { de, en };
function labelFor(locale: string, key: string): string {
const base = locale.split('-')[0] ?? locale;
return STRINGS[base]?.[key] ?? STRINGS.en?.[key] ?? key;
}
interface SketchData {
/** Excalidraw scene as a serialized JSON string (serializeAsJSON). */
scene?: string;
/** Rendered snapshot as raw SVG markup. */
svg?: string;
}
function dataOf(context: RenderContext): SketchData {
return context.data && typeof context.data === 'object' ? (context.data as SketchData) : {};
}
// Fonts, locales and font metadata load from the plugin's own asset path (the
// directory this module was served from), which the sandbox CSP allows.
// Excalidraw resolves them relative to EXCALIDRAW_ASSET_PATH (e.g. `fonts/…`);
// build.mjs copies dist/prod/{fonts,locales,data} to the ZIP root.
(window as unknown as { EXCALIDRAW_ASSET_PATH: string }).EXCALIDRAW_ASSET_PATH = new URL(
'.',
import.meta.url,
).href;
let cssInjected = false;
function ensureCss(): void {
if (cssInjected) return;
const style = document.createElement('style');
style.textContent = excalidrawCss;
document.head.appendChild(style);
cssInjected = true;
}
const { host } = createPlugin({
transport: windowTransport({
target: { postMessage: (message) => window.parent.postMessage(message, '*') },
source: window,
}),
onRender: (context) => renderMode(context),
onEdit: (context) => editMode(context),
onDestroy: () => closeEditor(),
});
function resize(): void {
void host.ui.resize(Math.max(64, document.body.scrollHeight + 16));
}
/** The stored snapshot as an inline drawing, or the empty-state hint. */
function snapshotElement(data: SketchData, locale: string): HTMLElement {
if (data.svg && data.svg.trim() !== '') {
const holder = document.createElement('div');
holder.className = 'dt-excalidraw__snapshot';
holder.innerHTML = data.svg;
const svg = holder.querySelector('svg');
if (svg) {
svg.style.maxWidth = '100%';
svg.style.height = 'auto';
}
return holder;
}
const hint = document.createElement('p');
hint.textContent = labelFor(locale, 'empty');
hint.style.color = '#6b7280';
return hint;
}
function renderMode(context: RenderContext): void {
closeEditor();
const data = dataOf(context);
document.body.textContent = '';
document.body.className = 'dt-excalidraw dt-excalidraw--render';
document.body.appendChild(snapshotElement(data, context.locale));
resize();
}
function editMode(context: RenderContext): void {
closeEditor();
const data = dataOf(context);
document.body.textContent = '';
document.body.className = 'dt-excalidraw dt-excalidraw--edit';
document.body.appendChild(snapshotElement(data, context.locale));
const button = document.createElement('button');
button.type = 'button';
button.textContent = labelFor(context.locale, data.svg ? 'editButton' : 'createButton');
button.style.cssText = 'display:block;margin:8px 0;padding:6px 12px;cursor:pointer;font:inherit;';
button.addEventListener('click', () => void openEditor(context));
document.body.appendChild(button);
resize();
// A block that has no sketch yet goes straight into the editor.
if (!data.svg && !data.scene) void openEditor(context);
}
/** The active fullscreen editing session, if any. */
let session: { container: HTMLDivElement; root: Root } | null = null;
function closeEditor(): void {
if (!session) return;
const { container, root } = session;
session = null;
root.unmount();
container.remove();
void host.ui.exitFullscreen();
}
function parseScene(scene: string | undefined): {
elements: readonly unknown[];
appState: Record<string, unknown>;
files: Record<string, unknown>;
} {
if (!scene) return { elements: [], appState: {}, files: {} };
try {
const parsed = JSON.parse(scene) as {
elements?: unknown[];
appState?: Record<string, unknown>;
files?: Record<string, unknown>;
};
return {
elements: parsed.elements ?? [],
appState: parsed.appState ?? {},
files: parsed.files ?? {},
};
} catch {
return { elements: [], appState: {}, files: {} };
}
}
async function openEditor(context: RenderContext): Promise<void> {
if (session) return;
ensureCss();
const data = dataOf(context);
const initial = parseScene(data.scene);
await host.ui.enterFullscreen();
const container = document.createElement('div');
container.className = 'dt-excalidraw__editor';
container.style.cssText =
'position:fixed;inset:0;background:#fff;display:flex;flex-direction:column;';
document.body.appendChild(container);
const bar = document.createElement('div');
bar.style.cssText =
'display:flex;gap:8px;justify-content:flex-end;padding:8px;border-bottom:1px solid #e5e7eb;background:#fff;z-index:5;';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.textContent = labelFor(context.locale, 'cancel');
cancel.style.cssText = 'padding:6px 12px;cursor:pointer;font:inherit;';
const save = document.createElement('button');
save.type = 'button';
save.textContent = labelFor(context.locale, 'save');
save.style.cssText = 'padding:6px 12px;cursor:pointer;font:inherit;font-weight:600;';
bar.append(cancel, save);
container.appendChild(bar);
const canvas = document.createElement('div');
canvas.style.cssText = 'flex:1;min-height:0;position:relative;';
container.appendChild(canvas);
let api: ExcalidrawApi | null = null;
const root = createRoot(canvas);
session = { container, root };
root.render(
createElement(Excalidraw, {
initialData: {
elements: initial.elements,
appState: initial.appState,
files: initial.files,
},
excalidrawAPI: (instance: ExcalidrawApi) => {
api = instance;
},
} as Parameters<typeof Excalidraw>[0]),
);
cancel.addEventListener('click', () => {
closeEditor();
finishEdit(dataOf(context), context.locale);
});
save.addEventListener('click', () => {
void (async () => {
if (!api) return;
const elements = api.getSceneElements();
const appState = api.getAppState();
const files = api.getFiles();
const scene = serializeAsJSON(
elements as Parameters<typeof serializeAsJSON>[0],
appState as Parameters<typeof serializeAsJSON>[1],
files as Parameters<typeof serializeAsJSON>[2],
'local',
);
const svgEl = await exportToSvg({
elements,
appState: { ...appState, exportBackground: true, exportWithDarkMode: false },
files,
} as Parameters<typeof exportToSvg>[0]);
const svg = new XMLSerializer().serializeToString(svgEl);
await host.blockData.setData({ scene, svg } satisfies SketchData);
closeEditor();
finishEdit({ scene, svg }, context.locale);
})();
});
}
/** Redraws the inline edit surface after the fullscreen editor closed. */
function finishEdit(data: SketchData, locale: string): void {
document.body.textContent = '';
document.body.className = 'dt-excalidraw dt-excalidraw--edit';
document.body.appendChild(snapshotElement(data, locale));
const button = document.createElement('button');
button.type = 'button';
button.textContent = labelFor(locale, data.svg ? 'editButton' : 'createButton');
button.style.cssText = 'display:block;margin:8px 0;padding:6px 12px;cursor:pointer;font:inherit;';
button.addEventListener(
'click',
() => void openEditor({ extensionPointId: 'diagram', locale, data }),
);
document.body.appendChild(button);
if (data.svg) {
const note = document.createElement('p');
note.textContent = labelFor(locale, 'saved');
note.style.cssText = 'color:#6b7280;font-size:13px;';
document.body.appendChild(note);
}
resize();
}

View File

@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src", "*.ts"]
}

View File

@ -1,4 +1,11 @@
{
"statusbar": {
"label": "Seiteninformationen",
"updated": "Aktualisiert {{date}}",
"words_one": "{{count}} Wort",
"words_other": "{{count}} Wörter",
"readingTime": "ca. {{minutes}} Min. Lesezeit"
},
"layout": {
"sidebar": {
"expand": "Seitenleiste einblenden",

View File

@ -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"

View File

@ -1,4 +1,11 @@
{
"statusbar": {
"label": "Page information",
"updated": "Updated {{date}}",
"words_one": "{{count}} word",
"words_other": "{{count}} words",
"readingTime": "~{{minutes}} min read"
},
"layout": {
"sidebar": {
"expand": "Show sidebar",

View File

@ -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}}”"

View File

@ -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':

View File

@ -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) {

View File

@ -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: {} }),
},

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';
/**
* 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);

1249
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -12,3 +12,11 @@ allowBuilds:
argon2: true
esbuild: true
prisma: true
# Excalidraw 0.18.1 (excalidraw plugin, issue #136) transitively pulls
# @floating-ui/react-dom@2.1.9, which requires @floating-ui/dom@^1.8.0 — a
# version not published on the registry (latest 1.7.6), breaking `pnpm install`
# repo-wide. Pin react-dom to 2.1.2, which needs @floating-ui/dom@^1.0.0. Only
# the excalidraw plugin pulls @floating-ui at all; drop this once upstream
# @floating-ui/dom@1.8.0 is published.
overrides:
'@floating-ui/react-dom': '2.1.2'