#146: Transparente Einbettung $[[Seite]] ohne Rahmen und Titel
Neues bare-Attr am transclusion-Node; $-Präfix in Markdown-Regel, Serializer und Autocomplete; HTML-Placeholder trägt data-transclusion-bare, Server-Expansion und NodeView lassen bei bare Rahmen und Titel weg. Gleiche Tiefen-/Zyklen-/Permission-Regeln, zählt weiter als Link. Unit- und DB-Tests ergänzt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
This commit is contained in:
parent
8d1172154b
commit
9bd25f6ce3
@ -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
|
||||||
|
|||||||
@ -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)}">` +
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -175,6 +175,7 @@
|
|||||||
},
|
},
|
||||||
"transclusion": {
|
"transclusion": {
|
||||||
"embedded": "Eingebettet: {{title}}",
|
"embedded": "Eingebettet: {{title}}",
|
||||||
|
"embeddedBare": "Nahtlos eingebettet: {{title}}",
|
||||||
"open": "Öffnen"
|
"open": "Öffnen"
|
||||||
},
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
|
|||||||
@ -175,6 +175,7 @@
|
|||||||
},
|
},
|
||||||
"transclusion": {
|
"transclusion": {
|
||||||
"embedded": "Embedded: {{title}}",
|
"embedded": "Embedded: {{title}}",
|
||||||
|
"embeddedBare": "Embedded seamlessly: {{title}}",
|
||||||
"open": "Open"
|
"open": "Open"
|
||||||
},
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
|
|||||||
@ -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>`;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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];
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user