feat: enforce strict transclusion schema

This commit is contained in:
Philipinho
2026-05-08 00:26:31 +01:00
parent c5ebc29d6b
commit 09f2b84988
12 changed files with 91 additions and 308 deletions
@@ -0,0 +1,41 @@
/**
* Top-level block node types allowed inside a `transclusionSource`.
* Notably excludes:
* - `transclusionSource` — sync blocks cannot wrap other sync blocks (sources are leaves).
* - `transclusionReference` — sync blocks cannot transclude other sync blocks,
* which keeps the transclusion graph acyclic and lets the renderer skip
* cycle-aware traversal entirely.
*
* Also excludes child-only nodes (`listItem`, `tableRow`, `column`, etc.)
* — they're already constrained by their parent containers.
*/
export const TRANSCLUSION_SOURCE_ALLOWED_NODE_TYPES = [
'paragraph',
'heading',
'blockquote',
'codeBlock',
'horizontalRule',
'bulletList',
'orderedList',
'taskList',
'image',
'video',
'audio',
'attachment',
'callout',
'details',
'embed',
'mathBlock',
'table',
'drawio',
'excalidraw',
'pdf',
'subpages',
'columns',
'youtube',
] as const;
export type TransclusionSourceAllowedNodeType =
(typeof TRANSCLUSION_SOURCE_ALLOWED_NODE_TYPES)[number];
export const TRANSCLUSION_SOURCE_CONTENT_EXPRESSION = `(${TRANSCLUSION_SOURCE_ALLOWED_NODE_TYPES.join(' | ')})+`;
@@ -1,2 +1,3 @@
export * from "./constants";
export * from "./transclusion-source";
export * from "./transclusion-reference";
@@ -1,6 +1,6 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { TRANSCLUSION_SOURCE_CONTENT_EXPRESSION } from "./constants";
export interface TransclusionSourceOptions {
HTMLAttributes: Record<string, any>;
@@ -34,7 +34,8 @@ export const TransclusionSource = Node.create<TransclusionSourceOptions>({
},
group: "block",
content: "block+",
// Schema-enforced allow-list. Excludes `transclusionSource` (no nesting)
content: TRANSCLUSION_SOURCE_CONTENT_EXPRESSION,
defining: true,
isolating: true,
@@ -130,30 +131,4 @@ export const TransclusionSource = Node.create<TransclusionSourceOptions>({
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
addProseMirrorPlugins() {
const typeName = this.name;
return [
new Plugin({
key: new PluginKey(`${typeName}-noNesting`),
filterTransaction: (tr) => {
if (!tr.docChanged) return true;
let nested = false;
tr.doc.descendants((node, pos) => {
if (nested) return false;
if (node.type.name !== typeName) return true;
const $pos = tr.doc.resolve(pos);
for (let depth = $pos.depth; depth > 0; depth -= 1) {
if ($pos.node(depth).type.name === typeName) {
nested = true;
return false;
}
}
return false;
});
return !nested;
},
}),
];
},
});