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; - }, - }), - ]; - }, });