diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b32b1d1..5815f24 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -396,6 +396,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ 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 run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/src/public/public.e2e.db.test.ts b/apps/api/src/public/public.e2e.db.test.ts index 9cb5a39..def1f8f 100644 --- a/apps/api/src/public/public.e2e.db.test.ts +++ b/apps/api/src/public/public.e2e.db.test.ts @@ -169,6 +169,28 @@ describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => { 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', '

Bare body text.

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

Before.

` + + `
Bare Target
`, + ); + + 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'); + expect(html).not.toContain('dt-transclusion'); + }); + it('404s all public endpoints once the public grant is removed', async () => { await prisma.roleGrant.delete({ where: { id: publicGrantId } }); // The API route invalidates on its own mutations; this test deletes diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index fa33498..58236d5 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -115,9 +115,11 @@ export class PublicService { depth: number, visited: Set, ): Promise { - const placeholder = /
[^<]*<\/div>/g; - return replaceAsync(html, placeholder, async (_match, rawSlug) => { + const placeholder = + /
[^<]*<\/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 && ( + + )} +
+ {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')}

-
- {( - [ - 'quota.editorsPerPond', - 'quota.readersPerPond', - 'quota.additionalPonds', - 'quota.storageBytes', - 'quota.maxFileBytes', - ] as const - ).map((key) => ( - - + +
+

{t('settings:admin.general')}

+ + + + + - ))} - - -
+ + + + + + + + +
- - - - +
+

{tQuotas('defaults.title')}

+
+ {( + [ + 'quota.editorsPerPond', + 'quota.readersPerPond', + 'quota.additionalPonds', + 'quota.storageBytes', + 'quota.maxFileBytes', + ] as const + ).map((key) => ( + + + + ))} + +
+
- - - + + + + + + + + + ); } 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 && ( +
+

{tFont('heading')}

+ +
+ )} + {canModify && } + {canModify && ( +
+

{tCommon('layout.sidebar.view.defaultTitle')}

+ +
+ )} + {canModify && ( +
+

{tComments('policy.title')}

+ +
+ )} + {canModify && ( +
+

{tApiTokens('pond.title')}

+ +
+ )} +
+

{tExport('pond.heading')}

+

{tExport('pond.hint')}

+
+ {tExport('pond.zip')} +
- )} - {canModify && ( -
-

{tImport('vault.title')}

- -
- )} - {canModify && ( -
-

{tFiles('manager.title')}

- -
- )} - {canModify && ( -
-

{tFont('heading')}

- -
- )} - {canModify && } - {canModify && ( -
-

{tCommon('layout.sidebar.view.defaultTitle')}

- -
- )} - {canModify && ( -
-

{tComments('policy.title')}

- -
- )} - {canModify && ( -
-

{tApiTokens('pond.title')}

- -
- )} -
-

{tExport('pond.heading')}

-

{tExport('pond.hint')}

- - {tExport('pond.zip')} - -
- {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(); + }); +});