Skip to content

Commit 91a7140

Browse files
jordanl17Copilot
andauthored
fix(@sanity/presets): rich text nested array auto handling (#874)
* fix(@sanity/presets): support rich text in definePage's pageBuilderBlock * refactor(@sanity/presets): move pageType array lookup off RegistryContext * refactor(@sanity/presets): simplify array preset handling in pageType --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 0f3cc43 commit 91a7140

6 files changed

Lines changed: 219 additions & 66 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sanity/presets': patch
3+
---
4+
5+
`definePage` now accepts rich text presets in `pageBuilderBlocks`, both inline (`defineRichText({...})`) and by name (`'richText'`). Documents store each rich text block as `{_type, content: [...]}`.

dev/test-studio/src/presets/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,11 @@ export const presetsWorkspace = definePlugin(() => ({
5656
// `pageBuilderBlocks` accepts both string references to types defined
5757
// elsewhere (like `'blockquote'`, defined further down in this file)
5858
// and inline preset instances created with a `define<Type>` factory.
59-
pageBuilderBlocks: ['blockquote', defineImage({name: 'imageBlock', title: 'Image'})],
59+
pageBuilderBlocks: [
60+
'blockquote',
61+
defineImage({name: 'imageBlock', title: 'Image'}),
62+
'richTextDefaults',
63+
],
6064
// The page preset includes "Main" and "Metadata" groups for structuring
6165
// the document editor. Additional groups can be created by adding them
6266
// to the `groups` array.

plugins/@sanity/presets/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ definePage({
165165

166166
Each entry in `pageBuilderBlocks` is either a string referencing a type in your schema, or an inline schema type definition - typically a preset instance such as `defineImage({name: 'imageBlock'})`. Mix both freely.
167167

168+
Rich text presets work in `pageBuilderBlocks`, both inline (`defineRichText({...})`) and by name (`'richText'`). Documents store each rich text block under `content[].content`.
169+
168170
**Fields:**
169171

170172
| Field | Type | Group | Description |
Lines changed: 113 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
ALL_FIELDS_GROUP,
3+
type ArrayDefinition,
34
defineArrayMember,
45
defineField,
56
defineType,
@@ -11,68 +12,121 @@ import {definePresetType} from '../../definePresetType'
1112

1213
export type PageBuilderBlock = string | (SchemaTypeDefinition & FieldDefinition)
1314

15+
type LookupArrayPreset = (name: string) => ArrayDefinition | undefined
16+
17+
// Sanity rejects array members whose `type` resolves to another array. Wrap
18+
// array presets in an object so they can serve as page-builder members.
19+
function wrapArrayAsPageBuilderBlock(arraySchema: ArrayDefinition) {
20+
const components = 'components' in arraySchema ? arraySchema.components : undefined
21+
return defineArrayMember({
22+
name: arraySchema.name,
23+
title: arraySchema.title,
24+
type: 'object',
25+
fields: [
26+
defineField({
27+
name: 'content',
28+
type: 'array',
29+
of: arraySchema.of,
30+
components,
31+
}),
32+
],
33+
})
34+
}
35+
36+
function toPageBuilderArrayMember(block: PageBuilderBlock, lookupArrayPreset: LookupArrayPreset) {
37+
if (typeof block === 'string') {
38+
const arrayPreset = lookupArrayPreset(block)
39+
if (arrayPreset) {
40+
return wrapArrayAsPageBuilderBlock(arrayPreset)
41+
}
42+
return defineArrayMember({type: block})
43+
}
44+
if (block.type === 'array') {
45+
// oxlint-disable-next-line no-unsafe-type-assertion -- discriminating on `type` does not narrow the intersected union
46+
return wrapArrayAsPageBuilderBlock(block as ArrayDefinition)
47+
}
48+
return defineArrayMember(block)
49+
}
50+
51+
// Defer building `content.of` so by-name references resolve after every
52+
// `define<X>` has run. Memoised for stable array identity across reads.
53+
function defineLazyContentField(
54+
blocks: PageBuilderBlock[],
55+
lookupArrayPreset: LookupArrayPreset,
56+
): FieldDefinition {
57+
let cached: ReturnType<typeof toPageBuilderArrayMember>[] | undefined
58+
return defineField({
59+
name: 'content',
60+
title: 'Content',
61+
group: 'main',
62+
type: 'array',
63+
get of() {
64+
cached ??= blocks.map((block) => toPageBuilderArrayMember(block, lookupArrayPreset))
65+
return cached
66+
},
67+
})
68+
}
69+
1470
export interface PageTypeConfig {
1571
pageBuilderBlocks?: PageBuilderBlock[]
1672
}
1773

18-
export const pageType = definePresetType<PageTypeConfig, 'document'>({
19-
name: 'page',
20-
identifier: 'core.page',
21-
schemaType: (config, registry) => {
22-
const {pageBuilderBlocks, groups, fields, ...documentConfig} = config
74+
// `lookupArrayPreset` is closure-injected by the registry to keep it off
75+
// the public `RegistryContext`. The default `pageType` below uses a no-op.
76+
export function createPageType({lookupArrayPreset}: {lookupArrayPreset: LookupArrayPreset}) {
77+
return definePresetType<PageTypeConfig, 'document'>({
78+
name: 'page',
79+
identifier: 'core.page',
80+
schemaType: (config, registry) => {
81+
const {pageBuilderBlocks, groups, fields, ...documentConfig} = config
2382

24-
return defineType({
25-
...documentConfig,
26-
type: 'document',
27-
groups: [
28-
{
29-
...ALL_FIELDS_GROUP,
30-
hidden: true,
31-
},
32-
{
33-
name: 'main',
34-
title: 'Main',
35-
default: true,
36-
},
37-
{
38-
name: 'metadata',
39-
title: 'Metadata',
40-
},
41-
...(groups ?? []),
42-
],
43-
fields: [
44-
defineField({
45-
name: 'name',
46-
title: 'Name',
47-
type: 'string',
48-
group: 'main',
49-
validation: (rule) => rule.required(),
50-
}),
51-
defineField({
52-
name: 'slug',
53-
title: 'Slug',
54-
type: 'slug',
55-
group: 'main',
56-
options: {
57-
source: 'name',
83+
return defineType({
84+
...documentConfig,
85+
type: 'document',
86+
groups: [
87+
{
88+
...ALL_FIELDS_GROUP,
89+
hidden: true,
90+
},
91+
{
92+
name: 'main',
93+
title: 'Main',
94+
default: true,
95+
},
96+
{
97+
name: 'metadata',
98+
title: 'Metadata',
5899
},
59-
}),
60-
defineField({
61-
name: 'content',
62-
title: 'Content',
63-
group: 'main',
64-
type: 'array',
65-
of: (pageBuilderBlocks ?? []).map((block) =>
66-
typeof block === 'string' ? defineArrayMember({type: block}) : defineArrayMember(block),
67-
),
68-
}),
69-
registry.getPreset('seo', {
70-
name: 'seo',
71-
title: 'SEO',
72-
group: 'metadata',
73-
}),
74-
...(fields ?? []),
75-
],
76-
})
77-
},
78-
})
100+
...(groups ?? []),
101+
],
102+
fields: [
103+
defineField({
104+
name: 'name',
105+
title: 'Name',
106+
type: 'string',
107+
group: 'main',
108+
validation: (rule) => rule.required(),
109+
}),
110+
defineField({
111+
name: 'slug',
112+
title: 'Slug',
113+
type: 'slug',
114+
group: 'main',
115+
options: {
116+
source: 'name',
117+
},
118+
}),
119+
defineLazyContentField(pageBuilderBlocks ?? [], lookupArrayPreset),
120+
registry.getPreset('seo', {
121+
name: 'seo',
122+
title: 'SEO',
123+
group: 'metadata',
124+
}),
125+
...(fields ?? []),
126+
],
127+
})
128+
},
129+
})
130+
}
131+
132+
export const pageType = createPageType({lookupArrayPreset: () => undefined})

plugins/@sanity/presets/src/presets/page-type/page-type.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,64 @@ describe('pageType', () => {
3131
expect(contentField.of).toEqual([{type: 'hero'}, {type: 'callout'}])
3232
})
3333

34+
test('pageBuilderBlocks wraps inline array presets in an object so they can render', ({
35+
registry,
36+
}) => {
37+
const result = registry.definePage({
38+
name: 'landingPage',
39+
pageBuilderBlocks: [registry.defineRichText({name: 'inlineRichText', title: 'Inline rich'})],
40+
})
41+
const contentField = getField(getFields(result), 'content')
42+
43+
assertArrayField(contentField)
44+
expect(contentField.of).toHaveLength(1)
45+
const wrapped = contentField.of[0]
46+
assertDefined(wrapped)
47+
expect(wrapped).toEqual(
48+
expect.objectContaining({name: 'inlineRichText', type: 'object', title: 'Inline rich'}),
49+
)
50+
expect(wrapped).toHaveProperty('fields')
51+
expect(wrapped.fields).toEqual([expect.objectContaining({name: 'content', type: 'array'})])
52+
})
53+
54+
test('pageBuilderBlocks wraps a by-name string reference to an array preset registered before the page', ({
55+
registry,
56+
}) => {
57+
registry.defineRichText({name: 'richText', title: 'Rich text'})
58+
const result = registry.definePage({
59+
name: 'landingPage',
60+
pageBuilderBlocks: ['richText'],
61+
})
62+
const contentField = getField(getFields(result), 'content')
63+
64+
assertArrayField(contentField)
65+
const wrapped = contentField.of[0]
66+
assertDefined(wrapped)
67+
expect(wrapped).toEqual(
68+
expect.objectContaining({name: 'richText', type: 'object', title: 'Rich text'}),
69+
)
70+
expect(wrapped).toHaveProperty('fields')
71+
expect(wrapped.fields).toEqual([expect.objectContaining({name: 'content', type: 'array'})])
72+
})
73+
74+
test('pageBuilderBlocks wraps a by-name string reference even when the array preset is registered AFTER the page', ({
75+
registry,
76+
}) => {
77+
const page = registry.definePage({
78+
name: 'landingPage',
79+
pageBuilderBlocks: ['richText'],
80+
})
81+
registry.defineRichText({name: 'richText', title: 'Rich text'})
82+
83+
const contentField = getField(getFields(page), 'content')
84+
assertArrayField(contentField)
85+
const wrapped = contentField.of[0]
86+
assertDefined(wrapped)
87+
expect(wrapped).toEqual(
88+
expect.objectContaining({name: 'richText', type: 'object', title: 'Rich text'}),
89+
)
90+
})
91+
3492
test('pageBuilderBlocks accepts inline preset instances alongside string references', ({
3593
registry,
3694
}) => {

plugins/@sanity/presets/src/registry.tsx

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {uuid} from '@sanity/uuid'
22
import {type ComponentType} from 'react'
3-
import type {FieldDefinition, InputProps, SchemaTypeDefinition} from 'sanity'
3+
import type {ArrayDefinition, FieldDefinition, InputProps, SchemaTypeDefinition} from 'sanity'
44

55
import {PresetsTelemetryCollector} from './components/PresetsTelemetryCollector'
66
import type {
@@ -12,13 +12,11 @@ import type {
1212
import {ctaType} from './presets/cta-type'
1313
import {imageType, type ImageTypeConfig} from './presets/image-type'
1414
import {linkType, type LinkTypeConfig} from './presets/link-type'
15-
import {pageType, type PageTypeConfig} from './presets/page-type'
15+
import {createPageType, pageType, type PageTypeConfig} from './presets/page-type'
1616
import {richTextType} from './presets/rich-text-type'
1717
import {seoType} from './presets/seo-type'
1818
import {recordPresetUsage, registerRegistry} from './telemetry'
1919

20-
const systemPresets = [linkType, ctaType, seoType, imageType, pageType, richTextType] as const
21-
2220
export interface PresetsRegistryConfig {
2321
link?: LinkTypeConfig
2422
image?: ImageTypeConfig
@@ -48,9 +46,32 @@ export function createPresetsRegistry(config: PresetsRegistryConfig = {}): Prese
4846
// oxlint-disable-next-line no-unsafe-type-assertion -- seeding reduce with an empty object that is populated by each iteration
4947
const seed = {} as DefinerRecord & PresetsRegistry
5048

49+
// Tracks the schema produced under each defined preset name so `pageType`
50+
// can resolve string references in `pageBuilderBlocks` regardless of
51+
// definition order, and wrap array-typed presets at the page builder
52+
// boundary. Closure-injected into `pageType` via `createPageType` so it
53+
// does not need to live on the public `RegistryContext`.
54+
const registeredSchemas = new Map<string, SchemaTypeDefinition>()
55+
const pageTypeWithLookup = createPageType({
56+
lookupArrayPreset: (name) => {
57+
const registered = registeredSchemas.get(name)
58+
// oxlint-disable-next-line no-unsafe-type-assertion -- discriminating on `type` does not narrow the intersected union
59+
return registered?.type === 'array' ? (registered as ArrayDefinition) : undefined
60+
},
61+
})
62+
63+
const systemPresets = [
64+
linkType,
65+
ctaType,
66+
seoType,
67+
imageType,
68+
pageTypeWithLookup,
69+
richTextType,
70+
] as const
71+
5172
return systemPresets.reduce((registry, preset) => {
5273
const key = getPresetKey(preset.name)
53-
registry[key] = createDefiner({registryId, preset, config, registry})
74+
registry[key] = createDefiner({registryId, preset, config, registry, registeredSchemas})
5475
return registry
5576
}, seed)
5677
}
@@ -80,9 +101,16 @@ interface CreateDefinerOptions {
80101
preset: AnyPresetDefinition
81102
config: PresetsRegistryConfig
82103
registry: DefinerRecord
104+
registeredSchemas: Map<string, SchemaTypeDefinition>
83105
}
84106

85-
function createDefiner({registryId, preset, config, registry}: CreateDefinerOptions): Definer {
107+
function createDefiner({
108+
registryId,
109+
preset,
110+
config,
111+
registry,
112+
registeredSchemas,
113+
}: CreateDefinerOptions): Definer {
86114
return function define(userConfig = {}) {
87115
const name = userConfig['name']
88116
if (typeof name !== 'string' || name.length === 0) {
@@ -112,6 +140,8 @@ function createDefiner({registryId, preset, config, registry}: CreateDefinerOpti
112140
registryContext,
113141
)
114142

143+
registeredSchemas.set(name, schemaType)
144+
115145
// oxlint-disable-next-line no-unsafe-type-assertion -- runtime value is a valid field definition
116146
return applyMapHooks(
117147
addTelemetryComponent(schemaType, registryId),

0 commit comments

Comments
 (0)