├── .babelrc ├── .gitignore ├── .prettierrc ├── .npmignore ├── tsconfig.jest.json ├── jest.config.js ├── rollup.config.js ├── tsconfig.json ├── LICENSE ├── package.json ├── src ├── types.ts └── index.ts ├── test ├── serializer.test.ts ├── lists.test.ts └── blocks.test.ts └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist 3 | coverage 4 | node_modules 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .gitignore 3 | .prettierrc 4 | jest.config.js 5 | node_modules 6 | rollup.config.js 7 | src 8 | test 9 | tsconfig.json 10 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "experimentalDecorators": true, 5 | "importHelpers": true, 6 | "lib": ["es2019"], 7 | "moduleResolution": "node", 8 | "pretty": true, 9 | "skipLibCheck": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | collectCoverage: true, 4 | coverageDirectory: './coverage/', 5 | testEnvironment: 'jsdom', 6 | testRegex: '.*\\.test\\.tsx?$', 7 | transform: { 8 | '^.+\\.tsx?$': 'ts-jest', 9 | '^.+\\.vue$': 'vue-jest', 10 | }, 11 | watchPathIgnorePatterns: ['/node_modules/'], 12 | globals: { 13 | 'ts-jest': { 14 | tsconfig: 'tsconfig.jest.json', 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import ts from 'rollup-plugin-typescript2'; 2 | import { babel } from '@rollup/plugin-babel'; 3 | 4 | const pkg = require('./package.json'); 5 | 6 | const createConfig = (file, format, plugins = []) => ({ 7 | input: 'src/index.ts', 8 | output: { file, format }, 9 | external: Object.keys(pkg.dependencies), 10 | plugins: [ 11 | ts({ 12 | tsconfig: 'tsconfig.json', 13 | }), 14 | ...plugins, 15 | ], 16 | }); 17 | 18 | export default [ 19 | createConfig(pkg.module, 'es'), 20 | createConfig(pkg.main, 'cjs', [ 21 | // babel({ 22 | // extensions: ['js', '.ts'], 23 | // babelHelpers: 'bundled', 24 | // }), 25 | ]), 26 | ]; 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "declaration": true, 5 | "declarationMap": false, 6 | "downlevelIteration": true, 7 | "esModuleInterop": true, 8 | "experimentalDecorators": true, 9 | "importHelpers": true, 10 | "lib": ["es2019"], 11 | "moduleResolution": "node", 12 | "noEmitOnError": true, 13 | "noImplicitAny": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "outDir": "./dist", 18 | "pretty": true, 19 | "rootDir": "src", 20 | "skipLibCheck": true, 21 | "sourceMap": true, 22 | "strict": true, 23 | "strictFunctionTypes": true, 24 | "strictNullChecks": true, 25 | "target": "es5" 26 | }, 27 | "include": ["./src/**/*.ts", "./src/**/*.tsx"], 28 | "exclude": [ 29 | "**/*.test.ts", 30 | "**/*.test.tsx", 31 | "**/test/*", 32 | "node_modules", 33 | "**/node_modules/*" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Rupert Dunk. https://rupertdunk.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-blocks-vue-component", 3 | "version": "1.0.1", 4 | "description": "Vue 3 component for transforming Sanity block content", 5 | "author": "Rupert Dunk (http://rupertdunk.com)", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/rdunk/sanity-blocks-vue-component.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/rdunk/sanity-blocks-vue-component/issues" 13 | }, 14 | "homepage": "https://github.com/rdunk/sanity-blocks-vue-component#readme", 15 | "main": "dist/index.cjs.js", 16 | "module": "dist/index.esm.js", 17 | "types": "dist/index.d.js", 18 | "scripts": { 19 | "build": "npm run clean && npm run build:pkg", 20 | "build:pkg": "rollup -c rollup.config.js", 21 | "clean": "rimraf dist coverage", 22 | "postpublish": "npm run clean", 23 | "prepublishOnly": "npm run build", 24 | "test": "jest" 25 | }, 26 | "dependencies": { 27 | "lodash.merge": "^4.6.2", 28 | "vue": "^3.0.0" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.13.10", 32 | "@babel/preset-env": "^7.13.10", 33 | "@rollup/plugin-babel": "^5.3.0", 34 | "@types/jest": "^26.0.20", 35 | "@types/lodash.merge": "^4.6.6", 36 | "@vue/compiler-sfc": "^3.0.7", 37 | "@vue/test-utils": "^2.0.0-rc.3", 38 | "jest": "^26.6.3", 39 | "rimraf": "^3.0.2", 40 | "rollup": "^2.41.1", 41 | "rollup-plugin-typescript2": "^0.30.0", 42 | "ts-jest": "^26.5.3", 43 | "tslib": "^2.1.0", 44 | "typescript": "^4.2.3", 45 | "vue-jest": "^5.0.0-alpha.7" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DefineComponent, VNode } from 'vue'; 2 | 3 | export interface BaseBlock { 4 | _key: string; 5 | _type: string; 6 | } 7 | 8 | export type MarkDefinition = BaseBlock & Record; 9 | 10 | export type CustomBlock = BaseBlock & Record; 11 | 12 | export interface BlockSpan extends BaseBlock { 13 | _type: 'span'; 14 | marks: string[]; 15 | text: string; 16 | } 17 | 18 | export interface BlockText extends BaseBlock { 19 | _type: 'block'; 20 | children: Array; 21 | markDefs: MarkDefinition[]; 22 | style: string; 23 | } 24 | 25 | export interface BlockListItem extends BaseBlock { 26 | _type: 'block'; 27 | children: Array; 28 | level: number; 29 | listItem: string; 30 | markDefs: MarkDefinition[]; 31 | style: string; 32 | } 33 | 34 | export type Block = BlockText | CustomBlock; 35 | 36 | export interface BlockList { 37 | _type: 'list'; 38 | _key: string; 39 | level: number; 40 | listItem: string; 41 | children: Block[]; 42 | } 43 | 44 | // Serializers 45 | 46 | export type SerializedNode = string | VNode; 47 | 48 | export type BlockSerializer = ( 49 | block: T, 50 | serializers: Serializers 51 | ) => SerializedNode | SerializedNode[]; 52 | 53 | export type MarkSerializer = ( 54 | props: Record, 55 | children: SerializedNode | SerializedNode[] 56 | ) => SerializedNode | SerializedNode[]; 57 | 58 | export type SpanSerializer = ( 59 | span: BlockSpan, 60 | serializers: Serializers, 61 | markDefs: MarkDefinition[] 62 | ) => SerializedNode | SerializedNode[]; 63 | 64 | export type Serializer = 65 | | string 66 | | DefineComponent 67 | | SpanSerializer 68 | | BlockSerializer 69 | | BlockSerializer 70 | | BlockSerializer 71 | | MarkSerializer; 72 | 73 | export type DynamicSerializer = string | DefineComponent | T; 74 | 75 | export interface Serializers { 76 | types: { 77 | block: BlockSerializer; 78 | [key: string]: 79 | | BlockSerializer 80 | | DynamicSerializer; 81 | }; 82 | marks: Record>; 83 | styles: Record; 84 | list: DynamicSerializer>; 85 | listItem: DynamicSerializer>; 86 | span: SpanSerializer; 87 | hardBreak: () => VNode; 88 | } 89 | -------------------------------------------------------------------------------- /test/serializer.test.ts: -------------------------------------------------------------------------------- 1 | import { h, defineComponent } from 'vue'; 2 | import { mount } from '@vue/test-utils'; 3 | import { SanityBlocks } from '../src'; 4 | 5 | test('custom type serializer with template', () => { 6 | const wrapper = mount(SanityBlocks, { 7 | props: { 8 | blocks: [ 9 | { 10 | _type: 'message', 11 | _key: '3l37kf8jq1b4', 12 | msg: 'Foobar!', 13 | }, 14 | ], 15 | serializers: { 16 | types: { 17 | message: defineComponent({ 18 | template: '

{{ msg }}

', 19 | props: ['msg'], 20 | }), 21 | }, 22 | }, 23 | }, 24 | }); 25 | 26 | expect(wrapper.html()).toMatch('

Foobar!

'); 27 | }); 28 | 29 | test('custom type serializer with setup', () => { 30 | const wrapper = mount(SanityBlocks, { 31 | props: { 32 | blocks: [ 33 | { 34 | _type: 'message', 35 | _key: '3l37kf8jq1b4', 36 | msg: 'Foobar!', 37 | }, 38 | ], 39 | serializers: { 40 | types: { 41 | message: defineComponent({ 42 | props: ['msg'], 43 | setup(props) { 44 | return () => h('p', props.msg); 45 | }, 46 | }), 47 | }, 48 | }, 49 | }, 50 | }); 51 | 52 | expect(wrapper.html()).toMatch('

Foobar!

'); 53 | }); 54 | 55 | test('custom mark serializer with setup', () => { 56 | const wrapper = mount(SanityBlocks, { 57 | props: { 58 | blocks: [ 59 | { 60 | _key: 'bf9c6389cddf', 61 | _type: 'block', 62 | children: [ 63 | { 64 | _key: 'bf9c6389cddf0', 65 | _type: 'span', 66 | marks: [], 67 | text: 'OK.\nA ', 68 | }, 69 | { 70 | _key: 'bf9c6389cddf1', 71 | _type: 'span', 72 | marks: ['1376a4796fb6'], 73 | text: 'link', 74 | }, 75 | { 76 | _key: 'bf9c6389cddf2', 77 | _type: 'span', 78 | marks: [], 79 | text: '.', 80 | }, 81 | ], 82 | markDefs: [ 83 | { 84 | _key: '1376a4796fb6', 85 | _type: 'link', 86 | href: 'https://google.com', 87 | data: { key: 'foo', value: 'bar' }, 88 | }, 89 | ], 90 | style: 'normal', 91 | }, 92 | ], 93 | serializers: { 94 | marks: { 95 | link: defineComponent({ 96 | props: ['href', 'data'], 97 | setup(props, { slots }) { 98 | return () => 99 | h( 100 | 'a', 101 | { 102 | href: props.href, 103 | [`data-${props.data.key}`]: props.data.value, 104 | }, 105 | slots.default ? slots.default() : undefined 106 | ); 107 | }, 108 | }), 109 | }, 110 | }, 111 | }, 112 | }); 113 | 114 | expect(wrapper.html()).toMatch( 115 | '

OK.
A link.

' 116 | ); 117 | }); 118 | -------------------------------------------------------------------------------- /test/lists.test.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import { SanityBlocks } from '../src'; 3 | 4 | test('handles sequences of ordered and unordered lists', () => { 5 | const blocks = [ 6 | { 7 | _key: '2c6fdfe3b63f', 8 | _type: 'block', 9 | children: [ 10 | { 11 | _key: '0d6f58f717a3', 12 | _type: 'span', 13 | marks: [], 14 | text: 'One', 15 | }, 16 | ], 17 | level: 1, 18 | listItem: 'bullet', 19 | markDefs: [], 20 | style: 'normal', 21 | }, 22 | { 23 | _key: '7e26d666420d', 24 | _type: 'block', 25 | children: [ 26 | { 27 | _key: '568c79b83f5d', 28 | _type: 'span', 29 | marks: [], 30 | text: 'Two', 31 | }, 32 | ], 33 | level: 1, 34 | listItem: 'bullet', 35 | markDefs: [], 36 | style: 'normal', 37 | }, 38 | { 39 | _key: 'e543c684b6ac', 40 | _type: 'block', 41 | children: [ 42 | { 43 | _key: '4b2a4a47827e', 44 | _type: 'span', 45 | marks: [], 46 | text: 'Three', 47 | }, 48 | ], 49 | level: 1, 50 | listItem: 'number', 51 | markDefs: [], 52 | style: 'normal', 53 | }, 54 | { 55 | _key: '28ed94e89ee2', 56 | _type: 'block', 57 | children: [ 58 | { 59 | _key: '04f728babe2e', 60 | _type: 'span', 61 | marks: [], 62 | text: 'Four', 63 | }, 64 | ], 65 | level: 1, 66 | listItem: 'number', 67 | markDefs: [], 68 | style: 'normal', 69 | }, 70 | { 71 | _key: '7b3ed455e5c8', 72 | _type: 'block', 73 | children: [ 74 | { 75 | _key: 'b136c78e14ee', 76 | _type: 'span', 77 | marks: [], 78 | text: 'Five', 79 | }, 80 | ], 81 | level: 1, 82 | listItem: 'bullet', 83 | markDefs: [], 84 | style: 'normal', 85 | }, 86 | { 87 | _key: '875b9781290a', 88 | _type: 'block', 89 | children: [ 90 | { 91 | _key: '3566c13afd15', 92 | _type: 'span', 93 | marks: [], 94 | text: 'Six', 95 | }, 96 | ], 97 | level: 1, 98 | listItem: 'bullet', 99 | markDefs: [], 100 | style: 'normal', 101 | }, 102 | { 103 | _key: 'd279400dfff2', 104 | _type: 'block', 105 | children: [ 106 | { 107 | _key: 'c09e0dc89560', 108 | _type: 'span', 109 | marks: [], 110 | text: 'Seven', 111 | }, 112 | ], 113 | level: 2, 114 | listItem: 'number', 115 | markDefs: [], 116 | style: 'normal', 117 | }, 118 | { 119 | _key: '2cabbe83f886', 120 | _type: 'block', 121 | children: [ 122 | { 123 | _key: 'f15c8ac1ca4c', 124 | _type: 'span', 125 | marks: [], 126 | text: 'Eight', 127 | }, 128 | ], 129 | level: 2, 130 | listItem: 'number', 131 | markDefs: [], 132 | style: 'normal', 133 | }, 134 | { 135 | _key: '8c39120c5830', 136 | _type: 'block', 137 | children: [ 138 | { 139 | _key: '195af314c5d4', 140 | _type: 'span', 141 | marks: [], 142 | text: 'Nine', 143 | }, 144 | ], 145 | level: 2, 146 | listItem: 'bullet', 147 | markDefs: [], 148 | style: 'normal', 149 | }, 150 | { 151 | _key: '24686faf7e52', 152 | _type: 'block', 153 | children: [ 154 | { 155 | _key: 'b968d408863a', 156 | _type: 'span', 157 | marks: [], 158 | text: 'Ten', 159 | }, 160 | ], 161 | level: 2, 162 | listItem: 'bullet', 163 | markDefs: [], 164 | style: 'normal', 165 | }, 166 | ]; 167 | 168 | const wrapper = mount(SanityBlocks, { 169 | props: { 170 | blocks, 171 | }, 172 | }); 173 | 174 | expect(wrapper.html()).toMatch( 175 | `
    176 |
  • One
  • 177 |
  • Two
  • 178 |
179 |
    180 |
  1. Three
  2. 181 |
  3. Four
  4. 182 |
183 |
    184 |
  • Five
  • 185 |
  • Six
      186 |
    1. Seven
    2. 187 |
    3. Eight
    4. 188 |
    189 |
      190 |
    • Nine
    • 191 |
    • Ten
    • 192 |
    193 |
  • 194 |
` 195 | ); 196 | }); 197 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## **⚠️ Note:** This package is deprecated. Please use [@portabletext/vue](https://github.com/portabletext/vue-portabletext) instead. If you are migrating, refer to [this guide](https://github.com/portabletext/vue-portabletext/blob/main/MIGRATING.md). 2 | 3 |
4 |

Sanity Blocks Vue Component

5 |

6 | Maintenance Status 7 | NPM version 8 | NPM downloads 9 | GitHub Release Date 10 | Vue version 11 | License 12 |

13 |

14 |

15 |

A Vue component for rendering block content from Sanity.

16 |
17 |
18 |
19 | 20 | ## Install 21 | 22 | > **Notice**: This version is a complete rewrite for **Vue 3**. For **Vue 2** and **Nuxt 2**, follow the instructions in the [legacy branch](https://github.com/rdunk/sanity-blocks-vue-component/tree/legacy#sanity-blocks-vue-component). 23 | 24 | ```bash 25 | $ npm i sanity-blocks-vue-component # or yarn add sanity-blocks-vue-component 26 | ``` 27 | 28 | ## Usage 29 | 30 | Import directly into your component or view: 31 | 32 | ```vue 33 | 36 | 37 | 54 | ``` 55 | 56 | Or install globally: 57 | 58 | ```ts 59 | import { createApp } from 'vue'; 60 | import { SanityBlocks } from 'sanity-blocks-vue-component'; 61 | import App from './App.vue'; 62 | 63 | const app = createApp(App); 64 | app.component('sanity-blocks', SanityBlocks); 65 | app.mount('#app'); 66 | ``` 67 | 68 | ## Props 69 | 70 | The following props can be passed to the component. 71 | 72 | | Prop | Required | Description | Type | 73 | | ------------- | -------- | -------------------------------------------------- | ------ | 74 | | `blocks` | Yes | Block content retrieved from Sanity. | Array | 75 | | `serializers` | No | Any custom serializers you want to use. See below. | Object | 76 | 77 | ## Serializers 78 | 79 | Serializers are the functions used for rendering block content. They can be defined as a string or a Vue Component. This package comes with default serializers for rendering basic block content, you can pass a `serializer` prop to override or extend the defaults. 80 | 81 | | Property | Description | 82 | | ----------- | -------------------------------------- | 83 | | `types` | Object of serializers for block types. | 84 | | `marks` | Object of serializers for marks. | 85 | | `styles` | Object of serializers for styles. | 86 | | `list` | Serializer for list containers. | 87 | | `listItem` | Serializer for list items. | 88 | | `hardBreak` | Serializer for hard breaks. | 89 | 90 | ## Component Serializers 91 | 92 | The most common use case is defining serializers for custom block types and marks, using the `types` and `marks` serializer properties. For example, if you have a block of type `custom`, you can add a property to the `serializers.types` object with the key `custom` and a value of the Vue component that should serialize blocks of that type. 93 | 94 | When using a custom Vue component as a serializer, all properties of the block or mark object (excluding `_key` and `_type`) will be passed as [props](https://v3.vuejs.org/guide/component-props.html). 95 | 96 | > **To access the data, you should define the correpsonding props in your component.** 97 | 98 | For mark serializers, you can also use [slots](https://v3.vuejs.org/guide/component-slots.html) to access the mark text or content. 99 | 100 | ## License 101 | 102 | [MIT](http://opensource.org/licenses/MIT) 103 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import merge from 'lodash.merge'; 2 | import { defineComponent, h, DefineComponent, PropType } from 'vue'; 3 | 4 | import { 5 | Block, 6 | BlockList, 7 | BlockListItem, 8 | BlockSerializer, 9 | BlockSpan, 10 | BlockText, 11 | CustomBlock, 12 | MarkDefinition, 13 | MarkSerializer, 14 | SerializedNode, 15 | Serializer, 16 | Serializers, 17 | SpanSerializer, 18 | } from './types'; 19 | 20 | const notNull = (x: T | null): x is T => x !== null; 21 | 22 | const createElementFromStyle = ( 23 | block: BlockText | BlockListItem, 24 | serializers: Serializers, 25 | children: (SerializedNode | SerializedNode[])[] 26 | ) => { 27 | if (block.style) { 28 | const styleSerializer = serializers.styles[block.style]; 29 | if (styleSerializer) { 30 | return h(styleSerializer, {}, children); 31 | } 32 | } 33 | return children.flatMap((a) => a); 34 | }; 35 | 36 | const blockIsSpan = (block: Block | BlockSpan): block is BlockSpan => { 37 | return block._type === 'span' && 'marks' in block && 'text' in block; 38 | }; 39 | 40 | // @TODO This probably needs improving... 41 | const serializerIsVueComponent = ( 42 | serializer: Serializer 43 | ): serializer is DefineComponent => { 44 | return ( 45 | typeof serializer === 'object' && 46 | ('template' in serializer || 47 | 'setup' in serializer || 48 | 'render' in serializer || 49 | 'ssrRender' in serializer) 50 | ); 51 | }; 52 | 53 | const findBlockSerializer = (block: Block, serializers: Serializers) => { 54 | if (block._type === 'list') { 55 | return serializers.list; 56 | } 57 | if ('listItem' in block) { 58 | return serializers.listItem; 59 | } 60 | if (blockIsSpan(block)) { 61 | return serializers.span; 62 | } 63 | return serializers.types[block._type]; 64 | }; 65 | 66 | // Typically returns an array of text nodes 67 | // but might also include a VNode of a line break (
) 68 | const renderText = (text: string, serializers: Serializers) => { 69 | const lines: Array = text.split('\n'); 70 | for (let line = lines.length; line-- > 1; ) { 71 | lines.splice(line, 0, serializers.hardBreak()); 72 | } 73 | return lines; 74 | }; 75 | 76 | const attachMarks = ( 77 | span: BlockSpan, 78 | remainingMarks: string[], 79 | serializers: Serializers, 80 | markDefs: MarkDefinition[] 81 | ): SerializedNode | SerializedNode[] => { 82 | const [mark, ...marks] = remainingMarks; 83 | if (!mark) { 84 | return renderText(span.text, serializers); 85 | } 86 | 87 | const markDef = 88 | mark in serializers.marks 89 | ? { _type: mark, _key: '' } 90 | : markDefs.find((m) => m._key === mark); 91 | 92 | const serializer = markDef ? serializers.marks[markDef._type] : 'span'; 93 | 94 | if (serializerIsVueComponent(serializer)) { 95 | const props = extractProps(markDef); 96 | return h(serializer, props, () => 97 | attachMarks(span, marks, serializers, markDefs) 98 | ); 99 | } 100 | 101 | if (typeof serializer === 'function') { 102 | return serializer( 103 | markDef || {}, 104 | attachMarks(span, marks, serializers, markDefs) 105 | ); 106 | } 107 | 108 | return h( 109 | serializer, 110 | extractProps(markDef), 111 | attachMarks(span, marks, serializers, markDefs) 112 | ); 113 | }; 114 | 115 | const spanSerializer: SpanSerializer = (span, serializers, markDefs) => { 116 | const defaults = ['em', 'strong', 'code']; 117 | // Defaults first 118 | const marks = [...span.marks].sort((a, b) => { 119 | if (defaults.includes(a)) return 1; 120 | if (defaults.includes(b)) return -1; 121 | return 0; 122 | }); 123 | return attachMarks(span, marks, serializers, markDefs); 124 | }; 125 | 126 | const blockTextSerializer = (block: BlockText, serializers: Serializers) => { 127 | const nodes = block.children.flatMap((span) => { 128 | return spanSerializer(span, serializers, block.markDefs); 129 | }); 130 | return createElementFromStyle(block, serializers, nodes); 131 | }; 132 | 133 | const underlineSerializer: MarkSerializer = (_, children) => 134 | h('span', { style: 'text-decoration: underline;' }, children); 135 | 136 | const linkSerializer: MarkSerializer = (props, children) => { 137 | return h( 138 | 'a', 139 | { href: props.href, target: props.newtab ? '_blank' : undefined }, 140 | children 141 | ); 142 | }; 143 | 144 | const listSerializer = (block: BlockListItem, serializers: Serializers) => { 145 | const el = block.listItem === 'number' ? 'ol' : 'ul'; 146 | return h(el, {}, renderBlocks(block.children, serializers, block.level)); 147 | }; 148 | 149 | const listItemSerializer = (block: BlockListItem, serializers: Serializers) => { 150 | // Array of array of strings or nodes 151 | const children = renderBlocks(block.children, serializers, block.level); 152 | const shouldWrap = block.style && block.style !== 'normal'; 153 | return h( 154 | 'li', 155 | {}, 156 | shouldWrap ? createElementFromStyle(block, serializers, children) : children 157 | ); 158 | }; 159 | 160 | // Remove extraneous object properties 161 | const extractProps = (item: CustomBlock | MarkDefinition | undefined) => { 162 | if (item) { 163 | const { _key, _type, ...props } = item; 164 | return props; 165 | } 166 | return {}; 167 | }; 168 | 169 | const serializeBlock = (block: Block | BlockSpan, serializers: Serializers) => { 170 | // Find the serializer for this type of block 171 | const serializer = findBlockSerializer(block, serializers); 172 | // If none found, return null 173 | if (!serializer) return null; 174 | // If the serializer is a vue component, render it 175 | if (serializerIsVueComponent(serializer)) { 176 | const props = extractProps(block); 177 | return h(serializer, props); 178 | } 179 | // Probably block text i.e. type 'block' 180 | // Could also be a span 181 | if (typeof serializer === 'function') { 182 | // We do some manual type assertion here 183 | // the findBlockSerializer method will have narrowed down the serializer if the block is a span type 184 | if (blockIsSpan(block)) { 185 | return (serializer as SpanSerializer)(block, serializers, []); 186 | } 187 | return (serializer as BlockSerializer)(block, serializers); 188 | } 189 | // Must be a string by this point 190 | return h(serializer, {}); 191 | }; 192 | 193 | const createList = (block: BlockListItem): BlockList => { 194 | return { 195 | _type: 'list', 196 | _key: `${block._key}-parent`, 197 | level: block.level, 198 | listItem: block.listItem, 199 | children: [block], 200 | }; 201 | }; 202 | 203 | const nestBlocks = (blocks: Array, level = 0) => { 204 | const isListOrListItem = (block: Block | BlockSpan): block is BlockListItem => 205 | 'level' in block; 206 | const hasChildren = ( 207 | block: Block | BlockSpan 208 | ): block is BlockText | BlockList => block && 'children' in block; 209 | const newBlocks: Array = []; 210 | 211 | blocks.forEach((block) => { 212 | if (!isListOrListItem(block)) { 213 | newBlocks.push(block); 214 | return; 215 | } 216 | 217 | const lastBlock = newBlocks[newBlocks.length - 1]; 218 | 219 | if (block.level === level) { 220 | newBlocks.push(block); 221 | return; 222 | } 223 | 224 | if (block.level && block.level > level) { 225 | if ( 226 | !hasChildren(lastBlock) || 227 | !isListOrListItem(lastBlock) || 228 | (lastBlock.level && lastBlock.level > block.level) 229 | ) { 230 | newBlocks.push(createList(block)); 231 | } else if ( 232 | lastBlock.level === block.level && 233 | lastBlock.listItem !== block.listItem 234 | ) { 235 | newBlocks.push(createList(block)); 236 | } else { 237 | lastBlock.children.push(block); 238 | } 239 | } 240 | }); 241 | 242 | return newBlocks; 243 | }; 244 | 245 | // Returns an array of strings, vnodes, or arrays of either 246 | const renderBlocks = ( 247 | blocks: Block[] | BlockSpan[], 248 | serializers: Serializers, 249 | level = 0 250 | ) => { 251 | // Nest list items in lists 252 | const nestedBlocks = nestBlocks(blocks, level); 253 | 254 | // Loop through each block, and serialize it 255 | return nestedBlocks 256 | .map((block) => serializeBlock(block, serializers)) 257 | .filter(notNull); 258 | }; 259 | 260 | const defaultSerializers: Serializers = { 261 | // For blocks 262 | types: { 263 | image: 'image', 264 | block: blockTextSerializer, 265 | }, 266 | // For marks 267 | marks: { 268 | strong: 'strong', 269 | em: 'em', 270 | link: linkSerializer, 271 | underline: underlineSerializer, 272 | }, 273 | // For span styles 274 | styles: { 275 | h1: 'h1', 276 | h2: 'h2', 277 | h3: 'h3', 278 | h4: 'h4', 279 | h5: 'h5', 280 | h6: 'h6', 281 | normal: 'p', 282 | }, 283 | hardBreak: () => h('br'), 284 | span: spanSerializer, 285 | list: listSerializer, 286 | listItem: listItemSerializer, 287 | }; 288 | 289 | export const SanityBlocks = defineComponent({ 290 | functional: true, 291 | props: { 292 | blocks: { 293 | type: Array as PropType, 294 | default: () => [], 295 | }, 296 | serializers: { 297 | type: Object as PropType>, 298 | default: () => ({}), 299 | }, 300 | }, 301 | setup(props) { 302 | const serializers = merge({}, defaultSerializers, props.serializers); 303 | return () => renderBlocks(props.blocks, serializers); 304 | }, 305 | }); 306 | -------------------------------------------------------------------------------- /test/blocks.test.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import { SanityBlocks } from '../src'; 3 | 4 | const content = [ 5 | { 6 | _key: '64501e9fa998', 7 | _type: 'block', 8 | children: [ 9 | { 10 | _key: 'e96a503c11c2', 11 | _type: 'span', 12 | marks: [], 13 | text: 'Test ', 14 | }, 15 | { 16 | _key: '5dd4c472ac41', 17 | _type: 'span', 18 | marks: ['strong'], 19 | text: 'content', 20 | }, 21 | { 22 | _key: '31cd73804778', 23 | _type: 'span', 24 | marks: [], 25 | text: ' ', 26 | }, 27 | { 28 | _key: '433a2cd61ea6', 29 | _type: 'span', 30 | marks: ['em', 'underline', 'strong'], 31 | text: 'with', 32 | }, 33 | { 34 | _key: '83467a588d01', 35 | _type: 'span', 36 | marks: [], 37 | text: ' ', 38 | }, 39 | { 40 | _key: '6b235298b73e', 41 | _type: 'span', 42 | marks: ['strong'], 43 | text: 'styling', 44 | }, 45 | ], 46 | markDefs: [], 47 | style: 'normal', 48 | }, 49 | { 50 | _key: 'ec35b232aaf0', 51 | _type: 'block', 52 | children: [ 53 | { 54 | _key: 'c0d40a831dd6', 55 | _type: 'span', 56 | marks: [], 57 | text: 'List', 58 | }, 59 | ], 60 | markDefs: [], 61 | style: 'h1', 62 | }, 63 | { 64 | _key: '5ae06c2e3a72', 65 | _type: 'block', 66 | children: [ 67 | { 68 | _key: 'f642ad11fb0b', 69 | _type: 'span', 70 | marks: [], 71 | text: 'One ', 72 | }, 73 | { 74 | _key: '1f6454c6a1e9', 75 | _type: 'span', 76 | marks: ['strong', 'em'], 77 | text: 'some', 78 | }, 79 | { 80 | _key: '12cbf1935db0', 81 | _type: 'span', 82 | marks: [], 83 | text: ' stuff', 84 | }, 85 | ], 86 | level: 1, 87 | listItem: 'number', 88 | markDefs: [], 89 | style: 'normal', 90 | }, 91 | { 92 | _key: '3daf31788cef', 93 | _type: 'block', 94 | children: [ 95 | { 96 | _key: '6e8f39d5c5a8', 97 | _type: 'span', 98 | marks: [], 99 | text: 'Two\nLine break...\n', 100 | }, 101 | ], 102 | level: 2, 103 | listItem: 'number', 104 | markDefs: [], 105 | style: 'normal', 106 | }, 107 | { 108 | _key: 'fdc820e4db7a', 109 | _type: 'block', 110 | children: [ 111 | { 112 | _key: 'aeac8050a4f8', 113 | _type: 'span', 114 | marks: [], 115 | text: 'Three ', 116 | }, 117 | { 118 | _key: '3730fb1a3206', 119 | _type: 'span', 120 | marks: ['underline'], 121 | text: 'with', 122 | }, 123 | { 124 | _key: '22259cbe3615', 125 | _type: 'span', 126 | marks: [], 127 | text: ' ', 128 | }, 129 | { 130 | _key: '814adf000456', 131 | _type: 'span', 132 | marks: ['strong'], 133 | text: 'extra', 134 | }, 135 | { 136 | _key: 'c248b7acf9d2', 137 | _type: 'span', 138 | marks: [], 139 | text: ' bits', 140 | }, 141 | ], 142 | level: 2, 143 | listItem: 'number', 144 | markDefs: [], 145 | style: 'normal', 146 | }, 147 | { 148 | _key: '50f06b80a39f', 149 | _type: 'block', 150 | children: [ 151 | { 152 | _key: '4a9cb90f0f3a', 153 | _type: 'span', 154 | marks: [], 155 | text: 'Four', 156 | }, 157 | ], 158 | level: 3, 159 | listItem: 'number', 160 | markDefs: [], 161 | style: 'normal', 162 | }, 163 | { 164 | _key: 'a1362ea9d33a', 165 | _type: 'block', 166 | children: [ 167 | { 168 | _key: '1f9a06d0ffe0', 169 | _type: 'span', 170 | marks: [], 171 | text: 'Five', 172 | }, 173 | ], 174 | level: 3, 175 | listItem: 'number', 176 | markDefs: [], 177 | style: 'normal', 178 | }, 179 | { 180 | _key: 'd204018d3b21', 181 | _type: 'block', 182 | children: [ 183 | { 184 | _key: '09720aad4447', 185 | _type: 'span', 186 | marks: [], 187 | text: 'Six', 188 | }, 189 | ], 190 | level: 4, 191 | listItem: 'number', 192 | markDefs: [], 193 | style: 'normal', 194 | }, 195 | { 196 | _key: 'faa8fd028bc5', 197 | _type: 'block', 198 | children: [ 199 | { 200 | _key: 'fe38e2962715', 201 | _type: 'span', 202 | marks: [], 203 | text: 'Seven', 204 | }, 205 | ], 206 | level: 3, 207 | listItem: 'number', 208 | markDefs: [], 209 | style: 'normal', 210 | }, 211 | { 212 | _key: '3828a9b9b019', 213 | _type: 'block', 214 | children: [ 215 | { 216 | _key: '11028e42fe94', 217 | _type: 'span', 218 | marks: [], 219 | text: 'Erm?', 220 | }, 221 | ], 222 | markDefs: [], 223 | style: 'normal', 224 | }, 225 | { 226 | _key: '1a4a0dae6925', 227 | _type: 'block', 228 | children: [ 229 | { 230 | _key: '60a909c57149', 231 | _type: 'span', 232 | marks: [], 233 | text: 'Eight', 234 | }, 235 | ], 236 | level: 2, 237 | listItem: 'number', 238 | markDefs: [], 239 | style: 'normal', 240 | }, 241 | { 242 | _key: 'c91645d01c61', 243 | _type: 'block', 244 | children: [ 245 | { 246 | _key: '0c5c8c453de1', 247 | _type: 'span', 248 | marks: [], 249 | text: 'Nine', 250 | }, 251 | ], 252 | level: 2, 253 | listItem: 'number', 254 | markDefs: [], 255 | style: 'normal', 256 | }, 257 | { 258 | _key: '60b2b9b12147', 259 | _type: 'block', 260 | children: [ 261 | { 262 | _key: '9b87d745a58e', 263 | _type: 'span', 264 | marks: ['em'], 265 | text: 'Ten', 266 | }, 267 | ], 268 | level: 1, 269 | listItem: 'number', 270 | markDefs: [], 271 | style: 'normal', 272 | }, 273 | { 274 | _key: '26fdf1d0d0b3', 275 | _type: 'block', 276 | children: [ 277 | { 278 | _key: 'bc02198f6cef', 279 | _type: 'span', 280 | marks: ['underline'], 281 | text: 'OK', 282 | }, 283 | ], 284 | level: 1, 285 | listItem: 'number', 286 | markDefs: [], 287 | style: 'normal', 288 | }, 289 | { 290 | _key: '3a6530b5886e', 291 | _type: 'block', 292 | children: [ 293 | { 294 | _key: '322bc8f7d1fc', 295 | _type: 'span', 296 | marks: [], 297 | text: 'wtf', 298 | }, 299 | ], 300 | level: 1, 301 | listItem: 'number', 302 | markDefs: [], 303 | style: 'h1', 304 | }, 305 | { 306 | _key: '30a132394fc4', 307 | _type: 'block', 308 | children: [ 309 | { 310 | _key: '7e4752ad2500', 311 | _type: 'span', 312 | marks: ['strong'], 313 | text: 'Eleven', 314 | }, 315 | ], 316 | level: 1, 317 | listItem: 'number', 318 | markDefs: [], 319 | style: 'normal', 320 | }, 321 | { 322 | _key: 'cc4d88a49aba', 323 | _type: 'block', 324 | children: [ 325 | { 326 | _key: '82406da1c8c0', 327 | _type: 'span', 328 | marks: [], 329 | text: 'Twelve', 330 | }, 331 | ], 332 | level: 2, 333 | listItem: 'number', 334 | markDefs: [], 335 | style: 'normal', 336 | }, 337 | { 338 | _key: 'c1a64b743298', 339 | _type: 'block', 340 | children: [ 341 | { 342 | _key: 'e574cad1d20c', 343 | _type: 'span', 344 | marks: [], 345 | text: 'Hello!', 346 | }, 347 | ], 348 | markDefs: [], 349 | style: 'normal', 350 | }, 351 | { 352 | _key: '6ad2dff85803', 353 | _type: 'block', 354 | children: [ 355 | { 356 | _key: '63357d455724', 357 | _type: 'span', 358 | marks: [], 359 | text: 'Second list...', 360 | }, 361 | ], 362 | level: 1, 363 | listItem: 'bullet', 364 | markDefs: [], 365 | style: 'normal', 366 | }, 367 | { 368 | _key: '8ed902624b38', 369 | _type: 'block', 370 | children: [ 371 | { 372 | _key: '2e052eb11a3b', 373 | _type: 'span', 374 | marks: [], 375 | text: 'More..', 376 | }, 377 | ], 378 | level: 1, 379 | listItem: 'bullet', 380 | markDefs: [], 381 | style: 'normal', 382 | }, 383 | { 384 | _key: '43f102963941', 385 | _type: 'block', 386 | children: [ 387 | { 388 | _key: '376993bba2ca', 389 | _type: 'span', 390 | marks: [], 391 | text: 'Hmm...', 392 | }, 393 | ], 394 | level: 2, 395 | listItem: 'number', 396 | markDefs: [], 397 | style: 'normal', 398 | }, 399 | ]; 400 | 401 | test('matches html', () => { 402 | const wrapper = mount(SanityBlocks, { 403 | props: { 404 | blocks: content, 405 | }, 406 | }); 407 | 408 | expect(wrapper.html()).toMatch( 409 | `

Test content with styling

410 |

List

411 |
    412 |
  1. One some stuff
      413 |
    1. Two
      Line break...
    2. 414 |
    3. Three with extra bits
        415 |
      1. Four
      2. 416 |
      3. Five
          417 |
        1. Six
        2. 418 |
        419 |
      4. 420 |
      5. Seven
      6. 421 |
      422 |
    4. 423 |
    424 |
  2. 425 |
426 |

Erm?

427 |
    428 |
  1. Eight
  2. 429 |
  3. Nine
  4. 430 |
431 |
    432 |
  1. Ten
  2. 433 |
  3. OK
  4. 434 |
  5. 435 |

    wtf

    436 |
  6. 437 |
  7. Eleven 438 |
      439 |
    1. Twelve
    2. 440 |
    441 |
  8. 442 |
443 |

Hello!

444 |
    445 |
  • Second list...
  • 446 |
  • More..
      447 |
    1. Hmm...
    2. 448 |
    449 |
  • 450 |
` 451 | ); 452 | }); 453 | --------------------------------------------------------------------------------