M20: Einstellungs-Sektionsnavigation (#145) + transparente Einbettung $[[…]] (#146) #156

Merged
fable-5 merged 2 commits from m20-ui-einbettung into main 2026-07-20 00:48:18 +02:00
22 changed files with 598 additions and 173 deletions

View File

@ -396,6 +396,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/favorites.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/favorites.spec.ts
- name: Reset login rate limit before settings-nav pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run settings-nav pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/settings-nav.spec.ts
- name: Reset login rate limit before create-missing-page pack - name: Reset login rate limit before create-missing-page pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -169,6 +169,28 @@ describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => {
expect(html).toContain(`data-wikilink="ghost-${suffix}"`); expect(html).toContain(`data-wikilink="ghost-${suffix}"`);
}); });
it('expands a bare embed without frame or title (issue #146)', async () => {
const targetSlug = `bare-target-${suffix}`;
const hostSlug = `bare-host-${suffix}`;
await makePage(pondId, targetSlug, 'Bare Target', '<p>Bare body text.</p>');
await makePage(
pondId,
hostSlug,
'Bare Host',
`<p>Before.</p>` +
`<div class="dt-transclusion" data-transclusion="${targetSlug}"` +
` data-transclusion-bare="1">Bare Target</div>`,
);
const res = await api().get(`/api/v1/public/${pondSlug}/${hostSlug}/content`).expect(200);
const html = (res.body as { html: string }).html;
// The target's body is spliced in verbatim — no dt-embed frame, no title.
expect(html).toContain('Bare body text.');
expect(html).not.toContain('dt-embed');
expect(html).not.toContain('Bare Target</a>');
expect(html).not.toContain('dt-transclusion');
});
it('404s all public endpoints once the public grant is removed', async () => { it('404s all public endpoints once the public grant is removed', async () => {
await prisma.roleGrant.delete({ where: { id: publicGrantId } }); await prisma.roleGrant.delete({ where: { id: publicGrantId } });
// The API route invalidates on its own mutations; this test deletes // The API route invalidates on its own mutations; this test deletes

View File

@ -115,9 +115,11 @@ export class PublicService {
depth: number, depth: number,
visited: Set<string>, visited: Set<string>,
): Promise<string> { ): Promise<string> {
const placeholder = /<div class="dt-transclusion" data-transclusion="([^"]+)">[^<]*<\/div>/g; const placeholder =
return replaceAsync(html, placeholder, async (_match, rawSlug) => { /<div class="dt-transclusion" data-transclusion="([^"]+)"( data-transclusion-bare="1")?>[^<]*<\/div>/g;
return replaceAsync(html, placeholder, async (_match, rawSlug, bareAttr) => {
const slug = rawSlug as string; const slug = rawSlug as string;
const bare = Boolean(bareAttr);
const target = await this.prisma.page.findFirst({ const target = await this.prisma.page.findFirst({
where: { pondId, slug, deletedAt: null }, where: { pondId, slug, deletedAt: null },
select: { id: true, pondId: true, slug: true, title: true }, select: { id: true, pondId: true, slug: true, title: true },
@ -133,6 +135,9 @@ export class PublicService {
depth + 1, depth + 1,
new Set(visited).add(slug), new Set(visited).add(slug),
); );
// A bare embed (`$[[…]]`, #146) reads as part of the host page: no
// frame, no title — just the expanded content.
if (bare) return inner;
return ( return (
`<div class="dt-embed"><div class="dt-embed__title">` + `<div class="dt-embed"><div class="dt-embed__title">` +
`<a class="wikilink" href="${escapeHtml(slug)}" data-wikilink="${escapeHtml(slug)}">` + `<a class="wikilink" href="${escapeHtml(slug)}" data-wikilink="${escapeHtml(slug)}">` +

View File

@ -0,0 +1,60 @@
import { expect, test, type BrowserContext } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Settings section navigation pack (issue #145): every settings page derives
* a jump nav from its stacked sections; clicking an entry scrolls the section
* into view and marks it active. Language-independent selectors (CSS classes)
* throughout.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
async function json<T>(context: BrowserContext, url: string, data: unknown): Promise<T> {
const response = await context.request.post(url, { data });
if (!response.ok()) throw new Error(`post ${url}${response.status()}`);
return response.json() as Promise<T>;
}
test('user settings show the jump nav and clicking scrolls + activates', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const page = await context.newPage();
await page.goto('/settings');
const nav = page.locator('.settings-nav');
await expect(nav).toBeVisible();
const links = nav.locator('.settings-nav__link');
// Profile, password, sessions, watches, API tokens, data export.
await expect(links).toHaveCount(6);
// Jump to the last section: it scrolls into view and becomes active.
const last = links.last();
await last.click();
const lastSection = page.locator('.settings-layout section[id]').last();
await expect(lastSection).toBeInViewport();
await expect(last).toHaveClass(/settings-nav__link--active/);
await context.close();
});
test('pond settings derive the nav from their sections', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await json<{ id: string; slug: string }>(context, '/api/v1/ponds', {
name: `Nav Pack ${Date.now()}`,
});
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/settings`);
const links = page.locator('.settings-nav .settings-nav__link');
// The pond owner sees the full section stack — at least members, labels,
// missing links, import, files, appearance, plugins, sidebar, comments,
// API, export.
await expect(links.first()).toBeVisible();
expect(await links.count()).toBeGreaterThanOrEqual(8);
await links.last().click();
await expect(page.locator('.settings-layout section[id]').last()).toBeInViewport();
await context.close();
});

View File

@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface SectionEntry {
id: string;
title: string;
}
/** A stable, readable anchor id derived from the section heading. */
function anchorId(title: string, taken: Set<string>): string {
const base =
'sec-' +
(title
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'section');
let id = base;
let n = 2;
while (taken.has(id)) id = `${base}-${n++}`;
taken.add(id);
return id;
}
/**
* Wraps a settings page's stacked sections and derives a jump navigation from
* them (issue #145): every top-level `<section>` with an `<h2>` becomes a nav
* entry. The list is read from the DOM (and kept fresh via MutationObserver),
* so conditionally rendered and component-owned sections need no wiring. On
* wide viewports the nav sits sticky beside the content; on narrow ones it
* collapses to a horizontal chip bar above it.
*/
export function SettingsLayout({ children }: { children: React.ReactNode }): React.JSX.Element {
const { t } = useTranslation();
const contentRef = useRef<HTMLDivElement>(null);
const [sections, setSections] = useState<SectionEntry[]>([]);
const [active, setActive] = useState<string | null>(null);
useEffect(() => {
const container = contentRef.current;
if (!container) return undefined;
const scan = (): void => {
const taken = new Set<string>();
const found: SectionEntry[] = [];
container.querySelectorAll('section').forEach((section) => {
// Only top-level sections: a nested <section> belongs to its parent's
// entry, not the nav.
const parent = section.parentElement?.closest('section');
if (parent && container.contains(parent)) return;
const title = section.querySelector('h2')?.textContent?.trim();
if (!title) return;
if (!section.id) section.id = anchorId(title, taken);
else taken.add(section.id);
found.push({ id: section.id, title });
});
setSections((prev) =>
prev.length === found.length &&
prev.every((p, i) => p.id === found[i]!.id && p.title === found[i]!.title)
? prev
: found,
);
};
const scrollParent: HTMLElement | Window = container.closest<HTMLElement>('.main') ?? window;
const atScrollEnd = (): boolean =>
scrollParent instanceof Window
? window.innerHeight + window.scrollY >= document.body.scrollHeight - 2
: scrollParent.scrollTop + scrollParent.clientHeight >= scrollParent.scrollHeight - 2;
const updateActive = (): void => {
const anchors = Array.from(container.querySelectorAll<HTMLElement>('section[id]'));
if (anchors.length === 0) return;
// The active section is the last one whose top has passed the reading
// line (a bit below the viewport top). At the very bottom the last
// section wins even if its top never reaches the line.
let current = anchors[0]!.id;
for (const section of anchors) {
if (section.getBoundingClientRect().top <= 160) current = section.id;
}
if (atScrollEnd()) current = anchors[anchors.length - 1]!.id;
setActive(current);
};
scan();
updateActive();
const observer = new MutationObserver(() => {
scan();
updateActive();
});
observer.observe(container, { childList: true, subtree: true });
scrollParent.addEventListener('scroll', updateActive, { passive: true });
window.addEventListener('resize', updateActive);
return () => {
observer.disconnect();
scrollParent.removeEventListener('scroll', updateActive);
window.removeEventListener('resize', updateActive);
};
}, []);
const jump = (id: string): void => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
setActive(id);
};
return (
<div className="settings-layout">
{sections.length > 1 && (
<nav className="settings-nav" aria-label={t('common:settingsNav.label')}>
<ul>
{sections.map((section) => (
<li key={section.id}>
<button
type="button"
className={
'settings-nav__link' +
(active === section.id ? ' settings-nav__link--active' : '')
}
aria-current={active === section.id ? 'true' : undefined}
onClick={() => jump(section.id)}
>
{section.title}
</button>
</li>
))}
</ul>
</nav>
)}
<div className="settings-layout__content" ref={contentRef}>
{children}
</div>
</div>
);
}

View File

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

View File

@ -27,6 +27,7 @@ function TransclusionView({ node, editor }: NodeViewProps): React.JSX.Element {
const slug = node.attrs.targetSlug as string; const slug = node.attrs.targetSlug as string;
const display = node.attrs.displayText as string | null; const display = node.attrs.displayText as string | null;
const bare = node.attrs.bare as boolean;
const { title, exists } = resolve(slug); const { title, exists } = resolve(slug);
const label = display ?? title ?? slug; const label = display ?? title ?? slug;
const editable = editor.isEditable; const editable = editor.isEditable;
@ -45,7 +46,7 @@ function TransclusionView({ node, editor }: NodeViewProps): React.JSX.Element {
</span> </span>
<span className="dt-transclusion-card__label"> <span className="dt-transclusion-card__label">
{t('transclusion.embedded', { title: label })} {t(bare ? 'transclusion.embeddedBare' : 'transclusion.embedded', { title: label })}
</span> </span>
<Link className="dt-transclusion-card__open" to={`/p/${pondSlug}/${slug}`}> <Link className="dt-transclusion-card__open" to={`/p/${pondSlug}/${slug}`}>
{t('transclusion.open')} {t('transclusion.open')}
@ -54,6 +55,20 @@ function TransclusionView({ node, editor }: NodeViewProps): React.JSX.Element {
); );
} }
if (bare) {
// `$[[…]]` (#146): the embed reads as part of the host page — no frame,
// no title, just the target's rendered content.
return (
<NodeViewWrapper className="dt-embed-bare" contentEditable={false}>
{content.data ? (
<div dangerouslySetInnerHTML={{ __html: content.data.html }} />
) : (
<div aria-busy="true" />
)}
</NodeViewWrapper>
);
}
return ( return (
<NodeViewWrapper className="dt-embed" contentEditable={false}> <NodeViewWrapper className="dt-embed" contentEditable={false}>
<div className="dt-embed__title"> <div className="dt-embed__title">

View File

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Field, FormError, FormSuccess } from '../components/forms'; import { Field, FormError, FormSuccess } from '../components/forms';
import { SettingsLayout } from '../components/SettingsLayout';
import { apiGet, apiPatch } from '../lib/api'; import { apiGet, apiPatch } from '../lib/api';
import { PluginManager } from './PluginManager'; import { PluginManager } from './PluginManager';
import { QuotaManager } from './QuotaManager'; import { QuotaManager } from './QuotaManager';
@ -63,7 +64,9 @@ export function AdminSettingsPage(): React.JSX.Element {
<p> <p>
<Link to="/admin/system">{t('system:settingsLink')} </Link> <Link to="/admin/system">{t('system:settingsLink')} </Link>
</p> </p>
<SettingsLayout>
<section className="settings-section"> <section className="settings-section">
<h2>{t('settings:admin.general')}</h2>
<form onSubmit={onSubmit} noValidate> <form onSubmit={onSubmit} noValidate>
<FormError error={error} /> <FormError error={error} />
<FormSuccess message={saved ? t('settings:admin.saved') : null} /> <FormSuccess message={saved ? t('settings:admin.saved') : null} />
@ -118,6 +121,7 @@ export function AdminSettingsPage(): React.JSX.Element {
<PluginManager /> <PluginManager />
<QuotaManager /> <QuotaManager />
<UserManager /> <UserManager />
</SettingsLayout>
</> </>
); );
} }

View File

@ -11,6 +11,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { SettingsLayout } from '../components/SettingsLayout';
import { formatBytes } from '../files/file-format'; import { formatBytes } from '../files/file-format';
import { apiGet, apiPost } from '../lib/api'; import { apiGet, apiPost } from '../lib/api';
import { BackupSection } from './AdminBackupSection'; import { BackupSection } from './AdminBackupSection';
@ -29,10 +30,12 @@ export function AdminSystemPage(): React.JSX.Element {
<p> <p>
<Link to="/admin"> {t('backLink')}</Link> <Link to="/admin"> {t('backLink')}</Link>
</p> </p>
<SettingsLayout>
<JobsSection /> <JobsSection />
<BackupSection /> <BackupSection />
<AuditViewer /> <AuditViewer />
<StorageSection /> <StorageSection />
</SettingsLayout>
</> </>
); );
} }

View File

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { SettingsLayout } from '../components/SettingsLayout';
import { ApiOptInSetting } from '../api-tokens/ApiOptInSetting'; import { ApiOptInSetting } from '../api-tokens/ApiOptInSetting';
import { CommentPolicySetting } from '../comments/CommentPolicySetting'; import { CommentPolicySetting } from '../comments/CommentPolicySetting';
import { WatchToggle } from '../watches/WatchToggle'; import { WatchToggle } from '../watches/WatchToggle';
@ -60,6 +61,7 @@ export function PondSettingsPage(): React.JSX.Element {
<h1>{pond.data.name}</h1> <h1>{pond.data.name}</h1>
<WatchToggle targetType="pond" targetId={pond.data.id} variant="icon" /> <WatchToggle targetType="pond" targetId={pond.data.id} variant="icon" />
</div> </div>
<SettingsLayout>
<section> <section>
<h2>{tMembers('title')}</h2> <h2>{tMembers('title')}</h2>
<MemberManager pondId={pond.data.id} /> <MemberManager pondId={pond.data.id} />
@ -150,6 +152,7 @@ export function PondSettingsPage(): React.JSX.Element {
{canModify && pond.data.type === 'shared' && ( {canModify && pond.data.type === 'shared' && (
<DeletePondSection pondId={pond.data.id} pondName={pond.data.name} /> <DeletePondSection pondId={pond.data.id} pondName={pond.data.name} />
)} )}
</SettingsLayout>
</div> </div>
); );
} }

View File

@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { Field, FormError, FormSuccess, applyFieldErrors } from '../components/forms'; import { Field, FormError, FormSuccess, applyFieldErrors } from '../components/forms';
import { SettingsLayout } from '../components/SettingsLayout';
import { useDataExport } from '../export/use-data-export'; import { useDataExport } from '../export/use-data-export';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
import { ApiTokensSection } from '../api-tokens/ApiTokensSection'; import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
@ -25,12 +26,14 @@ export function SettingsPage(): React.JSX.Element {
return ( return (
<> <>
<h1>{t('settings:title')}</h1> <h1>{t('settings:title')}</h1>
<SettingsLayout>
<ProfileSection /> <ProfileSection />
<PasswordSection /> <PasswordSection />
<SessionsSection /> <SessionsSection />
<WatchesSection /> <WatchesSection />
<ApiTokensSection /> <ApiTokensSection />
<DataExportSection /> <DataExportSection />
</SettingsLayout>
</> </>
); );
} }

View File

@ -3720,3 +3720,91 @@ ul[data-type='task_list'] li p:last-of-type {
transform: translateY(0); transform: translateY(0);
} }
} }
/* Settings section jump navigation (issue #145). Wide viewports: sticky rail
beside the content; narrow ones: horizontal chip bar above it. */
.settings-layout {
display: flex;
flex-direction: row-reverse;
gap: var(--space-6);
align-items: flex-start;
}
.settings-layout__content {
flex: 1;
min-width: 0;
}
.settings-layout section[id] {
scroll-margin-top: var(--space-4);
}
.settings-nav {
position: sticky;
top: var(--space-2);
width: 13rem;
flex-shrink: 0;
}
.settings-nav ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-nav__link {
display: block;
width: 100%;
text-align: left;
padding: var(--space-1) var(--space-2);
border: none;
border-left: 2px solid var(--color-border);
border-radius: 0 6px 6px 0;
background: none;
color: var(--color-text-muted);
font: inherit;
font-size: 0.9375rem;
cursor: pointer;
}
.settings-nav__link:hover {
color: var(--color-text);
background: var(--color-bg-subtle);
}
.settings-nav__link--active {
color: var(--color-accent);
border-left-color: var(--color-accent);
font-weight: var(--font-weight-heading);
}
@media (max-width: 60rem) {
.settings-layout {
flex-direction: column;
}
.settings-nav {
position: static;
width: 100%;
overflow-x: auto;
}
.settings-nav ul {
flex-direction: row;
gap: var(--space-2);
}
.settings-nav__link {
white-space: nowrap;
border-left: none;
border-bottom: 2px solid var(--color-border);
border-radius: 6px 6px 0 0;
}
.settings-nav__link--active {
border-bottom-color: var(--color-accent);
}
}

View File

@ -92,5 +92,8 @@
"submit": "Teich löschen", "submit": "Teich löschen",
"deleted": "Teich gelöscht." "deleted": "Teich gelöscht."
} }
},
"settingsNav": {
"label": "Abschnitte"
} }
} }

View File

@ -175,6 +175,7 @@
}, },
"transclusion": { "transclusion": {
"embedded": "Eingebettet: {{title}}", "embedded": "Eingebettet: {{title}}",
"embeddedBare": "Nahtlos eingebettet: {{title}}",
"open": "Öffnen" "open": "Öffnen"
}, },
"notFound": { "notFound": {

View File

@ -4,7 +4,10 @@
"title": "Profil", "title": "Profil",
"displayName": "Anzeigename", "displayName": "Anzeigename",
"locale": "Sprache", "locale": "Sprache",
"locales": { "de": "Deutsch", "en": "English" }, "locales": {
"de": "Deutsch",
"en": "English"
},
"save": "Speichern", "save": "Speichern",
"saved": "Gespeichert." "saved": "Gespeichert."
}, },
@ -38,6 +41,7 @@
}, },
"admin": { "admin": {
"title": "Administration", "title": "Administration",
"general": "Allgemein",
"instanceName": "Name der Instanz", "instanceName": "Name der Instanz",
"defaultLocale": "Standardsprache", "defaultLocale": "Standardsprache",
"registrationMode": "Selbst-Registrierung", "registrationMode": "Selbst-Registrierung",

View File

@ -92,5 +92,8 @@
"submit": "Delete pond", "submit": "Delete pond",
"deleted": "Pond deleted." "deleted": "Pond deleted."
} }
},
"settingsNav": {
"label": "Sections"
} }
} }

View File

@ -175,6 +175,7 @@
}, },
"transclusion": { "transclusion": {
"embedded": "Embedded: {{title}}", "embedded": "Embedded: {{title}}",
"embeddedBare": "Embedded seamlessly: {{title}}",
"open": "Open" "open": "Open"
}, },
"notFound": { "notFound": {

View File

@ -4,7 +4,10 @@
"title": "Profile", "title": "Profile",
"displayName": "Display name", "displayName": "Display name",
"locale": "Language", "locale": "Language",
"locales": { "de": "Deutsch", "en": "English" }, "locales": {
"de": "Deutsch",
"en": "English"
},
"save": "Save", "save": "Save",
"saved": "Saved." "saved": "Saved."
}, },
@ -38,6 +41,7 @@
}, },
"admin": { "admin": {
"title": "Administration", "title": "Administration",
"general": "General",
"instanceName": "Instance name", "instanceName": "Instance name",
"defaultLocale": "Default language", "defaultLocale": "Default language",
"registrationMode": "Self-registration", "registrationMode": "Self-registration",

View File

@ -136,10 +136,12 @@ function renderBlock(node: Node): string {
// A page embed (#135). The static HTML is a placeholder carrying the // A page embed (#135). The static HTML is a placeholder carrying the
// target slug; the read view / public renderer expands it server-side to // target slug; the read view / public renderer expands it server-side to
// the target page's rendered HTML (permission-checked, recursion limited). // the target page's rendered HTML (permission-checked, recursion limited).
// Left un-expanded (here) it degrades to a labelled block. // Left un-expanded (here) it degrades to a labelled block. The `bare`
// flag (#146) rides along so the expansion can drop frame + title.
const slug = escapeHtml(node.attrs.targetSlug as string); const slug = escapeHtml(node.attrs.targetSlug as string);
const display = node.attrs.displayText ? escapeHtml(node.attrs.displayText as string) : slug; const display = node.attrs.displayText ? escapeHtml(node.attrs.displayText as string) : slug;
return `<div class="dt-transclusion" data-transclusion="${slug}">${display}</div>`; const bare = node.attrs.bare ? ' data-transclusion-bare="1"' : '';
return `<div class="dt-transclusion" data-transclusion="${slug}"${bare}>${display}</div>`;
} }
case 'code_block': case 'code_block':
return `<pre><code>${escapeHtml(node.textContent)}</code></pre>`; return `<pre><code>${escapeHtml(node.textContent)}</code></pre>`;

View File

@ -192,14 +192,16 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
return true; return true;
} }
/** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page (#135). */ /** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page
const TRANSCLUSION_LINE = /^!\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/; * (#135); the `$[[slug]]` prefix embeds without frame or title (#146). */
const TRANSCLUSION_LINE = /^([!$])\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/;
/** /**
* Block rule for page embeds (issue #135). A line consisting solely of * Block rule for page embeds (issue #135). A line consisting solely of
* `![[slug]]` becomes a `transclusion` block node; anything else (including * `![[slug]]` becomes a `transclusion` block node; anything else (including
* `![[x]]` mid-paragraph) is left untouched. Registered before `paragraph` so * `![[x]]` mid-paragraph) is left untouched. Registered before `paragraph` so
* the lone-embed line is not swallowed as ordinary text. * the lone-embed line is not swallowed as ordinary text. A `$` prefix marks
* the embed as `bare` (frameless/titleless, issue #146).
*/ */
function transclusionRule( function transclusionRule(
state: StateBlock, state: StateBlock,
@ -213,9 +215,10 @@ function transclusionRule(
if (!match) return false; if (!match) return false;
if (silent) return true; if (silent) return true;
const token = state.push('transclusion', '', 0); const token = state.push('transclusion', '', 0);
token.attrSet('target', match[1]!.trim()); token.attrSet('target', match[2]!.trim());
const display = match[2]?.trim(); const display = match[3]?.trim();
if (display) token.attrSet('display', display); if (display) token.attrSet('display', display);
if (match[1] === '$') token.attrSet('bare', '1');
token.map = [startLine, startLine + 1]; token.map = [startLine, startLine + 1];
state.line = startLine + 1; state.line = startLine + 1;
return true; return true;
@ -349,6 +352,7 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
getAttrs: (tok) => ({ getAttrs: (tok) => ({
targetSlug: tok.attrGet('target') ?? '', targetSlug: tok.attrGet('target') ?? '',
displayText: tok.attrGet('display') || null, displayText: tok.attrGet('display') || null,
bare: tok.attrGet('bare') === '1',
}), }),
}, },
em: { mark: 'italic' }, em: { mark: 'italic' },
@ -477,7 +481,8 @@ const markdownSerializer = new MarkdownSerializer(
transclusion(state, node) { transclusion(state, node) {
const slug = node.attrs.targetSlug as string; const slug = node.attrs.targetSlug as string;
const display = node.attrs.displayText as string | null; const display = node.attrs.displayText as string | null;
state.write(display ? `![[${slug}|${display}]]` : `![[${slug}]]`); const prefix = node.attrs.bare ? '$' : '!';
state.write(display ? `${prefix}[[${slug}|${display}]]` : `${prefix}[[${slug}]]`);
state.closeBlock(node); state.closeBlock(node);
}, },
hard_break(state, node, parent, index) { hard_break(state, node, parent, index) {

View File

@ -261,13 +261,16 @@ export const editorSchema = new Schema({
// it to the target page's rendered HTML (permission-checked, recursion // it to the target page's rendered HTML (permission-checked, recursion
// limited), while the editor shows a placeholder card. Like `wikilink` it // limited), while the editor shows a placeholder card. Like `wikilink` it
// stores slug + optional display text only — live resolution happens where // stores slug + optional display text only — live resolution happens where
// the pond's pages are known. // the pond's pages are known. The `$[[slug]]` variant (issue #146) sets
// `bare`: the expansion drops the frame and title, so the embedded content
// reads as part of the host page.
transclusion: { transclusion: {
group: 'block', group: 'block',
atom: true, atom: true,
attrs: { attrs: {
targetSlug: { validate: 'string' }, targetSlug: { validate: 'string' },
displayText: { default: null }, displayText: { default: null },
bare: { default: false },
}, },
parseDOM: [ parseDOM: [
{ {
@ -275,6 +278,7 @@ export const editorSchema = new Schema({
getAttrs: (dom) => ({ getAttrs: (dom) => ({
targetSlug: dom.getAttribute('data-transclusion'), targetSlug: dom.getAttribute('data-transclusion'),
displayText: dom.getAttribute('data-display') || null, displayText: dom.getAttribute('data-display') || null,
bare: dom.getAttribute('data-transclusion-bare') === '1',
}), }),
}, },
], ],
@ -286,6 +290,7 @@ export const editorSchema = new Schema({
class: 'dt-transclusion', class: 'dt-transclusion',
}; };
if (display) attrs['data-display'] = display; if (display) attrs['data-display'] = display;
if (node.attrs.bare) attrs['data-transclusion-bare'] = '1';
return ['div', attrs, display ?? slug]; return ['div', attrs, display ?? slug];
}, },
}, },

View File

@ -48,3 +48,44 @@ describe('page embed / transclusion (issue #135)', () => {
expect(firstTransclusion(doc)).toBeNull(); expect(firstTransclusion(doc)).toBeNull();
}); });
}); });
describe('bare page embed `$[[…]]` (issue #146)', () => {
it('parses a lone $[[slug]] line to a bare transclusion 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.bare).toBe(true);
expect(docToMarkdown(doc)).toContain('$[[rennrad]]');
});
it('keeps `![[…]]` non-bare and round-trips both prefixes side by side', () => {
const doc = markdownToDoc('![[dota]]\n\n$[[rennrad]]');
const markdown = docToMarkdown(doc);
expect(markdown).toContain('![[dota]]');
expect(markdown).toContain('$[[rennrad]]');
let bares = 0;
doc.descendants((node) => {
if (node.type.name === 'transclusion' && node.attrs.bare) bares += 1;
});
expect(bares).toBe(1);
});
it('marks the placeholder div so the server expansion can drop the frame', () => {
const html = docToHtml(markdownToDoc('$[[rennrad]]'));
expect(html).toContain('data-transclusion="rennrad"');
expect(html).toContain('data-transclusion-bare="1"');
// The framed variant must NOT carry the marker.
expect(docToHtml(markdownToDoc('![[rennrad]]'))).not.toContain('data-transclusion-bare');
});
it('registers the bare embed target as an outgoing link too', () => {
const doc = markdownToDoc('$[[rennrad]]');
expect(extractWikilinkSlugs(doc)).toEqual(['rennrad']);
});
it('does not treat mid-paragraph $[[x]] as a transclusion', () => {
const doc = markdownToDoc('costs $[[rennrad]] inline');
expect(firstTransclusion(doc)).toBeNull();
});
});