From d0ea2744e29a5961176359b7d045e9fdc7a2c054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Mon, 16 Feb 2026 08:55:04 +0000 Subject: [PATCH 1/2] feat(schema): add nestedBlocks, OfDefinition discriminated union, and of on FieldDefinition - Add NestedBlockSchemaType/NestedBlockDefinition for block types that appear inside block objects (e.g. table cells containing PTE content) - Add OfDefinition = BlockOfDefinition | ObjectOfDefinition discriminated union on FieldDefinition.of for array field member types - BlockOfDefinition (type: 'block') carries PTE sub-schema: styles, decorators, annotations, lists - ObjectOfDefinition (type: string) carries fields for non-block types - compileSchema() emits nestedBlocks: [] for existing schemas (additive) - 8 tests: backwards compat, nested blocks, of with block/object types, full table schema example --- .../block-tools/src/util/normalizeBlock.ts | 1 + packages/schema/src/compile-schema.test.ts | 249 ++++++++++++++++++ packages/schema/src/compile-schema.ts | 43 ++- packages/schema/src/define-schema.ts | 10 + packages/schema/src/index.ts | 5 + packages/schema/src/schema.ts | 47 ++++ 6 files changed, 351 insertions(+), 4 deletions(-) diff --git a/packages/block-tools/src/util/normalizeBlock.ts b/packages/block-tools/src/util/normalizeBlock.ts index e27f73fd8..8dd509c14 100644 --- a/packages/block-tools/src/util/normalizeBlock.ts +++ b/packages/block-tools/src/util/normalizeBlock.ts @@ -67,6 +67,7 @@ export function normalizeBlock( annotations: [], blockObjects: [], inlineObjects: [], + nestedBlocks: [], } if (node._type !== (options.blockTypeName || 'block')) { diff --git a/packages/schema/src/compile-schema.test.ts b/packages/schema/src/compile-schema.test.ts index af16609a6..ae5eaa5a1 100644 --- a/packages/schema/src/compile-schema.test.ts +++ b/packages/schema/src/compile-schema.test.ts @@ -19,6 +19,7 @@ describe(compileSchema.name, () => { annotations: [], blockObjects: [], inlineObjects: [], + nestedBlocks: [], }) expect(consoleWarnSpy).toHaveBeenCalledWith( @@ -40,7 +41,255 @@ describe(compileSchema.name, () => { annotations: [], blockObjects: [], inlineObjects: [], + nestedBlocks: [], }) }) }) + + describe('nested blocks', () => { + test('empty schema has nestedBlocks: []', () => { + expect(compileSchema({})).toEqual({ + block: {name: 'block'}, + span: {name: 'span'}, + styles: [{value: 'normal', name: 'normal', title: 'Normal'}], + lists: [], + decorators: [], + annotations: [], + blockObjects: [], + inlineObjects: [], + nestedBlocks: [], + }) + }) + + test('nested blocks are compiled with fields defaulting to []', () => { + expect( + compileSchema({ + nestedBlocks: [{name: 'tableCell'}], + }), + ).toEqual({ + block: {name: 'block'}, + span: {name: 'span'}, + styles: [{value: 'normal', name: 'normal', title: 'Normal'}], + lists: [], + decorators: [], + annotations: [], + blockObjects: [], + inlineObjects: [], + nestedBlocks: [{name: 'tableCell', fields: []}], + }) + }) + + test('nested blocks with fields are compiled', () => { + expect( + compileSchema({ + nestedBlocks: [ + { + name: 'tableCell', + fields: [ + {name: 'colspan', type: 'number'}, + {name: 'rowspan', type: 'number'}, + ], + }, + ], + }), + ).toEqual({ + block: {name: 'block'}, + span: {name: 'span'}, + styles: [{value: 'normal', name: 'normal', title: 'Normal'}], + lists: [], + decorators: [], + annotations: [], + blockObjects: [], + inlineObjects: [], + nestedBlocks: [ + { + name: 'tableCell', + fields: [ + {name: 'colspan', type: 'number'}, + {name: 'rowspan', type: 'number'}, + ], + }, + ], + }) + }) + }) + + describe('field of', () => { + test('of with object type is preserved on array fields', () => { + expect( + compileSchema({ + blockObjects: [ + { + name: 'gallery', + fields: [ + { + name: 'images', + type: 'array', + of: [ + { + type: 'galleryImage', + name: 'galleryImage', + fields: [{name: 'alt', type: 'string'}], + }, + ], + }, + ], + }, + ], + }).blockObjects, + ).toEqual([ + { + name: 'gallery', + fields: [ + { + name: 'images', + type: 'array', + of: [ + { + type: 'galleryImage', + name: 'galleryImage', + fields: [{name: 'alt', type: 'string'}], + }, + ], + }, + ], + }, + ]) + }) + + test('of with block type preserves PTE sub-schema', () => { + expect( + compileSchema({ + nestedBlocks: [ + { + name: 'tableCell', + fields: [ + { + name: 'content', + type: 'array', + of: [ + { + type: 'block', + styles: [{name: 'normal'}, {name: 'h1'}], + decorators: [{name: 'strong'}], + }, + ], + }, + ], + }, + ], + }).nestedBlocks, + ).toEqual([ + { + name: 'tableCell', + fields: [ + { + name: 'content', + type: 'array', + of: [ + { + type: 'block', + styles: [{name: 'normal'}, {name: 'h1'}], + decorators: [{name: 'strong'}], + }, + ], + }, + ], + }, + ]) + }) + + test('table schema: blockObjects with of, nestedBlocks with block of', () => { + const schema = compileSchema({ + blockObjects: [ + { + name: 'table', + fields: [ + { + name: 'rows', + type: 'array', + of: [ + { + type: 'tableRow', + name: 'tableRow', + fields: [ + { + name: 'cells', + type: 'array', + of: [{type: 'tableCell', name: 'tableCell'}], + }, + ], + }, + ], + }, + ], + }, + ], + nestedBlocks: [ + { + name: 'tableCell', + fields: [ + { + name: 'content', + type: 'array', + of: [ + { + type: 'block', + styles: [{name: 'normal'}], + lists: [], + }, + ], + }, + {name: 'colspan', type: 'number'}, + ], + }, + ], + }) + + expect(schema.nestedBlocks).toEqual([ + { + name: 'tableCell', + fields: [ + { + name: 'content', + type: 'array', + of: [ + { + type: 'block', + styles: [{name: 'normal'}], + lists: [], + }, + ], + }, + {name: 'colspan', type: 'number'}, + ], + }, + ]) + + expect(schema.blockObjects).toEqual([ + { + name: 'table', + fields: [ + { + name: 'rows', + type: 'array', + of: [ + { + type: 'tableRow', + name: 'tableRow', + fields: [ + { + name: 'cells', + type: 'array', + of: [{type: 'tableCell', name: 'tableCell'}], + }, + ], + }, + ], + }, + ], + }, + ]) + }) + }) }) diff --git a/packages/schema/src/compile-schema.ts b/packages/schema/src/compile-schema.ts index 44e3ad8e2..46ef83224 100644 --- a/packages/schema/src/compile-schema.ts +++ b/packages/schema/src/compile-schema.ts @@ -1,5 +1,36 @@ import type {SchemaDefinition} from './define-schema' -import type {FieldDefinition, Schema} from './schema' +import type { + FieldDefinition, + ObjectOfDefinition, + OfDefinition, + Schema, +} from './schema' + +function compileOfMember(member: OfDefinition): OfDefinition { + if (member.type === 'block') { + // BlockOfDefinition — pass through as-is + return member + } + // ObjectOfDefinition — recursively compile nested fields + return compileObjectOfMember(member) +} + +function compileObjectOfMember(member: ObjectOfDefinition): ObjectOfDefinition { + if (member.fields) { + return {...member, fields: member.fields.map(compileField)} + } + return member +} + +function compileField(field: FieldDefinition): FieldDefinition { + if (field.of) { + return { + ...field, + of: field.of.map(compileOfMember), + } + } + return field +} /** * @public @@ -54,15 +85,19 @@ export function compileSchema(definition: SchemaDefinition): Schema { })), annotations: (definition.annotations ?? []).map((annotation) => ({ ...annotation, - fields: annotation.fields ?? [], + fields: annotation.fields?.map(compileField) ?? [], })), blockObjects: (definition.blockObjects ?? []).map((blockObject) => ({ ...blockObject, - fields: blockObject.fields ?? [], + fields: blockObject.fields?.map(compileField) ?? [], })), inlineObjects: (definition.inlineObjects ?? []).map((inlineObject) => ({ ...inlineObject, - fields: inlineObject.fields ?? [], + fields: inlineObject.fields?.map(compileField) ?? [], + })), + nestedBlocks: (definition.nestedBlocks ?? []).map((nestedBlock) => ({ + ...nestedBlock, + fields: nestedBlock.fields?.map(compileField) ?? [], })), } } diff --git a/packages/schema/src/define-schema.ts b/packages/schema/src/define-schema.ts index c176cd7bf..82ffdda01 100644 --- a/packages/schema/src/define-schema.ts +++ b/packages/schema/src/define-schema.ts @@ -14,6 +14,7 @@ export type SchemaDefinition = { annotations?: ReadonlyArray blockObjects?: ReadonlyArray inlineObjects?: ReadonlyArray + nestedBlocks?: ReadonlyArray } /** @@ -92,3 +93,12 @@ export type InlineObjectDefinition< > = TBaseDefinition & { fields?: ReadonlyArray } + +/** + * @public + */ +export type NestedBlockDefinition< + TBaseDefinition extends BaseDefinition = BaseDefinition, +> = TBaseDefinition & { + fields?: ReadonlyArray +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 85f505856..fcb8eb618 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -6,6 +6,7 @@ export { type DecoratorDefinition, type InlineObjectDefinition, type ListDefinition, + type NestedBlockDefinition, type SchemaDefinition, type StyleDefinition, } from './define-schema' @@ -13,10 +14,14 @@ export type { AnnotationSchemaType, BaseDefinition, BlockObjectSchemaType, + BlockOfDefinition, DecoratorSchemaType, FieldDefinition, InlineObjectSchemaType, ListSchemaType, + NestedBlockSchemaType, + ObjectOfDefinition, + OfDefinition, Schema, StyleSchemaType, } from './schema' diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index 3721ff013..f373677b4 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -15,6 +15,7 @@ export type Schema = { annotations: ReadonlyArray blockObjects: ReadonlyArray inlineObjects: ReadonlyArray + nestedBlocks: ReadonlyArray } /** @@ -71,11 +72,57 @@ export type InlineObjectSchemaType = BaseDefinition & { fields: ReadonlyArray } +/** + * @public + */ +export type NestedBlockSchemaType = BaseDefinition & { + fields: ReadonlyArray +} + +/** + * @public + * Describes a member type within an array field's `of`. + * When `type` is `'block'`, PTE sub-schema properties (styles, decorators, + * annotations, lists) are available for configuring the nested block editor. + */ +export type OfDefinition = BlockOfDefinition | ObjectOfDefinition + +/** + * @public + * An `of` member with `type: 'block'` — supports nested PTE sub-schema. + */ +export type BlockOfDefinition = { + type: 'block' + name?: string + title?: string + styles?: ReadonlyArray + decorators?: ReadonlyArray + annotations?: ReadonlyArray< + BaseDefinition & {fields?: ReadonlyArray} + > + lists?: ReadonlyArray +} + +/** + * @public + * An `of` member with any non-block type — a named type reference. + */ +export type ObjectOfDefinition = { + type: string + name?: string + title?: string + fields?: ReadonlyArray +} + /** * @public */ export type FieldDefinition = BaseDefinition & { type: 'string' | 'number' | 'boolean' | 'array' | 'object' + /** + * For array fields, describes the allowed member types. + */ + of?: ReadonlyArray } /** From bdad5de4cd5f4a948b12bc00a6af9164bf6921d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Mon, 16 Feb 2026 08:59:04 +0000 Subject: [PATCH 2/2] feat(sanity-bridge): walk Sanity type graph for nestedBlocks, carry of on array fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add nestedBlocks to PortableTextMemberSchemaTypes - Walk block object fields recursively to detect objects containing array-of-blocks fields (nested block containers like table cells) - Emit nestedBlocks in portableTextMemberSchemaTypesToSchema() - Carry OfDefinition (block vs object) through on array fields via sanityFieldToFieldDefinition() and sanityFieldToOfDefinition() - Use safeGetOf() to handle Sanity schema getter edge cases - Add table schema integration test: table → tableRow → tableCell with nested block content correctly detected - All existing tests updated with nestedBlocks: [] (additive) --- ...able-text-member-schema-types-to-schema.ts | 81 ++++++++++++++++--- .../src/portable-text-member-schema-types.ts | 72 +++++++++++++++++ ...ity-schema-to-portable-text-schema.test.ts | 73 +++++++++++++++++ ...-portable-text-member-schema-types.test.ts | 1 + 4 files changed, 216 insertions(+), 11 deletions(-) diff --git a/packages/sanity-bridge/src/portable-text-member-schema-types-to-schema.ts b/packages/sanity-bridge/src/portable-text-member-schema-types-to-schema.ts index 37a3c0656..aa24361fe 100644 --- a/packages/sanity-bridge/src/portable-text-member-schema-types-to-schema.ts +++ b/packages/sanity-bridge/src/portable-text-member-schema-types-to-schema.ts @@ -1,6 +1,68 @@ -import type {Schema} from '@portabletext/schema' +import type {FieldDefinition, OfDefinition, Schema} from '@portabletext/schema' +import type {ArraySchemaType, ObjectSchemaType, SchemaType} from '@sanity/types' import type {PortableTextMemberSchemaTypes} from './portable-text-member-schema-types' +/** + * Safely get the `of` array from a schema type, returning undefined if + * the type doesn't have one or if accessing it throws (Sanity schema + * getters can throw on certain types). + */ +function safeGetOf(schemaType: SchemaType): readonly SchemaType[] | undefined { + try { + if (schemaType.jsonType === 'array') { + const arrayOf = (schemaType as ArraySchemaType).of + return Array.isArray(arrayOf) ? arrayOf : undefined + } + } catch { + // Sanity schema getters can throw — ignore + } + return undefined +} + +function isBlockType(type: SchemaType): boolean { + if (type.type) { + return isBlockType(type.type) + } + return type.name === 'block' +} + +function sanityFieldToOfDefinition(memberType: SchemaType): OfDefinition { + if (isBlockType(memberType)) { + return {type: 'block' as const} + } + const objectType = memberType as ObjectSchemaType + return { + type: objectType.name, + name: objectType.name, + title: objectType.title, + ...(objectType.fields?.length + ? {fields: objectType.fields.map(sanityFieldToFieldDefinition)} + : {}), + } +} + +function sanityFieldToFieldDefinition(field: { + name: string + type: SchemaType +}): FieldDefinition { + const base: FieldDefinition = { + name: field.name, + type: field.type.jsonType as FieldDefinition['type'], + title: field.type.title, + } + + // Carry `of` through on array fields + const ofMembers = safeGetOf(field.type) + if (ofMembers?.length) { + return { + ...base, + of: ofMembers.map(sanityFieldToOfDefinition), + } + } + + return base +} + /** * @public * Convert Sanity-specific schema types for Portable Text to a first-class @@ -24,11 +86,7 @@ export function portableTextMemberSchemaTypesToSchema( }, blockObjects: schema.blockObjects.map((blockObject) => ({ name: blockObject.name, - fields: blockObject.fields.map((field) => ({ - name: field.name, - type: field.type.jsonType, - title: field.type.title, - })), + fields: blockObject.fields.map(sanityFieldToFieldDefinition), title: blockObject.title, })), decorators: schema.decorators.map((decorator) => ({ @@ -38,13 +96,14 @@ export function portableTextMemberSchemaTypesToSchema( })), inlineObjects: schema.inlineObjects.map((inlineObject) => ({ name: inlineObject.name, - fields: inlineObject.fields.map((field) => ({ - name: field.name, - type: field.type.jsonType, - title: field.type.title, - })), + fields: inlineObject.fields.map(sanityFieldToFieldDefinition), title: inlineObject.title, })), + nestedBlocks: schema.nestedBlocks.map((nestedBlock) => ({ + name: nestedBlock.name, + fields: nestedBlock.fields.map(sanityFieldToFieldDefinition), + title: nestedBlock.title, + })), span: { name: schema.span.name, }, diff --git a/packages/sanity-bridge/src/portable-text-member-schema-types.ts b/packages/sanity-bridge/src/portable-text-member-schema-types.ts index d8b5a35e5..e26138202 100644 --- a/packages/sanity-bridge/src/portable-text-member-schema-types.ts +++ b/packages/sanity-bridge/src/portable-text-member-schema-types.ts @@ -20,6 +20,7 @@ export type PortableTextMemberSchemaTypes = { blockObjects: ObjectSchemaType[] decorators: BlockDecoratorDefinition[] inlineObjects: ObjectSchemaType[] + nestedBlocks: ObjectSchemaType[] portableText: ArraySchemaType span: ObjectSchemaType styles: BlockStyleDefinition[] @@ -67,6 +68,10 @@ export function createPortableTextMemberSchemaTypes( const blockObjectTypes = (portableTextType.of?.filter( (field) => field.name !== blockType.name, ) || []) as ObjectSchemaType[] + + // Walk block object fields to find nested block types + const nestedBlockTypes = collectNestedBlockTypes(blockObjectTypes) + return { styles: resolveEnabledStyles(blockType), decorators: resolveEnabledDecorators(spanType), @@ -76,10 +81,77 @@ export function createPortableTextMemberSchemaTypes( portableText: portableTextType, inlineObjects: inlineObjectTypes, blockObjects: blockObjectTypes, + nestedBlocks: nestedBlockTypes, annotations: (spanType as SpanSchemaType).annotations, } } +/** + * Safely get the `of` array from a schema type, returning undefined if + * the type doesn't have one or if accessing it throws (Sanity schema + * getters can throw on certain types). + */ +function safeGetOf(schemaType: SchemaType): readonly SchemaType[] | undefined { + try { + if (schemaType.jsonType === 'array') { + const arrayOf = (schemaType as ArraySchemaType).of + return Array.isArray(arrayOf) ? arrayOf : undefined + } + } catch { + // Sanity schema getters can throw — ignore + } + return undefined +} + +/** + * Walk object types recursively to find objects that contain an array field + * whose `of` includes a block type. Those objects are "nested blocks" — + * they contain their own PTE content. + */ +function collectNestedBlockTypes( + objectTypes: ObjectSchemaType[], +): ObjectSchemaType[] { + const nestedBlocks: ObjectSchemaType[] = [] + const seen = new Set() + + function walkObjectType(objectType: ObjectSchemaType) { + if (seen.has(objectType.name)) { + return + } + seen.add(objectType.name) + + for (const field of objectType.fields ?? []) { + const ofMembers = safeGetOf(field.type) + if (ofMembers) { + for (const memberType of ofMembers) { + if (findBlockType(memberType)) { + // This object has an array-of-blocks field — it's a nested block + if (!nestedBlocks.some((nb) => nb.name === objectType.name)) { + nestedBlocks.push(objectType) + } + } else if ( + memberType.jsonType === 'object' && + memberType.name !== objectType.name + ) { + walkObjectType(memberType as ObjectSchemaType) + } + } + } else if ( + field.type.jsonType === 'object' && + field.type.name !== objectType.name + ) { + walkObjectType(field.type as ObjectSchemaType) + } + } + } + + for (const objectType of objectTypes) { + walkObjectType(objectType) + } + + return nestedBlocks +} + function resolveEnabledStyles(blockType: ObjectSchemaType) { const styleField = blockType.fields?.find( (btField) => btField.name === 'style', diff --git a/packages/sanity-bridge/src/sanity-schema-to-portable-text-schema.test.ts b/packages/sanity-bridge/src/sanity-schema-to-portable-text-schema.test.ts index da2f9c0cf..fd84df5ae 100644 --- a/packages/sanity-bridge/src/sanity-schema-to-portable-text-schema.test.ts +++ b/packages/sanity-bridge/src/sanity-schema-to-portable-text-schema.test.ts @@ -116,6 +116,7 @@ describe(sanitySchemaToPortableTextSchema.name, () => { }, ], blockObjects: [], + nestedBlocks: [], inlineObjects: [], } @@ -297,3 +298,75 @@ describe(sanitySchemaToPortableTextSchema.name, () => { ) }) }) + +describe('nested blocks', () => { + test('table schema with nested block content', () => { + const tableCellType = defineType({ + name: 'tableCell', + type: 'object', + fields: [ + defineField({ + name: 'content', + type: 'array', + of: [{type: 'block'}], + }), + defineField({ + name: 'colspan', + type: 'number', + }), + ], + }) + const tableRowType = defineType({ + name: 'tableRow', + type: 'object', + fields: [ + defineField({ + name: 'cells', + type: 'array', + of: [{type: 'tableCell'}], + }), + ], + }) + const tableType = defineType({ + name: 'table', + type: 'object', + fields: [ + defineField({ + name: 'rows', + type: 'array', + of: [{type: 'tableRow'}], + }), + ], + }) + const portableTextType = defineType({ + type: 'array', + name: 'body', + of: [{type: 'block', name: 'block'}, {type: 'table'}], + }) + + const sanitySchema = SanitySchema.compile({ + types: [portableTextType, tableType, tableRowType, tableCellType], + }) + + const schema = sanitySchemaToPortableTextSchema(sanitySchema.get('body')) + + // Table should be a block object + expect(schema.blockObjects.map((bo) => bo.name)).toContain('table') + + // tableCell should be detected as a nested block (it contains array-of-blocks) + expect(schema.nestedBlocks.map((nb) => nb.name)).toContain('tableCell') + + // tableCell should have content and colspan fields + const tableCell = schema.nestedBlocks.find((nb) => nb.name === 'tableCell') + expect(tableCell).toBeDefined() + expect(tableCell!.fields.map((f) => f.name)).toContain('content') + expect(tableCell!.fields.map((f) => f.name)).toContain('colspan') + + // The content field should have of with a block type + const contentField = tableCell!.fields.find((f) => f.name === 'content') + expect(contentField).toBeDefined() + expect(contentField!.type).toBe('array') + expect(contentField!.of).toBeDefined() + expect(contentField!.of!.some((m) => m.type === 'block')).toBe(true) + }) +}) diff --git a/packages/sanity-bridge/src/schema-definition-to-portable-text-member-schema-types.test.ts b/packages/sanity-bridge/src/schema-definition-to-portable-text-member-schema-types.test.ts index d116e2649..6ae2e11bb 100644 --- a/packages/sanity-bridge/src/schema-definition-to-portable-text-member-schema-types.test.ts +++ b/packages/sanity-bridge/src/schema-definition-to-portable-text-member-schema-types.test.ts @@ -315,6 +315,7 @@ describe(compileSchemaDefinitionToPortableTextMemberSchemaTypes.name, () => { }, ], inlineObjects: [], + nestedBlocks: [], span: { name: 'span', },