├── .prettierignore ├── test ├── fixtures │ ├── 020-empty-array.ts │ ├── 001-empty-block.ts │ ├── 002-single-span.ts │ ├── 008-plain-header-block.ts │ ├── 023-hard-breaks.ts │ ├── 026-inline-block-with-text.ts │ ├── 052-custom-marks.ts │ ├── 062-custom-block-type-with-children.ts │ ├── 053-override-default-marks.ts │ ├── 003-multiple-spans.ts │ ├── 004-basic-mark-single-span.ts │ ├── 050-custom-block-type.ts │ ├── 061-missing-mark-component.ts │ ├── 005-basic-mark-multiple-adjacent-spans.ts │ ├── 006-basic-mark-nested-marks.ts │ ├── 007-link-mark-def.ts │ ├── 019-keyless.ts │ ├── 018-marks-all-the-way-down.ts │ ├── 022-inline-nodes.ts │ ├── 009-messy-link-text.ts │ ├── 015-all-basic-marks.ts │ ├── 010-basic-bullet-list.ts │ ├── 011-basic-numbered-list.ts │ ├── 027-styled-list-items.ts │ ├── 028-custom-list-item-type.ts │ ├── 024-inline-objects.ts │ ├── index.ts │ ├── 017-all-default-block-styles.ts │ ├── 014-nested-lists.ts │ ├── 016-deep-weird-lists.ts │ ├── 021-list-without-level.ts │ └── 060-list-issue.ts ├── components.test.ts ├── mutations.test.ts ├── toPlainText.test.ts └── portable-text.test.ts ├── .npmignore ├── src ├── components │ ├── basic.ts │ ├── list.ts │ ├── flatten.ts │ ├── marks.ts │ ├── unknown.ts │ ├── defaults.ts │ └── merge.ts ├── index.ts ├── warnings.ts ├── vue-portable-text.vue ├── node-renderer.ts └── types.ts ├── .changeset └── config.json ├── tsconfig.json ├── .github ├── workflows │ ├── release.yml │ ├── renovate.yml │ ├── main.yml │ └── format-if-needed.yml └── renovate.json ├── .gitignore ├── LICENSE ├── vite.config.ts ├── package.json ├── MIGRATING.md ├── CHANGELOG.md └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | -------------------------------------------------------------------------------- /test/fixtures/020-empty-array.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | input: [], 3 | output: '', 4 | }; 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .prettierrc 3 | node_modules 4 | rollup.config.js 5 | src 6 | test 7 | tsconfig.json 8 | vite.config.json 9 | -------------------------------------------------------------------------------- /src/components/basic.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'vue'; 2 | import type { Component } from 'vue'; 3 | 4 | export const basicElement = 5 | (name: string): Component => 6 | (_, { slots }) => 7 | h(name, slots.default?.()); 8 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "portabletext/vue-portabletext" }], 4 | "commit": false, 5 | "access": "public", 6 | "baseBranch": "main", 7 | "updateInternalDependencies": "patch" 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/001-empty-block.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _key: 'R5FvMrjo', 5 | _type: 'block', 6 | children: [], 7 | markDefs: [], 8 | style: 'normal', 9 | }; 10 | 11 | export default { 12 | input, 13 | output: '

', 14 | }; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["./package.json", "./src"], 4 | "compilerOptions": { 5 | "rootDir": ".", 6 | "outDir": "./dist", 7 | 8 | "lib": ["ES2016", "DOM"], 9 | "noUncheckedIndexedAccess": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/list.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextListComponent, PortableTextListItemComponent } from '../types'; 2 | import { basicElement } from './basic'; 3 | 4 | export const defaultLists: Record<'number' | 'bullet', PortableTextListComponent> = { 5 | number: basicElement('ol'), 6 | bullet: basicElement('ul'), 7 | }; 8 | 9 | export const DefaultListItem: PortableTextListItemComponent = basicElement('li'); 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export type { ToolkitListNestMode as ListNestMode } from '@portabletext/toolkit'; 3 | export { toPlainText } from '@portabletext/toolkit'; 4 | export { mergeComponents } from './components/merge'; 5 | export { defaultComponents } from './components/defaults'; 6 | export { flattenProps } from './components/flatten'; 7 | import PortableText from './vue-portable-text.vue'; 8 | export { PortableText }; 9 | -------------------------------------------------------------------------------- /test/fixtures/002-single-span.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _key: 'R5FvMrjo', 5 | _type: 'block', 6 | children: [ 7 | { 8 | _key: 'cZUQGmh4', 9 | _type: 'span', 10 | marks: [], 11 | text: 'Plain text.', 12 | }, 13 | ], 14 | markDefs: [], 15 | style: 'normal', 16 | }; 17 | 18 | export default { 19 | input, 20 | output: '

Plain text.

', 21 | }; 22 | -------------------------------------------------------------------------------- /test/fixtures/008-plain-header-block.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _key: 'R5FvMrjo', 5 | _type: 'block', 6 | children: [ 7 | { 8 | _key: 'cZUQGmh4', 9 | _type: 'span', 10 | marks: [], 11 | text: 'Dat heading', 12 | }, 13 | ], 14 | markDefs: [], 15 | style: 'h2', 16 | }; 17 | 18 | export default { 19 | input, 20 | output: '

Dat heading

', 21 | }; 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | permissions: 11 | contents: read # for checkout 12 | 13 | jobs: 14 | release: 15 | uses: portabletext/.github/.github/workflows/changesets.yml@main 16 | permissions: 17 | contents: read # for checkout 18 | id-token: write # to enable use of OIDC for npm provenance 19 | secrets: inherit 20 | -------------------------------------------------------------------------------- /.github/workflows/renovate.yml: -------------------------------------------------------------------------------- 1 | name: Add changeset to Renovate updates 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened, synchronize] 6 | 7 | concurrency: ${{ github.workflow }}-${{ github.ref }} 8 | 9 | permissions: 10 | contents: read # for checkout 11 | 12 | jobs: 13 | call: 14 | uses: portabletext/.github/.github/workflows/changesets-from-conventional-commits.yml@main 15 | if: github.event.pull_request.user.login == 'renovate[bot]' 16 | secrets: inherit 17 | -------------------------------------------------------------------------------- /test/fixtures/023-hard-breaks.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | _type: 'block', 6 | _key: 'bd73ec5f61a1', 7 | style: 'normal', 8 | markDefs: [], 9 | children: [ 10 | { 11 | _type: 'span', 12 | text: 'A paragraph\ncan have hard\n\nbreaks.', 13 | marks: [], 14 | }, 15 | ], 16 | }, 17 | ]; 18 | 19 | export default { 20 | input, 21 | output: '

A paragraph
can have hard

breaks.

', 22 | }; 23 | -------------------------------------------------------------------------------- /test/fixtures/026-inline-block-with-text.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | _type: 'block', 6 | _key: 'foo', 7 | style: 'normal', 8 | children: [ 9 | { _type: 'span', text: 'Men, ' }, 10 | { _type: 'button', text: 'bli med du også' }, 11 | { _type: 'span', text: ', da!' }, 12 | ], 13 | }, 14 | ]; 15 | 16 | export default { 17 | input, 18 | output: '

Men, , da!

', 19 | }; 20 | -------------------------------------------------------------------------------- /test/fixtures/052-custom-marks.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _type: 'block', 5 | children: [ 6 | { 7 | _key: 'a1ph4', 8 | _type: 'span', 9 | marks: ['mark1'], 10 | text: 'Sanity', 11 | }, 12 | ], 13 | markDefs: [ 14 | { 15 | _key: 'mark1', 16 | _type: 'highlight', 17 | thickness: 5, 18 | }, 19 | ], 20 | }; 21 | 22 | export default { 23 | input, 24 | output: '

Sanity

', 25 | }; 26 | -------------------------------------------------------------------------------- /test/fixtures/062-custom-block-type-with-children.ts: -------------------------------------------------------------------------------- 1 | import type { ArbitraryTypedObject } from '@portabletext/types'; 2 | 3 | const input: ArbitraryTypedObject[] = [ 4 | { 5 | _type: 'quote', 6 | _key: '9a15ea2ed8a2', 7 | background: 'blue', 8 | children: [ 9 | { 10 | _type: 'span', 11 | _key: '9a15ea2ed8a2', 12 | text: 'This is an inspirational quote', 13 | }, 14 | ], 15 | }, 16 | ]; 17 | 18 | export default { 19 | input, 20 | output: '

Customers say: This is an inspirational quote

', 21 | }; 22 | -------------------------------------------------------------------------------- /test/fixtures/053-override-default-marks.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _type: 'block', 5 | children: [ 6 | { 7 | _key: 'a1ph4', 8 | _type: 'span', 9 | marks: ['mark1'], 10 | text: 'Sanity', 11 | }, 12 | ], 13 | markDefs: [ 14 | { 15 | _key: 'mark1', 16 | _type: 'link', 17 | href: 'https://sanity.io', 18 | }, 19 | ], 20 | }; 21 | 22 | export default { 23 | input, 24 | output: '

Sanity

', 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/flatten.ts: -------------------------------------------------------------------------------- 1 | import { h, type Component } from 'vue'; 2 | import type { PortableTextComponentProps } from '../types'; 3 | 4 | /** 5 | * Component wrapper function to flatten props 6 | * @experimental 7 | * @todo inherit component props using generics 8 | */ 9 | export const flattenProps = 10 | (component: Component, includeInternalProps = false) => 11 | (props: PortableTextComponentProps) => { 12 | if (includeInternalProps) { 13 | const { value, ...rest } = props; 14 | return h(component, { ...rest, ...value }); 15 | } 16 | return h(component, { ...props.value }); 17 | }; 18 | -------------------------------------------------------------------------------- /test/fixtures/003-multiple-spans.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _key: 'R5FvMrjo', 5 | _type: 'block', 6 | children: [ 7 | { 8 | _key: 'cZUQGmh4', 9 | _type: 'span', 10 | marks: [], 11 | text: 'Span number one. ', 12 | }, 13 | { 14 | _key: 'toaiCqIK', 15 | _type: 'span', 16 | marks: [], 17 | text: 'And span number two.', 18 | }, 19 | ], 20 | markDefs: [], 21 | style: 'normal', 22 | }; 23 | 24 | export default { 25 | input, 26 | output: '

Span number one. And span number two.

', 27 | }; 28 | -------------------------------------------------------------------------------- /test/fixtures/004-basic-mark-single-span.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _key: 'R5FvMrjo', 5 | _type: 'block', 6 | children: [ 7 | { 8 | _key: 'cZUQGmh4', 9 | _type: 'span', 10 | marks: ['code'], 11 | text: 'sanity', 12 | }, 13 | { 14 | _key: 'toaiCqIK', 15 | _type: 'span', 16 | marks: [], 17 | text: ' is the name of the CLI tool.', 18 | }, 19 | ], 20 | markDefs: [], 21 | style: 'normal', 22 | }; 23 | 24 | export default { 25 | input, 26 | output: '

sanity is the name of the CLI tool.

', 27 | }; 28 | -------------------------------------------------------------------------------- /test/fixtures/050-custom-block-type.ts: -------------------------------------------------------------------------------- 1 | import type { ArbitraryTypedObject } from '@portabletext/types'; 2 | 3 | const input: ArbitraryTypedObject[] = [ 4 | { 5 | _type: 'code', 6 | _key: '9a15ea2ed8a2', 7 | language: 'javascript', 8 | code: "const foo = require('foo')\n\nfoo('hi there', (err, thing) => {\n console.log(err)\n})\n", 9 | }, 10 | ]; 11 | 12 | export default { 13 | input, 14 | output: [ 15 | '
',
16 |     '',
17 |     "const foo = require('foo')\n\n",
18 |     "foo('hi there', (err, thing) => {\n",
19 |     '  console.log(err)\n',
20 |     '})\n',
21 |     '
', 22 | ].join(''), 23 | }; 24 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>sanity-io/renovate-config"], 4 | "packageRules": [ 5 | { 6 | "matchDepTypes": ["dependencies"], 7 | "matchPackageNames": ["@portabletext/toolkit", "@portabletext/types"], 8 | "rangeStrategy": "bump", 9 | "groupName": null, 10 | "groupSlug": null, 11 | "semanticCommitType": "fix" 12 | }, 13 | { 14 | "matchDepTypes": ["peerDependencies"], 15 | "matchPackageNames": ["vue"], 16 | "rangeStrategy": "widen", 17 | "groupName": null, 18 | "groupSlug": null, 19 | "semanticCommitType": "fix" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /test/fixtures/061-missing-mark-component.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _type: 'block', 5 | children: [ 6 | { 7 | _key: 'cZUQGmh4', 8 | _type: 'span', 9 | marks: ['abc'], 10 | text: 'A word of ', 11 | }, 12 | { 13 | _key: 'toaiCqIK', 14 | _type: 'span', 15 | marks: ['abc', 'em'], 16 | text: 'warning;', 17 | }, 18 | { 19 | _key: 'gaZingA', 20 | _type: 'span', 21 | marks: [], 22 | text: ' Sanity is addictive.', 23 | }, 24 | ], 25 | markDefs: [], 26 | }; 27 | 28 | export default { 29 | input, 30 | output: 31 | '

A word of warning; Sanity is addictive.

', 32 | }; 33 | -------------------------------------------------------------------------------- /test/fixtures/005-basic-mark-multiple-adjacent-spans.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _key: 'R5FvMrjo', 5 | _type: 'block', 6 | children: [ 7 | { 8 | _key: 'cZUQGmh4', 9 | _type: 'span', 10 | marks: ['strong'], 11 | text: 'A word of', 12 | }, 13 | { 14 | _key: 'toaiCqIK', 15 | _type: 'span', 16 | marks: ['strong'], 17 | text: ' warning;', 18 | }, 19 | { 20 | _key: 'gaZingA', 21 | _type: 'span', 22 | marks: [], 23 | text: ' Sanity is addictive.', 24 | }, 25 | ], 26 | markDefs: [], 27 | style: 'normal', 28 | }; 29 | 30 | export default { 31 | input, 32 | output: '

A word of warning; Sanity is addictive.

', 33 | }; 34 | -------------------------------------------------------------------------------- /test/fixtures/006-basic-mark-nested-marks.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _key: 'R5FvMrjo', 5 | _type: 'block', 6 | children: [ 7 | { 8 | _key: 'cZUQGmh4', 9 | _type: 'span', 10 | marks: ['strong'], 11 | text: 'A word of ', 12 | }, 13 | { 14 | _key: 'toaiCqIK', 15 | _type: 'span', 16 | marks: ['strong', 'em'], 17 | text: 'warning;', 18 | }, 19 | { 20 | _key: 'gaZingA', 21 | _type: 'span', 22 | marks: [], 23 | text: ' Sanity is addictive.', 24 | }, 25 | ], 26 | markDefs: [], 27 | style: 'normal', 28 | }; 29 | 30 | export default { 31 | input, 32 | output: '

A word of warning; Sanity is addictive.

', 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/marks.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'vue'; 2 | import type { TypedObject } from '@portabletext/types'; 3 | import type { PortableTextMarkComponent } from '../types'; 4 | import { basicElement } from './basic'; 5 | 6 | interface DefaultLink extends TypedObject { 7 | _type: 'link'; 8 | href: string; 9 | } 10 | 11 | const link: PortableTextMarkComponent = ({ value }, { slots }) => 12 | h('a', { href: value?.href }, slots.default?.()); 13 | 14 | const underlineStyle = { textDecoration: 'underline' }; 15 | 16 | export const defaultMarks: Record = { 17 | code: basicElement('code'), 18 | em: basicElement('em'), 19 | link, 20 | 'strike-through': basicElement('del'), 21 | strong: basicElement('strong'), 22 | underline: (_, { slots }) => h('span', { style: underlineStyle }, slots.default?.()), 23 | }; 24 | -------------------------------------------------------------------------------- /test/fixtures/007-link-mark-def.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _key: 'R5FvMrjo', 5 | _type: 'block', 6 | children: [ 7 | { 8 | _key: 'cZUQGmh4', 9 | _type: 'span', 10 | marks: [], 11 | text: 'A word of warning; ', 12 | }, 13 | { 14 | _key: 'toaiCqIK', 15 | _type: 'span', 16 | marks: ['someLinkId'], 17 | text: 'Sanity', 18 | }, 19 | { 20 | _key: 'gaZingA', 21 | _type: 'span', 22 | marks: [], 23 | text: ' is addictive.', 24 | }, 25 | ], 26 | markDefs: [ 27 | { 28 | _type: 'link', 29 | _key: 'someLinkId', 30 | href: 'https://sanity.io/', 31 | }, 32 | ], 33 | style: 'normal', 34 | }; 35 | 36 | export default { 37 | input, 38 | output: '

A word of warning; Sanity is addictive.

', 39 | }; 40 | -------------------------------------------------------------------------------- /src/warnings.ts: -------------------------------------------------------------------------------- 1 | const getTemplate = (type: string, prop: string): string => 2 | `[@portabletext/vue] Unknown ${type}, specify a component for it in the \`components.${prop}\` prop`; 3 | 4 | export const unknownTypeWarning = (typeName: string): string => 5 | getTemplate(`block type "${typeName}"`, 'types'); 6 | 7 | export const unknownMarkWarning = (markType: string): string => 8 | getTemplate(`mark type "${markType}"`, 'marks'); 9 | 10 | export const unknownBlockStyleWarning = (blockStyle: string): string => 11 | getTemplate(`block style "${blockStyle}"`, 'block'); 12 | 13 | export const unknownListStyleWarning = (listStyle: string): string => 14 | getTemplate(`list style "${listStyle}"`, 'list'); 15 | 16 | export const unknownListItemStyleWarning = (listStyle: string): string => 17 | getTemplate(`list item style "${listStyle}"`, 'listItem'); 18 | 19 | export function printWarning(message: string): void { 20 | console.warn(message); 21 | } 22 | -------------------------------------------------------------------------------- /test/fixtures/019-keyless.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | _type: 'block', 6 | children: [ 7 | { 8 | _type: 'span', 9 | marks: [], 10 | text: 'sanity', 11 | }, 12 | { 13 | _type: 'span', 14 | marks: [], 15 | text: ' is a full time job', 16 | }, 17 | ], 18 | markDefs: [], 19 | style: 'normal', 20 | }, 21 | { 22 | _type: 'block', 23 | children: [ 24 | { 25 | _type: 'span', 26 | marks: [], 27 | text: 'in a world that ', 28 | }, 29 | { 30 | _type: 'span', 31 | marks: [], 32 | text: 'is always changing', 33 | }, 34 | ], 35 | markDefs: [], 36 | style: 'normal', 37 | }, 38 | ]; 39 | 40 | export default { 41 | input, 42 | output: '

sanity is a full time job

in a world that is always changing

', 43 | }; 44 | -------------------------------------------------------------------------------- /test/fixtures/018-marks-all-the-way-down.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _type: 'block', 5 | children: [ 6 | { 7 | _key: 'a1ph4', 8 | _type: 'span', 9 | marks: ['mark1', 'em', 'mark2'], 10 | text: 'Sanity', 11 | }, 12 | { 13 | _key: 'b374', 14 | _type: 'span', 15 | marks: ['mark2', 'mark1', 'em'], 16 | text: ' FTW', 17 | }, 18 | ], 19 | markDefs: [ 20 | { 21 | _key: 'mark1', 22 | _type: 'highlight', 23 | thickness: 1, 24 | }, 25 | { 26 | _key: 'mark2', 27 | _type: 'highlight', 28 | thickness: 3, 29 | }, 30 | ], 31 | }; 32 | 33 | export default { 34 | input, 35 | output: [ 36 | '

', 37 | '', 38 | '', 39 | 'Sanity FTW', 40 | '', 41 | '', 42 | '

', 43 | ].join(''), 44 | }; 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # macOS finder cache file 40 | .DS_Store 41 | 42 | # VS Code settings 43 | .vscode 44 | 45 | # Cache 46 | .cache 47 | 48 | # Compiled portable text library + demo 49 | /dist 50 | /demo/dist 51 | 52 | *.iml 53 | .idea/ 54 | 55 | .yalc 56 | yalc.lock 57 | -------------------------------------------------------------------------------- /test/components.test.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'vue'; 2 | import { expect, test } from 'vitest'; 3 | import { mount } from '@vue/test-utils'; 4 | import { PortableText, PortableTextProps } from '../src'; 5 | 6 | const render = (props: PortableTextProps) => mount(PortableText, { props }).html({ raw: true }); 7 | 8 | test('can override unknown mark component', () => { 9 | const result = render({ 10 | value: { 11 | _type: 'block', 12 | markDefs: [{ _key: 'unknown-mark', _type: 'unknown-mark' }], 13 | children: [ 14 | { _type: 'span', marks: ['unknown-deco'], text: 'simple' }, 15 | { _type: 'span', marks: ['unknown-mark'], text: 'advanced' }, 16 | ], 17 | }, 18 | components: { 19 | unknownMark: ({ markType }, { slots }) => { 20 | return h('span', { class: 'unknown' }, [`Unknown (${markType}): `, slots.default?.()]); 21 | }, 22 | }, 23 | }); 24 | expect(result).toBe( 25 | '

Unknown (unknown-deco): simpleUnknown (unknown-mark): advanced

', 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /src/components/unknown.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'vue'; 2 | import type { PortableTextVueComponents } from '../types'; 3 | import { unknownTypeWarning } from '../warnings'; 4 | import { basicElement } from './basic'; 5 | 6 | const hidden = { display: 'none' }; 7 | 8 | export const DefaultUnknownType: PortableTextVueComponents['unknownType'] = ({ 9 | value, 10 | isInline, 11 | }) => { 12 | const warning = unknownTypeWarning(value._type); 13 | return isInline ? h('span', { style: hidden }, warning) : h('div', { style: hidden }, warning); 14 | }; 15 | 16 | export const DefaultUnknownMark: PortableTextVueComponents['unknownMark'] = ( 17 | { markType }, 18 | { slots }, 19 | ) => { 20 | return h('span', { class: `unknown__pt__mark__${markType}` }, slots.default?.()); 21 | }; 22 | 23 | export const DefaultUnknownBlockStyle: PortableTextVueComponents['unknownBlockStyle'] = 24 | basicElement('p'); 25 | 26 | export const DefaultUnknownList: PortableTextVueComponents['unknownList'] = basicElement('ul'); 27 | 28 | export const DefaultUnknownListItem: PortableTextVueComponents['unknownListItem'] = 29 | basicElement('li'); 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 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 | -------------------------------------------------------------------------------- /test/mutations.test.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'vue'; 2 | import { expect, test } from 'vitest'; 3 | import { mount } from '@vue/test-utils'; 4 | import { PortableText, PortableTextVueComponents, PortableTextProps } from '../src'; 5 | import * as fixtures from './fixtures'; 6 | 7 | const render = (props: PortableTextProps) => mount(PortableText, { props }).html({ raw: true }); 8 | 9 | test('never mutates input', () => { 10 | for (const [key, fixture] of Object.entries(fixtures)) { 11 | if (key === 'default') { 12 | continue; 13 | } 14 | 15 | const highlight = () => h('mark'); 16 | const components: Partial = { 17 | marks: { highlight }, 18 | unknownMark: (_, { slots }) => h('span', slots.default?.()), 19 | unknownType: (_, { slots }) => h('div', slots.default?.()), 20 | }; 21 | const originalInput = JSON.parse(JSON.stringify(fixture.input)); 22 | const passedInput = fixture.input; 23 | try { 24 | render({ 25 | value: passedInput as any, 26 | components, 27 | }); 28 | } catch (error) { 29 | // ignore 30 | } 31 | expect(originalInput).toStrictEqual(passedInput); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /test/toPlainText.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { toPlainText } from '../src'; 3 | import * as fixtures from './fixtures'; 4 | 5 | test('can extract text from all fixtures without crashing', () => { 6 | for (const [key, fixture] of Object.entries(fixtures)) { 7 | if (key === 'default') { 8 | continue; 9 | } 10 | 11 | const output = toPlainText(fixture.input); 12 | expect(typeof output).toBe('string'); 13 | } 14 | }); 15 | 16 | test('can extract text from a properly formatted block', () => { 17 | const text = toPlainText([ 18 | { 19 | _type: 'block', 20 | markDefs: [{ _type: 'link', _key: 'a1b', href: 'https://some.url/' }], 21 | children: [ 22 | { _type: 'span', text: 'Plain ' }, 23 | { _type: 'span', text: 'text', marks: ['em'] }, 24 | { _type: 'span', text: ', even with ' }, 25 | { _type: 'span', text: 'annotated value', marks: ['a1b'] }, 26 | { _type: 'span', text: '.' }, 27 | ], 28 | }, 29 | { 30 | _type: 'otherBlockType', 31 | children: [{ _type: 'span', text: 'Should work?' }], 32 | }, 33 | ]); 34 | 35 | expect(text).toBe('Plain text, even with annotated value.\n\nShould work?'); 36 | }); 37 | -------------------------------------------------------------------------------- /test/fixtures/022-inline-nodes.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | _type: 'block', 6 | _key: 'bd73ec5f61a1', 7 | style: 'normal', 8 | markDefs: [], 9 | children: [ 10 | { 11 | _type: 'span', 12 | text: "I enjoyed it. It's not perfect, but I give it a strong ", 13 | marks: [], 14 | }, 15 | { 16 | _type: 'rating', 17 | _key: 'd234a4fa317a', 18 | type: 'dice', 19 | rating: 5, 20 | }, 21 | { 22 | _type: 'span', 23 | text: ', and look forward to the next season!', 24 | marks: [], 25 | }, 26 | ], 27 | }, 28 | { 29 | _type: 'block', 30 | _key: 'foo', 31 | markDefs: [], 32 | children: [ 33 | { 34 | _type: 'span', 35 | text: 'Sibling paragraph', 36 | marks: [], 37 | }, 38 | ], 39 | }, 40 | ]; 41 | 42 | export default { 43 | input, 44 | output: [ 45 | "

I enjoyed it. It's not perfect, but I give it a strong ", 46 | '', 47 | ', and look forward to the next season!

', 48 | '

Sibling paragraph

', 49 | ].join(''), 50 | }; 51 | -------------------------------------------------------------------------------- /test/fixtures/009-messy-link-text.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _type: 'block', 5 | children: [ 6 | { 7 | _key: 'a1ph4', 8 | _type: 'span', 9 | marks: ['zomgLink'], 10 | text: 'Sanity', 11 | }, 12 | { 13 | _key: 'b374', 14 | _type: 'span', 15 | marks: [], 16 | text: ' can be used to power almost any ', 17 | }, 18 | { 19 | _key: 'ch4r1i3', 20 | _type: 'span', 21 | marks: ['zomgLink', 'strong', 'em'], 22 | text: 'app', 23 | }, 24 | { 25 | _key: 'd3174', 26 | _type: 'span', 27 | marks: ['em', 'zomgLink'], 28 | text: ' or website', 29 | }, 30 | { 31 | _key: 'ech0', 32 | _type: 'span', 33 | marks: [], 34 | text: '.', 35 | }, 36 | ], 37 | markDefs: [ 38 | { 39 | _key: 'zomgLink', 40 | _type: 'link', 41 | href: 'https://sanity.io/', 42 | }, 43 | ], 44 | style: 'blockquote', 45 | }; 46 | 47 | export default { 48 | input, 49 | output: 50 | '
Sanity can be used to power almost any app or website.
', 51 | }; 52 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | merge_group: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 14 | cancel-in-progress: true 15 | 16 | permissions: 17 | contents: read # for checkout 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | name: Lint & Build 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: pnpm/action-setup@v4 26 | - uses: actions/setup-node@v4 27 | with: 28 | cache: pnpm 29 | node-version: lts/* 30 | - run: pnpm install 31 | - run: pnpm build 32 | 33 | test: 34 | runs-on: ${{ matrix.platform }} 35 | name: Node.js ${{ matrix.node-version }} / ${{ matrix.platform }} 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | platform: [macos-latest, ubuntu-latest, windows-latest] 40 | node-version: [lts/*] 41 | include: 42 | - platform: ubuntu-latest 43 | node-version: current 44 | steps: 45 | - uses: actions/checkout@v4 46 | - uses: pnpm/action-setup@v4 47 | - uses: actions/setup-node@v4 48 | with: 49 | cache: pnpm 50 | node-version: ${{ matrix.node-version }} 51 | - run: pnpm install 52 | - run: pnpm test 53 | -------------------------------------------------------------------------------- /.github/workflows/format-if-needed.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Auto format 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }} 10 | cancel-in-progress: true 11 | 12 | permissions: 13 | contents: read # for checkout 14 | 15 | jobs: 16 | run: 17 | name: Can the code be formatted? 🤔 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: pnpm/action-setup@v4 22 | - uses: actions/setup-node@v4 23 | with: 24 | cache: pnpm 25 | node-version: lts/* 26 | - run: pnpm install --ignore-scripts 27 | - run: pnpm format 28 | - run: git restore .github/workflows CHANGELOG.md 29 | - uses: actions/create-github-app-token@v1 30 | id: generate-token 31 | with: 32 | app-id: ${{ secrets.ECOSCRIPT_APP_ID }} 33 | private-key: ${{ secrets.ECOSCRIPT_APP_PRIVATE_KEY }} 34 | - uses: peter-evans/create-pull-request@6d6857d36972b65feb161a90e484f2984215f83e # v6 35 | with: 36 | author: github-actions <41898282+github-actions[bot]@users.noreply.github.com> 37 | body: I ran `pnpm format` 🧑‍💻 38 | branch: actions/format 39 | commit-message: 'chore(format): 🤖 ✨' 40 | delete-branch: true 41 | labels: 🤖 bot 42 | title: 'chore(format): 🤖 ✨' 43 | token: ${{ steps.generate-token.outputs.token }} 44 | -------------------------------------------------------------------------------- /src/vue-portable-text.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 40 | -------------------------------------------------------------------------------- /src/components/defaults.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'vue'; 2 | import type { PortableTextBlockStyle } from '@portabletext/types'; 3 | import type { PortableTextBlockComponent, PortableTextVueComponents } from '../types'; 4 | import { defaultMarks } from './marks'; 5 | import { defaultLists, DefaultListItem } from './list'; 6 | import { 7 | DefaultUnknownType, 8 | DefaultUnknownMark, 9 | DefaultUnknownList, 10 | DefaultUnknownListItem, 11 | DefaultUnknownBlockStyle, 12 | } from './unknown'; 13 | import { basicElement } from './basic'; 14 | 15 | export const DefaultHardBreak = () => h('br'); 16 | 17 | export const defaultBlockStyles: Record< 18 | PortableTextBlockStyle, 19 | PortableTextBlockComponent | undefined 20 | > = { 21 | normal: basicElement('p'), 22 | blockquote: basicElement('blockquote'), 23 | h1: basicElement('h1'), 24 | h2: basicElement('h2'), 25 | h3: basicElement('h3'), 26 | h4: basicElement('h4'), 27 | h5: basicElement('h5'), 28 | h6: basicElement('h6'), 29 | }; 30 | 31 | export const defaultComponents: PortableTextVueComponents = { 32 | types: {}, 33 | 34 | block: defaultBlockStyles, 35 | marks: defaultMarks, 36 | list: defaultLists, 37 | listItem: DefaultListItem, 38 | hardBreak: DefaultHardBreak, 39 | 40 | unknownType: DefaultUnknownType, 41 | unknownMark: DefaultUnknownMark, 42 | unknownList: DefaultUnknownList, 43 | unknownListItem: DefaultUnknownListItem, 44 | unknownBlockStyle: DefaultUnknownBlockStyle, 45 | }; 46 | -------------------------------------------------------------------------------- /test/fixtures/015-all-basic-marks.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock = { 4 | _key: 'R5FvMrjo', 5 | _type: 'block', 6 | children: [ 7 | { 8 | _key: 'a', 9 | _type: 'span', 10 | marks: ['code'], 11 | text: 'code', 12 | }, 13 | { 14 | _key: 'b', 15 | _type: 'span', 16 | marks: ['strong'], 17 | text: 'strong', 18 | }, 19 | { 20 | _key: 'c', 21 | _type: 'span', 22 | marks: ['em'], 23 | text: 'em', 24 | }, 25 | { 26 | _key: 'd', 27 | _type: 'span', 28 | marks: ['underline'], 29 | text: 'underline', 30 | }, 31 | { 32 | _key: 'e', 33 | _type: 'span', 34 | marks: ['strike-through'], 35 | text: 'strike-through', 36 | }, 37 | { 38 | _key: 'f', 39 | _type: 'span', 40 | marks: ['dat-link'], 41 | text: 'link', 42 | }, 43 | ], 44 | markDefs: [ 45 | { 46 | _key: 'dat-link', 47 | _type: 'link', 48 | href: 'https://www.sanity.io/', 49 | }, 50 | ], 51 | style: 'normal', 52 | }; 53 | 54 | export default { 55 | input, 56 | output: [ 57 | '

', 58 | 'code', 59 | 'strong', 60 | 'em', 61 | 'underline', 62 | 'strike-through', 63 | 'link', 64 | '

', 65 | ].join(''), 66 | }; 67 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import { defineConfig } from 'vite'; 4 | import vue from '@vitejs/plugin-vue'; 5 | import dts from 'vite-plugin-dts'; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | vue(), 10 | dts({ 11 | rollupTypes: true, 12 | async afterBuild() { 13 | // This is to satisfy typechecker here 14 | // https://arethetypeswrong.github.io/ 15 | // but seems ugly, types should be emitted per build? 16 | const src = path.resolve(__dirname, 'dist/vue-portable-text.d.ts'); 17 | const mts = path.resolve(__dirname, 'dist/vue-portable-text.d.mts'); 18 | await fs.copyFile(src, mts, () => {}); 19 | const cts = path.resolve(__dirname, 'dist/vue-portable-text.d.cts'); 20 | await fs.copyFile(src, cts, () => {}); 21 | }, 22 | }), 23 | ], 24 | build: { 25 | lib: { 26 | entry: path.resolve(__dirname, 'src/index.ts'), 27 | name: 'vue-portable-text', 28 | formats: ['es', 'cjs'], 29 | fileName: (format) => { 30 | if (format === 'es') { 31 | return `vue-portable-text.mjs`; 32 | } 33 | if (format === 'cjs') { 34 | return `vue-portable-text.cjs`; 35 | } 36 | return `vue-portable-text.${format}.js`; 37 | }, 38 | }, 39 | rollupOptions: { 40 | external: ['vue'], 41 | output: { 42 | globals: { 43 | vue: 'Vue', 44 | }, 45 | }, 46 | }, 47 | }, 48 | test: { 49 | environment: 'happy-dom', 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /src/components/merge.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextVueComponents, PortableTextComponents } from '../types'; 2 | 3 | export function mergeComponents( 4 | parent: PortableTextVueComponents, 5 | overrides: PortableTextComponents, 6 | ): PortableTextVueComponents { 7 | const { block, list, listItem, marks, types, ...rest } = overrides; 8 | // @todo figure out how to not `as ...` these 9 | return { 10 | ...parent, 11 | block: mergeDeeply(parent, overrides, 'block') as PortableTextVueComponents['block'], 12 | list: mergeDeeply(parent, overrides, 'list') as PortableTextVueComponents['list'], 13 | listItem: mergeDeeply(parent, overrides, 'listItem') as PortableTextVueComponents['listItem'], 14 | marks: mergeDeeply(parent, overrides, 'marks') as PortableTextVueComponents['marks'], 15 | types: mergeDeeply(parent, overrides, 'types') as PortableTextVueComponents['types'], 16 | ...rest, 17 | }; 18 | } 19 | 20 | function mergeDeeply( 21 | parent: PortableTextVueComponents, 22 | overrides: PortableTextComponents, 23 | key: 'block' | 'list' | 'listItem' | 'marks' | 'types', 24 | ): PortableTextVueComponents[typeof key] { 25 | const override = overrides[key]; 26 | const parentVal = parent[key]; 27 | 28 | if (typeof override === 'function') { 29 | return override; 30 | } 31 | 32 | if (override && typeof parentVal === 'function') { 33 | return override; 34 | } 35 | 36 | if (override) { 37 | return { 38 | ...parentVal, 39 | ...override, 40 | } as PortableTextVueComponents[typeof key]; 41 | } 42 | 43 | return parentVal; 44 | } 45 | -------------------------------------------------------------------------------- /test/fixtures/010-basic-bullet-list.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | style: 'normal', 6 | _type: 'block', 7 | _key: 'f94596b05b41', 8 | markDefs: [], 9 | children: [ 10 | { 11 | _type: 'span', 12 | text: "Let's test some of these lists!", 13 | marks: [], 14 | }, 15 | ], 16 | }, 17 | { 18 | listItem: 'bullet', 19 | style: 'normal', 20 | level: 1, 21 | _type: 'block', 22 | _key: '937effb1cd06', 23 | markDefs: [], 24 | children: [ 25 | { 26 | _type: 'span', 27 | text: 'Bullet 1', 28 | marks: [], 29 | }, 30 | ], 31 | }, 32 | { 33 | listItem: 'bullet', 34 | style: 'normal', 35 | level: 1, 36 | _type: 'block', 37 | _key: 'bd2d22278b88', 38 | markDefs: [], 39 | children: [ 40 | { 41 | _type: 'span', 42 | text: 'Bullet 2', 43 | marks: [], 44 | }, 45 | ], 46 | }, 47 | { 48 | listItem: 'bullet', 49 | style: 'normal', 50 | level: 1, 51 | _type: 'block', 52 | _key: 'a97d32e9f747', 53 | markDefs: [], 54 | children: [ 55 | { 56 | _type: 'span', 57 | text: 'Bullet 3', 58 | marks: [], 59 | }, 60 | ], 61 | }, 62 | ]; 63 | 64 | export default { 65 | input, 66 | output: [ 67 | "

Let's test some of these lists!

", 68 | '
    ', 69 | '
  • Bullet 1
  • ', 70 | '
  • Bullet 2
  • ', 71 | '
  • Bullet 3
  • ', 72 | '
', 73 | ].join(''), 74 | }; 75 | -------------------------------------------------------------------------------- /test/fixtures/011-basic-numbered-list.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | style: 'normal', 6 | _type: 'block', 7 | _key: 'f94596b05b41', 8 | markDefs: [], 9 | children: [ 10 | { 11 | _type: 'span', 12 | text: "Let's test some of these lists!", 13 | marks: [], 14 | }, 15 | ], 16 | }, 17 | { 18 | listItem: 'number', 19 | style: 'normal', 20 | level: 1, 21 | _type: 'block', 22 | _key: '937effb1cd06', 23 | markDefs: [], 24 | children: [ 25 | { 26 | _type: 'span', 27 | text: 'Number 1', 28 | marks: [], 29 | }, 30 | ], 31 | }, 32 | { 33 | listItem: 'number', 34 | style: 'normal', 35 | level: 1, 36 | _type: 'block', 37 | _key: 'bd2d22278b88', 38 | markDefs: [], 39 | children: [ 40 | { 41 | _type: 'span', 42 | text: 'Number 2', 43 | marks: [], 44 | }, 45 | ], 46 | }, 47 | { 48 | listItem: 'number', 49 | style: 'normal', 50 | level: 1, 51 | _type: 'block', 52 | _key: 'a97d32e9f747', 53 | markDefs: [], 54 | children: [ 55 | { 56 | _type: 'span', 57 | text: 'Number 3', 58 | marks: [], 59 | }, 60 | ], 61 | }, 62 | ]; 63 | 64 | export default { 65 | input, 66 | output: [ 67 | "

Let's test some of these lists!

", 68 | '
    ', 69 | '
  1. Number 1
  2. ', 70 | '
  3. Number 2
  4. ', 71 | '
  5. Number 3
  6. ', 72 | '
', 73 | ].join(''), 74 | }; 75 | -------------------------------------------------------------------------------- /test/fixtures/027-styled-list-items.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | style: 'normal', 6 | _type: 'block', 7 | _key: 'f94596b05b41', 8 | markDefs: [], 9 | children: [ 10 | { 11 | _type: 'span', 12 | text: "Let's test some of these lists!", 13 | marks: [], 14 | }, 15 | ], 16 | }, 17 | { 18 | listItem: 'bullet', 19 | style: 'normal', 20 | level: 1, 21 | _type: 'block', 22 | _key: '937effb1cd06', 23 | markDefs: [], 24 | children: [ 25 | { 26 | _type: 'span', 27 | text: 'Bullet 1', 28 | marks: [], 29 | }, 30 | ], 31 | }, 32 | { 33 | listItem: 'bullet', 34 | style: 'h1', 35 | level: 1, 36 | _type: 'block', 37 | _key: 'bd2d22278b88', 38 | markDefs: [], 39 | children: [ 40 | { 41 | _type: 'span', 42 | text: 'Bullet 2', 43 | marks: [], 44 | }, 45 | ], 46 | }, 47 | { 48 | listItem: 'bullet', 49 | style: 'normal', 50 | level: 1, 51 | _type: 'block', 52 | _key: 'a97d32e9f747', 53 | markDefs: [], 54 | children: [ 55 | { 56 | _type: 'span', 57 | text: 'Bullet 3', 58 | marks: [], 59 | }, 60 | ], 61 | }, 62 | ]; 63 | 64 | export default { 65 | input, 66 | output: [ 67 | "

Let's test some of these lists!

", 68 | '
    ', 69 | '
  • Bullet 1
  • ', 70 | '
  • Bullet 2

  • ', 71 | '
  • Bullet 3
  • ', 72 | '
', 73 | ].join(''), 74 | }; 75 | -------------------------------------------------------------------------------- /test/fixtures/028-custom-list-item-type.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | listItem: 'square', 6 | style: 'normal', 7 | level: 1, 8 | _type: 'block', 9 | _key: '937effb1cd06', 10 | markDefs: [], 11 | children: [ 12 | { 13 | _type: 'span', 14 | text: 'Square 1', 15 | marks: [], 16 | }, 17 | ], 18 | }, 19 | { 20 | listItem: 'square', 21 | style: 'normal', 22 | level: 1, 23 | _type: 'block', 24 | _key: 'bd2d22278b88', 25 | markDefs: [], 26 | children: [ 27 | { 28 | _type: 'span', 29 | text: 'Square 2', 30 | marks: [], 31 | }, 32 | ], 33 | }, 34 | { 35 | listItem: 'disc', 36 | style: 'normal', 37 | level: 2, 38 | _type: 'block', 39 | _key: 'a97d32e9f747', 40 | markDefs: [], 41 | children: [ 42 | { 43 | _type: 'span', 44 | text: 'Dat disc', 45 | marks: [], 46 | }, 47 | ], 48 | }, 49 | { 50 | listItem: 'square', 51 | style: 'normal', 52 | level: 1, 53 | _type: 'block', 54 | _key: 'a97d32e9f747', 55 | markDefs: [], 56 | children: [ 57 | { 58 | _type: 'span', 59 | text: 'Square 3', 60 | marks: [], 61 | }, 62 | ], 63 | }, 64 | ]; 65 | 66 | export default { 67 | input, 68 | output: [ 69 | '
    ', 70 | '
  • Square 1
  • ', 71 | '
  • ', 72 | ' Square 2', 73 | '
      ', 74 | '
    • Dat disc
    • ', 75 | '
    ', 76 | '
  • ', 77 | '
  • Square 3
  • ', 78 | '
', 79 | ] 80 | .map((line) => line.trim()) 81 | .join(''), 82 | }; 83 | -------------------------------------------------------------------------------- /test/fixtures/024-inline-objects.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | _key: '08707ed2945b', 6 | _type: 'block', 7 | style: 'normal', 8 | children: [ 9 | { 10 | _key: '08707ed2945b0', 11 | text: 'Foo! Bar!', 12 | _type: 'span', 13 | marks: ['code'], 14 | }, 15 | { 16 | _key: 'a862cadb584f', 17 | _type: 'localCurrency', 18 | sourceCurrency: 'USD', 19 | sourceAmount: 13.5, 20 | }, 21 | { _key: '08707ed2945b1', text: 'Neat', _type: 'span', marks: [] }, 22 | ], 23 | markDefs: [], 24 | }, 25 | 26 | { 27 | _key: 'abc', 28 | _type: 'block', 29 | style: 'normal', 30 | children: [ 31 | { 32 | _key: '08707ed2945b0', 33 | text: 'Foo! Bar! ', 34 | _type: 'span', 35 | marks: ['code'], 36 | }, 37 | { 38 | _key: 'a862cadb584f', 39 | _type: 'localCurrency', 40 | sourceCurrency: 'DKK', 41 | sourceAmount: 200, 42 | }, 43 | { _key: '08707ed2945b1', text: ' Baz!', _type: 'span', marks: ['code'] }, 44 | ], 45 | markDefs: [], 46 | }, 47 | 48 | { 49 | _key: 'def', 50 | _type: 'block', 51 | style: 'normal', 52 | children: [ 53 | { 54 | _key: '08707ed2945b0', 55 | text: 'Foo! Bar! ', 56 | _type: 'span', 57 | marks: [], 58 | }, 59 | { 60 | _key: 'a862cadb584f', 61 | _type: 'localCurrency', 62 | sourceCurrency: 'EUR', 63 | sourceAmount: 25, 64 | }, 65 | { _key: '08707ed2945b1', text: ' Baz!', _type: 'span', marks: ['code'] }, 66 | ], 67 | markDefs: [], 68 | }, 69 | ]; 70 | 71 | export default { 72 | input, 73 | output: 74 | '

Foo! Bar!~119 NOKNeat

Foo! Bar! ~270 NOK Baz!

Foo! Bar! ~251 NOK Baz!

', 75 | }; 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@portabletext/vue", 3 | "version": "1.0.14", 4 | "description": "Render Portable Text with Vue", 5 | "keywords": [ 6 | "portable-text" 7 | ], 8 | "homepage": "https://github.com/portabletext/vue-portabletext#readme", 9 | "bugs": { 10 | "url": "https://github.com/portabletext/vue-portabletext/issues" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+ssh://git@github.com/portabletext/vue-portabletext.git" 15 | }, 16 | "license": "MIT", 17 | "author": "Rupert Dunk (https://rupertdunk.com)", 18 | "exports": { 19 | ".": { 20 | "import": { 21 | "types": "./dist/vue-portable-text.d.mts", 22 | "default": "./dist/vue-portable-text.mjs" 23 | }, 24 | "require": { 25 | "types": "./dist/vue-portable-text.d.cts", 26 | "default": "./dist/vue-portable-text.cjs" 27 | } 28 | }, 29 | "./*": "./*" 30 | }, 31 | "main": "./dist/vue-portable-text.cjs", 32 | "module": "./dist/vue-portable-text.mjs", 33 | "types": "./dist/vue-portable-text.d.ts", 34 | "files": [ 35 | "dist" 36 | ], 37 | "scripts": { 38 | "build": "run-s clean pkg:build", 39 | "clean": "rimraf dist coverage .nyc_output", 40 | "format": "prettier --write --cache --ignore-unknown .", 41 | "pkg:build": "vite build", 42 | "postpublish": "npm run clean", 43 | "prepublishOnly": "npm run build", 44 | "release": "changeset publish", 45 | "test": "vitest" 46 | }, 47 | "prettier": { 48 | "plugins": [ 49 | "prettier-plugin-packagejson" 50 | ], 51 | "printWidth": 100, 52 | "semi": true, 53 | "singleQuote": true 54 | }, 55 | "dependencies": { 56 | "@portabletext/toolkit": "^2.0.18", 57 | "@portabletext/types": "^2.0.14" 58 | }, 59 | "devDependencies": { 60 | "@changesets/changelog-github": "^0.5.1", 61 | "@changesets/cli": "^2.29.6", 62 | "@vitejs/plugin-vue": "^5.0.4", 63 | "@vue/compiler-sfc": "^3.4.27", 64 | "@vue/test-utils": "^2.4.6", 65 | "@vue/tsconfig": "^0.5.1", 66 | "happy-dom": "^13.10.1", 67 | "npm-run-all2": "^5.0.2", 68 | "prettier": "^3.2.5", 69 | "prettier-plugin-packagejson": "^2.5.0", 70 | "rimraf": "^5.0.1", 71 | "tslib": "^2.6.2", 72 | "typescript": "^5.4.5", 73 | "vite": "^5.2.11", 74 | "vite-plugin-dts": "^3.9.1", 75 | "vitest": "^1.6.0" 76 | }, 77 | "peerDependencies": { 78 | "vue": "^3.3.4" 79 | }, 80 | "packageManager": "pnpm@9.1.3", 81 | "publishConfig": { 82 | "access": "public" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /test/fixtures/index.ts: -------------------------------------------------------------------------------- 1 | import emptyBlock from './001-empty-block'; 2 | import singleSpan from './002-single-span'; 3 | import multipleSpans from './003-multiple-spans'; 4 | import basicMarkSingleSpan from './004-basic-mark-single-span'; 5 | import basicMarkMultipleAdjacentSpans from './005-basic-mark-multiple-adjacent-spans'; 6 | import basicMarkNestedMarks from './006-basic-mark-nested-marks'; 7 | import linkMarkDef from './007-link-mark-def'; 8 | import plainHeaderBlock from './008-plain-header-block'; 9 | import messyLinkText from './009-messy-link-text'; 10 | import basicBulletList from './010-basic-bullet-list'; 11 | import basicNumberedList from './011-basic-numbered-list'; 12 | import nestedLists from './014-nested-lists'; 13 | import allBasicMarks from './015-all-basic-marks'; 14 | import deepWeirdLists from './016-deep-weird-lists'; 15 | import allDefaultBlockStyles from './017-all-default-block-styles'; 16 | import marksAllTheWayDown from './018-marks-all-the-way-down'; 17 | import keyless from './019-keyless'; 18 | import emptyArray from './020-empty-array'; 19 | import listWithoutLevel from './021-list-without-level'; 20 | import inlineNodes from './022-inline-nodes'; 21 | import hardBreaks from './023-hard-breaks'; 22 | import inlineObjects from './024-inline-objects'; 23 | import inlineBlockWithText from './026-inline-block-with-text'; 24 | import styledListItems from './027-styled-list-items'; 25 | import customListItemType from './028-custom-list-item-type'; 26 | import customBlockType from './050-custom-block-type'; 27 | import customMarks from './052-custom-marks'; 28 | import overrideDefaultMarks from './053-override-default-marks'; 29 | import listIssue from './060-list-issue'; 30 | import missingMarkComponent from './061-missing-mark-component'; 31 | import customBlockTypeWithChildren from './062-custom-block-type-with-children'; 32 | 33 | export { 34 | emptyBlock, 35 | singleSpan, 36 | multipleSpans, 37 | basicMarkSingleSpan, 38 | basicMarkMultipleAdjacentSpans, 39 | basicMarkNestedMarks, 40 | linkMarkDef, 41 | plainHeaderBlock, 42 | messyLinkText, 43 | basicBulletList, 44 | basicNumberedList, 45 | nestedLists, 46 | allBasicMarks, 47 | deepWeirdLists, 48 | allDefaultBlockStyles, 49 | marksAllTheWayDown, 50 | keyless, 51 | emptyArray, 52 | listWithoutLevel, 53 | inlineNodes, 54 | hardBreaks, 55 | inlineObjects, 56 | inlineBlockWithText, 57 | styledListItems, 58 | customListItemType, 59 | customBlockType, 60 | customBlockTypeWithChildren, 61 | customMarks, 62 | overrideDefaultMarks, 63 | listIssue, 64 | missingMarkComponent, 65 | }; 66 | -------------------------------------------------------------------------------- /test/fixtures/017-all-default-block-styles.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | style: 'h1', 6 | _type: 'block', 7 | _key: 'b07278ae4e5a', 8 | markDefs: [], 9 | children: [ 10 | { 11 | _type: 'span', 12 | text: 'Sanity', 13 | marks: [], 14 | }, 15 | ], 16 | }, 17 | { 18 | style: 'h2', 19 | _type: 'block', 20 | _key: '0546428bbac2', 21 | markDefs: [], 22 | children: [ 23 | { 24 | _type: 'span', 25 | text: 'The outline', 26 | marks: [], 27 | }, 28 | ], 29 | }, 30 | { 31 | style: 'h3', 32 | _type: 'block', 33 | _key: '34024674e160', 34 | markDefs: [], 35 | children: [ 36 | { 37 | _type: 'span', 38 | text: 'More narrow details', 39 | marks: [], 40 | }, 41 | ], 42 | }, 43 | { 44 | style: 'h4', 45 | _type: 'block', 46 | _key: '06ca981a1d18', 47 | markDefs: [], 48 | children: [ 49 | { 50 | _type: 'span', 51 | text: 'Even less thing', 52 | marks: [], 53 | }, 54 | ], 55 | }, 56 | { 57 | style: 'h5', 58 | _type: 'block', 59 | _key: '06ca98afnjkg', 60 | markDefs: [], 61 | children: [ 62 | { 63 | _type: 'span', 64 | text: 'Small header', 65 | marks: [], 66 | }, 67 | ], 68 | }, 69 | { 70 | style: 'h6', 71 | _type: 'block', 72 | _key: 'cc0afafn', 73 | markDefs: [], 74 | children: [ 75 | { 76 | _type: 'span', 77 | text: 'Lowest thing', 78 | marks: [], 79 | }, 80 | ], 81 | }, 82 | { 83 | style: 'blockquote', 84 | _type: 'block', 85 | _key: '0ee0381658d0', 86 | markDefs: [], 87 | children: [ 88 | { 89 | _type: 'span', 90 | text: 'A block quote of awesomeness', 91 | marks: [], 92 | }, 93 | ], 94 | }, 95 | { 96 | style: 'normal', 97 | _type: 'block', 98 | _key: '44fb584a634c', 99 | markDefs: [], 100 | children: [ 101 | { 102 | _type: 'span', 103 | text: 'Plain old normal block', 104 | marks: [], 105 | }, 106 | ], 107 | }, 108 | { 109 | _type: 'block', 110 | _key: 'abcdefg', 111 | markDefs: [], 112 | children: [ 113 | { 114 | _type: 'span', 115 | text: 'Default to "normal" style', 116 | marks: [], 117 | }, 118 | ], 119 | }, 120 | ]; 121 | 122 | export default { 123 | input, 124 | output: [ 125 | '

Sanity

', 126 | '

The outline

', 127 | '

More narrow details

', 128 | '

Even less thing

', 129 | '
Small header
', 130 | '
Lowest thing
', 131 | '
A block quote of awesomeness
', 132 | '

Plain old normal block

', 133 | '

Default to "normal" style

', 134 | ].join(''), 135 | }; 136 | -------------------------------------------------------------------------------- /test/fixtures/014-nested-lists.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | _type: 'block', 6 | _key: 'a', 7 | markDefs: [], 8 | style: 'normal', 9 | children: [{ _type: 'span', marks: [], text: 'Span' }], 10 | }, 11 | { 12 | _type: 'block', 13 | _key: 'b', 14 | markDefs: [], 15 | level: 1, 16 | children: [{ _type: 'span', marks: [], text: 'Item 1, level 1' }], 17 | listItem: 'bullet', 18 | }, 19 | { 20 | _type: 'block', 21 | _key: 'c', 22 | markDefs: [], 23 | level: 1, 24 | children: [{ _type: 'span', marks: [], text: 'Item 2, level 1' }], 25 | listItem: 'bullet', 26 | }, 27 | { 28 | _type: 'block', 29 | _key: 'd', 30 | markDefs: [], 31 | level: 2, 32 | children: [{ _type: 'span', marks: [], text: 'Item 3, level 2' }], 33 | listItem: 'number', 34 | }, 35 | { 36 | _type: 'block', 37 | _key: 'e', 38 | markDefs: [], 39 | level: 3, 40 | children: [{ _type: 'span', marks: [], text: 'Item 4, level 3' }], 41 | listItem: 'number', 42 | }, 43 | { 44 | _type: 'block', 45 | _key: 'f', 46 | markDefs: [], 47 | level: 2, 48 | children: [{ _type: 'span', marks: [], text: 'Item 5, level 2' }], 49 | listItem: 'number', 50 | }, 51 | { 52 | _type: 'block', 53 | _key: 'g', 54 | markDefs: [], 55 | level: 2, 56 | children: [{ _type: 'span', marks: [], text: 'Item 6, level 2' }], 57 | listItem: 'number', 58 | }, 59 | { 60 | _type: 'block', 61 | _key: 'h', 62 | markDefs: [], 63 | level: 1, 64 | children: [{ _type: 'span', marks: [], text: 'Item 7, level 1' }], 65 | listItem: 'bullet', 66 | }, 67 | { 68 | _type: 'block', 69 | _key: 'i', 70 | markDefs: [], 71 | level: 1, 72 | children: [{ _type: 'span', marks: [], text: 'Item 8, level 1' }], 73 | listItem: 'bullet', 74 | }, 75 | { 76 | _type: 'block', 77 | _key: 'j', 78 | markDefs: [], 79 | level: 1, 80 | children: [{ _type: 'span', marks: [], text: 'Item 1 of list 2' }], 81 | listItem: 'number', 82 | }, 83 | { 84 | _type: 'block', 85 | _key: 'k', 86 | markDefs: [], 87 | level: 1, 88 | children: [{ _type: 'span', marks: [], text: 'Item 2 of list 2' }], 89 | listItem: 'number', 90 | }, 91 | { 92 | _type: 'block', 93 | _key: 'l', 94 | markDefs: [], 95 | level: 2, 96 | children: [{ _type: 'span', marks: [], text: 'Item 3 of list 2, level 2' }], 97 | listItem: 'number', 98 | }, 99 | { 100 | _type: 'block', 101 | _key: 'm', 102 | markDefs: [], 103 | style: 'normal', 104 | children: [{ _type: 'span', marks: [], text: 'Just a block' }], 105 | }, 106 | ]; 107 | 108 | export default { 109 | input, 110 | output: [ 111 | '

Span

', 112 | '
    ', 113 | '
  • Item 1, level 1
  • ', 114 | '
  • ', 115 | ' Item 2, level 1', 116 | '
      ', 117 | '
    1. ', 118 | ' Item 3, level 2', 119 | '
        ', 120 | '
      1. Item 4, level 3
      2. ', 121 | '
      ', 122 | '
    2. ', 123 | '
    3. Item 5, level 2
    4. ', 124 | '
    5. Item 6, level 2
    6. ', 125 | '
    ', 126 | '
  • ', 127 | '
  • Item 7, level 1
  • ', 128 | '
  • Item 8, level 1
  • ', 129 | '
', 130 | '
    ', 131 | '
  1. Item 1 of list 2
  2. ', 132 | '
  3. ', 133 | ' Item 2 of list 2', 134 | '
      ', 135 | '
    1. Item 3 of list 2, level 2
    2. ', 136 | '
    ', 137 | '
  4. ', 138 | '
', 139 | '

Just a block

', 140 | ] 141 | .map((line) => line.trim()) 142 | .join(''), 143 | }; 144 | -------------------------------------------------------------------------------- /MIGRATING.md: -------------------------------------------------------------------------------- 1 | # Migrating from sanity-blocks-vue-component to @portabletext/vue 2 | 3 | This document outlines the differences between [@portabletext/vue](https://www.npmjs.com/package/@portabletext/vue) and [sanity-blocks-vue-component](https://www.npmjs.com/package/sanity-blocks-vue-component) so you can adjust your code to use the newer @portabletext/vue. 4 | 5 | ## `SanityBlocks` renamed to `PortableText` 6 | 7 | PortableText is an [open-source specification](https://portabletext.org/), and as such we're giving it more prominence through the library and component renaming. 8 | 9 | From: 10 | 11 | ```vue 12 | 15 | 16 | 19 | ``` 20 | 21 | ✅ To: 22 | 23 | ```vue 24 | 27 | 28 | 31 | ``` 32 | 33 | ## `blocks` renamed to `value` 34 | 35 | This component renders any Portable Text content or custom object (such as `codeBlock`, `mapLocation` or `callToAction`). As `blocks` is tightly coupled to text blocks, we've renamed the main input to `value`. 36 | 37 | From: 38 | 39 | ```vue 40 | 47 | ``` 48 | 49 | ✅ To: 50 | 51 | ```vue 52 | 59 | ``` 60 | 61 | ## `serializers` renamed to `components` 62 | 63 | "Serializers" are now named "Components". 64 | 65 | From: 66 | 67 | ```vue 68 | 83 | ``` 84 | 85 | ✅ To: 86 | 87 | ```vue 88 | 103 | ``` 104 | 105 | ## Component Props 106 | 107 | The props passed to custom components (previously "serializers") have changed. Previously, all properties of the block or mark object (excluding `_key` and `_type`) were flattened and passed to components as props. 108 | 109 | Block or mark properties are now passed via a `value` prop. This better aligns the Vue renderer with those built for other frameworks. Refer to the [readme](https://github.com/portabletext/vue-portabletext/blob/main/README.md#single-file-components) to see the list of props passed and how to write component prop definitions. 110 | 111 | ## Images aren't handled by default anymore 112 | 113 | We've removed the only Sanity-specific part of the module, which was image handling. You'll have to provide a component to specify how images should be rendered yourself in this new version. 114 | 115 | We've seen the community have vastly different preferences on how images should be rendered, so having a generic image component included out of the box felt unnecessary. 116 | 117 | ```vue 118 | 136 | 137 | 149 | ``` 150 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @portabletext/vue 2 | 3 | ## 1.0.14 4 | 5 | ### Patch Changes 6 | 7 | - [#75](https://github.com/portabletext/vue-portabletext/pull/75) [`27278b9`](https://github.com/portabletext/vue-portabletext/commit/27278b9e43b222b775f9003892c2bfa14fbfac77) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency @portabletext/types to ^2.0.14 8 | 9 | ## 1.0.13 10 | 11 | ### Patch Changes 12 | 13 | - [#74](https://github.com/portabletext/vue-portabletext/pull/74) [`4035082`](https://github.com/portabletext/vue-portabletext/commit/40350829c25c55ea510da108e43565a4d590c139) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency @portabletext/toolkit to ^2.0.18 14 | 15 | - [`fb1be7e`](https://github.com/portabletext/vue-portabletext/commit/fb1be7e27eb46076a5aae1b9f0a28cf21b8c7010) Thanks [@stipsan](https://github.com/stipsan)! - Update LICENSE year to 2025 16 | 17 | ## [1.0.12](https://github.com/portabletext/vue-portabletext/compare/v1.0.11...v1.0.12) (2025-02-06) 18 | 19 | ### Bug Fixes 20 | 21 | - **deps:** update dependency @portabletext/toolkit to ^2.0.17 ([#67](https://github.com/portabletext/vue-portabletext/issues/67)) ([9d7c120](https://github.com/portabletext/vue-portabletext/commit/9d7c120e655382dc257fafe6b4ebc853ad41881f)) 22 | 23 | ## [1.0.11](https://github.com/portabletext/vue-portabletext/compare/v1.0.10...v1.0.11) (2024-04-11) 24 | 25 | ### Bug Fixes 26 | 27 | - **deps:** update dependency @portabletext/toolkit to ^2.0.15 ([#63](https://github.com/portabletext/vue-portabletext/issues/63)) ([3e46d26](https://github.com/portabletext/vue-portabletext/commit/3e46d261832a04e5f7caf3abb6cbfcf46425ae2d)) 28 | - **deps:** update dependency @portabletext/types to ^2.0.13 ([#61](https://github.com/portabletext/vue-portabletext/issues/61)) ([5d5804c](https://github.com/portabletext/vue-portabletext/commit/5d5804c5968d3ef52c29ef75c4f9f4afbc4da17f)) 29 | 30 | ## [1.0.10](https://github.com/portabletext/vue-portabletext/compare/v1.0.9...v1.0.10) (2024-04-05) 31 | 32 | ### Bug Fixes 33 | 34 | - **deps:** update dependency @portabletext/toolkit to ^2.0.14 ([#55](https://github.com/portabletext/vue-portabletext/issues/55)) ([fff5520](https://github.com/portabletext/vue-portabletext/commit/fff5520ef65e70521732b462aad314ab4bfd4239)) 35 | - **deps:** update dependency @portabletext/types to ^2.0.12 ([#56](https://github.com/portabletext/vue-portabletext/issues/56)) ([0fc9c98](https://github.com/portabletext/vue-portabletext/commit/0fc9c989d0089ca430e8c54a2c75a9655f8a2d6a)) 36 | 37 | ## [1.0.9](https://github.com/portabletext/vue-portabletext/compare/v1.0.8...v1.0.9) (2024-03-18) 38 | 39 | ### Bug Fixes 40 | 41 | - **deps:** update dependency @portabletext/toolkit to ^2.0.12 ([#50](https://github.com/portabletext/vue-portabletext/issues/50)) ([171aeb5](https://github.com/portabletext/vue-portabletext/commit/171aeb544754169fd5fa323f033d33aec0eeed11)) 42 | - **deps:** update dependency @portabletext/types to ^2.0.10 ([#49](https://github.com/portabletext/vue-portabletext/issues/49)) ([6cf8c33](https://github.com/portabletext/vue-portabletext/commit/6cf8c3386fc69bc771726a63efdab4929a84a7b0)) 43 | 44 | ## [1.0.8](https://github.com/portabletext/vue-portabletext/compare/v1.0.7...v1.0.8) (2024-03-16) 45 | 46 | ### Bug Fixes 47 | 48 | - **deps:** update dependency @portabletext/toolkit to ^2.0.11 ([#44](https://github.com/portabletext/vue-portabletext/issues/44)) ([9898f51](https://github.com/portabletext/vue-portabletext/commit/9898f51cc8b8e910dbd2082a44bccaf0d71e2b10)) 49 | - **deps:** update dependency @portabletext/types to ^2.0.9 ([#45](https://github.com/portabletext/vue-portabletext/issues/45)) ([c5cdd19](https://github.com/portabletext/vue-portabletext/commit/c5cdd1993caa87db7757266f983437876185e1f2)) 50 | 51 | ## [1.0.7](https://github.com/portabletext/vue-portabletext/compare/v1.0.6...v1.0.7) (2024-03-14) 52 | 53 | ### Bug Fixes 54 | 55 | - react to props ([#40](https://github.com/portabletext/vue-portabletext/issues/40)) ([24076f4](https://github.com/portabletext/vue-portabletext/commit/24076f4d6e758ba224ed62e9de6b32ab88efa74a)) 56 | 57 | ## [1.0.6](https://github.com/portabletext/vue-portabletext/compare/v1.0.5...v1.0.6) (2023-10-10) 58 | 59 | ### Bug Fixes 60 | 61 | - **deps:** update dependency @portabletext/toolkit to ^2.0.10 ([#28](https://github.com/portabletext/vue-portabletext/issues/28)) ([7e7a8b3](https://github.com/portabletext/vue-portabletext/commit/7e7a8b392e5a9376467fbb5206c5f91d5bca50b3)) 62 | - **deps:** update dependency @portabletext/types to ^2.0.8 ([#25](https://github.com/portabletext/vue-portabletext/issues/25)) ([e90c9b3](https://github.com/portabletext/vue-portabletext/commit/e90c9b3ade9477637d9548b757646a5333022c0e)) 63 | 64 | ## [1.0.5](https://github.com/portabletext/vue-portabletext/compare/v1.0.4...v1.0.5) (2023-09-29) 65 | 66 | ### Bug Fixes 67 | 68 | - **deps:** update dependency @portabletext/toolkit to ^2.0.9 ([#20](https://github.com/portabletext/vue-portabletext/issues/20)) ([d5c86ff](https://github.com/portabletext/vue-portabletext/commit/d5c86ffab2ca8e1a2aecdbf0229c6e819b250dbc)) 69 | - **deps:** update dependency @portabletext/types to ^2.0.7 ([#21](https://github.com/portabletext/vue-portabletext/issues/21)) ([fa2d965](https://github.com/portabletext/vue-portabletext/commit/fa2d96548b879eb0257d0fa4f820abd2f7f50890)) 70 | 71 | ## [1.0.4](https://github.com/portabletext/vue-portabletext/compare/v1.0.3...v1.0.4) (2023-08-29) 72 | 73 | ### Bug Fixes 74 | 75 | - move default exports to last position ([63eecea](https://github.com/portabletext/vue-portabletext/commit/63eeceabbc68506fdd889f01d6242875f275e965)) 76 | 77 | ## [1.0.3](https://github.com/portabletext/vue-portabletext/compare/v1.0.2...v1.0.3) (2023-08-25) 78 | 79 | ### Bug Fixes 80 | 81 | - release, add experimental flatten ([39edc46](https://github.com/portabletext/vue-portabletext/commit/39edc4666bd397ccaa68224705e76f6eb1547745)) 82 | 83 | ## [1.0.2](https://github.com/portabletext/vue-portabletext/compare/v1.0.1...v1.0.2) (2023-08-24) 84 | 85 | ### Bug Fixes 86 | 87 | - test semantic release ([4ab526b](https://github.com/portabletext/vue-portabletext/commit/4ab526b11345fa20849c75c2cbf4155148aa3e3e)) 88 | 89 | ## [1.0.1](https://github.com/portabletext/vue-portabletext/compare/v1.0.0...v1.0.1) (2023-08-24) 90 | 91 | ### Bug Fixes 92 | 93 | - **deps:** update dependency @portabletext/toolkit to ^2.0.8 ([#7](https://github.com/portabletext/vue-portabletext/issues/7)) ([e5ec63d](https://github.com/portabletext/vue-portabletext/commit/e5ec63d989909727cc7690d06fea668e00ee63d8)) 94 | - **deps:** update dependency @portabletext/types to ^2.0.6 ([#5](https://github.com/portabletext/vue-portabletext/issues/5)) ([c49b334](https://github.com/portabletext/vue-portabletext/commit/c49b3348db24a85a0dc2ccb420e3dd150667aa48)) 95 | 96 | ## 1.0.0 (2023-08-24) 97 | 98 | ### Bug Fixes 99 | 100 | - **ci:** setup release automation and enable provenance ([38f6647](https://github.com/portabletext/vue-portabletext/commit/38f66478336b2542278eb3b66536a0ca508b22e8)) 101 | -------------------------------------------------------------------------------- /test/fixtures/016-deep-weird-lists.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from '@portabletext/types'; 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | listItem: 'bullet', 6 | style: 'normal', 7 | level: 1, 8 | _type: 'block', 9 | _key: 'fde2e840a29c', 10 | markDefs: [], 11 | children: [ 12 | { 13 | _type: 'span', 14 | text: 'Item a', 15 | marks: [], 16 | }, 17 | ], 18 | }, 19 | { 20 | listItem: 'bullet', 21 | style: 'normal', 22 | level: 1, 23 | _type: 'block', 24 | _key: 'c16f11c71638', 25 | markDefs: [], 26 | children: [ 27 | { 28 | _type: 'span', 29 | text: 'Item b', 30 | marks: [], 31 | }, 32 | ], 33 | }, 34 | { 35 | listItem: 'number', 36 | style: 'normal', 37 | level: 1, 38 | _type: 'block', 39 | _key: 'e92f55b185ae', 40 | markDefs: [], 41 | children: [ 42 | { 43 | _type: 'span', 44 | text: 'Item 1', 45 | marks: [], 46 | }, 47 | ], 48 | }, 49 | { 50 | listItem: 'number', 51 | style: 'normal', 52 | level: 1, 53 | _type: 'block', 54 | _key: 'a77e71209aff', 55 | markDefs: [], 56 | children: [ 57 | { 58 | _type: 'span', 59 | text: 'Item 2', 60 | marks: [], 61 | }, 62 | ], 63 | }, 64 | { 65 | listItem: 'number', 66 | style: 'normal', 67 | level: 2, 68 | _type: 'block', 69 | _key: 'da1f863df265', 70 | markDefs: [], 71 | children: [ 72 | { 73 | _type: 'span', 74 | text: 'Item 2, a', 75 | marks: [], 76 | }, 77 | ], 78 | }, 79 | { 80 | listItem: 'number', 81 | style: 'normal', 82 | level: 2, 83 | _type: 'block', 84 | _key: '60d8c92bed0d', 85 | markDefs: [], 86 | children: [ 87 | { 88 | _type: 'span', 89 | text: 'Item 2, b', 90 | marks: [], 91 | }, 92 | ], 93 | }, 94 | { 95 | listItem: 'number', 96 | style: 'normal', 97 | level: 1, 98 | _type: 'block', 99 | _key: '6dbc061d5d36', 100 | markDefs: [], 101 | children: [ 102 | { 103 | _type: 'span', 104 | text: 'Item 3', 105 | marks: [], 106 | }, 107 | ], 108 | }, 109 | { 110 | style: 'normal', 111 | _type: 'block', 112 | _key: 'bb89bd1ef2c9', 113 | markDefs: [], 114 | children: [ 115 | { 116 | _type: 'span', 117 | text: '', 118 | marks: [], 119 | }, 120 | ], 121 | }, 122 | { 123 | listItem: 'bullet', 124 | style: 'normal', 125 | level: 1, 126 | _type: 'block', 127 | _key: '289c1f176eab', 128 | markDefs: [], 129 | children: [ 130 | { 131 | _type: 'span', 132 | text: 'In', 133 | marks: [], 134 | }, 135 | ], 136 | }, 137 | { 138 | listItem: 'bullet', 139 | style: 'normal', 140 | level: 2, 141 | _type: 'block', 142 | _key: '011f8cc6d19b', 143 | markDefs: [], 144 | children: [ 145 | { 146 | _type: 'span', 147 | text: 'Out', 148 | marks: [], 149 | }, 150 | ], 151 | }, 152 | { 153 | listItem: 'bullet', 154 | style: 'normal', 155 | level: 1, 156 | _type: 'block', 157 | _key: 'ccfb4e37b798', 158 | markDefs: [], 159 | children: [ 160 | { 161 | _type: 'span', 162 | text: 'In', 163 | marks: [], 164 | }, 165 | ], 166 | }, 167 | { 168 | listItem: 'bullet', 169 | style: 'normal', 170 | level: 2, 171 | _type: 'block', 172 | _key: 'bd0102405e5c', 173 | markDefs: [], 174 | children: [ 175 | { 176 | _type: 'span', 177 | text: 'Out', 178 | marks: [], 179 | }, 180 | ], 181 | }, 182 | { 183 | listItem: 'bullet', 184 | style: 'normal', 185 | level: 3, 186 | _type: 'block', 187 | _key: '030fda546030', 188 | markDefs: [], 189 | children: [ 190 | { 191 | _type: 'span', 192 | text: 'Even More', 193 | marks: [], 194 | }, 195 | ], 196 | }, 197 | { 198 | listItem: 'bullet', 199 | style: 'normal', 200 | level: 4, 201 | _type: 'block', 202 | _key: '80369435aed0', 203 | markDefs: [], 204 | children: [ 205 | { 206 | _type: 'span', 207 | text: 'Even deeper', 208 | marks: [], 209 | }, 210 | ], 211 | }, 212 | { 213 | listItem: 'bullet', 214 | style: 'normal', 215 | level: 2, 216 | _type: 'block', 217 | _key: '3b36919a8914', 218 | markDefs: [], 219 | children: [ 220 | { 221 | _type: 'span', 222 | text: 'Two steps back', 223 | marks: [], 224 | }, 225 | ], 226 | }, 227 | { 228 | listItem: 'bullet', 229 | style: 'normal', 230 | level: 1, 231 | _type: 'block', 232 | _key: '9193cbc6ba54', 233 | markDefs: [], 234 | children: [ 235 | { 236 | _type: 'span', 237 | text: 'All the way back', 238 | marks: [], 239 | }, 240 | ], 241 | }, 242 | { 243 | listItem: 'bullet', 244 | style: 'normal', 245 | level: 3, 246 | _type: 'block', 247 | _key: '256fe8487d7a', 248 | markDefs: [], 249 | children: [ 250 | { 251 | _type: 'span', 252 | text: 'Skip a step', 253 | marks: [], 254 | }, 255 | ], 256 | }, 257 | { 258 | listItem: 'number', 259 | style: 'normal', 260 | level: 1, 261 | _type: 'block', 262 | _key: 'aaa', 263 | markDefs: [], 264 | children: [ 265 | { 266 | _type: 'span', 267 | text: 'New list', 268 | marks: [], 269 | }, 270 | ], 271 | }, 272 | { 273 | listItem: 'number', 274 | style: 'normal', 275 | level: 2, 276 | _type: 'block', 277 | _key: 'bbb', 278 | markDefs: [], 279 | children: [ 280 | { 281 | _type: 'span', 282 | text: 'Next level', 283 | marks: [], 284 | }, 285 | ], 286 | }, 287 | { 288 | listItem: 'bullet', 289 | style: 'normal', 290 | level: 1, 291 | _type: 'block', 292 | _key: 'ccc', 293 | markDefs: [], 294 | children: [ 295 | { 296 | _type: 'span', 297 | text: 'New bullet list', 298 | marks: [], 299 | }, 300 | ], 301 | }, 302 | ]; 303 | 304 | export default { 305 | input, 306 | output: [ 307 | '
    ', 308 | '
  • Item a
  • ', 309 | '
  • Item b
  • ', 310 | '
', 311 | '
    ', 312 | '
  1. Item 1
  2. ', 313 | '
  3. ', 314 | 'Item 2', 315 | '
      ', 316 | '
    1. Item 2, a
    2. ', 317 | '
    3. Item 2, b
    4. ', 318 | '
    ', 319 | '
  4. ', 320 | '
  5. Item 3
  6. ', 321 | '
', 322 | '

', 323 | '
    ', 324 | '
  • ', 325 | 'In', 326 | '
      ', 327 | '
    • Out
    • ', 328 | '
    ', 329 | '
  • ', 330 | '
  • ', 331 | 'In', 332 | '
      ', 333 | '
    • ', 334 | 'Out', 335 | '
        ', 336 | '
      • ', 337 | 'Even More', 338 | '
          ', 339 | '
        • Even deeper
        • ', 340 | '
        ', 341 | '
      • ', 342 | '
      ', 343 | '
    • ', 344 | '
    • Two steps back
    • ', 345 | '
    ', 346 | '
  • ', 347 | '
  • ', 348 | 'All the way back', 349 | '
      ', 350 | '
    • Skip a step
    • ', 351 | '
    ', 352 | '
  • ', 353 | '
', 354 | '
    ', 355 | '
  1. New list
    1. Next level
  2. ', 356 | '
', 357 | '
  • New bullet list
', 358 | ].join(''), 359 | }; 360 | -------------------------------------------------------------------------------- /src/node-renderer.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'vue'; 2 | import type { ToolkitNestedPortableTextSpan, ToolkitTextNode } from '@portabletext/toolkit'; 3 | import type { 4 | MissingComponentHandler, 5 | NodeRenderer, 6 | PortableTextVueComponents, 7 | Serializable, 8 | SerializedBlock, 9 | VueNode, 10 | VuePortableTextList, 11 | } from './types'; 12 | import type { 13 | PortableTextBlock, 14 | PortableTextListItemBlock, 15 | PortableTextMarkDefinition, 16 | PortableTextSpan, 17 | TypedObject, 18 | } from '@portabletext/types'; 19 | import { 20 | isPortableTextBlock, 21 | isPortableTextListItemBlock, 22 | isPortableTextToolkitList, 23 | isPortableTextToolkitSpan, 24 | isPortableTextToolkitTextNode, 25 | spanToPlainText, 26 | buildMarksTree, 27 | } from '@portabletext/toolkit'; 28 | import { 29 | unknownBlockStyleWarning, 30 | unknownListItemStyleWarning, 31 | unknownListStyleWarning, 32 | unknownMarkWarning, 33 | unknownTypeWarning, 34 | } from './warnings'; 35 | 36 | export const getNodeRenderer = ( 37 | components: PortableTextVueComponents, 38 | handleMissingComponent: MissingComponentHandler, 39 | ): NodeRenderer => { 40 | function renderNode(options: Serializable): VueNode { 41 | const { node, index, isInline } = options; 42 | const key = node._key || `node-${index}`; 43 | 44 | if (isPortableTextToolkitList(node)) { 45 | return renderList(node, index, key); 46 | } 47 | 48 | if (isPortableTextListItemBlock(node)) { 49 | return renderListItem(node, index, key); 50 | } 51 | 52 | if (isPortableTextToolkitSpan(node)) { 53 | return renderSpan(node, index, key); 54 | } 55 | 56 | if (hasCustomComponentForNode(node)) { 57 | return renderCustomBlock(node, index, key, isInline); 58 | } 59 | 60 | if (isPortableTextBlock(node)) { 61 | return renderBlock(node, index, key, isInline); 62 | } 63 | 64 | if (isPortableTextToolkitTextNode(node)) { 65 | return renderText(node, key); 66 | } 67 | 68 | return renderUnknownType(node, index, key, isInline); 69 | } 70 | 71 | function hasCustomComponentForNode(node: TypedObject): boolean { 72 | return node._type in components.types; 73 | } 74 | 75 | function renderListItem( 76 | node: PortableTextListItemBlock, 77 | index: number, 78 | key: string, 79 | ) { 80 | const tree = serializeBlock({ node, index, isInline: false, renderNode }); 81 | const renderer = components.listItem; 82 | const handler = typeof renderer === 'function' ? renderer : renderer[node.listItem]; 83 | const Li = handler || components.unknownListItem; 84 | 85 | if (Li === components.unknownListItem) { 86 | const style = node.listItem || 'bullet'; 87 | handleMissingComponent(unknownListItemStyleWarning(style), { 88 | type: style, 89 | nodeType: 'listItemStyle', 90 | }); 91 | } 92 | 93 | let children = tree.children; 94 | if (node.style && node.style !== 'normal') { 95 | // Wrap any other style in whatever the block serializer says to use 96 | const { listItem, ...blockNode } = node; 97 | children = renderNode({ 98 | node: blockNode, 99 | index, 100 | isInline: false, 101 | renderNode, 102 | }); 103 | } 104 | 105 | return h( 106 | Li, 107 | { 108 | key, 109 | value: node, 110 | index, 111 | isInline: false, 112 | renderNode, 113 | }, 114 | () => children, 115 | ); 116 | } 117 | 118 | function renderList(node: VuePortableTextList, index: number, key: string) { 119 | const children = node.children.map((child, childIndex) => 120 | renderNode({ 121 | node: child._key ? child : { ...child, _key: `li-${index}-${childIndex}` }, 122 | index: childIndex, 123 | isInline: false, 124 | renderNode, 125 | }), 126 | ); 127 | 128 | const component = components.list; 129 | const handler = typeof component === 'function' ? component : component[node.listItem]; 130 | const List = handler || components.unknownList; 131 | 132 | if (List === components.unknownList) { 133 | const style = node.listItem || 'bullet'; 134 | handleMissingComponent(unknownListStyleWarning(style), { 135 | nodeType: 'listStyle', 136 | type: style, 137 | }); 138 | } 139 | 140 | return h( 141 | List, 142 | { 143 | key, 144 | value: node, 145 | index, 146 | isInline: false, 147 | renderNode, 148 | }, 149 | () => children, 150 | ); 151 | } 152 | 153 | function renderSpan(node: ToolkitNestedPortableTextSpan, _index: number, key: string) { 154 | const { markDef, markType, markKey } = node; 155 | const Span = components.marks[markType] || components.unknownMark; 156 | const children = node.children.map((child, childIndex) => 157 | renderNode({ node: child, index: childIndex, isInline: true, renderNode }), 158 | ); 159 | 160 | if (Span === components.unknownMark) { 161 | handleMissingComponent(unknownMarkWarning(markType), { 162 | nodeType: 'mark', 163 | type: markType, 164 | }); 165 | } 166 | 167 | return h( 168 | Span, 169 | { 170 | key, 171 | text: spanToPlainText(node), 172 | value: markDef, 173 | markType, 174 | markKey, 175 | renderNode, 176 | }, 177 | () => children, 178 | ); 179 | } 180 | 181 | function renderBlock(node: PortableTextBlock, index: number, key: string, isInline: boolean) { 182 | const { _key, children, ...props } = serializeBlock({ 183 | node, 184 | index, 185 | isInline, 186 | renderNode, 187 | }); 188 | const style = props.node.style || 'normal'; 189 | const handler = 190 | typeof components.block === 'function' ? components.block : components.block[style]; 191 | const Block = handler || components.unknownBlockStyle; 192 | 193 | if (Block === components.unknownBlockStyle) { 194 | handleMissingComponent(unknownBlockStyleWarning(style), { 195 | nodeType: 'blockStyle', 196 | type: style, 197 | }); 198 | } 199 | 200 | return h(Block, { key, ...props, value: props.node, renderNode }, () => children); 201 | } 202 | 203 | function renderText(node: ToolkitTextNode, key: string) { 204 | if (node.text === '\n') { 205 | const HardBreak = components.hardBreak; 206 | return HardBreak ? h(HardBreak, { key }) : '\n'; 207 | } 208 | 209 | return node.text; 210 | } 211 | 212 | function renderUnknownType(node: TypedObject, index: number, key: string, isInline: boolean) { 213 | const nodeOptions = { 214 | value: node, 215 | isInline, 216 | index, 217 | renderNode, 218 | }; 219 | 220 | handleMissingComponent(unknownTypeWarning(node._type), { 221 | nodeType: 'block', 222 | type: node._type, 223 | }); 224 | 225 | const UnknownType = components.unknownType; 226 | return h(UnknownType, { key, ...nodeOptions }); 227 | } 228 | 229 | function renderCustomBlock(node: TypedObject, index: number, key: string, isInline: boolean) { 230 | const nodeOptions = { 231 | value: node, 232 | isInline, 233 | index, 234 | renderNode, 235 | }; 236 | 237 | const Node = components.types[node._type]; 238 | return Node ? h(Node, { key, ...nodeOptions }) : undefined; 239 | } 240 | 241 | return renderNode; 242 | }; 243 | 244 | function serializeBlock(options: Serializable): SerializedBlock { 245 | const { node, index, isInline, renderNode } = options; 246 | const tree = buildMarksTree(node); 247 | const children = tree.map((child, i) => 248 | renderNode({ node: child, isInline: true, index: i, renderNode }), 249 | ) as VueNode; // @todo Is casting here acceptable? 250 | 251 | return { 252 | _key: node._key || `block-${index}`, 253 | children, 254 | index, 255 | isInline, 256 | node, 257 | }; 258 | } 259 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Component as VueComponent, VNode } from 'vue'; 2 | import type { 3 | ToolkitListNestMode, 4 | ToolkitPortableTextList, 5 | ToolkitPortableTextListItem, 6 | } from '@portabletext/toolkit'; 7 | import type { 8 | ArbitraryTypedObject, 9 | PortableTextBlock, 10 | PortableTextBlockStyle, 11 | PortableTextListItemBlock, 12 | PortableTextListItemType, 13 | TypedObject, 14 | } from '@portabletext/types'; 15 | 16 | export type VueNode = undefined | VNode | string | Array; 17 | 18 | /** 19 | * Properties for the Portable Text Vue component 20 | * 21 | * @template B Types that can appear in the array of blocks 22 | */ 23 | export interface PortableTextProps< 24 | B extends TypedObject = PortableTextBlock | ArbitraryTypedObject, 25 | > { 26 | /** 27 | * One or more blocks to render 28 | */ 29 | value: B | B[]; 30 | 31 | /** 32 | * Vue components to use for rendering 33 | */ 34 | components?: Partial; 35 | 36 | /** 37 | * Function to call when encountering unknown unknown types, eg blocks, marks, 38 | * block style, list styles without an associated Vue component. 39 | * 40 | * Will print a warning message to the console by default. 41 | * Pass `false` to disable. 42 | */ 43 | onMissingComponent?: MissingComponentHandler | false; 44 | 45 | /** 46 | * Determines whether or not lists are nested inside of list items (`html`) 47 | * or as a direct child of another list (`direct` - for Vue Native) 48 | * 49 | * You rarely (if ever) need/want to customize this 50 | */ 51 | listNestingMode?: ToolkitListNestMode; 52 | } 53 | 54 | /** 55 | * Generic type for portable text rendering components that takes blocks/inline blocks 56 | * 57 | * @template N Node types we expect to be rendering (`PortableTextBlock` should usually be part of this) 58 | */ 59 | export type PortableTextComponent = VueComponent>; 60 | 61 | /** 62 | * Vue component type for rendering portable text blocks (paragraphs, headings, blockquotes etc) 63 | */ 64 | export type PortableTextBlockComponent = PortableTextComponent; 65 | 66 | /** 67 | * Vue component type for rendering (virtual, not part of the spec) portable text lists 68 | */ 69 | export type PortableTextListComponent = PortableTextComponent; 70 | 71 | /** 72 | * Vue component type for rendering portable text list items 73 | */ 74 | export type PortableTextListItemComponent = PortableTextComponent; 75 | 76 | /** 77 | * Vue component type for rendering portable text marks and/or decorators 78 | * 79 | * @template M The mark type we expect 80 | */ 81 | export type PortableTextMarkComponent = VueComponent< 82 | PortableTextMarkComponentProps 83 | >; 84 | 85 | export type PortableTextTypeComponent = VueComponent< 86 | PortableTextComponentProps 87 | >; 88 | 89 | /** 90 | * Object defining the different Vue components to use for rendering various aspects 91 | * of Portable Text and user-provided types, where only the overrides needs to be provided. 92 | */ 93 | export type PortableTextComponents = Partial; 94 | 95 | /** 96 | * Object definining the different Vue components to use for rendering various aspects 97 | * of Portable Text and user-provided types. 98 | */ 99 | export interface PortableTextVueComponents { 100 | /** 101 | * Object of Vue components that renders different types of objects that might appear 102 | * both as part of the blocks array, or as inline objects _inside_ of a block, 103 | * alongside text spans. 104 | * 105 | * Use the `isInline` property to check whether or not this is an inline object or a block 106 | * 107 | * The object has the shape `{typeName: VueComponent}`, where `typeName` is the value set 108 | * in individual `_type` attributes. 109 | */ 110 | types: Record; 111 | 112 | /** 113 | * Object of Vue components that renders different types of marks that might appear in spans. 114 | * 115 | * The object has the shape `{markName: VueComponent}`, where `markName` is the value set 116 | * in individual `_type` attributes, values being stored in the parent blocks `markDefs`. 117 | */ 118 | marks: Record; 119 | 120 | /** 121 | * Object of Vue components that renders blocks with different `style` properties. 122 | * 123 | * The object has the shape `{styleName: VueComponent}`, where `styleName` is the value set 124 | * in individual `style` attributes on blocks. 125 | * 126 | * Can also be set to a single Vue component, which would handle block styles of _any_ type. 127 | */ 128 | block: 129 | | Record 130 | | PortableTextBlockComponent; 131 | 132 | /** 133 | * Object of Vue components used to render lists of different types (bulleted vs numbered, 134 | * for instance, which by default is `