[^<]*<\/div>/g;
+ return replaceAsync(html, placeholder, async (_match, rawSlug, bareAttr) => {
const slug = rawSlug as string;
+ const bare = Boolean(bareAttr);
const target = await this.prisma.page.findFirst({
where: { pondId, slug, deletedAt: null },
select: { id: true, pondId: true, slug: true, title: true },
@@ -133,6 +135,9 @@ export class PublicService {
depth + 1,
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 (
`
` +
`
` +
diff --git a/apps/web/e2e/settings-nav.spec.ts b/apps/web/e2e/settings-nav.spec.ts
new file mode 100644
index 0000000..7019175
--- /dev/null
+++ b/apps/web/e2e/settings-nav.spec.ts
@@ -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(context: BrowserContext, url: string, data: unknown): Promise {
+ const response = await context.request.post(url, { data });
+ if (!response.ok()) throw new Error(`post ${url} → ${response.status()}`);
+ return response.json() as Promise;
+}
+
+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();
+});
diff --git a/apps/web/src/components/SettingsLayout.tsx b/apps/web/src/components/SettingsLayout.tsx
new file mode 100644
index 0000000..5c7e88e
--- /dev/null
+++ b/apps/web/src/components/SettingsLayout.tsx
@@ -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 {
+ 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 `` with an `` 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(null);
+ const [sections, setSections] = useState([]);
+ const [active, setActive] = useState(null);
+
+ useEffect(() => {
+ const container = contentRef.current;
+ if (!container) return undefined;
+
+ const scan = (): void => {
+ const taken = new Set();
+ const found: SectionEntry[] = [];
+ container.querySelectorAll('section').forEach((section) => {
+ // Only top-level sections: a nested 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('.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('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 (
+
+ {sections.length > 1 && (
+
+
+ {sections.map((section) => (
+
+ jump(section.id)}
+ >
+ {section.title}
+
+
+ ))}
+
+
+ )}
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/src/editor/WikilinkAutocomplete.tsx b/apps/web/src/editor/WikilinkAutocomplete.tsx
index 18f1a8e..6d46315 100644
--- a/apps/web/src/editor/WikilinkAutocomplete.tsx
+++ b/apps/web/src/editor/WikilinkAutocomplete.tsx
@@ -6,11 +6,13 @@ import { useTranslation } from 'react-i18next';
import { useWikilinks } from './wikilink-context';
/** 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 {
query: string;
from: number;
embed: boolean;
+ bare: boolean;
coords: { left: number; bottom: number };
}
@@ -18,21 +20,24 @@ interface QueryState {
type Suggestion =
{ kind: 'page'; slug: string; label: string } | { kind: 'create'; slug: string; label: string };
-/** 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 {
+/** Detects a `[[query` (link), `![[query` (embed, #135) or `$[[query` (bare
+ * embed, #146) immediately before a collapsed cursor (issue #46). */
+function detectQuery(
+ editor: Editor,
+): { query: string; from: number; embed: boolean; bare: 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 embed = match[1] === '!';
+ const embed = match[1] === '!' || match[1] === '$';
+ const bare = 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 };
+ // `[[` is 2 chars; an embed's leading `!`/`$` is one more to swallow.
+ 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) {
// A page embed is a block node (#135) — replace the typed `![[query` with
// the transclusion block; ProseMirror lifts it out of the paragraph.
+ // `$[[` opens the frameless variant (#146).
editor
.chain()
.focus()
.insertContentAt(range, {
type: 'transclusion',
- attrs: { targetSlug: item.slug, displayText: null },
+ attrs: { targetSlug: item.slug, displayText: null, bare: current.bare },
})
.run();
} else {
diff --git a/apps/web/src/editor/nodes/transclusion.tsx b/apps/web/src/editor/nodes/transclusion.tsx
index 4cd0d6e..0873080 100644
--- a/apps/web/src/editor/nodes/transclusion.tsx
+++ b/apps/web/src/editor/nodes/transclusion.tsx
@@ -27,6 +27,7 @@ function TransclusionView({ node, editor }: NodeViewProps): React.JSX.Element {
const slug = node.attrs.targetSlug as string;
const display = node.attrs.displayText as string | null;
+ const bare = node.attrs.bare as boolean;
const { title, exists } = resolve(slug);
const label = display ?? title ?? slug;
const editable = editor.isEditable;
@@ -45,7 +46,7 @@ function TransclusionView({ node, editor }: NodeViewProps): React.JSX.Element {
⧉
- {t('transclusion.embedded', { title: label })}
+ {t(bare ? 'transclusion.embeddedBare' : 'transclusion.embedded', { title: label })}
{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 (
+
+ {content.data ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+
return (
diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx
index 53cf5c9..88d32b8 100644
--- a/apps/web/src/pages/AdminSettingsPage.tsx
+++ b/apps/web/src/pages/AdminSettingsPage.tsx
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Field, FormError, FormSuccess } from '../components/forms';
+import { SettingsLayout } from '../components/SettingsLayout';
import { apiGet, apiPatch } from '../lib/api';
import { PluginManager } from './PluginManager';
import { QuotaManager } from './QuotaManager';
@@ -63,61 +64,64 @@ export function AdminSettingsPage(): React.JSX.Element {
{t('system:settingsLink')} →
-
-
-
- {tQuotas('defaults.title')}
-
+
-
-
-
-
+
-
-
-
+
+
+
+
+
+
+
+
+
>
);
}
diff --git a/apps/web/src/pages/AdminSystemPage.tsx b/apps/web/src/pages/AdminSystemPage.tsx
index c98b7af..428b64b 100644
--- a/apps/web/src/pages/AdminSystemPage.tsx
+++ b/apps/web/src/pages/AdminSystemPage.tsx
@@ -11,6 +11,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
+import { SettingsLayout } from '../components/SettingsLayout';
import { formatBytes } from '../files/file-format';
import { apiGet, apiPost } from '../lib/api';
import { BackupSection } from './AdminBackupSection';
@@ -29,10 +30,12 @@ export function AdminSystemPage(): React.JSX.Element {
← {t('backLink')}
-
-
-
-
+
+
+
+
+
+
>
);
}
diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx
index 721cb54..f7707d4 100644
--- a/apps/web/src/pages/PondSettingsPage.tsx
+++ b/apps/web/src/pages/PondSettingsPage.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
+import { SettingsLayout } from '../components/SettingsLayout';
import { ApiOptInSetting } from '../api-tokens/ApiOptInSetting';
import { CommentPolicySetting } from '../comments/CommentPolicySetting';
import { WatchToggle } from '../watches/WatchToggle';
@@ -60,96 +61,98 @@ export function PondSettingsPage(): React.JSX.Element {
{pond.data.name}
-
- {tMembers('title')}
-
-
-
-
-
- {t('settings.title')}
- {canModify ? (
-
- ) : (
-
- {tErrors('forbidden')}
-
+
+
+ {tMembers('title')}
+
+
+
+
+
+ {t('settings.title')}
+ {canModify ? (
+
+ ) : (
+
+ {tErrors('forbidden')}
+
+ )}
+
+ {canModify && (
+
+ {tLinks('missing.title')}
+
+
)}
-
- {canModify && (
-
- {tLinks('missing.title')}
-
+ {canModify && (
+
+ {tImport('vault.title')}
+
+
+ )}
+ {canModify && (
+
+ {tFiles('manager.title')}
+
+
+ )}
+ {canModify && (
+
+ )}
+ {canModify && }
+ {canModify && (
+
+ {tCommon('layout.sidebar.view.defaultTitle')}
+
+
+ )}
+ {canModify && (
+
+ {tComments('policy.title')}
+
+
+ )}
+ {canModify && (
+
+ {tApiTokens('pond.title')}
+
+
+ )}
+
- )}
- {canModify && (
-
- {tImport('vault.title')}
-
-
- )}
- {canModify && (
-
- {tFiles('manager.title')}
-
-
- )}
- {canModify && (
-
- )}
- {canModify && }
- {canModify && (
-
- {tCommon('layout.sidebar.view.defaultTitle')}
-
-
- )}
- {canModify && (
-
- {tComments('policy.title')}
-
-
- )}
- {canModify && (
-
- {tApiTokens('pond.title')}
-
-
- )}
-
- {canModify && pond.data.type === 'shared' && (
-
- )}
+ {canModify && pond.data.type === 'shared' && (
+
+ )}
+
);
}
diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx
index 4ac3cf7..e2b8425 100644
--- a/apps/web/src/pages/SettingsPage.tsx
+++ b/apps/web/src/pages/SettingsPage.tsx
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/auth-context';
import { Field, FormError, FormSuccess, applyFieldErrors } from '../components/forms';
+import { SettingsLayout } from '../components/SettingsLayout';
import { useDataExport } from '../export/use-data-export';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
@@ -25,12 +26,14 @@ export function SettingsPage(): React.JSX.Element {
return (
<>
{t('settings:title')}
-
-
-
-
-
-
+
+
+
+
+
+
+
+
>
);
}
diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css
index f31836a..7cb3e8a 100644
--- a/apps/web/src/styles/base.css
+++ b/apps/web/src/styles/base.css
@@ -3720,3 +3720,91 @@ ul[data-type='task_list'] li p:last-of-type {
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);
+ }
+}
diff --git a/packages/shared/i18n/de/common.json b/packages/shared/i18n/de/common.json
index 0117fa0..a2bdae7 100644
--- a/packages/shared/i18n/de/common.json
+++ b/packages/shared/i18n/de/common.json
@@ -92,5 +92,8 @@
"submit": "Teich löschen",
"deleted": "Teich gelöscht."
}
+ },
+ "settingsNav": {
+ "label": "Abschnitte"
}
}
diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json
index 379b840..08fe3f6 100644
--- a/packages/shared/i18n/de/editor.json
+++ b/packages/shared/i18n/de/editor.json
@@ -175,6 +175,7 @@
},
"transclusion": {
"embedded": "Eingebettet: {{title}}",
+ "embeddedBare": "Nahtlos eingebettet: {{title}}",
"open": "Öffnen"
},
"notFound": {
diff --git a/packages/shared/i18n/de/settings.json b/packages/shared/i18n/de/settings.json
index e441707..a3177a9 100644
--- a/packages/shared/i18n/de/settings.json
+++ b/packages/shared/i18n/de/settings.json
@@ -4,7 +4,10 @@
"title": "Profil",
"displayName": "Anzeigename",
"locale": "Sprache",
- "locales": { "de": "Deutsch", "en": "English" },
+ "locales": {
+ "de": "Deutsch",
+ "en": "English"
+ },
"save": "Speichern",
"saved": "Gespeichert."
},
@@ -38,6 +41,7 @@
},
"admin": {
"title": "Administration",
+ "general": "Allgemein",
"instanceName": "Name der Instanz",
"defaultLocale": "Standardsprache",
"registrationMode": "Selbst-Registrierung",
diff --git a/packages/shared/i18n/en/common.json b/packages/shared/i18n/en/common.json
index b9e8576..a552651 100644
--- a/packages/shared/i18n/en/common.json
+++ b/packages/shared/i18n/en/common.json
@@ -92,5 +92,8 @@
"submit": "Delete pond",
"deleted": "Pond deleted."
}
+ },
+ "settingsNav": {
+ "label": "Sections"
}
}
diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json
index 9684ddf..846f907 100644
--- a/packages/shared/i18n/en/editor.json
+++ b/packages/shared/i18n/en/editor.json
@@ -175,6 +175,7 @@
},
"transclusion": {
"embedded": "Embedded: {{title}}",
+ "embeddedBare": "Embedded seamlessly: {{title}}",
"open": "Open"
},
"notFound": {
diff --git a/packages/shared/i18n/en/settings.json b/packages/shared/i18n/en/settings.json
index 09c9197..6f0db76 100644
--- a/packages/shared/i18n/en/settings.json
+++ b/packages/shared/i18n/en/settings.json
@@ -4,7 +4,10 @@
"title": "Profile",
"displayName": "Display name",
"locale": "Language",
- "locales": { "de": "Deutsch", "en": "English" },
+ "locales": {
+ "de": "Deutsch",
+ "en": "English"
+ },
"save": "Save",
"saved": "Saved."
},
@@ -38,6 +41,7 @@
},
"admin": {
"title": "Administration",
+ "general": "General",
"instanceName": "Instance name",
"defaultLocale": "Default language",
"registrationMode": "Self-registration",
diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts
index 9f79ec3..28014c1 100644
--- a/packages/shared/src/editor-schema/html.ts
+++ b/packages/shared/src/editor-schema/html.ts
@@ -136,10 +136,12 @@ function renderBlock(node: Node): string {
// 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.
+ // 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 display = node.attrs.displayText ? escapeHtml(node.attrs.displayText as string) : slug;
- return `
${display}
`;
+ const bare = node.attrs.bare ? ' data-transclusion-bare="1"' : '';
+ return `
${display}
`;
}
case 'code_block':
return `
${escapeHtml(node.textContent)}`;
diff --git a/packages/shared/src/editor-schema/markdown.ts b/packages/shared/src/editor-schema/markdown.ts
index 5bb7565..d2a9a83 100644
--- a/packages/shared/src/editor-schema/markdown.ts
+++ b/packages/shared/src/editor-schema/markdown.ts
@@ -192,14 +192,16 @@ 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*$/;
+/** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page
+ * (#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
* `![[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.
+ * the lone-embed line is not swallowed as ordinary text. A `$` prefix marks
+ * the embed as `bare` (frameless/titleless, issue #146).
*/
function transclusionRule(
state: StateBlock,
@@ -213,9 +215,10 @@ function transclusionRule(
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();
+ token.attrSet('target', match[2]!.trim());
+ const display = match[3]?.trim();
if (display) token.attrSet('display', display);
+ if (match[1] === '$') token.attrSet('bare', '1');
token.map = [startLine, startLine + 1];
state.line = startLine + 1;
return true;
@@ -349,6 +352,7 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
getAttrs: (tok) => ({
targetSlug: tok.attrGet('target') ?? '',
displayText: tok.attrGet('display') || null,
+ bare: tok.attrGet('bare') === '1',
}),
},
em: { mark: 'italic' },
@@ -477,7 +481,8 @@ const markdownSerializer = new MarkdownSerializer(
transclusion(state, node) {
const slug = node.attrs.targetSlug as string;
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);
},
hard_break(state, node, parent, index) {
diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts
index 543cddd..640b5e8 100644
--- a/packages/shared/src/editor-schema/schema.ts
+++ b/packages/shared/src/editor-schema/schema.ts
@@ -261,13 +261,16 @@ export const editorSchema = new Schema({
// 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.
+ // 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: {
group: 'block',
atom: true,
attrs: {
targetSlug: { validate: 'string' },
displayText: { default: null },
+ bare: { default: false },
},
parseDOM: [
{
@@ -275,6 +278,7 @@ export const editorSchema = new Schema({
getAttrs: (dom) => ({
targetSlug: dom.getAttribute('data-transclusion'),
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',
};
if (display) attrs['data-display'] = display;
+ if (node.attrs.bare) attrs['data-transclusion-bare'] = '1';
return ['div', attrs, display ?? slug];
},
},
diff --git a/packages/shared/src/editor-schema/transclusion.test.ts b/packages/shared/src/editor-schema/transclusion.test.ts
index a92e79b..0b023d6 100644
--- a/packages/shared/src/editor-schema/transclusion.test.ts
+++ b/packages/shared/src/editor-schema/transclusion.test.ts
@@ -48,3 +48,44 @@ describe('page embed / transclusion (issue #135)', () => {
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();
+ });
+});