From 09f2b849883134c4a17fffae50856613600e69f0 Mon Sep 17 00:00:00 2001
From: Philipinho <16838612+Philipinho@users.noreply.github.com>
Date: Fri, 8 May 2026 00:26:31 +0100
Subject: [PATCH] feat: enforce strict transclusion schema
---
.../transclusion/transclusion-content.tsx | 31 +++----
.../extensions/persistence.extension.ts | 9 +-
.../transclusion-prosemirror.util.spec.ts | 18 ++--
.../spec/transclusion.service.spec.ts | 65 +-------------
.../page/transclusion/transclusion.service.ts | 74 +---------------
.../utils/transclusion-prosemirror.util.ts | 33 +++----
.../20260501T202258-page-transclusions.ts | 8 --
.../page-transclusion-references.repo.ts | 87 +------------------
apps/server/src/database/types/db.d.ts | 1 -
.../src/lib/transclusion/constants.ts | 41 +++++++++
.../editor-ext/src/lib/transclusion/index.ts | 1 +
.../lib/transclusion/transclusion-source.ts | 31 +------
12 files changed, 91 insertions(+), 308 deletions(-)
create mode 100644 packages/editor-ext/src/lib/transclusion/constants.ts
diff --git a/apps/client/src/features/editor/components/transclusion/transclusion-content.tsx b/apps/client/src/features/editor/components/transclusion/transclusion-content.tsx
index a412a4f4..94629122 100644
--- a/apps/client/src/features/editor/components/transclusion/transclusion-content.tsx
+++ b/apps/client/src/features/editor/components/transclusion/transclusion-content.tsx
@@ -2,7 +2,6 @@ import { EditorProvider } from "@tiptap/react";
import { useMemo } from "react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { UniqueID } from "@docmost/editor-ext";
-import { TransclusionLookupProvider } from "./transclusion-lookup-context";
type Props = {
content: unknown;
@@ -31,21 +30,19 @@ export default function TransclusionContent({ content }: Props) {
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
return (
-
-
-
-
-
+
+
+
);
}
diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts
index 76bf4267..fd20af7a 100644
--- a/apps/server/src/collaboration/extensions/persistence.extension.ts
+++ b/apps/server/src/collaboration/extensions/persistence.extension.ts
@@ -255,14 +255,17 @@ export class PersistenceExtension implements Extension {
try {
await this.transclusionService.syncPageTransclusions(pageId, tiptapJson);
} catch (err) {
- this.logger.error(`Failed to sync transclusions for page ${pageId}`, err);
+ this.logger.error(
+ { err, pageId },
+ 'Failed to sync transclusions for page',
+ );
}
try {
await this.transclusionService.syncPageReferences(pageId, tiptapJson);
} catch (err) {
this.logger.error(
- `Failed to sync transclusion references for page ${pageId}`,
- err,
+ { err, pageId },
+ 'Failed to sync transclusion references for page',
);
}
}
diff --git a/apps/server/src/core/page/transclusion/spec/transclusion-prosemirror.util.spec.ts b/apps/server/src/core/page/transclusion/spec/transclusion-prosemirror.util.spec.ts
index 03070fb5..1661a090 100644
--- a/apps/server/src/core/page/transclusion/spec/transclusion-prosemirror.util.spec.ts
+++ b/apps/server/src/core/page/transclusion/spec/transclusion-prosemirror.util.spec.ts
@@ -149,7 +149,7 @@ describe('collectReferencesFromPmJson', () => {
],
};
expect(collectReferencesFromPmJson(doc)).toEqual([
- { containingTransclusionId: null, sourcePageId: 'p1', transclusionId: 'e1' },
+ { sourcePageId: 'p1', transclusionId: 'e1' },
]);
});
@@ -190,12 +190,12 @@ describe('collectReferencesFromPmJson', () => {
],
};
expect(collectReferencesFromPmJson(doc)).toEqual([
- { containingTransclusionId: null, sourcePageId: 'p1', transclusionId: 'e1' },
- { containingTransclusionId: null, sourcePageId: 'p2', transclusionId: 'e2' },
+ { sourcePageId: 'p1', transclusionId: 'e1' },
+ { sourcePageId: 'p2', transclusionId: 'e2' },
]);
});
- it('also finds references nested inside a transclusion (source) node', () => {
+ it('does not recurse into a transclusion source (schema forbids references inside)', () => {
const doc = {
type: 'doc',
content: [
@@ -211,12 +211,10 @@ describe('collectReferencesFromPmJson', () => {
},
],
};
- expect(collectReferencesFromPmJson(doc)).toEqual([
- { containingTransclusionId: 'src1', sourcePageId: 'p1', transclusionId: 'e1' },
- ]);
+ expect(collectReferencesFromPmJson(doc)).toEqual([]);
});
- it('dedupes identical (containingTransclusionId, sourcePageId, transclusionId) triples', () => {
+ it('dedupes identical (sourcePageId, transclusionId) pairs', () => {
const doc = {
type: 'doc',
content: [
@@ -235,8 +233,8 @@ describe('collectReferencesFromPmJson', () => {
],
};
expect(collectReferencesFromPmJson(doc)).toEqual([
- { containingTransclusionId: null, sourcePageId: 'p1', transclusionId: 'e1' },
- { containingTransclusionId: null, sourcePageId: 'p2', transclusionId: 'e2' },
+ { sourcePageId: 'p1', transclusionId: 'e1' },
+ { sourcePageId: 'p2', transclusionId: 'e2' },
]);
});
});
diff --git a/apps/server/src/core/page/transclusion/spec/transclusion.service.spec.ts b/apps/server/src/core/page/transclusion/spec/transclusion.service.spec.ts
index 3a05047e..15a7ea09 100644
--- a/apps/server/src/core/page/transclusion/spec/transclusion.service.spec.ts
+++ b/apps/server/src/core/page/transclusion/spec/transclusion.service.spec.ts
@@ -177,8 +177,6 @@ describe('TransclusionService.syncPageReferences', () => {
findByReferencePageId: jest.fn(),
insertMany: jest.fn(),
deleteByReferenceAndKeys: jest.fn(),
- findCyclicEdgesForSource: jest.fn().mockResolvedValue([]),
- deleteByIds: jest.fn(),
};
const module = await Test.createTestingModule({
providers: [
@@ -220,13 +218,11 @@ describe('TransclusionService.syncPageReferences', () => {
[
{
referencePageId,
- containingTransclusionId: null,
sourcePageId: 'p1',
transclusionId: 'e1',
},
{
referencePageId,
- containingTransclusionId: null,
sourcePageId: 'p2',
transclusionId: 'e2',
},
@@ -234,11 +230,9 @@ describe('TransclusionService.syncPageReferences', () => {
undefined,
);
expect(refRepo.deleteByReferenceAndKeys).not.toHaveBeenCalled();
- // Loose references never seed cycle detection.
- expect(refRepo.findCyclicEdgesForSource).not.toHaveBeenCalled();
});
- it('records the containing transclusion when references nest in a source', async () => {
+ it('ignores references nested inside a source (schema-forbidden)', async () => {
refRepo.findByReferencePageId.mockResolvedValue([]);
const pm = {
type: 'doc',
@@ -258,60 +252,8 @@ describe('TransclusionService.syncPageReferences', () => {
const result = await service.syncPageReferences(referencePageId, pm);
- expect(result).toEqual({ inserted: 1, deleted: 0 });
- expect(refRepo.insertMany).toHaveBeenCalledWith(
- [
- {
- referencePageId,
- containingTransclusionId: 's1',
- sourcePageId: 'p2',
- transclusionId: 'e2',
- },
- ],
- undefined,
- );
- expect(refRepo.findCyclicEdgesForSource).toHaveBeenCalledWith(
- 'p2',
- 'e2',
- undefined,
- );
- });
-
- it('deletes edges that close a cycle and excludes them from the inserted count', async () => {
- refRepo.findByReferencePageId.mockResolvedValue([]);
- refRepo.findCyclicEdgesForSource.mockResolvedValue([
- {
- id: 'closing-edge-id',
- referencePageId,
- containingTransclusionId: 's1',
- sourcePageId: 'p2',
- transclusionId: 'e2',
- createdAt: new Date(),
- } as any,
- ]);
- const pm = {
- type: 'doc',
- content: [
- {
- type: 'transclusionSource',
- attrs: { id: 's1' },
- content: [
- {
- type: 'transclusionReference',
- attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
- },
- ],
- },
- ],
- };
-
- const result = await service.syncPageReferences(referencePageId, pm);
-
expect(result).toEqual({ inserted: 0, deleted: 0 });
- expect(refRepo.deleteByIds).toHaveBeenCalledWith(
- ['closing-edge-id'],
- undefined,
- );
+ expect(refRepo.insertMany).not.toHaveBeenCalled();
});
it('deletes references that no longer appear', async () => {
@@ -319,7 +261,6 @@ describe('TransclusionService.syncPageReferences', () => {
{
id: 'r1',
referencePageId,
- containingTransclusionId: null,
sourcePageId: 'p1',
transclusionId: 'e1',
createdAt: new Date(),
@@ -334,7 +275,6 @@ describe('TransclusionService.syncPageReferences', () => {
referencePageId,
[
{
- containingTransclusionId: null,
sourcePageId: 'p1',
transclusionId: 'e1',
},
@@ -349,7 +289,6 @@ describe('TransclusionService.syncPageReferences', () => {
{
id: 'r',
referencePageId,
- containingTransclusionId: null,
sourcePageId: 'p1',
transclusionId: 'e1',
createdAt: new Date(),
diff --git a/apps/server/src/core/page/transclusion/transclusion.service.ts b/apps/server/src/core/page/transclusion/transclusion.service.ts
index 09c4215d..d6945fe7 100644
--- a/apps/server/src/core/page/transclusion/transclusion.service.ts
+++ b/apps/server/src/core/page/transclusion/transclusion.service.ts
@@ -107,11 +107,9 @@ export class TransclusionService {
): Promise<{ inserted: number; deleted: number }> {
const desired = collectReferencesFromPmJson(pmJson);
const keyOf = (s: {
- containingTransclusionId: string | null;
sourcePageId: string;
transclusionId: string;
- }) =>
- `${s.containingTransclusionId ?? ''}::${s.sourcePageId}::${s.transclusionId}`;
+ }) => `${s.sourcePageId}::${s.transclusionId}`;
const desiredKeys = new Set(desired.map(keyOf));
const existing = await this.pageTransclusionReferencesRepo.findByReferencePageId(
@@ -124,7 +122,6 @@ export class TransclusionService {
.filter((d) => !existingKeys.has(keyOf(d)))
.map((d) => ({
referencePageId,
- containingTransclusionId: d.containingTransclusionId,
sourcePageId: d.sourcePageId,
transclusionId: d.transclusionId,
}));
@@ -132,7 +129,6 @@ export class TransclusionService {
const toDelete = existing
.filter((e) => !desiredKeys.has(keyOf(e)))
.map((e) => ({
- containingTransclusionId: e.containingTransclusionId,
sourcePageId: e.sourcePageId,
transclusionId: e.transclusionId,
}));
@@ -148,66 +144,12 @@ export class TransclusionService {
);
}
- const removedCount = await this.removeCyclicEdgesIntroducedBy(
- toInsert,
- trx,
- );
-
return {
- inserted: toInsert.length - removedCount,
+ inserted: toInsert.length,
deleted: toDelete.length,
};
}
- /**
- * Run cycle detection rooted at each newly-introduced edge's target and
- * delete any closing edge that belongs to a cycle. Lookups for those rows
- * then return `not_found`, which the editor renders as the cycle-aware
- * placeholder. Returns the count of rows removed.
- */
- private async removeCyclicEdgesIntroducedBy(
- candidates: ReadonlyArray<{
- referencePageId: string;
- containingTransclusionId: string | null;
- sourcePageId: string;
- transclusionId: string;
- }>,
- trx?: KyselyTransaction,
- ): Promise {
- const seedKeys = new Set();
- const seeds: Array<{ sourcePageId: string; transclusionId: string }> = [];
- for (const c of candidates) {
- if (c.containingTransclusionId === null) continue;
- const key = `${c.sourcePageId}::${c.transclusionId}`;
- if (seedKeys.has(key)) continue;
- seedKeys.add(key);
- seeds.push({
- sourcePageId: c.sourcePageId,
- transclusionId: c.transclusionId,
- });
- }
- if (seeds.length === 0) return 0;
-
- const offendingIds = new Set();
- for (const seed of seeds) {
- const cyclicEdges =
- await this.pageTransclusionReferencesRepo.findCyclicEdgesForSource(
- seed.sourcePageId,
- seed.transclusionId,
- trx,
- );
- for (const edge of cyclicEdges) offendingIds.add(edge.id);
- }
-
- if (offendingIds.size === 0) return 0;
-
- await this.pageTransclusionReferencesRepo.deleteByIds(
- Array.from(offendingIds),
- trx,
- );
- return offendingIds.size;
- }
-
/**
* Extract transclusions from each page's PM JSON and bulk-insert into
* `page_transclusions` in a single statement. Intended for brand-new pages
@@ -235,12 +177,8 @@ export class TransclusionService {
/**
* Walk each page's PM JSON for `transclusionReference` nodes and bulk-insert
- * one row per `(containing, source, target)` triple. For brand-new pages
+ * one row per `(referencePage, source, target)`. For brand-new pages
* (duplication, import) where there is nothing to diff against.
- *
- * Cycle detection runs once per distinct seed source after the bulk insert;
- * any closing edges are removed so lookups return `not_found` and the
- * editor renders the cycle-aware placeholder.
*/
async insertReferencesForPages(
pages: Array<{ id: string; content: unknown }>,
@@ -248,7 +186,6 @@ export class TransclusionService {
): Promise<{ inserted: number }> {
const rows: Array<{
referencePageId: string;
- containingTransclusionId: string | null;
sourcePageId: string;
transclusionId: string;
}> = [];
@@ -257,7 +194,6 @@ export class TransclusionService {
for (const r of refs) {
rows.push({
referencePageId: page.id,
- containingTransclusionId: r.containingTransclusionId,
sourcePageId: r.sourcePageId,
transclusionId: r.transclusionId,
});
@@ -265,9 +201,7 @@ export class TransclusionService {
}
if (rows.length === 0) return { inserted: 0 };
await this.pageTransclusionReferencesRepo.insertMany(rows, trx);
-
- const removedCount = await this.removeCyclicEdgesIntroducedBy(rows, trx);
- return { inserted: rows.length - removedCount };
+ return { inserted: rows.length };
}
async lookup(
diff --git a/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts b/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts
index 35e0e0b8..307985f8 100644
--- a/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts
+++ b/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts
@@ -4,12 +4,6 @@ const TRANSCLUSION_TYPE = 'transclusionSource';
const REFERENCE_TYPE = 'transclusionReference';
export type TransclusionReferenceSnapshot = {
- /**
- * Id of the `transclusion` (source) node whose content holds this reference,
- * or `null` if the reference is loose on the page (not nested inside a source).
- * Used by the cycle-detection CTE to walk source-to-source edges.
- */
- containingTransclusionId: string | null;
sourcePageId: string;
transclusionId: string;
};
@@ -53,9 +47,9 @@ export function collectTransclusionsFromPmJson(
/**
* Walks a ProseMirror JSON document and returns one snapshot per unique
- * `(containingTransclusionId, sourcePageId, transclusionId)` triple found on
- * `transclusionReference` nodes. Recurses into every container, including
- * `transclusion` (a source node may contain a reference to another source).
+ * `(sourcePageId, transclusionId)` pair found on `transclusionReference`
+ * nodes. The schema forbids references inside a `transclusionSource` so this
+ * walk stops at source boundaries — references can only appear at page level.
* Order preserved by first-seen.
*/
export function collectReferencesFromPmJson(
@@ -66,7 +60,7 @@ export function collectReferencesFromPmJson(
const seen = new Set();
const out: TransclusionReferenceSnapshot[] = [];
- const visit = (node: any, containingTransclusionId: string | null): void => {
+ const visit = (node: any): void => {
if (!node || typeof node !== 'object') return;
if (node.type === REFERENCE_TYPE) {
@@ -78,29 +72,24 @@ export function collectReferencesFromPmJson(
typeof transclusionId === 'string' &&
transclusionId.length > 0
) {
- const key = `${containingTransclusionId ?? ''}::${sourcePageId}::${transclusionId}`;
+ const key = `${sourcePageId}::${transclusionId}`;
if (!seen.has(key)) {
seen.add(key);
- out.push({
- containingTransclusionId,
- sourcePageId,
- transclusionId,
- });
+ out.push({ sourcePageId, transclusionId });
}
}
return; // atom node - no children
}
- const nextContainer =
- node.type === TRANSCLUSION_TYPE && typeof node.attrs?.id === 'string'
- ? node.attrs.id
- : containingTransclusionId;
+ // References cannot live inside a source (schema-enforced); skip recursing
+ // so a malformed inbound doc can't sneak in a nested reference here.
+ if (node.type === TRANSCLUSION_TYPE) return;
if (Array.isArray(node.content)) {
- for (const child of node.content) visit(child, nextContainer);
+ for (const child of node.content) visit(child);
}
};
- visit(doc, null);
+ visit(doc);
return out;
}
diff --git a/apps/server/src/database/migrations/20260501T202258-page-transclusions.ts b/apps/server/src/database/migrations/20260501T202258-page-transclusions.ts
index 98126b01..a4f502e6 100644
--- a/apps/server/src/database/migrations/20260501T202258-page-transclusions.ts
+++ b/apps/server/src/database/migrations/20260501T202258-page-transclusions.ts
@@ -31,7 +31,6 @@ export async function up(db: Kysely): Promise {
.addColumn('reference_page_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
- .addColumn('containing_transclusion_id', 'varchar')
.addColumn('source_page_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
@@ -41,7 +40,6 @@ export async function up(db: Kysely): Promise {
)
.addUniqueConstraint('page_transclusion_references_unique', [
'reference_page_id',
- 'containing_transclusion_id',
'source_page_id',
'transclusion_id',
])
@@ -52,12 +50,6 @@ export async function up(db: Kysely): Promise {
.on('page_transclusion_references')
.columns(['source_page_id', 'transclusion_id'])
.execute();
-
- await db.schema
- .createIndex('idx_page_transclusion_references_container')
- .on('page_transclusion_references')
- .columns(['reference_page_id', 'containing_transclusion_id'])
- .execute();
}
export async function down(db: Kysely): Promise {
diff --git a/apps/server/src/database/repos/page-transclusions/page-transclusion-references.repo.ts b/apps/server/src/database/repos/page-transclusions/page-transclusion-references.repo.ts
index 0601747d..f195dc50 100644
--- a/apps/server/src/database/repos/page-transclusions/page-transclusion-references.repo.ts
+++ b/apps/server/src/database/repos/page-transclusions/page-transclusion-references.repo.ts
@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
-import { sql } from 'kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import {
@@ -9,7 +8,6 @@ import {
} from '@docmost/db/types/entity.types';
export type TransclusionReferenceKey = {
- containingTransclusionId: string | null;
sourcePageId: string;
transclusionId: string;
};
@@ -54,12 +52,7 @@ export class PageTransclusionReferencesRepo {
.values(rows)
.onConflict((oc) =>
oc
- .columns([
- 'referencePageId',
- 'containingTransclusionId',
- 'sourcePageId',
- 'transclusionId',
- ])
+ .columns(['referencePageId', 'sourcePageId', 'transclusionId'])
.doNothing(),
)
.execute();
@@ -78,13 +71,6 @@ export class PageTransclusionReferencesRepo {
eb.or(
keys.map((k) =>
eb.and([
- k.containingTransclusionId === null
- ? eb('containingTransclusionId', 'is', null)
- : eb(
- 'containingTransclusionId',
- '=',
- k.containingTransclusionId,
- ),
eb('sourcePageId', '=', k.sourcePageId),
eb('transclusionId', '=', k.transclusionId),
]),
@@ -107,75 +93,4 @@ export class PageTransclusionReferencesRepo {
.where('transclusionId', '=', transclusionId)
.execute();
}
-
- async deleteByIds(ids: string[], trx?: KyselyTransaction): Promise {
- if (ids.length === 0) return;
- await dbOrTx(this.db, trx)
- .deleteFrom('pageTransclusionReferences')
- .where('id', 'in', ids)
- .execute();
- }
-
- /**
- * Finds reference rows that participate in a cycle reachable from a given
- * source `(pageId, transclusionId)`. The walk follows source-to-source edges
- * (rows where `containing_transclusion_id IS NOT NULL`); loose page-level
- * references are not graph edges and are ignored.
- *
- * Returned rows are the *closing edges* — those whose insertion completed a
- * cycle. They are the safe set to remove to break the cycle while preserving
- * unrelated structure.
- */
- async findCyclicEdgesForSource(
- sourcePageId: string,
- transclusionId: string,
- trx?: KyselyTransaction,
- ): Promise {
- const rows = await sql`
- WITH RECURSIVE walk(
- start_page,
- start_id,
- page_id,
- transclusion_id,
- edge_id,
- is_cycle,
- path
- ) AS (
- SELECT
- ${sourcePageId}::uuid,
- ${transclusionId}::varchar,
- ${sourcePageId}::uuid,
- ${transclusionId}::varchar,
- NULL::uuid,
- false,
- ARRAY[(${sourcePageId}::uuid, ${transclusionId}::varchar)]
- UNION ALL
- SELECT
- w.start_page,
- w.start_id,
- r.source_page_id,
- r.transclusion_id,
- r.id,
- (r.source_page_id, r.transclusion_id) = ANY(w.path),
- w.path || ARRAY[(r.source_page_id, r.transclusion_id)]
- FROM page_transclusion_references r
- JOIN walk w
- ON r.reference_page_id = w.page_id
- AND r.containing_transclusion_id = w.transclusion_id
- WHERE r.containing_transclusion_id IS NOT NULL
- AND NOT w.is_cycle
- )
- SELECT
- r.id,
- r.created_at AS "createdAt",
- r.reference_page_id AS "referencePageId",
- r.containing_transclusion_id AS "containingTransclusionId",
- r.source_page_id AS "sourcePageId",
- r.transclusion_id AS "transclusionId"
- FROM walk w
- JOIN page_transclusion_references r ON r.id = w.edge_id
- WHERE w.is_cycle
- `.execute(dbOrTx(this.db, trx));
- return rows.rows;
- }
}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 3365de0e..9cc18dba 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -232,7 +232,6 @@ export interface PageTransclusionReferences {
createdAt: Generated;
transclusionId: string;
referencePageId: string;
- containingTransclusionId: string | null;
id: Generated;
sourcePageId: string;
}
diff --git a/packages/editor-ext/src/lib/transclusion/constants.ts b/packages/editor-ext/src/lib/transclusion/constants.ts
new file mode 100644
index 00000000..762addea
--- /dev/null
+++ b/packages/editor-ext/src/lib/transclusion/constants.ts
@@ -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(' | ')})+`;
diff --git a/packages/editor-ext/src/lib/transclusion/index.ts b/packages/editor-ext/src/lib/transclusion/index.ts
index 89c8f1c7..619bc922 100644
--- a/packages/editor-ext/src/lib/transclusion/index.ts
+++ b/packages/editor-ext/src/lib/transclusion/index.ts
@@ -1,2 +1,3 @@
+export * from "./constants";
export * from "./transclusion-source";
export * from "./transclusion-reference";
diff --git a/packages/editor-ext/src/lib/transclusion/transclusion-source.ts b/packages/editor-ext/src/lib/transclusion/transclusion-source.ts
index f04a3ae1..9e1b6165 100644
--- a/packages/editor-ext/src/lib/transclusion/transclusion-source.ts
+++ b/packages/editor-ext/src/lib/transclusion/transclusion-source.ts
@@ -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;
@@ -34,7 +34,8 @@ export const TransclusionSource = Node.create({
},
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({
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;
- },
- }),
- ];
- },
});