@portabletext/block-tools 0.0.0

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.
package/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type {ArraySchemaType, PortableTextTextBlock} from '@sanity/types'
2
+ import HtmlDeserializer from './HtmlDeserializer'
3
+ import type {
4
+ BlockContentFeatures,
5
+ HtmlDeserializerOptions,
6
+ TypedObject,
7
+ } from './types'
8
+ import blockContentTypeFeatures from './util/blockContentTypeFeatures'
9
+ import {normalizeBlock} from './util/normalizeBlock'
10
+
11
+ /**
12
+ * Convert HTML to blocks respecting the block content type's schema
13
+ *
14
+ * @param html - The HTML to convert to blocks
15
+ * @param blockContentType - A compiled version of the schema type for the block content
16
+ * @param options - Options for deserializing HTML to blocks
17
+ * @returns Array of blocks
18
+ * @public
19
+ */
20
+ export function htmlToBlocks(
21
+ html: string,
22
+ blockContentType: ArraySchemaType,
23
+ options: HtmlDeserializerOptions = {},
24
+ ): (TypedObject | PortableTextTextBlock)[] {
25
+ const deserializer = new HtmlDeserializer(blockContentType, options)
26
+ return deserializer.deserialize(html).map((block) => normalizeBlock(block))
27
+ }
28
+
29
+ /**
30
+ * Normalize and extract features of an schema type containing a block type
31
+ *
32
+ * @param blockContentType - Schema type for the block type
33
+ * @returns Returns the featureset of a compiled block content type.
34
+ * @public
35
+ */
36
+ export function getBlockContentFeatures(
37
+ blockContentType: ArraySchemaType,
38
+ ): BlockContentFeatures {
39
+ return blockContentTypeFeatures(blockContentType)
40
+ }
41
+
42
+ export {normalizeBlock}
43
+ export {randomKey} from './util/randomKey'
44
+ export type {BlockContentFeatures, HtmlDeserializerOptions, TypedObject}
45
+ export type {
46
+ ArbitraryTypedObject,
47
+ BlockEditorSchemaProps,
48
+ DeserializerRule,
49
+ HtmlParser,
50
+ ResolvedAnnotationType,
51
+ } from './types'
52
+ export type {BlockNormalizationOptions} from './util/normalizeBlock'
package/src/types.ts ADDED
@@ -0,0 +1,139 @@
1
+ import type {
2
+ ArraySchemaType,
3
+ I18nTitledListValue,
4
+ ObjectSchemaType,
5
+ PortableTextObject,
6
+ SpanSchemaType,
7
+ TitledListValue,
8
+ } from '@sanity/types'
9
+ import type {ComponentType} from 'react'
10
+
11
+ /**
12
+ * @public
13
+ */
14
+ export interface BlockContentFeatures {
15
+ styles: TitledListValue<string>[]
16
+ decorators: TitledListValue<string>[]
17
+ annotations: ResolvedAnnotationType[]
18
+ lists: I18nTitledListValue<string>[]
19
+ types: {
20
+ block: ArraySchemaType
21
+ span: SpanSchemaType
22
+ inlineObjects: ObjectSchemaType[]
23
+ blockObjects: ObjectSchemaType[]
24
+ }
25
+ }
26
+
27
+ /**
28
+ * @beta
29
+ */
30
+ export interface BlockEditorSchemaProps {
31
+ icon?: string | ComponentType
32
+ render?: ComponentType
33
+ }
34
+
35
+ /**
36
+ * @public
37
+ */
38
+ export interface ResolvedAnnotationType {
39
+ blockEditor?: BlockEditorSchemaProps
40
+ title: string | undefined
41
+ value: string
42
+ type: ObjectSchemaType
43
+ icon: ComponentType | undefined
44
+ }
45
+
46
+ /**
47
+ * @public
48
+ */
49
+ export interface TypedObject {
50
+ _type: string
51
+ _key?: string
52
+ }
53
+
54
+ /**
55
+ * @public
56
+ */
57
+ export interface ArbitraryTypedObject extends TypedObject {
58
+ [key: string]: unknown
59
+ }
60
+
61
+ export interface MinimalSpan {
62
+ _type: 'span'
63
+ _key?: string
64
+ text: string
65
+ marks?: string[]
66
+ }
67
+
68
+ export interface MinimalBlock extends TypedObject {
69
+ _type: 'block'
70
+ children: TypedObject[]
71
+ markDefs?: TypedObject[]
72
+ style?: string
73
+ level?: number
74
+ listItem?: string
75
+ }
76
+
77
+ export interface PlaceholderDecorator {
78
+ _type: '__decorator'
79
+ name: string
80
+ children: TypedObject[]
81
+ }
82
+
83
+ export interface PlaceholderAnnotation {
84
+ _type: '__annotation'
85
+ markDef: PortableTextObject
86
+ children: TypedObject[]
87
+ }
88
+
89
+ /**
90
+ * @public
91
+ */
92
+ export type HtmlParser = (html: string) => Document
93
+
94
+ /**
95
+ * @public
96
+ */
97
+ export type WhiteSpacePasteMode = 'preserve' | 'remove' | 'normalize'
98
+
99
+ /**
100
+ * @public
101
+ */
102
+ export interface HtmlDeserializerOptions {
103
+ rules?: DeserializerRule[]
104
+ parseHtml?: HtmlParser
105
+ unstable_whitespaceOnPasteMode?: WhiteSpacePasteMode
106
+ }
107
+
108
+ /**
109
+ * @public
110
+ */
111
+ export interface HtmlPreprocessorOptions {
112
+ unstable_whitespaceOnPasteMode?: WhiteSpacePasteMode
113
+ }
114
+
115
+ /**
116
+ * @public
117
+ */
118
+ export interface DeserializerRule {
119
+ deserialize: (
120
+ el: Node,
121
+ next: (
122
+ elements: Node | Node[] | NodeList,
123
+ ) => TypedObject | TypedObject[] | undefined,
124
+ createBlock: (props: ArbitraryTypedObject) => {
125
+ _type: string
126
+ block: ArbitraryTypedObject
127
+ },
128
+ ) => TypedObject | TypedObject[] | undefined
129
+ }
130
+
131
+ /**
132
+ * @public
133
+ */
134
+ export interface BlockEnabledFeatures {
135
+ enabledBlockStyles: string[]
136
+ enabledSpanDecorators: string[]
137
+ enabledListTypes: string[]
138
+ enabledBlockAnnotations: string[]
139
+ }
@@ -0,0 +1,141 @@
1
+ import {
2
+ isBlockChildrenObjectField,
3
+ isBlockListObjectField,
4
+ isBlockSchemaType,
5
+ isBlockStyleObjectField,
6
+ isObjectSchemaType,
7
+ isTitledListValue,
8
+ type ArraySchemaType,
9
+ type BlockSchemaType,
10
+ type EnumListProps,
11
+ type I18nTitledListValue,
12
+ type ObjectSchemaType,
13
+ type SpanSchemaType,
14
+ type TitledListValue,
15
+ } from '@sanity/types'
16
+ import type {BlockContentFeatures, ResolvedAnnotationType} from '../types'
17
+ import {findBlockType} from './findBlockType'
18
+
19
+ // Helper method for describing a blockContentType's feature set
20
+ export default function blockContentFeatures(
21
+ blockContentType: ArraySchemaType,
22
+ ): BlockContentFeatures {
23
+ if (!blockContentType) {
24
+ throw new Error("Parameter 'blockContentType' required")
25
+ }
26
+
27
+ const blockType = blockContentType.of.find(findBlockType)
28
+ if (!isBlockSchemaType(blockType)) {
29
+ throw new Error("'block' type is not defined in this schema (required).")
30
+ }
31
+
32
+ const ofType = blockType.fields.find(isBlockChildrenObjectField)?.type?.of
33
+ if (!ofType) {
34
+ throw new Error('No `of` declaration found for blocks `children` field')
35
+ }
36
+
37
+ const spanType = ofType.find(
38
+ (member): member is SpanSchemaType => member.name === 'span',
39
+ )
40
+ if (!spanType) {
41
+ throw new Error(
42
+ 'No `span` type found in `block` schema type `children` definition',
43
+ )
44
+ }
45
+
46
+ const inlineObjectTypes = ofType.filter(
47
+ (inlineType): inlineType is ObjectSchemaType =>
48
+ inlineType.name !== 'span' && isObjectSchemaType(inlineType),
49
+ )
50
+
51
+ const blockObjectTypes = blockContentType.of.filter(
52
+ (memberType): memberType is ObjectSchemaType =>
53
+ memberType.name !== blockType.name && isObjectSchemaType(memberType),
54
+ )
55
+
56
+ return {
57
+ styles: resolveEnabledStyles(blockType),
58
+ decorators: resolveEnabledDecorators(spanType),
59
+ annotations: resolveEnabledAnnotationTypes(spanType),
60
+ lists: resolveEnabledListItems(blockType),
61
+ types: {
62
+ block: blockContentType,
63
+ span: spanType,
64
+ inlineObjects: inlineObjectTypes,
65
+ blockObjects: blockObjectTypes,
66
+ },
67
+ }
68
+ }
69
+
70
+ function resolveEnabledStyles(
71
+ blockType: BlockSchemaType,
72
+ ): TitledListValue<string>[] {
73
+ const styleField = blockType.fields.find(isBlockStyleObjectField)
74
+ if (!styleField) {
75
+ throw new Error(
76
+ "A field with name 'style' is not defined in the block type (required).",
77
+ )
78
+ }
79
+
80
+ const textStyles = getTitledListValuesFromEnumListOptions(
81
+ styleField.type.options,
82
+ )
83
+ if (textStyles.length === 0) {
84
+ throw new Error(
85
+ 'The style fields need at least one style ' +
86
+ "defined. I.e: {title: 'Normal', value: 'normal'}.",
87
+ )
88
+ }
89
+
90
+ return textStyles
91
+ }
92
+
93
+ function resolveEnabledAnnotationTypes(
94
+ spanType: SpanSchemaType,
95
+ ): ResolvedAnnotationType[] {
96
+ return spanType.annotations.map((annotation) => ({
97
+ title: annotation.title,
98
+ type: annotation,
99
+ value: annotation.name,
100
+ icon: annotation.icon,
101
+ }))
102
+ }
103
+
104
+ function resolveEnabledDecorators(
105
+ spanType: SpanSchemaType,
106
+ ): TitledListValue<string>[] {
107
+ return spanType.decorators
108
+ }
109
+
110
+ function resolveEnabledListItems(
111
+ blockType: BlockSchemaType,
112
+ ): I18nTitledListValue<string>[] {
113
+ const listField = blockType.fields.find(isBlockListObjectField)
114
+ if (!listField) {
115
+ throw new Error(
116
+ "A field with name 'list' is not defined in the block type (required).",
117
+ )
118
+ }
119
+
120
+ const listItems = getTitledListValuesFromEnumListOptions(
121
+ listField.type.options,
122
+ )
123
+ if (!listItems) {
124
+ throw new Error('The list field need at least to be an empty array')
125
+ }
126
+
127
+ return listItems
128
+ }
129
+
130
+ function getTitledListValuesFromEnumListOptions(
131
+ options: EnumListProps<string> | undefined,
132
+ ): I18nTitledListValue<string>[] {
133
+ const list = options ? options.list : undefined
134
+ if (!Array.isArray(list)) {
135
+ return []
136
+ }
137
+
138
+ return list.map((item) =>
139
+ isTitledListValue(item) ? item : {title: item, value: item},
140
+ )
141
+ }
@@ -0,0 +1,13 @@
1
+ import type {BlockSchemaType, SchemaType} from '@sanity/types'
2
+
3
+ export function findBlockType(type: SchemaType): type is BlockSchemaType {
4
+ if (type.type) {
5
+ return findBlockType(type.type)
6
+ }
7
+
8
+ if (type.name === 'block') {
9
+ return true
10
+ }
11
+
12
+ return false
13
+ }
@@ -0,0 +1,142 @@
1
+ import {
2
+ isPortableTextSpan,
3
+ type PortableTextSpan,
4
+ type PortableTextTextBlock,
5
+ } from '@sanity/types'
6
+ import {isEqual} from 'lodash'
7
+ import type {TypedObject} from '../types'
8
+ import {randomKey} from './randomKey'
9
+
10
+ /**
11
+ * Block normalization options
12
+ *
13
+ * @public
14
+ */
15
+ export interface BlockNormalizationOptions {
16
+ /**
17
+ * Decorator names that are allowed within portable text blocks, eg `em`, `strong`
18
+ */
19
+ allowedDecorators?: string[]
20
+
21
+ /**
22
+ * Name of the portable text block type, if not `block`
23
+ */
24
+ blockTypeName?: string
25
+ }
26
+
27
+ /**
28
+ * Normalizes a block by ensuring it has a `_key` property. If the block is a
29
+ * portable text block, additional normalization is applied:
30
+ *
31
+ * - Ensures it has `children` and `markDefs` properties
32
+ * - Ensures it has at least one child (adds an empty span if empty)
33
+ * - Joins sibling spans that has the same marks
34
+ * - Removes decorators that are not allowed according to the schema
35
+ * - Removes marks that have no annotation definition
36
+ *
37
+ * @param node - The block to normalize
38
+ * @param options - Options for normalization process. See {@link BlockNormalizationOptions}
39
+ * @returns Normalized block
40
+ * @public
41
+ */
42
+ export function normalizeBlock(
43
+ node: TypedObject,
44
+ options: BlockNormalizationOptions = {},
45
+ ): Omit<
46
+ TypedObject | PortableTextTextBlock<TypedObject | PortableTextSpan>,
47
+ '_key'
48
+ > & {
49
+ _key: string
50
+ } {
51
+ if (node._type !== (options.blockTypeName || 'block')) {
52
+ return '_key' in node
53
+ ? (node as TypedObject & {_key: string})
54
+ : {...node, _key: randomKey(12)}
55
+ }
56
+
57
+ const block: Omit<
58
+ PortableTextTextBlock<TypedObject | PortableTextSpan>,
59
+ 'style'
60
+ > = {
61
+ _key: randomKey(12),
62
+ children: [],
63
+ markDefs: [],
64
+ ...node,
65
+ }
66
+
67
+ const lastChild = block.children[block.children.length - 1]
68
+ if (!lastChild) {
69
+ // A block must at least have an empty span type child
70
+ block.children = [
71
+ {
72
+ _type: 'span',
73
+ _key: `${block._key}${0}`,
74
+ text: '',
75
+ marks: [],
76
+ },
77
+ ]
78
+ return block
79
+ }
80
+
81
+ const usedMarkDefs: string[] = []
82
+ const allowedDecorators =
83
+ options.allowedDecorators && Array.isArray(options.allowedDecorators)
84
+ ? options.allowedDecorators
85
+ : false
86
+
87
+ block.children = block.children
88
+ .reduce(
89
+ (acc, child) => {
90
+ const previousChild = acc[acc.length - 1]
91
+ if (
92
+ previousChild &&
93
+ isPortableTextSpan(child) &&
94
+ isPortableTextSpan(previousChild) &&
95
+ isEqual(previousChild.marks, child.marks)
96
+ ) {
97
+ if (
98
+ lastChild &&
99
+ lastChild === child &&
100
+ child.text === '' &&
101
+ block.children.length > 1
102
+ ) {
103
+ return acc
104
+ }
105
+
106
+ previousChild.text += child.text
107
+ return acc
108
+ }
109
+ acc.push(child)
110
+ return acc
111
+ },
112
+ [] as (TypedObject | PortableTextSpan)[],
113
+ )
114
+ .map((child, index) => {
115
+ if (!child) {
116
+ throw new Error('missing child')
117
+ }
118
+
119
+ child._key = `${block._key}${index}`
120
+ if (isPortableTextSpan(child)) {
121
+ if (!child.marks) {
122
+ child.marks = []
123
+ } else if (allowedDecorators) {
124
+ child.marks = child.marks.filter((mark) => {
125
+ const isAllowed = allowedDecorators.includes(mark)
126
+ const isUsed = block.markDefs?.some((def) => def._key === mark)
127
+ return isAllowed || isUsed
128
+ })
129
+ }
130
+
131
+ usedMarkDefs.push(...child.marks)
132
+ }
133
+
134
+ return child
135
+ })
136
+
137
+ // Remove leftover (unused) markDefs
138
+ block.markDefs = (block.markDefs || []).filter((markDef) =>
139
+ usedMarkDefs.includes(markDef._key),
140
+ )
141
+ return block
142
+ }
@@ -0,0 +1,26 @@
1
+ import getRandomValues from 'get-random-values-esm'
2
+
3
+ // WHATWG crypto RNG - https://w3c.github.io/webcrypto/Overview.html
4
+ function whatwgRNG(length = 16) {
5
+ const rnds8 = new Uint8Array(length)
6
+ getRandomValues(rnds8)
7
+ return rnds8
8
+ }
9
+
10
+ const byteToHex: string[] = []
11
+ for (let i = 0; i < 256; ++i) {
12
+ byteToHex[i] = (i + 0x100).toString(16).slice(1)
13
+ }
14
+
15
+ /**
16
+ * Generate a random key of the given length
17
+ *
18
+ * @param length - Length of string to generate
19
+ * @returns A string of the given length
20
+ * @public
21
+ */
22
+ export function randomKey(length: number): string {
23
+ return whatwgRNG(length)
24
+ .reduce((str, n) => str + byteToHex[n], '')
25
+ .slice(0, length)
26
+ }
@@ -0,0 +1,44 @@
1
+ const objectToString = Object.prototype.toString
2
+
3
+ // Copied from https://github.com/ForbesLindesay/type-of
4
+ // but inlined to have fine grained control
5
+ export function resolveJsType(val: unknown) {
6
+ switch (objectToString.call(val)) {
7
+ case '[object Function]':
8
+ return 'function'
9
+ case '[object Date]':
10
+ return 'date'
11
+ case '[object RegExp]':
12
+ return 'regexp'
13
+ case '[object Arguments]':
14
+ return 'arguments'
15
+ case '[object Array]':
16
+ return 'array'
17
+ case '[object String]':
18
+ return 'string'
19
+ default:
20
+ }
21
+
22
+ if (val === null) {
23
+ return 'null'
24
+ }
25
+
26
+ if (val === undefined) {
27
+ return 'undefined'
28
+ }
29
+
30
+ if (
31
+ val &&
32
+ typeof val === 'object' &&
33
+ 'nodeType' in val &&
34
+ (val as {nodeType: unknown}).nodeType === 1
35
+ ) {
36
+ return 'element'
37
+ }
38
+
39
+ if (val === Object(val)) {
40
+ return 'object'
41
+ }
42
+
43
+ return typeof val
44
+ }