@portabletext/block-tools 3.2.1 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ import {compileSchema, defineSchema} from '@portabletext/schema'
2
+ import {describe, expect, test} from 'vitest'
3
+ import {createTestKeyGenerator} from '../../test/test-key-generator'
4
+ import {flattenNestedBlocks} from './helpers'
5
+
6
+ describe(flattenNestedBlocks.name, () => {
7
+ test('splitting text block', () => {
8
+ const keyGenerator = createTestKeyGenerator('k')
9
+ const schema = compileSchema(
10
+ defineSchema({
11
+ styles: [{name: 'h1'}],
12
+ annotations: [{name: 'link'}],
13
+ blockObjects: [{name: 'image'}],
14
+ }),
15
+ )
16
+ const blockKey = keyGenerator()
17
+ const fooKey = keyGenerator()
18
+ const imageKey = keyGenerator()
19
+ const barKey = keyGenerator()
20
+ const linkKey = keyGenerator()
21
+
22
+ expect(
23
+ flattenNestedBlocks({schema, keyGenerator}, [
24
+ {
25
+ _key: blockKey,
26
+ _type: 'block',
27
+ children: [
28
+ {
29
+ _key: fooKey,
30
+ _type: 'span',
31
+ text: 'foo',
32
+ },
33
+ {
34
+ _key: imageKey,
35
+ _type: 'image',
36
+ },
37
+ {
38
+ _key: barKey,
39
+ _type: 'span',
40
+ text: 'bar',
41
+ marks: [linkKey],
42
+ },
43
+ ],
44
+ style: 'h1',
45
+ markDefs: [
46
+ {
47
+ _key: linkKey,
48
+ _type: 'link',
49
+ },
50
+ ],
51
+ },
52
+ ]),
53
+ ).toEqual([
54
+ {
55
+ _key: blockKey,
56
+ _type: 'block',
57
+ children: [{_key: fooKey, _type: 'span', text: 'foo'}],
58
+ style: 'h1',
59
+ markDefs: [{_key: linkKey, _type: 'link'}],
60
+ },
61
+ {
62
+ _key: imageKey,
63
+ _type: 'image',
64
+ },
65
+ {
66
+ _key: blockKey,
67
+ _type: 'block',
68
+ children: [
69
+ {_key: barKey, _type: 'span', text: 'bar', marks: [linkKey]},
70
+ ],
71
+ style: 'h1',
72
+ markDefs: [{_key: linkKey, _type: 'link'}],
73
+ },
74
+ ])
75
+ })
76
+
77
+ test('splitting text block with __block', () => {
78
+ const keyGenerator = createTestKeyGenerator('k')
79
+ const schema = compileSchema(
80
+ defineSchema({
81
+ blockObjects: [{name: 'image'}],
82
+ }),
83
+ )
84
+ const blockKey = keyGenerator()
85
+ const fooKey = keyGenerator()
86
+ const imageKey = keyGenerator()
87
+ const barKey = keyGenerator()
88
+
89
+ expect(
90
+ flattenNestedBlocks({schema, keyGenerator}, [
91
+ {
92
+ _key: blockKey,
93
+ _type: 'block',
94
+ children: [
95
+ {
96
+ _key: fooKey,
97
+ _type: 'span',
98
+ text: 'foo',
99
+ marks: [],
100
+ },
101
+ {
102
+ _type: '__block',
103
+ block: {
104
+ _key: imageKey,
105
+ _type: 'image',
106
+ },
107
+ },
108
+ {
109
+ _key: barKey,
110
+ _type: 'span',
111
+ text: 'bar',
112
+ marks: [],
113
+ },
114
+ ],
115
+ style: 'normal',
116
+ markDefs: [],
117
+ },
118
+ ]),
119
+ ).toEqual([
120
+ {
121
+ _key: blockKey,
122
+ _type: 'block',
123
+ children: [{_key: fooKey, _type: 'span', text: 'foo', marks: []}],
124
+ style: 'normal',
125
+ markDefs: [],
126
+ },
127
+ {
128
+ _key: imageKey,
129
+ _type: 'image',
130
+ },
131
+ {
132
+ _key: blockKey,
133
+ _type: 'block',
134
+ children: [{_key: barKey, _type: 'span', text: 'bar', marks: []}],
135
+ style: 'normal',
136
+ markDefs: [],
137
+ },
138
+ ])
139
+ })
140
+ })
@@ -3,6 +3,7 @@ import {vercelStegaClean} from '@vercel/stega'
3
3
  import {isEqual} from 'lodash'
4
4
  import {DEFAULT_BLOCK} from '../constants'
5
5
  import type {
6
+ ArbitraryTypedObject,
6
7
  HtmlParser,
7
8
  HtmlPreprocessorOptions,
8
9
  MinimalBlock,
@@ -12,8 +13,10 @@ import type {
12
13
  TypedObject,
13
14
  } from '../types'
14
15
  import {
16
+ isSpan,
15
17
  isTextBlock,
16
18
  type PortableTextObject,
19
+ type PortableTextSpan,
17
20
  type PortableTextTextBlock,
18
21
  } from '../types.portable-text'
19
22
  import {resolveJsType} from '../util/resolveJsType'
@@ -70,18 +73,121 @@ export function defaultParseHtml(): HtmlParser {
70
73
  }
71
74
 
72
75
  export function flattenNestedBlocks(
73
- schema: Schema,
74
- blocks: TypedObject[],
76
+ context: {
77
+ schema: Schema
78
+ keyGenerator: () => string
79
+ },
80
+ blocks: Array<ArbitraryTypedObject>,
75
81
  ): TypedObject[] {
76
82
  let depth = 0
77
83
  const flattened: TypedObject[] = []
84
+
78
85
  const traverse = (nodes: TypedObject[]) => {
79
86
  const toRemove: TypedObject[] = []
80
87
  nodes.forEach((node) => {
81
88
  if (depth === 0) {
82
- flattened.push(node)
89
+ //Only apply splitting logic if we have block objects defined in the schema
90
+ if (
91
+ context.schema.blockObjects.length > 0 &&
92
+ isTextBlock(context.schema, node)
93
+ ) {
94
+ const hasBlockObjects = node.children.some((child) => {
95
+ const knownBlockObject = context.schema.blockObjects.some(
96
+ (blockObject) => blockObject.name === child._type,
97
+ )
98
+ return knownBlockObject
99
+ })
100
+ const hasBlocks = node.children.some(
101
+ (child) => child._type === '__block',
102
+ )
103
+
104
+ if (hasBlockObjects || hasBlocks) {
105
+ // Split the block when it contains block objects
106
+ const splitChildren = node.children.reduce(
107
+ (slices, child) => {
108
+ const knownInlineObject = context.schema.inlineObjects.some(
109
+ (inlineObject) => inlineObject.name === child._type,
110
+ )
111
+ const knownBlockObject = context.schema.blockObjects.some(
112
+ (blockObject) => blockObject.name === child._type,
113
+ )
114
+
115
+ const lastSlice = slices.pop()
116
+
117
+ if (!isSpan(context.schema, child) && !knownInlineObject) {
118
+ if (knownBlockObject) {
119
+ return [
120
+ ...slices,
121
+ ...(lastSlice ? [lastSlice] : []),
122
+ {type: 'block object' as const, block: child},
123
+ ]
124
+ }
125
+ }
126
+
127
+ if (child._type === '__block') {
128
+ return [
129
+ ...slices,
130
+ ...(lastSlice ? [lastSlice] : []),
131
+ {
132
+ type: 'block object' as const,
133
+ block: (child as any).block,
134
+ },
135
+ ]
136
+ }
137
+
138
+ if (lastSlice) {
139
+ if (lastSlice.type === 'children') {
140
+ return [
141
+ ...slices,
142
+ {
143
+ type: 'children' as const,
144
+ children: [...lastSlice.children, child],
145
+ },
146
+ ]
147
+ }
148
+ }
149
+
150
+ return [
151
+ ...slices,
152
+ ...(lastSlice ? [lastSlice] : []),
153
+ {type: 'children' as const, children: [child]},
154
+ ]
155
+ },
156
+ [] as Array<
157
+ | {
158
+ type: 'children'
159
+ children: Array<PortableTextSpan | PortableTextObject>
160
+ }
161
+ | {type: 'block object'; block: PortableTextObject}
162
+ >,
163
+ )
164
+
165
+ // Process each slice
166
+ splitChildren.forEach((slice) => {
167
+ if (slice.type === 'block object') {
168
+ // Add the block object directly
169
+ flattened.push(slice.block)
170
+ } else if (slice.children.length > 0) {
171
+ // Create a new text block with the remaining children
172
+ const newBlock = {
173
+ ...node,
174
+ children: slice.children,
175
+ }
176
+ flattened.push(newBlock)
177
+ }
178
+ })
179
+ return
180
+ } else {
181
+ // No block objects, add the block as is
182
+ flattened.push(node)
183
+ }
184
+ } else {
185
+ //Not a text block or no block objects in schema, add directly
186
+ flattened.push(node)
187
+ }
83
188
  }
84
- if (isTextBlock(schema, node)) {
189
+
190
+ if (isTextBlock(context.schema, node)) {
85
191
  if (depth > 0) {
86
192
  toRemove.push(node)
87
193
  flattened.push(node)
@@ -187,8 +293,8 @@ export function trimWhitespace(
187
293
 
188
294
  export function ensureRootIsBlocks(
189
295
  schema: Schema,
190
- blocks: TypedObject[],
191
- ): TypedObject[] {
296
+ blocks: Array<ArbitraryTypedObject>,
297
+ ): ArbitraryTypedObject[] {
192
298
  return blocks.reduce((memo, node, i, original) => {
193
299
  if (node._type === 'block') {
194
300
  memo.push(node)
@@ -217,7 +323,7 @@ export function ensureRootIsBlocks(
217
323
 
218
324
  memo.push(block)
219
325
  return memo
220
- }, [] as TypedObject[])
326
+ }, [] as ArbitraryTypedObject[])
221
327
  }
222
328
 
223
329
  export function isNodeList(node: unknown): node is NodeList {
@@ -13,6 +13,7 @@ import {
13
13
  type PortableTextBlock,
14
14
  type PortableTextObject,
15
15
  } from '../types.portable-text'
16
+ import {keyGenerator} from '../util/randomKey'
16
17
  import {resolveJsType} from '../util/resolveJsType'
17
18
  import {
18
19
  defaultParseHtml,
@@ -34,6 +35,7 @@ import {createRules} from './rules'
34
35
  *
35
36
  */
36
37
  export default class HtmlDeserializer {
38
+ keyGenerator: () => string
37
39
  schema: Schema
38
40
  rules: DeserializerRule[]
39
41
  parseHtml: (html: string) => HTMLElement
@@ -49,8 +51,10 @@ export default class HtmlDeserializer {
49
51
  const {rules = [], unstable_whitespaceOnPasteMode = 'preserve'} = options
50
52
  const standardRules = createRules(schema, {
51
53
  keyGenerator: options.keyGenerator,
54
+ matchers: options.matchers,
52
55
  })
53
56
  this.schema = schema
57
+ this.keyGenerator = options.keyGenerator ?? keyGenerator
54
58
  this.rules = [...rules, ...standardRules]
55
59
  const parseHtml = options.parseHtml || defaultParseHtml()
56
60
  this.parseHtml = (html) => {
@@ -74,8 +78,11 @@ export default class HtmlDeserializer {
74
78
  const blocks = trimWhitespace(
75
79
  this.schema,
76
80
  flattenNestedBlocks(
77
- this.schema,
78
- ensureRootIsBlocks(this.schema, this.deserializeElements(children)),
81
+ {schema: this.schema, keyGenerator: this.keyGenerator},
82
+ ensureRootIsBlocks(
83
+ this.schema,
84
+ this.deserializeElements(children) as Array<ArbitraryTypedObject>,
85
+ ),
79
86
  ),
80
87
  )
81
88
 
@@ -96,8 +96,16 @@ function getBlockStyle(schema: Schema, el: Node): string {
96
96
  export default function createGDocsRules(schema: Schema): DeserializerRule[] {
97
97
  return [
98
98
  {
99
- deserialize(el) {
99
+ deserialize(el, next) {
100
100
  if (isElement(el) && tagName(el) === 'span' && isGoogleDocs(el)) {
101
+ if (!el.textContent) {
102
+ if (!el.previousSibling && !el.nextSibling) {
103
+ el.setAttribute('data-lonely-child', 'true')
104
+ }
105
+
106
+ return next(el.childNodes)
107
+ }
108
+
101
109
  const span = {
102
110
  ...DEFAULT_SPAN,
103
111
  marks: [] as string[],
@@ -10,6 +10,7 @@ import {
10
10
  HTML_SPAN_TAGS,
11
11
  type PartialBlock,
12
12
  } from '../../constants'
13
+ import type {SchemaMatchers} from '../../schema-matchers'
13
14
  import type {DeserializerRule} from '../../types'
14
15
  import {keyGenerator} from '../../util/randomKey'
15
16
  import {isElement, tagName} from '../helpers'
@@ -36,7 +37,7 @@ export function resolveListItem(
36
37
 
37
38
  export default function createHTMLRules(
38
39
  schema: Schema,
39
- options: {keyGenerator?: () => string},
40
+ options: {keyGenerator?: () => string; matchers?: SchemaMatchers},
40
41
  ): DeserializerRule[] {
41
42
  return [
42
43
  whitespaceTextNodeRule,
@@ -265,5 +266,79 @@ export default function createHTMLRules(
265
266
  )
266
267
  },
267
268
  },
269
+ {
270
+ deserialize(el) {
271
+ if (isElement(el) && tagName(el) === 'img') {
272
+ const src = el.getAttribute('src') ?? undefined
273
+ const alt = el.getAttribute('alt') ?? undefined
274
+
275
+ const props = Object.fromEntries(
276
+ Array.from(el.attributes).map((attr) => [attr.name, attr.value]),
277
+ )
278
+
279
+ const ancestorOfLonelyChild =
280
+ el?.parentElement?.parentElement?.getAttribute('data-lonely-child')
281
+ const ancestorOfListItem = el.closest('li') !== null
282
+
283
+ if (ancestorOfLonelyChild && !ancestorOfListItem) {
284
+ const image = options.matchers?.image?.({
285
+ context: {
286
+ schema,
287
+ keyGenerator: options.keyGenerator ?? keyGenerator,
288
+ },
289
+ props: {
290
+ ...props,
291
+ ...(src ? {src} : {}),
292
+ ...(alt ? {alt} : {}),
293
+ },
294
+ })
295
+
296
+ if (image) {
297
+ return {
298
+ _type: '__block',
299
+ block: image,
300
+ }
301
+ }
302
+ }
303
+
304
+ const inlineImage = options.matchers?.inlineImage?.({
305
+ context: {
306
+ schema,
307
+ keyGenerator: options.keyGenerator ?? keyGenerator,
308
+ },
309
+ props: {
310
+ ...props,
311
+ ...(src ? {src} : {}),
312
+ ...(alt ? {alt} : {}),
313
+ },
314
+ })
315
+
316
+ if (inlineImage) {
317
+ return inlineImage
318
+ }
319
+
320
+ const image = options.matchers?.image?.({
321
+ context: {
322
+ schema,
323
+ keyGenerator: options.keyGenerator ?? keyGenerator,
324
+ },
325
+ props: {
326
+ ...props,
327
+ ...(src ? {src} : {}),
328
+ ...(alt ? {alt} : {}),
329
+ },
330
+ })
331
+
332
+ if (image) {
333
+ return {
334
+ _type: '__block',
335
+ block: image,
336
+ }
337
+ }
338
+ }
339
+
340
+ return undefined
341
+ },
342
+ },
268
343
  ]
269
344
  }
@@ -1,4 +1,5 @@
1
1
  import type {Schema} from '@portabletext/schema'
2
+ import type {SchemaMatchers} from '../../schema-matchers'
2
3
  import type {DeserializerRule} from '../../types'
3
4
  import createGDocsRules from './gdocs'
4
5
  import createHTMLRules from './html'
@@ -7,7 +8,7 @@ import createWordRules from './word'
7
8
 
8
9
  export function createRules(
9
10
  schema: Schema,
10
- options: {keyGenerator?: () => string},
11
+ options: {keyGenerator?: () => string; matchers?: SchemaMatchers},
11
12
  ): DeserializerRule[] {
12
13
  return [
13
14
  ...createWordRules(),
package/src/index.ts CHANGED
@@ -3,7 +3,6 @@ import type {Schema} from '@portabletext/schema'
3
3
  import type {ArraySchemaType} from '@sanity/types'
4
4
  import HtmlDeserializer from './HtmlDeserializer'
5
5
  import type {HtmlDeserializerOptions, TypedObject} from './types'
6
- import type {PortableTextTextBlock} from './types.portable-text'
7
6
  import {normalizeBlock} from './util/normalizeBlock'
8
7
 
9
8
  /**
@@ -19,7 +18,7 @@ export function htmlToBlocks(
19
18
  html: string,
20
19
  schemaType: ArraySchemaType | Schema,
21
20
  options: HtmlDeserializerOptions = {},
22
- ): (TypedObject | PortableTextTextBlock)[] {
21
+ ) {
23
22
  const schema = isSanitySchema(schemaType)
24
23
  ? sanitySchemaToPortableTextSchema(schemaType)
25
24
  : schemaType
@@ -30,6 +29,7 @@ export function htmlToBlocks(
30
29
  .map((block) => normalizeBlock(block, {keyGenerator: options.keyGenerator}))
31
30
  }
32
31
 
32
+ export type {ImageSchemaMatcher, SchemaMatchers} from './schema-matchers'
33
33
  export type {ArbitraryTypedObject, DeserializerRule, HtmlParser} from './types'
34
34
  export type {
35
35
  PortableTextBlock,
@@ -0,0 +1,41 @@
1
+ import type {Schema} from '@portabletext/schema'
2
+ import type {ArbitraryTypedObject} from './types'
3
+
4
+ /**
5
+ * Use the current `Schema` as well as the potential element props to determine
6
+ * what Portable Text Object to use to represent the element.
7
+ */
8
+ type ObjectSchemaMatcher<TProps extends Record<string, unknown>> = ({
9
+ context,
10
+ props,
11
+ }: {
12
+ context: {schema: Schema; keyGenerator: () => string}
13
+ props: TProps
14
+ }) => ArbitraryTypedObject | undefined
15
+
16
+ /**
17
+ * Use the current `Schema` as well as the potential img element props to
18
+ * determine what Portable Text Object to use to represent the image.
19
+ * @beta
20
+ */
21
+ export type ImageSchemaMatcher = ObjectSchemaMatcher<{
22
+ src?: string
23
+ alt?: string
24
+ [key: string]: string | undefined
25
+ }>
26
+
27
+ /**
28
+ * @beta
29
+ */
30
+ export type SchemaMatchers = {
31
+ /**
32
+ * Called whenever the HTML parsing encounters an `<img>` element that is
33
+ * inferred to be a block element.
34
+ */
35
+ image?: ImageSchemaMatcher
36
+ /**
37
+ * Called whenever the HTML parsing encounters an `<img>` element that is
38
+ * inferred to be an inline element.
39
+ */
40
+ inlineImage?: ImageSchemaMatcher
41
+ }
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type {SchemaMatchers} from './schema-matchers'
1
2
  import type {PortableTextObject} from './types.portable-text'
2
3
 
3
4
  /**
@@ -71,6 +72,11 @@ export interface HtmlDeserializerOptions {
71
72
  rules?: DeserializerRule[]
72
73
  parseHtml?: HtmlParser
73
74
  unstable_whitespaceOnPasteMode?: WhiteSpacePasteMode
75
+ /**
76
+ * Custom schema matchers to use when deserializing HTML to Portable Text.
77
+ * @beta
78
+ */
79
+ matchers?: SchemaMatchers
74
80
  }
75
81
 
76
82
  export interface HtmlPreprocessorOptions {