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