import type { ProvisioningIntent } from "../notion-as-code/provisioning.js"; /** Check App-local references without treating code examples as placements. */ export function validateCustomBlockReferences( intents: readonly ProvisioningIntent[], customBlockKeys: ReadonlySet, ): void { for (const intent of intents) { if (intent.type !== "page") continue; if (intent.customBlockKey !== undefined && !customBlockKeys.has(intent.customBlockKey)) { throw new Error( `APP.md custom_block "${intent.customBlockKey}" does not match any file in src/customBlocks.`, ); } if (!intent.content) continue; let fence: { marker: string; length: number } | undefined; for (const line of intent.content.split(/\r?\n/)) { // Nested list/quote contents use the same block syntax as top-level pages. const content = line.replace(/^\s*(?:>\s*)*/, "").replace(/^(?:[-+*]|\d+[.)])\s+/, ""); const delimiter = content.match(/^(`{3,}|~{3,})(.*)$/); const delimiterMarker = delimiter?.[1] ?? ""; const delimiterTail = delimiter?.[2] ?? ""; if (fence) { if ( delimiter && delimiterMarker.charAt(0) === fence.marker && delimiterMarker.length >= fence.length && !delimiterTail.trim() ) fence = undefined; continue; } if (delimiter) { fence = { marker: delimiterMarker.charAt(0), length: delimiterMarker.length }; continue; } const reference = content.match( /^]*)"|'([^'<>]*)')\s*\/>\s*$/, ); if (!reference) continue; // The server parser validates the complete NFM grammar. const key = (reference[1] ?? reference[2] ?? "").replace( /&(?:amp|quot|apos|lt|gt);/g, (entity) => ({ "&": "&", """: '"', "'": "'", "<": "<", ">": ">" })[ entity ]!, ); if (!customBlockKeys.has(key)) { throw new Error( `Page "${intent.resourceId}" references undeclared custom block key "${key}". Declare src/customBlocks/${key}.ts or use definition-id for an existing definition.`, ); } } } }