import type { BlockConfig, KsAppClientDataNoMeta } from '@knapsack/types'; import { type JSONContent, findUnrecognizedTiptapTypes, recognizedTiptapTypeNames, } from '@knapsack/tiptap-utils'; import { getTiptapContentFields } from './tiptap-content-fields.js'; export type UnrecognizedTiptapContentReport = { blockId: string; blockType: BlockConfig['blockType']; /** Which JSONContent field on the block, e.g. 'content' or 'guidelines[2].content' */ fieldPath: string; unrecognizedTypes: readonly string[]; }; // Immer's structural sharing means a block's JSONContent field reference is // unchanged across appClientData versions unless that specific block was // edited - so after any single edit, this cache is a hit for every other // block, avoiding a full site-wide re-walk on every debounced content change. // Keyed on `recognizedTypeNames` first (production always passes the same // singleton default, so this outer lookup is effectively free) so that a // caller passing a different set - e.g. tests - can never read back a // result computed under a different set for the same content reference. const unrecognizedTypesCache = new WeakMap< ReadonlySet, WeakMap >(); function findUnrecognizedTypesCached( content: JSONContent | null | undefined, recognizedTypeNames: ReadonlySet, ): readonly string[] { if (!content) return []; let cacheForSet = unrecognizedTypesCache.get(recognizedTypeNames); if (!cacheForSet) { cacheForSet = new WeakMap(); unrecognizedTypesCache.set(recognizedTypeNames, cacheForSet); } const cached = cacheForSet.get(content); if (cached) return cached; // Frozen before caching - this array is handed out by reference to every // caller sharing this content reference for the module's lifetime, so an // accidental downstream mutation must fail loudly instead of silently // corrupting results for every other block. const found = Object.freeze( findUnrecognizedTiptapTypes(content, recognizedTypeNames), ); cacheForSet.set(content, found); return found; } /** * Non-mutating. Walks every block in appClientData.db.blocks.byId that can * carry JSONContent, and reports any unrecognized node/mark type names * found. Does NOT resolve page/pattern location - callers already have * buildBlockLocationMap()/BlockCollectionLocation for that (this package * cannot import from app-ui). */ export function detectUnrecognizedTiptapContent({ appClientData, recognizedTypeNames = recognizedTiptapTypeNames, }: { appClientData: KsAppClientDataNoMeta; recognizedTypeNames?: ReadonlySet; }): UnrecognizedTiptapContentReport[] { const reports: UnrecognizedTiptapContentReport[] = []; for (const block of Object.values(appClientData.db.blocks.byId)) { for (const { fieldPath, content } of getTiptapContentFields(block)) { const unrecognizedTypes = findUnrecognizedTypesCached( content, recognizedTypeNames, ); if (unrecognizedTypes.length > 0) { reports.push({ blockId: block.id, blockType: block.blockType, fieldPath, unrecognizedTypes, }); } } } return reports; }