├── .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, bli med du også , 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): simple Unknown (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 | 'Number 1 ',
70 | 'Number 2 ',
71 | 'Number 3 ',
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 NOK Neat
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 | ' ',
118 | ' Item 3, level 2',
119 | ' ',
120 | ' Item 4, level 3 ',
121 | ' ',
122 | ' ',
123 | ' Item 5, level 2 ',
124 | ' Item 6, level 2 ',
125 | ' ',
126 | ' ',
127 | ' Item 7, level 1 ',
128 | ' Item 8, level 1 ',
129 | ' ',
130 | '',
131 | ' Item 1 of list 2 ',
132 | ' ',
133 | ' Item 2 of list 2',
134 | ' ',
135 | ' Item 3 of list 2, level 2 ',
136 | ' ',
137 | ' ',
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 |
17 |
18 |
19 | ```
20 |
21 | ✅ To:
22 |
23 | ```vue
24 |
27 |
28 |
29 |
30 |
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 |
41 |
46 |
47 | ```
48 |
49 | ✅ To:
50 |
51 | ```vue
52 |
53 |
58 |
59 | ```
60 |
61 | ## `serializers` renamed to `components`
62 |
63 | "Serializers" are now named "Components".
64 |
65 | From:
66 |
67 | ```vue
68 |
69 |
82 |
83 | ```
84 |
85 | ✅ To:
86 |
87 | ```vue
88 |
89 |
102 |
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 |
138 |
139 | ;
148 |
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 | 'Item 1 ',
313 | '',
314 | 'Item 2',
315 | '',
316 | 'Item 2, a ',
317 | 'Item 2, b ',
318 | ' ',
319 | ' ',
320 | 'Item 3 ',
321 | ' ',
322 | '
',
323 | '',
324 | '',
325 | 'In',
326 | '',
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 | 'New listNext level ',
356 | ' ',
357 | '',
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 `` and ``, respectively)
135 | *
136 | * There is no actual "list" node type in the Portable Text specification, but a series of
137 | * list item blocks with the same `level` and `listItem` properties will be grouped into a
138 | * virtual one inside of this library.
139 | *
140 | * Can also be set to a single Vue component, which would handle lists of _any_ type.
141 | */
142 | list:
143 | | Record
144 | | PortableTextListComponent;
145 |
146 | /**
147 | * Object of Vue components used to render different list item styles.
148 | *
149 | * The object has the shape `{listItemType: VueComponent}`, where `listItemType` is the value
150 | * set in individual `listItem` attributes on blocks.
151 | *
152 | * Can also be set to a single Vue component, which would handle list items of _any_ type.
153 | */
154 | listItem:
155 | | Record
156 | | PortableTextListItemComponent;
157 |
158 | /**
159 | * Component to use for rendering "hard breaks", eg `\n` inside of text spans
160 | * Will by default render a ` `. Pass `false` to render as-is (`\n`)
161 | */
162 | hardBreak: VueComponent<{ key: any }> | false;
163 |
164 | /**
165 | * Vue component used when encountering a mark type there is no registered component for
166 | * in the `components.marks` prop.
167 | */
168 | unknownMark: PortableTextMarkComponent;
169 |
170 | /**
171 | * Vue component used when encountering an object type there is no registered component for
172 | * in the `components.types` prop.
173 | */
174 | unknownType: PortableTextComponent;
175 |
176 | /**
177 | * Vue component used when encountering a block style there is no registered component for
178 | * in the `components.block` prop. Only used if `components.block` is an object.
179 | */
180 | unknownBlockStyle: PortableTextComponent;
181 |
182 | /**
183 | * Vue component used when encountering a list style there is no registered component for
184 | * in the `components.list` prop. Only used if `components.list` is an object.
185 | */
186 | unknownList: PortableTextComponent;
187 |
188 | /**
189 | * Vue component used when encountering a list item style there is no registered component for
190 | * in the `components.listItem` prop. Only used if `components.listItem` is an object.
191 | */
192 | unknownListItem: PortableTextComponent;
193 | }
194 |
195 | /**
196 | * Props received by most Portable Text components
197 | *
198 | * @template T Type of data this component will receive in its `value` property
199 | */
200 | export interface PortableTextComponentProps {
201 | /**
202 | * Data associated with this portable text node, eg the raw JSON value of a block/type
203 | */
204 | value: T;
205 |
206 | /**
207 | * Index within its parent
208 | */
209 | index: number;
210 |
211 | /**
212 | * Whether or not this node is "inline" - ie as a child of a text block,
213 | * alongside text spans, or a block in and of itself.
214 | */
215 | isInline: boolean;
216 |
217 | /**
218 | * Function used to render any node that might appear in a portable text array or block,
219 | * including virtual "toolkit"-nodes like lists and nested spans. You will rarely need
220 | * to use this.
221 | */
222 | renderNode: NodeRenderer;
223 | }
224 |
225 | /**
226 | * Props received by “flattened” Portable Text Components
227 | *
228 | * @template T Type of data this component will receive in its `value` property
229 | */
230 | export type PortableTextComponentFlattenedProps> = Omit<
231 | PortableTextComponentProps,
232 | 'value'
233 | > &
234 | T;
235 |
236 | /**
237 | * Props received by Portable Text mark rendering components
238 | *
239 | * @template M Shape describing the data associated with this mark, if it is an annotation
240 | */
241 | export interface PortableTextMarkComponentProps {
242 | /**
243 | * Mark definition, eg the actual data of the annotation. If the mark is a simple decorator, this will be `undefined`
244 | */
245 | value?: M;
246 |
247 | /**
248 | * Text content of this mark
249 | */
250 | text: string;
251 |
252 | /**
253 | * Key for this mark. The same key can be used amongst multiple text spans within the same block, so don't rely on this for Vue keys.
254 | */
255 | markKey?: string;
256 |
257 | /**
258 | * Type of mark - ie value of `_type` in the case of annotations, or the name of the decorator otherwise - eg `em`, `italic`.
259 | */
260 | markType: string;
261 |
262 | /**
263 | * Function used to render any node that might appear in a portable text array or block,
264 | * including virtual "toolkit"-nodes like lists and nested spans. You will rarely need
265 | * to use this.
266 | */
267 | renderNode: NodeRenderer;
268 | }
269 |
270 | /**
271 | * Any node type that we can't identify - eg it has an `_type`,
272 | * but we don't know anything about its other properties
273 | */
274 | export type UnknownNodeType = { [key: string]: unknown; _type: string } | TypedObject;
275 |
276 | /**
277 | * Function that renders any node that might appear in a portable text array or block,
278 | * including virtual "toolkit"-nodes like lists and nested spans
279 | */
280 | export type NodeRenderer = (options: Serializable) => VueNode;
281 |
282 | export type NodeType = 'block' | 'mark' | 'blockStyle' | 'listStyle' | 'listItemStyle';
283 |
284 | export type MissingComponentHandler = (
285 | message: string,
286 | options: { type: string; nodeType: NodeType },
287 | ) => void;
288 |
289 | export interface Serializable {
290 | node: T;
291 | index: number;
292 | isInline: boolean;
293 | renderNode: NodeRenderer;
294 | }
295 |
296 | export interface SerializedBlock {
297 | _key: string;
298 | children: VueNode;
299 | index: number;
300 | isInline: boolean;
301 | node: PortableTextBlock | PortableTextListItemBlock;
302 | }
303 |
304 | // Re-exporting these as we don't want to refer to "toolkit" outside of this module
305 |
306 | /**
307 | * A virtual "list" node for Portable Text - not strictly part of Portable Text,
308 | * but generated by this library to ease the rendering of lists in HTML etc
309 | */
310 | export type VuePortableTextList = ToolkitPortableTextList;
311 |
312 | /**
313 | * A virtual "list item" node for Portable Text - not strictly any different from a
314 | * regular Portable Text Block, but we can guarantee that it has a `listItem` property.
315 | */
316 | export type VuePortableTextListItem = ToolkitPortableTextListItem;
317 |
--------------------------------------------------------------------------------
/test/fixtures/021-list-without-level.ts:
--------------------------------------------------------------------------------
1 | import type { PortableTextBlock } from '@portabletext/types';
2 |
3 | const input: PortableTextBlock[] = [
4 | {
5 | _key: 'e3ac53b5b339',
6 | _type: 'block',
7 | children: [
8 | {
9 | _type: 'span',
10 | marks: [],
11 | text: 'In-person access: Research appointments',
12 | },
13 | ],
14 | markDefs: [],
15 | style: 'h2',
16 | },
17 | {
18 | _key: 'a25f0be55c47',
19 | _type: 'block',
20 | children: [
21 | {
22 | _type: 'span',
23 | marks: [],
24 | text: 'The collection may be examined by arranging a research appointment ',
25 | },
26 | {
27 | _type: 'span',
28 | marks: ['strong'],
29 | text: 'in advance',
30 | },
31 | {
32 | _type: 'span',
33 | marks: [],
34 | text: ' by contacting the ACT archivist by email or phone. ACT generally does not accept walk-in research patrons, although requests may be made in person at the Archivist’s office (E15-222). ACT recommends arranging appointments at least three weeks in advance in order to ensure availability. ACT reserves the right to cancel research appointments at any time. Appointment scheduling is subject to institute holidays and closings. ',
35 | },
36 | ],
37 | markDefs: [],
38 | style: 'normal',
39 | },
40 | {
41 | _key: '9490a3085498',
42 | _type: 'block',
43 | children: [
44 | {
45 | _type: 'span',
46 | marks: [],
47 | text: 'The collection space is located at:\n20 Ames Street\nBuilding E15-235\nCambridge, Massachusetts 02139',
48 | },
49 | ],
50 | markDefs: [],
51 | style: 'normal',
52 | },
53 | {
54 | _key: '4c37f3bc1d71',
55 | _type: 'block',
56 | children: [
57 | {
58 | _type: 'span',
59 | marks: [],
60 | text: 'In-person access: Space policies',
61 | },
62 | ],
63 | markDefs: [],
64 | style: 'h2',
65 | },
66 | {
67 | _key: 'a77cf4905e83',
68 | _type: 'block',
69 | children: [
70 | {
71 | _type: 'span',
72 | marks: [],
73 | text: 'The Archivist or an authorized ACT staff member must attend researchers at all times.',
74 | },
75 | ],
76 | listItem: 'bullet',
77 | markDefs: [],
78 | style: 'normal',
79 | },
80 | {
81 | _key: '9a039c533554',
82 | _type: 'block',
83 | children: [
84 | {
85 | _type: 'span',
86 | marks: [],
87 | text: 'No pens, markers, or adhesives (e.g. “Post-it” notes) are permitted in the collection space; pencils will be provided upon request.',
88 | },
89 | ],
90 | listItem: 'bullet',
91 | markDefs: [],
92 | style: 'normal',
93 | },
94 | {
95 | _key: 'beeee9405136',
96 | _type: 'block',
97 | children: [
98 | {
99 | _type: 'span',
100 | marks: [],
101 | text: 'Cotton gloves must be worn when handling collection materials; gloves will be provided by the Archivist.',
102 | },
103 | ],
104 | listItem: 'bullet',
105 | markDefs: [],
106 | style: 'normal',
107 | },
108 | {
109 | _key: '8b78daa65d60',
110 | _type: 'block',
111 | children: [
112 | {
113 | _type: 'span',
114 | marks: [],
115 | text: 'No food or beverages are permitted in the collection space.',
116 | },
117 | ],
118 | listItem: 'bullet',
119 | markDefs: [],
120 | style: 'normal',
121 | },
122 | {
123 | _key: 'd0188e00a887',
124 | _type: 'block',
125 | children: [
126 | {
127 | _type: 'span',
128 | marks: [],
129 | text: 'Laptop use is permitted in the collection space, as well as digital cameras and cellphones. Unless otherwise authorized, any equipment in the collection space (including but not limited to computers, telephones, scanners, and viewing equipment) is for use by ACT staff members only.',
130 | },
131 | ],
132 | listItem: 'bullet',
133 | markDefs: [],
134 | style: 'normal',
135 | },
136 | {
137 | _key: '06486dd9e1c6',
138 | _type: 'block',
139 | children: [
140 | {
141 | _type: 'span',
142 | marks: [],
143 | text: 'Photocopying machines in the ACT hallway will be accessible by patrons under the supervision of the Archivist.',
144 | },
145 | ],
146 | listItem: 'bullet',
147 | markDefs: [],
148 | style: 'normal',
149 | },
150 | {
151 | _key: 'e6f6f5255fb6',
152 | _type: 'block',
153 | children: [
154 | {
155 | _type: 'span',
156 | marks: [],
157 | text: 'Patrons may only browse materials that have been made available for access.',
158 | },
159 | ],
160 | listItem: 'bullet',
161 | markDefs: [],
162 | style: 'normal',
163 | },
164 | {
165 | _key: '99b3e265fa02',
166 | _type: 'block',
167 | children: [
168 | {
169 | _type: 'span',
170 | marks: [],
171 | text: 'Remote access: Reference requests',
172 | },
173 | ],
174 | markDefs: [],
175 | style: 'h2',
176 | },
177 | {
178 | _key: 'ea13459d9e46',
179 | _type: 'block',
180 | children: [
181 | {
182 | _type: 'span',
183 | marks: [],
184 | text: 'For patrons who are unable to arrange for an on-campus visit to the Archives and Special Collections, reference questions may be directed to the Archivist remotely by email or phone. Generally, emails and phone calls will receive a response within 72 hours of receipt. Requests are typically filled in the order they are received.',
185 | },
186 | ],
187 | markDefs: [],
188 | style: 'normal',
189 | },
190 | {
191 | _key: '100958e35c94',
192 | _type: 'block',
193 | children: [
194 | {
195 | _type: 'span',
196 | marks: ['strong'],
197 | text: 'Use of patron information',
198 | },
199 | ],
200 | markDefs: [],
201 | style: 'h2',
202 | },
203 | {
204 | _key: '2e0dde67b7df',
205 | _type: 'block',
206 | children: [
207 | {
208 | _type: 'span',
209 | marks: [],
210 | text: 'Patrons requesting collection materials in person or remotely may be asked to provide certain information to the Archivist, such as contact information and topic(s) of research. This information is only used to track requests for statistical evaluations of collection use and will not be disclosed to outside organizations for any purpose. ACT will endeavor to protect the privacy of all patrons accessing collections.',
211 | },
212 | ],
213 | markDefs: [],
214 | style: 'normal',
215 | },
216 | {
217 | _key: '8f39a1ec6366',
218 | _type: 'block',
219 | children: [
220 | {
221 | _type: 'span',
222 | marks: ['strong'],
223 | text: 'Fees',
224 | },
225 | ],
226 | markDefs: [],
227 | style: 'h2',
228 | },
229 | {
230 | _key: '090062c9e8ce',
231 | _type: 'block',
232 | children: [
233 | {
234 | _type: 'span',
235 | marks: [],
236 | text: 'ACT reserves the right to charge an hourly rate for requests that require more than three hours of research on behalf of a patron (remote requests). Collection materials may be scanned and made available upon request, but digitization of certain materials may incur costs. Additionally, requests to publish, exhibit, or otherwise reproduce and display collection materials may incur use fees.',
237 | },
238 | ],
239 | markDefs: [],
240 | style: 'normal',
241 | },
242 | {
243 | _key: 'e2b58e246069',
244 | _type: 'block',
245 | children: [
246 | {
247 | _type: 'span',
248 | marks: ['strong'],
249 | text: 'Use of MIT-owned materials by patrons',
250 | },
251 | ],
252 | markDefs: [],
253 | style: 'h2',
254 | },
255 | {
256 | _key: '7cedb6800dc6',
257 | _type: 'block',
258 | children: [
259 | {
260 | _type: 'span',
261 | marks: [],
262 | text: 'Permission to examine collection materials in person or remotely (by receiving transfers of digitized materials) does not imply or grant permission to publish or exhibit those materials. Permission to publish, exhibit, or otherwise use collection materials is granted on a case by case basis in accordance with MIT policy, restrictions that may have been placed on materials by donors or depositors, and copyright law. To request permission to publish, exhibit, or otherwise use collection materials, contact the Archivist. ',
263 | },
264 | {
265 | _type: 'span',
266 | marks: ['strong'],
267 | text: 'When permission is granted by MIT, patrons must comply with all guidelines provided by ACT for citations, credits, and copyright statements. Exclusive rights to examine or publish material will not be granted.',
268 | },
269 | ],
270 | markDefs: [],
271 | style: 'normal',
272 | },
273 | ];
274 |
275 | export default {
276 | input,
277 | output: `In-person access: Research appointments The collection may be examined by arranging a research appointment in advance by contacting the ACT archivist by email or phone. ACT generally does not accept walk-in research patrons, although requests may be made in person at the Archivist’s office (E15-222). ACT recommends arranging appointments at least three weeks in advance in order to ensure availability. ACT reserves the right to cancel research appointments at any time. Appointment scheduling is subject to institute holidays and closings.
The collection space is located at: 20 Ames Street Building E15-235 Cambridge, Massachusetts 02139
In-person access: Space policies The Archivist or an authorized ACT staff member must attend researchers at all times. No pens, markers, or adhesives (e.g. “Post-it” notes) are permitted in the collection space; pencils will be provided upon request. Cotton gloves must be worn when handling collection materials; gloves will be provided by the Archivist. No food or beverages are permitted in the collection space. Laptop use is permitted in the collection space, as well as digital cameras and cellphones. Unless otherwise authorized, any equipment in the collection space (including but not limited to computers, telephones, scanners, and viewing equipment) is for use by ACT staff members only. Photocopying machines in the ACT hallway will be accessible by patrons under the supervision of the Archivist. Patrons may only browse materials that have been made available for access. Remote access: Reference requests For patrons who are unable to arrange for an on-campus visit to the Archives and Special Collections, reference questions may be directed to the Archivist remotely by email or phone. Generally, emails and phone calls will receive a response within 72 hours of receipt. Requests are typically filled in the order they are received.
Use of patron information Patrons requesting collection materials in person or remotely may be asked to provide certain information to the Archivist, such as contact information and topic(s) of research. This information is only used to track requests for statistical evaluations of collection use and will not be disclosed to outside organizations for any purpose. ACT will endeavor to protect the privacy of all patrons accessing collections.
Fees ACT reserves the right to charge an hourly rate for requests that require more than three hours of research on behalf of a patron (remote requests). Collection materials may be scanned and made available upon request, but digitization of certain materials may incur costs. Additionally, requests to publish, exhibit, or otherwise reproduce and display collection materials may incur use fees.
Use of MIT-owned materials by patrons Permission to examine collection materials in person or remotely (by receiving transfers of digitized materials) does not imply or grant permission to publish or exhibit those materials. Permission to publish, exhibit, or otherwise use collection materials is granted on a case by case basis in accordance with MIT policy, restrictions that may have been placed on materials by donors or depositors, and copyright law. To request permission to publish, exhibit, or otherwise use collection materials, contact the Archivist. When permission is granted by MIT, patrons must comply with all guidelines provided by ACT for citations, credits, and copyright statements. Exclusive rights to examine or publish material will not be granted.
`,
278 | };
279 |
--------------------------------------------------------------------------------
/test/portable-text.test.ts:
--------------------------------------------------------------------------------
1 | import { h } from 'vue';
2 | import { expect, test } from 'vitest';
3 | import { mount } from '@vue/test-utils';
4 | import {
5 | PortableText,
6 | type PortableTextVueComponents,
7 | type PortableTextMarkComponent,
8 | type PortableTextProps,
9 | type MissingComponentHandler,
10 | } from '../src';
11 | import * as fixtures from './fixtures';
12 |
13 | const render = (props: PortableTextProps) => mount(PortableText, { props }).html({ raw: true });
14 |
15 | test('builds empty tree on empty block', () => {
16 | const { input, output } = fixtures.emptyBlock;
17 | const result = render({ value: input });
18 | expect(result).toBe(output);
19 | });
20 |
21 | test('builds simple one-node tree on single, markless span', () => {
22 | const { input, output } = fixtures.singleSpan;
23 | const result = render({ value: input });
24 | expect(result).toBe(output);
25 | });
26 |
27 | test('builds simple multi-node tree on markless spans', () => {
28 | const { input, output } = fixtures.multipleSpans;
29 | const result = render({ value: input });
30 | expect(result).toBe(output);
31 | });
32 |
33 | test('builds annotated span on simple mark', () => {
34 | const { input, output } = fixtures.basicMarkSingleSpan;
35 | const result = render({ value: input });
36 | expect(result).toBe(output);
37 | });
38 |
39 | test('builds annotated, joined span on adjacent, equal marks', () => {
40 | const { input, output } = fixtures.basicMarkMultipleAdjacentSpans;
41 | const result = render({ value: input });
42 | expect(result).toBe(output);
43 | });
44 |
45 | test('builds annotated, nested spans in tree format', () => {
46 | const { input, output } = fixtures.basicMarkNestedMarks;
47 | const result = render({ value: input });
48 | expect(result).toBe(output);
49 | });
50 |
51 | test('builds annotated spans with expanded marks on object-style marks', () => {
52 | const { input, output } = fixtures.linkMarkDef;
53 | const result = render({ value: input });
54 | expect(result).toBe(output);
55 | });
56 |
57 | test('builds correct structure from advanced, nested mark structure', () => {
58 | const { input, output } = fixtures.messyLinkText;
59 | const result = render({ value: input });
60 | expect(result).toBe(output);
61 | });
62 |
63 | test('builds bullet lists in parent container', () => {
64 | const { input, output } = fixtures.basicBulletList;
65 | const result = render({ value: input });
66 | expect(result).toBe(output);
67 | });
68 |
69 | test('builds numbered lists in parent container', () => {
70 | const { input, output } = fixtures.basicNumberedList;
71 | const result = render({ value: input });
72 | expect(result).toBe(output);
73 | });
74 |
75 | test('builds nested lists', () => {
76 | const { input, output } = fixtures.nestedLists;
77 | const result = render({ value: input });
78 | expect(result).toBe(output);
79 | });
80 |
81 | test('builds all basic marks as expected', () => {
82 | const { input, output } = fixtures.allBasicMarks;
83 | const result = render({ value: input });
84 | expect(result).toBe(output);
85 | });
86 |
87 | test('builds weirdly complex lists without any issues', () => {
88 | const { input, output } = fixtures.deepWeirdLists;
89 | const result = render({ value: input });
90 | expect(result).toBe(output);
91 | });
92 |
93 | test('renders all default block styles', () => {
94 | const { input, output } = fixtures.allDefaultBlockStyles;
95 | const result = render({ value: input });
96 | expect(result).toBe(output);
97 | });
98 |
99 | test('sorts marks correctly on equal number of occurences', () => {
100 | const { input, output } = fixtures.marksAllTheWayDown;
101 | const marks: PortableTextVueComponents['marks'] = {
102 | highlight: ({ value }, { slots }) =>
103 | h('span', { style: { border: `${value?.thickness}px solid` } }, slots.default?.()),
104 | };
105 | const result = render({ value: input, components: { marks } });
106 | expect(result).toBe(output);
107 | });
108 |
109 | test('handles keyless blocks/spans', () => {
110 | const { input, output } = fixtures.keyless;
111 | const result = render({ value: input });
112 | expect(result).toBe(output);
113 | });
114 |
115 | test('handles empty arrays', () => {
116 | const { input, output } = fixtures.emptyArray;
117 | const result = render({ value: input });
118 | expect(result).toBe(output);
119 | });
120 |
121 | test('handles lists without level', () => {
122 | const { input, output } = fixtures.listWithoutLevel;
123 | const result = render({ value: input });
124 | expect(result).toBe(output);
125 | });
126 |
127 | test('handles inline non-span nodes', () => {
128 | const { input, output } = fixtures.inlineNodes;
129 | const result = render({
130 | value: input,
131 | components: {
132 | types: {
133 | rating: ({ value }) => {
134 | return h('span', {
135 | class: `rating type-${value.type} rating-${value.rating}`,
136 | });
137 | },
138 | },
139 | },
140 | });
141 | expect(result).toBe(output);
142 | });
143 |
144 | test('handles hardbreaks', () => {
145 | const { input, output } = fixtures.hardBreaks;
146 | const result = render({ value: input });
147 | expect(result).toBe(output);
148 | });
149 |
150 | test('can disable hardbreak component', () => {
151 | const { input, output } = fixtures.hardBreaks;
152 | const result = render({ value: input, components: { hardBreak: false } });
153 | expect(result).toBe(output.replace(/ /g, '\n'));
154 | });
155 |
156 | test('can customize hardbreak component', () => {
157 | const { input, output } = fixtures.hardBreaks;
158 | const hardBreak = () => h('br', { class: 'dat-newline' });
159 | const result = render({ value: input, components: { hardBreak } });
160 | expect(result).toBe(output.replace(/ /g, ' '));
161 | });
162 |
163 | test('can nest marks correctly in block/marks context', () => {
164 | const { input, output } = fixtures.inlineObjects;
165 | const result = render({
166 | value: input,
167 | components: {
168 | types: {
169 | localCurrency: ({ value }) => {
170 | // in the real world we'd look up the users local currency,
171 | // do some rate calculations and render the result. Obviously.
172 | const rates: Record = {
173 | USD: 8.82,
174 | DKK: 1.35,
175 | EUR: 10.04,
176 | };
177 | const rate = rates[value.sourceCurrency] || 1;
178 | return h('span', { class: 'currency' }, `~${Math.round(value.sourceAmount * rate)} NOK`);
179 | },
180 | },
181 | },
182 | });
183 |
184 | expect(result).toBe(output);
185 | });
186 |
187 | test('can render inline block with text property', () => {
188 | const { input, output } = fixtures.inlineBlockWithText;
189 | const result = render({
190 | value: input,
191 | components: {
192 | types: {
193 | button: (props) => h('button', { type: 'button' }, props.value.text),
194 | },
195 | },
196 | });
197 | expect(result).toBe(output);
198 | });
199 |
200 | test('can render styled list items', () => {
201 | const { input, output } = fixtures.styledListItems;
202 | const result = render({ value: input });
203 | expect(result).toBe(output);
204 | });
205 |
206 | test('can render custom list item styles with fallback', () => {
207 | const { input, output } = fixtures.customListItemType;
208 | const result = render({ value: input });
209 | expect(result).toBe(output);
210 | });
211 |
212 | test('can render custom list item styles with provided list style component', () => {
213 | const { input } = fixtures.customListItemType;
214 | const result = render({
215 | value: input,
216 | components: {
217 | list: {
218 | square: (_, { slots }) => h('ul', { class: 'list-squared' }, slots.default?.()),
219 | },
220 | },
221 | });
222 | expect(result).toBe(
223 | '',
224 | );
225 | });
226 |
227 | test('can render custom list item styles with provided list style component', () => {
228 | const { input } = fixtures.customListItemType;
229 | const result = render({
230 | value: input,
231 | components: {
232 | listItem: {
233 | square: (_, { slots }) => h('li', { class: 'item-squared' }, slots.default?.()),
234 | },
235 | },
236 | });
237 | expect(result).toBe(
238 | '',
239 | );
240 | });
241 |
242 | test('warns on missing list style component', () => {
243 | const { input } = fixtures.customListItemType;
244 | const result = render({
245 | value: input,
246 | components: { list: {} },
247 | });
248 | expect(result).toBe(
249 | '',
250 | );
251 | });
252 |
253 | test('can render styled list items with custom list item component', () => {
254 | const { input, output } = fixtures.styledListItems;
255 | const result = render({
256 | value: input,
257 | components: {
258 | listItem: (_, { slots }) => {
259 | return h('li', slots.default?.());
260 | },
261 | },
262 | });
263 | expect(result).toBe(output);
264 | });
265 |
266 | test('can specify custom component for custom block types', () => {
267 | const { input, output } = fixtures.customBlockType;
268 | const types: Partial['types'] = {
269 | code: ({ renderNode, ...props }) => {
270 | expect(props).toStrictEqual({
271 | value: {
272 | _key: '9a15ea2ed8a2',
273 | _type: 'code',
274 | code: input[0]?.code,
275 | language: 'javascript',
276 | },
277 | index: 0,
278 | isInline: false,
279 | });
280 | return h('pre', { 'data-language': props.value.language }, h('code', props.value.code));
281 | },
282 | };
283 | const result = render({ value: input, components: { types } });
284 | expect(result).toBe(output);
285 | });
286 |
287 | test('can specify custom component for custom block types with children', () => {
288 | const { input, output } = fixtures.customBlockTypeWithChildren;
289 | const types: Partial['types'] = {
290 | quote: ({ renderNode, ...props }) => {
291 | expect(props).toStrictEqual({
292 | value: {
293 | _type: 'quote',
294 | _key: '9a15ea2ed8a2',
295 | background: 'blue',
296 | children: [
297 | {
298 | _type: 'span',
299 | _key: '9a15ea2ed8a2',
300 | text: 'This is an inspirational quote',
301 | },
302 | ],
303 | },
304 | index: 0,
305 | isInline: false,
306 | });
307 |
308 | return h(
309 | 'p',
310 | { style: { background: props.value.background } },
311 | props.value.children.map(({ text }) => `Customers say: ${text}`),
312 | );
313 | },
314 | };
315 | const result = render({ value: input, components: { types } });
316 | expect(result).toBe(output);
317 | });
318 |
319 | test('can specify custom components for custom marks', () => {
320 | const { input, output } = fixtures.customMarks;
321 | const highlight: PortableTextMarkComponent<{
322 | _type: 'highlight';
323 | thickness: number;
324 | }> = ({ value }, { slots }) =>
325 | h('span', { style: { border: `${value?.thickness}px solid` } }, slots.default?.());
326 |
327 | const result = render({ value: input, components: { marks: { highlight } } });
328 | expect(result).toBe(output);
329 | });
330 |
331 | test('can specify custom components for defaults marks', () => {
332 | const { input, output } = fixtures.overrideDefaultMarks;
333 | const link: PortableTextMarkComponent<{ _type: 'link'; href: string }> = ({ value }, { slots }) =>
334 | h('a', { class: 'mahlink', href: value?.href }, slots.default?.());
335 |
336 | const result = render({ value: input, components: { marks: { link } } });
337 | expect(result).toBe(output);
338 | });
339 |
340 | test('falls back to default component for missing mark components', () => {
341 | const { input, output } = fixtures.missingMarkComponent;
342 | const result = render({ value: input });
343 | expect(result).toBe(output);
344 | });
345 |
346 | test('can register custom `missing component` handler', () => {
347 | let warning = '';
348 | const onMissingComponent: MissingComponentHandler = (message) => {
349 | warning = message;
350 | };
351 |
352 | const { input } = fixtures.missingMarkComponent;
353 | render({ value: input, onMissingComponent });
354 | expect(warning).toBe(
355 | '[@portabletext/vue] Unknown mark type "abc", specify a component for it in the `components.marks` prop',
356 | );
357 | });
358 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @portabletext/vue
2 |
3 | [](https://www.npmjs.com/package/@portabletext/vue)[](https://bundlephobia.com/result?p=@portabletext/vue)
4 |
5 | Render [Portable Text](https://portabletext.org/) with Vue.
6 |
7 | Migrating from [sanity-blocks-vue-component](https://www.npmjs.com/package/rdunk/sanity-blocks-vue-component)? Refer to the [migration docs](https://github.com/portabletext/vue-portabletext/blob/main/MIGRATING.md).
8 |
9 | Note that for terseness, [render functions](https://vuejs.org/guide/extras/render-function.html) are used for many of examples below for simple elements, but single file components or JSX can also be used just as easily.
10 |
11 | ## Table of contents
12 |
13 | - [Installation](#installation)
14 | - [Basic usage](#basic-usage)
15 | - [Styling](#styling-the-output)
16 | - [Customizing components](#customizing-components)
17 | - [Single file components](#single-file-components)
18 | - [Defining props with TypeScript](#defining-props-with-typescript)
19 | - [Available components](#available-components)
20 | - [types](#types)
21 | - [marks](#marks)
22 | - [block](#block)
23 | - [list](#list)
24 | - [listItem](#listItem)
25 | - [hardBreak](#hardBreak)
26 | - [unknown components](#unknownMark)
27 | - [Disabling warnings / handling unknown types](#disabling-warnings--handling-unknown-types)
28 | - [Rendering Plain Text](#rendering-plain-text)
29 | - [Typing Portable Text](#typing-portable-text)
30 |
31 | ## Installation
32 |
33 | ```
34 | npm install --save @portabletext/vue
35 | ```
36 |
37 | ## Basic usage
38 |
39 | ```vue
40 |
43 |
44 |
45 |
53 |
54 | ```
55 |
56 | ## Styling the output
57 |
58 | The rendered HTML does not have any styling applied, so you will either render a parent container with a class name you can target in your CSS, or pass [custom components](#customizing-components) if you want to control the direct markup and CSS of each element.
59 |
60 | ## Customizing components
61 |
62 | Default components are provided for all standard features of the Portable Text spec, with logical HTML defaults. You can pass an object of components to use, both to override the defaults and to provide components for your custom content types.
63 |
64 | Provided components will be merged with the defaults. In other words, you only need to provide the things you want to override.
65 |
66 | ```vue
67 |
87 |
88 |
89 |
90 |
91 | ```
92 |
93 | ## Single file components
94 |
95 | Single file components can be used as custom renderers, and will receive the props listed below. In most components, the `value` prop will be the most frequently accessed. It is recommended that you define these props in your component, or you may want to [disable attribute inheritance](https://vuejs.org/guide/components/attrs.html#disabling-attribute-inheritance) if they are unused.
96 |
97 | | Prop | Description |
98 | | ---------- | ------------------------------------------------------ |
99 | | value | Data associated with the node, e.g. the raw JSON value |
100 | | index | Index with respect to the block’s parent |
101 | | isInline | Whether or not the node is “inline” |
102 | | renderNode | Internal rendering function, rarely needed |
103 |
104 | ### Defining props with TypeScript
105 |
106 | When using TypeScript, you can use `defineProps` with the `PortableTextComponentProps` type to prevent boilerplate prop definitions. Pass the structure of the `value` prop as a generic type parameter.
107 |
108 | Example of a single file component to render some custom buttons with an index and button text from the following PortableText:
109 |
110 | ```json
111 | [
112 | {
113 | "_type": "button",
114 | "_key": "abc",
115 | "text": "My Button"
116 | },
117 | {
118 | "_type": "button",
119 | "_key": "def",
120 | "text": "Your Button"
121 | }
122 | ]
123 | ```
124 |
125 | ```vue
126 |
135 |
136 |
137 | {{ index + 1 }}: {{ value.text }}
138 |
139 | ```
140 |
141 | ## Available components
142 |
143 | These are the overridable/implementable keys:
144 |
145 | ### `types`
146 |
147 | An object of Vue components that renders different types of objects that might appear both as part of the input array, or as inline objects within text blocks - eg alongside text spans.
148 |
149 | Use the `isInline` property to check whether or not this is an inline object or a block.
150 |
151 | The object has the shape `{typeName: VueComponent}`, where `typeName` is the value set in individual `_type` attributes.
152 |
153 | Example of rendering a custom `image` object:
154 |
155 | ```vue
156 |
190 |
191 |
192 |
193 |
194 | ```
195 |
196 | ### `marks`
197 |
198 | Object of Vue components that renders different types of marks that might appear in spans. Marks can be either be simple "decorators" (eg emphasis, underline, italic) or full "annotations" which include associated data (eg links, references, descriptions).
199 |
200 | If the mark is a decorator, the component will receive a `markType` prop which has the name of the decorator (eg `em`). If the mark is an annotation, it will receive both a `markType` with the associated `_type` property (eg `link`), and a `value` property with an object holding the data for this mark.
201 |
202 | The component also receives any children in the default slot that should (usually) be returned in whatever parent container component makes sense for this mark (eg ``, ``).
203 |
204 | ```ts
205 | // `components` object you'll pass to PortableText w/ optional TS definition
206 | import { PortableTextComponents } from '@portabletext/vue';
207 |
208 | const components: PortableTextComponents = {
209 | marks: {
210 | // Ex. 1: custom renderer for the em / italics decorator
211 | em: (_, { slots }) => h('em', { class: 'text-gray-600 font-semibold' }, slots.default?.()),
212 |
213 | // Ex. 2: rendering a custom `link` annotation
214 | link: ({ value }, { slots }) => {
215 | const target = (value?.href || '').startsWith('http') ? '_blank' : undefined;
216 | return;
217 | h(
218 | 'a',
219 | { href: value?.href, target, rel: target === '_blank' && 'noindex nofollow' },
220 | slots.default?.(),
221 | );
222 | },
223 | },
224 | };
225 | ```
226 |
227 | ### `block`
228 |
229 | An object of Vue components that renders portable text blocks with different `style` properties. The object has the shape `{styleName: VueComponent}`, where `styleName` is the value set in individual `style` attributes on blocks (`normal` being the default).
230 |
231 | ```ts
232 | // `components` object you'll pass to PortableText
233 | const components = {
234 | block: {
235 | // Ex. 1: customizing common block types
236 | h1: (_, { slots }) => h('h1', { class: 'text-2xl' }, slots.default?.()),
237 | blockquote: (_, { slots }) =>
238 | h('blockquote', { class: 'border-l-purple-500' }, slots.default?.()),
239 |
240 | // Ex. 2: rendering custom styles
241 | customHeading: (_, { slots }) =>
242 | h('h2', { class: 'text-lg text-primary text-purple-700' }, slots.default?.()),
243 | },
244 | };
245 | ```
246 |
247 | The `block` object can also be set to a single Vue component, which would handle block styles of _any_ type.
248 |
249 | ### `list`
250 |
251 | Object of Vue components used to render lists of different types (`bullet` vs `number`, for instance, which by default is `` and ``, respectively).
252 |
253 | Note that there is no actual "list" node type in the Portable Text specification, but a series of list item blocks with the same `level` and `listItem` properties will be grouped into a virtual one inside of this library.
254 |
255 | ```ts
256 | const components = {
257 | list: {
258 | // Ex. 1: customizing common list types
259 | bullet: (_, { slots }) => h('ul', { class: 'mt-xl' }, slots.default?.()),
260 | number: (_, { slots }) => h('ol', { class: 'mt-lg' }, slots.default?.()),
261 |
262 | // Ex. 2: rendering custom lists
263 | checkmarks: (_, { slots }) => h('ol', { class: 'm-auto text-lg' }, slots.default?.()),
264 | },
265 | };
266 | ```
267 |
268 | The `list` property can also be set to a single Vue component, which would handle lists of _any_ type.
269 |
270 | ### `listItem`
271 |
272 | Object of Vue components used to render different list item styles. The object has the shape `{listItemType: VueComponent}`, where `listItemType` is the value set in individual `listItem` attributes on blocks.
273 |
274 | ```ts
275 | const components = {
276 | listItem: {
277 | // Ex. 1: customizing common list types
278 | bullet: (_, { slots }) =>
279 | h('li', { style: { listStyleType: 'disclosure-closed' } }, slots.default?.()),
280 |
281 | // Ex. 2: rendering custom list items
282 | checkmarks: (_, { slots }) => h('li', ['✅', slots.default?.()]),
283 | },
284 | };
285 | ```
286 |
287 | The `listItem` property can also be set to a single Vue component, which would handle list items of _any_ type.
288 |
289 | ### `hardBreak`
290 |
291 | Component to use for rendering "hard breaks", eg `\n` inside of text spans.
292 |
293 | Will by default render a ` `. Pass `false` to render as-is (`\n`)
294 |
295 | ### `unknownMark`
296 |
297 | Vue component used when encountering a mark type there is no registered component for in the `components.marks` prop.
298 |
299 | ### `unknownType`
300 |
301 | Vue component used when encountering an object type there is no registered component for in the `components.types` prop.
302 |
303 | ### `unknownBlockStyle`
304 |
305 | Vue component used when encountering a block style there is no registered component for in the `components.block` prop. Only used if `components.block` is an object.
306 |
307 | ### `unknownList`
308 |
309 | Vue component used when encountering a list style there is no registered component for in the `components.list` prop. Only used if `components.list` is an object.
310 |
311 | ### `unknownListItem`
312 |
313 | Vue component used when encountering a list item style there is no registered component for in the `components.listItem` prop. Only used if `components.listItem` is an object.
314 |
315 | ## Disabling warnings / handling unknown types
316 |
317 | When the library encounters a block, mark, list or list item with a type that is not known (eg it has no corresponding component in the `components` property), it will by default print a console warning.
318 |
319 | To disable this behavior, you can either pass `false` to the `onMissingComponent` property, or give it a custom function you want to use to report the error. For instance:
320 |
321 | ```vue
322 |
334 |
335 |
336 |
342 |
343 |
349 |
350 | ```
351 |
352 | ## Rendering Plain Text
353 |
354 | This module also exports a function (`toPlainText()`) that will render one or more Portable Text blocks as plain text. This is helpful in cases where formatted text is not supported, or you need to process the raw text value.
355 |
356 | For instance, to render a meta description for a page:
357 |
358 | ```ts
359 | import { toPlainText } from '@portabletext/vue';
360 |
361 | // Imported from @unhead/vue
362 | useHead({
363 | meta: [{ name: 'description', value: toPlainText(myPortableTextData) }],
364 | });
365 | ```
366 |
367 | Or to generate element IDs for headers, in order for them to be linkable:
368 |
369 | ```ts
370 | import { PortableText, toPlainText, PortableTextComponents } from '@portabletext/vue';
371 | import slugify from 'slugify';
372 |
373 | const LinkableHeader = ({ value }, { slots }) => {
374 | // `value` is the single Portable Text block of this header
375 | const slug = slugify(toPlainText(value));
376 | return h('h2', { id: slug }, slots.default?.());
377 | };
378 |
379 | const components: PortableTextComponents = {
380 | block: {
381 | h2: LinkableHeader,
382 | },
383 | };
384 | ```
385 |
386 | ## Typing Portable Text
387 |
388 | Portable Text data can be typed using the `@portabletext/types` package.
389 |
390 | ### Basic usage
391 |
392 | Use `PortableTextBlock` without generics for loosely typed defaults.
393 |
394 | ```ts
395 | import { PortableTextBlock } from '@portabletext/types';
396 |
397 | interface MySanityDocument {
398 | portableTextField: (PortableTextBlock | SomeBlockType)[];
399 | }
400 | ```
401 |
402 | ### Narrow types, marks, inline-blocks and lists
403 |
404 | `PortableTextBlock` supports generics, and has the following signature:
405 |
406 | ```ts
407 | interface PortableTextBlock<
408 | M extends PortableTextMarkDefinition = PortableTextMarkDefinition,
409 | C extends TypedObject = ArbitraryTypedObject | PortableTextSpan,
410 | S extends string = PortableTextBlockStyle,
411 | L extends string = PortableTextListItemType,
412 | > {}
413 | ```
414 |
415 | Create your own, narrowed Portable text type:
416 |
417 | ```ts
418 | import {
419 | PortableTextBlock,
420 | PortableTextMarkDefinition,
421 | PortableTextSpan,
422 | } from '@portabletext/types';
423 |
424 | // MARKS
425 | interface FirstMark extends PortableTextMarkDefinition {
426 | _type: 'firstMark';
427 | // ...other fields
428 | }
429 |
430 | interface SecondMark extends PortableTextMarkDefinition {
431 | _type: 'secondMark';
432 | // ...other fields
433 | }
434 |
435 | type CustomMarks = FirstMark | SecondMark;
436 |
437 | // INLINE BLOCKS
438 |
439 | interface MyInlineBlock {
440 | _type: 'myInlineBlock';
441 | // ...other fields
442 | }
443 |
444 | type InlineBlocks = PortableTextSpan | MyInlineBlock;
445 |
446 | // STYLES
447 |
448 | type TextStyles = 'normal' | 'h1' | 'myCustomStyle';
449 |
450 | // LISTS
451 |
452 | type ListStyles = 'bullet' | 'myCustomList';
453 |
454 | // CUSTOM PORTABLE TEXT BLOCK
455 |
456 | // Putting it all together by specifying generics
457 | // all of these are valid:
458 | // type CustomPortableTextBlock = PortableTextBlock
459 | // type CustomPortableTextBlock = PortableTextBlock
460 | // type CustomPortableTextBlock = PortableTextBlock
461 | type CustomPortableTextBlock = PortableTextBlock;
462 |
463 | // Other BLOCKS that can appear inbetween text
464 |
465 | interface MyCustomBlock {
466 | _type: 'myCustomBlock';
467 | // ...other fields
468 | }
469 |
470 | // TYPE FOR PORTABLE TEXT FIELD ITEMS
471 | type PortableTextFieldType = CustomPortableTextBlock | MyCustomBlock;
472 |
473 | // Using it in your document type
474 | interface MyDocumentType {
475 | portableTextField: PortableTextFieldType[];
476 | }
477 | ```
478 |
479 | ## Credits
480 |
481 | This repository is adapted from [@portabletext/react](https://github.com/portabletext/react-portabletext) which provided the vast majority of node rendering logic.
482 |
483 | ## License
484 |
485 | MIT
486 |
--------------------------------------------------------------------------------
/test/fixtures/060-list-issue.ts:
--------------------------------------------------------------------------------
1 | import type { PortableTextBlock } from '@portabletext/types';
2 |
3 | const input: PortableTextBlock[] = [
4 | {
5 | _key: '68e32a42bc86',
6 | _type: 'block',
7 | children: [
8 | {
9 | _type: 'span',
10 | marks: [],
11 | text: 'Lorem ipsum',
12 | },
13 | ],
14 | markDefs: [],
15 | style: 'h2',
16 | },
17 | {
18 | _key: 'e5a6349a2145',
19 | _type: 'block',
20 | children: [
21 | {
22 | _type: 'span',
23 | marks: [],
24 | text: 'Lorem ipsum',
25 | },
26 | ],
27 | markDefs: [],
28 | style: 'normal',
29 | },
30 | {
31 | _key: '22659f66b40b',
32 | _type: 'block',
33 | children: [
34 | {
35 | _type: 'span',
36 | marks: [],
37 | text: 'Lorem ipsum',
38 | },
39 | ],
40 | level: 1,
41 | listItem: 'number',
42 | markDefs: [],
43 | style: 'normal',
44 | },
45 | {
46 | _key: '87b8d684fb9e',
47 | _type: 'block',
48 | children: [
49 | {
50 | _type: 'span',
51 | marks: [],
52 | text: 'Lorem ipsum',
53 | },
54 | ],
55 | level: 1,
56 | listItem: 'number',
57 | markDefs: [],
58 | style: 'normal',
59 | },
60 | {
61 | _key: 'a14d35e806c5',
62 | _type: 'block',
63 | children: [
64 | {
65 | _type: 'span',
66 | marks: [],
67 | text: 'Lorem ipsum',
68 | },
69 | ],
70 | level: 1,
71 | listItem: 'number',
72 | markDefs: [],
73 | style: 'normal',
74 | },
75 | {
76 | _key: '4bc360f7123a',
77 | _type: 'block',
78 | children: [
79 | {
80 | _type: 'span',
81 | marks: [],
82 | text: 'Lorem ipsum',
83 | },
84 | ],
85 | level: 1,
86 | listItem: 'number',
87 | markDefs: [],
88 | style: 'normal',
89 | },
90 | {
91 | _key: '22f50c9e40a6',
92 | _type: 'block',
93 | children: [
94 | {
95 | _type: 'span',
96 | marks: [],
97 | text: 'Lorem ipsum',
98 | },
99 | ],
100 | level: 1,
101 | listItem: 'number',
102 | markDefs: [],
103 | style: 'normal',
104 | },
105 | {
106 | _key: '664cca534e5d',
107 | _type: 'block',
108 | children: [
109 | {
110 | _type: 'span',
111 | marks: [],
112 | text: 'Lorem ipsum',
113 | },
114 | ],
115 | level: 1,
116 | listItem: 'number',
117 | markDefs: [],
118 | style: 'normal',
119 | },
120 | {
121 | _key: '1e9b2d0b4ef6',
122 | _type: 'block',
123 | children: [
124 | {
125 | _type: 'span',
126 | marks: [],
127 | text: 'Lorem ipsum',
128 | },
129 | ],
130 | level: 1,
131 | listItem: 'number',
132 | markDefs: [],
133 | style: 'normal',
134 | },
135 | {
136 | _key: '24ede750fde5',
137 | _type: 'block',
138 | children: [
139 | {
140 | _type: 'span',
141 | marks: [],
142 | text: 'Lorem ipsum',
143 | },
144 | ],
145 | level: 1,
146 | listItem: 'number',
147 | markDefs: [],
148 | style: 'normal',
149 | },
150 | {
151 | _key: '89eeaeac72c5',
152 | _type: 'block',
153 | children: [
154 | {
155 | _type: 'span',
156 | marks: [],
157 | text: 'Lorem ipsum',
158 | },
159 | ],
160 | level: 1,
161 | listItem: 'number',
162 | markDefs: [],
163 | style: 'normal',
164 | },
165 | {
166 | _key: '993fb23a4fbb',
167 | _type: 'block',
168 | children: [
169 | {
170 | _type: 'span',
171 | marks: [],
172 | text: 'Lorem ipsum',
173 | },
174 | ],
175 | level: 1,
176 | listItem: 'number',
177 | markDefs: [],
178 | style: 'normal',
179 | },
180 | {
181 | _key: '09b00b82c010',
182 | _type: 'block',
183 | children: [
184 | {
185 | _type: 'span',
186 | marks: [],
187 | text: 'Lorem ipsum',
188 | },
189 | ],
190 | markDefs: [],
191 | style: 'h2',
192 | },
193 | {
194 | _key: 'e1ec0b8bccbe',
195 | _type: 'block',
196 | children: [
197 | {
198 | _type: 'span',
199 | marks: [],
200 | text: 'Lorem ipsum',
201 | },
202 | ],
203 | markDefs: [],
204 | style: 'normal',
205 | },
206 | {
207 | _key: 'ff11fb1a52ad',
208 | _type: 'block',
209 | children: [
210 | {
211 | _type: 'span',
212 | marks: [],
213 | text: 'Lorem ipsum',
214 | },
215 | ],
216 | markDefs: [],
217 | style: 'normal',
218 | },
219 | {
220 | _key: '034604cee2d9',
221 | _type: 'block',
222 | children: [
223 | {
224 | _type: 'span',
225 | marks: [],
226 | text: 'Lorem ipsum',
227 | },
228 | ],
229 | markDefs: [],
230 | style: 'normal',
231 | },
232 | {
233 | _key: '836431a777a8',
234 | _type: 'block',
235 | children: [
236 | {
237 | _type: 'span',
238 | marks: [],
239 | text: 'Lorem ipsum',
240 | },
241 | ],
242 | markDefs: [],
243 | style: 'h2',
244 | },
245 | {
246 | _key: 'a2c1052ca675',
247 | _type: 'block',
248 | children: [
249 | {
250 | _type: 'span',
251 | marks: [],
252 | text: 'Lorem ipsum',
253 | },
254 | {
255 | _type: 'span',
256 | marks: ['abaab54abef7'],
257 | text: 'Lorem ipsum',
258 | },
259 | {
260 | _type: 'span',
261 | marks: [],
262 | text: 'Lorem ipsum',
263 | },
264 | {
265 | _type: 'span',
266 | marks: ['36e7c773d148'],
267 | text: 'Lorem ipsum',
268 | },
269 | {
270 | _type: 'span',
271 | marks: [],
272 | text: 'Lorem ipsum',
273 | },
274 | {
275 | _type: 'span',
276 | marks: ['4352c44c3077'],
277 | text: 'Lorem ipsum',
278 | },
279 | {
280 | _type: 'span',
281 | marks: [],
282 | text: 'Lorem ipsum',
283 | },
284 | ],
285 | markDefs: [
286 | {
287 | _key: 'abaab54abef7',
288 | _type: 'link',
289 | href: 'https://example.com',
290 | },
291 | {
292 | _key: '36e7c773d148',
293 | _type: 'link',
294 | href: 'https://example.com',
295 | },
296 | {
297 | _key: '4352c44c3077',
298 | _type: 'link',
299 | href: 'https://example.com',
300 | },
301 | ],
302 | style: 'normal',
303 | },
304 | {
305 | _key: '008e004a87e3',
306 | _type: 'block',
307 | children: [
308 | {
309 | _type: 'span',
310 | marks: [],
311 | text: 'Lorem ipsum',
312 | },
313 | ],
314 | markDefs: [],
315 | style: 'h3',
316 | },
317 | {
318 | _key: '383dddd69bef',
319 | _type: 'block',
320 | children: [
321 | {
322 | _type: 'span',
323 | marks: [],
324 | text: 'Lorem ipsum',
325 | },
326 | ],
327 | level: 1,
328 | listItem: 'bullet',
329 | markDefs: [],
330 | style: 'normal',
331 | },
332 | {
333 | _key: 'ea36cba89a66',
334 | _type: 'block',
335 | children: [
336 | {
337 | _type: 'span',
338 | marks: [],
339 | text: 'Lorem ipsum',
340 | },
341 | ],
342 | level: 1,
343 | listItem: 'bullet',
344 | markDefs: [],
345 | style: 'normal',
346 | },
347 | {
348 | _key: '57f05ea5c2bb',
349 | _type: 'block',
350 | children: [
351 | {
352 | _type: 'span',
353 | marks: [],
354 | text: 'Lorem ipsum',
355 | },
356 | ],
357 | level: 1,
358 | listItem: 'bullet',
359 | markDefs: [],
360 | style: 'normal',
361 | },
362 | {
363 | _key: 'd5df37eee363',
364 | _type: 'block',
365 | children: [
366 | {
367 | _type: 'span',
368 | marks: [],
369 | text: 'Lorem ipsum',
370 | },
371 | ],
372 | markDefs: [],
373 | style: 'h3',
374 | },
375 | {
376 | _key: '61231e9bb2f4',
377 | _type: 'block',
378 | children: [
379 | {
380 | _type: 'span',
381 | marks: [],
382 | text: 'Lorem ipsum',
383 | },
384 | ],
385 | level: 1,
386 | listItem: 'bullet',
387 | markDefs: [],
388 | style: 'normal',
389 | },
390 | {
391 | _key: 'e1091120de4d',
392 | _type: 'block',
393 | children: [
394 | {
395 | _type: 'span',
396 | marks: [],
397 | text: 'Lorem ipsum',
398 | },
399 | ],
400 | level: 1,
401 | listItem: 'bullet',
402 | markDefs: [],
403 | style: 'normal',
404 | },
405 | {
406 | _key: 'be53f0b95e8b',
407 | _type: 'block',
408 | children: [
409 | {
410 | _type: 'span',
411 | marks: [],
412 | text: 'Lorem ipsum',
413 | },
414 | ],
415 | level: 1,
416 | listItem: 'bullet',
417 | markDefs: [],
418 | style: 'normal',
419 | },
420 | {
421 | _key: 'e6538941fddf',
422 | _type: 'block',
423 | children: [
424 | {
425 | _type: 'span',
426 | marks: [],
427 | text: 'Lorem ipsum',
428 | },
429 | ],
430 | level: 1,
431 | listItem: 'bullet',
432 | markDefs: [],
433 | style: 'normal',
434 | },
435 | {
436 | _key: 'a852b3d1518a',
437 | _type: 'block',
438 | children: [
439 | {
440 | _type: 'span',
441 | marks: [],
442 | text: 'Lorem ipsum',
443 | },
444 | ],
445 | level: 1,
446 | listItem: 'bullet',
447 | markDefs: [],
448 | style: 'normal',
449 | },
450 | {
451 | _key: 'd77890703306',
452 | _type: 'block',
453 | children: [
454 | {
455 | _type: 'span',
456 | marks: [],
457 | text: 'Lorem ipsum',
458 | },
459 | ],
460 | level: 1,
461 | listItem: 'bullet',
462 | markDefs: [],
463 | style: 'normal',
464 | },
465 | {
466 | _key: 'd061261ee1d2',
467 | _type: 'block',
468 | children: [
469 | {
470 | _type: 'span',
471 | marks: [],
472 | text: 'Lorem ipsum',
473 | },
474 | ],
475 | markDefs: [],
476 | style: 'h3',
477 | },
478 | {
479 | _key: '248cc45717e0',
480 | _type: 'block',
481 | children: [
482 | {
483 | _type: 'span',
484 | marks: [],
485 | text: 'Lorem ipsum',
486 | },
487 | ],
488 | level: 1,
489 | listItem: 'bullet',
490 | markDefs: [],
491 | style: 'normal',
492 | },
493 | {
494 | _key: '09f2ab44df66',
495 | _type: 'block',
496 | children: [
497 | {
498 | _type: 'span',
499 | marks: [],
500 | text: 'Lorem ipsum',
501 | },
502 | ],
503 | level: 1,
504 | listItem: 'bullet',
505 | markDefs: [],
506 | style: 'normal',
507 | },
508 | {
509 | _key: 'ba7b45509071',
510 | _type: 'block',
511 | children: [
512 | {
513 | _type: 'span',
514 | marks: [],
515 | text: 'Lorem ipsum',
516 | },
517 | {
518 | _type: 'span',
519 | marks: ['em'],
520 | text: 'Lorem ipsum',
521 | },
522 | {
523 | _type: 'span',
524 | marks: [],
525 | text: 'Lorem ipsum',
526 | },
527 | ],
528 | level: 1,
529 | listItem: 'bullet',
530 | markDefs: [],
531 | style: 'normal',
532 | },
533 | {
534 | _key: '12c77502a595',
535 | _type: 'block',
536 | children: [
537 | {
538 | _type: 'span',
539 | marks: [],
540 | text: 'Lorem ipsum',
541 | },
542 | ],
543 | markDefs: [],
544 | style: 'h3',
545 | },
546 | {
547 | _key: '078039a7af96',
548 | _type: 'block',
549 | children: [
550 | {
551 | _type: 'span',
552 | marks: [],
553 | text: 'Lorem ipsum',
554 | },
555 | ],
556 | level: 1,
557 | listItem: 'bullet',
558 | markDefs: [],
559 | style: 'normal',
560 | },
561 | {
562 | _key: 'e2ea9480bfe5',
563 | _type: 'block',
564 | children: [
565 | {
566 | _type: 'span',
567 | marks: [],
568 | text: 'Lorem ipsum',
569 | },
570 | ],
571 | level: 1,
572 | listItem: 'bullet',
573 | markDefs: [],
574 | style: 'normal',
575 | },
576 | {
577 | _key: 'fdc3bfe19845',
578 | _type: 'block',
579 | children: [
580 | {
581 | _type: 'span',
582 | marks: [],
583 | text: 'Lorem ipsum',
584 | },
585 | ],
586 | level: 1,
587 | listItem: 'bullet',
588 | markDefs: [],
589 | style: 'normal',
590 | },
591 | {
592 | _key: '3201b3d02e0d',
593 | _type: 'block',
594 | children: [
595 | {
596 | _type: 'span',
597 | marks: [],
598 | text: 'Lorem ipsum',
599 | },
600 | ],
601 | level: 1,
602 | listItem: 'bullet',
603 | markDefs: [],
604 | style: 'normal',
605 | },
606 | {
607 | _key: '5ef716ee0309',
608 | _type: 'block',
609 | children: [
610 | {
611 | _type: 'span',
612 | marks: [],
613 | text: 'Lorem ipsum',
614 | },
615 | ],
616 | markDefs: [],
617 | style: 'h3',
618 | },
619 | {
620 | _key: '9a1430f39842',
621 | _type: 'block',
622 | children: [
623 | {
624 | _type: 'span',
625 | marks: [],
626 | text: 'Lorem ipsum',
627 | },
628 | ],
629 | level: 1,
630 | listItem: 'bullet',
631 | markDefs: [],
632 | style: 'normal',
633 | },
634 | {
635 | _key: '5fa8c1cd9d66',
636 | _type: 'block',
637 | children: [
638 | {
639 | _type: 'span',
640 | marks: [],
641 | text: 'Lorem ipsum',
642 | },
643 | ],
644 | level: 1,
645 | listItem: 'bullet',
646 | markDefs: [],
647 | style: 'normal',
648 | },
649 | {
650 | _key: '29240861e0c7',
651 | _type: 'block',
652 | children: [
653 | {
654 | _type: 'span',
655 | marks: [],
656 | text: 'Lorem ipsum',
657 | },
658 | ],
659 | markDefs: [],
660 | style: 'h3',
661 | },
662 | {
663 | _key: '471105eb4eb6',
664 | _type: 'block',
665 | children: [
666 | {
667 | _type: 'span',
668 | marks: [],
669 | text: 'Lorem ipsum',
670 | },
671 | ],
672 | level: 1,
673 | listItem: 'bullet',
674 | markDefs: [],
675 | style: 'normal',
676 | },
677 | {
678 | _key: '2a1754271e84',
679 | _type: 'block',
680 | children: [
681 | {
682 | _type: 'span',
683 | marks: [],
684 | text: 'Lorem ipsum',
685 | },
686 | ],
687 | level: 1,
688 | listItem: 'bullet',
689 | markDefs: [],
690 | style: 'normal',
691 | },
692 | {
693 | _key: 'c820d890f8c7',
694 | _type: 'block',
695 | children: [
696 | {
697 | _type: 'span',
698 | marks: [],
699 | text: 'Lorem ipsum',
700 | },
701 | ],
702 | markDefs: [],
703 | style: 'h3',
704 | },
705 | {
706 | _key: 'b58650f53e9e',
707 | _type: 'block',
708 | children: [
709 | {
710 | _type: 'span',
711 | marks: [],
712 | text: 'Lorem ipsum',
713 | },
714 | ],
715 | level: 1,
716 | listItem: 'bullet',
717 | markDefs: [],
718 | style: 'normal',
719 | },
720 | {
721 | _key: '0ca5f3fb129e',
722 | _type: 'block',
723 | children: [
724 | {
725 | _type: 'span',
726 | marks: [],
727 | text: 'Lorem ipsum',
728 | },
729 | ],
730 | level: 1,
731 | listItem: 'bullet',
732 | markDefs: [],
733 | style: 'normal',
734 | },
735 | {
736 | _key: 'f68449f61111',
737 | _type: 'block',
738 | children: [
739 | {
740 | _type: 'span',
741 | marks: [],
742 | text: 'Lorem ipsum',
743 | },
744 | ],
745 | level: 2,
746 | listItem: 'bullet',
747 | markDefs: [],
748 | style: 'normal',
749 | },
750 | {
751 | _key: '5433045c560a',
752 | _type: 'block',
753 | children: [
754 | {
755 | _type: 'span',
756 | marks: [],
757 | text: 'Lorem ipsum',
758 | },
759 | ],
760 | level: 2,
761 | listItem: 'bullet',
762 | markDefs: [],
763 | style: 'normal',
764 | },
765 | {
766 | _key: '3d85b3b16d79',
767 | _type: 'block',
768 | children: [
769 | {
770 | _type: 'span',
771 | marks: [],
772 | text: 'Lorem ipsum',
773 | },
774 | ],
775 | level: 2,
776 | listItem: 'bullet',
777 | markDefs: [],
778 | style: 'normal',
779 | },
780 | {
781 | _key: '03421acc9f6d',
782 | _type: 'block',
783 | children: [
784 | {
785 | _type: 'span',
786 | marks: [],
787 | text: 'Lorem ipsum',
788 | },
789 | ],
790 | level: 2,
791 | listItem: 'bullet',
792 | markDefs: [],
793 | style: 'normal',
794 | },
795 | {
796 | _key: '3a94842ddd74',
797 | _type: 'block',
798 | children: [
799 | {
800 | _type: 'span',
801 | marks: [],
802 | text: 'Lorem ipsum',
803 | },
804 | ],
805 | level: 1,
806 | listItem: 'bullet',
807 | markDefs: [],
808 | style: 'normal',
809 | },
810 | {
811 | _key: '4e3558037479',
812 | _type: 'block',
813 | children: [
814 | {
815 | _type: 'span',
816 | marks: [],
817 | text: 'Lorem ipsum',
818 | },
819 | ],
820 | level: 2,
821 | listItem: 'bullet',
822 | markDefs: [],
823 | style: 'normal',
824 | },
825 | {
826 | _key: '2cf4b5ddec6f',
827 | _type: 'block',
828 | children: [
829 | {
830 | _type: 'span',
831 | marks: [],
832 | text: 'Lorem ipsum',
833 | },
834 | ],
835 | level: 2,
836 | listItem: 'bullet',
837 | markDefs: [],
838 | style: 'normal',
839 | },
840 | {
841 | _key: '93051319ac3e',
842 | _type: 'block',
843 | children: [
844 | {
845 | _type: 'span',
846 | marks: [],
847 | text: 'Lorem ipsum',
848 | },
849 | ],
850 | level: 2,
851 | listItem: 'bullet',
852 | markDefs: [],
853 | style: 'normal',
854 | },
855 | {
856 | _key: '252749bb01d5',
857 | _type: 'block',
858 | children: [
859 | {
860 | _type: 'span',
861 | marks: [],
862 | text: 'Lorem ipsum',
863 | },
864 | ],
865 | level: 2,
866 | listItem: 'bullet',
867 | markDefs: [],
868 | style: 'normal',
869 | },
870 | {
871 | _key: 'd32eb8106d08',
872 | _type: 'block',
873 | children: [
874 | {
875 | _type: 'span',
876 | marks: [],
877 | text: 'Lorem ipsum',
878 | },
879 | ],
880 | level: 2,
881 | listItem: 'bullet',
882 | markDefs: [],
883 | style: 'normal',
884 | },
885 | {
886 | _key: 'dbdbc5839fb6',
887 | _type: 'block',
888 | children: [
889 | {
890 | _type: 'span',
891 | marks: [],
892 | text: 'Lorem ipsum',
893 | },
894 | ],
895 | level: 2,
896 | listItem: 'bullet',
897 | markDefs: [],
898 | style: 'normal',
899 | },
900 | {
901 | _key: 'f673698e2e27',
902 | _type: 'block',
903 | children: [
904 | {
905 | _type: 'span',
906 | marks: [],
907 | text: 'Lorem ipsum',
908 | },
909 | ],
910 | level: 2,
911 | listItem: 'bullet',
912 | markDefs: [],
913 | style: 'normal',
914 | },
915 | {
916 | _key: '2638df8609e7',
917 | _type: 'block',
918 | children: [
919 | {
920 | _type: 'span',
921 | marks: [],
922 | text: 'Lorem ipsum',
923 | },
924 | ],
925 | markDefs: [],
926 | style: 'h2',
927 | },
928 | {
929 | _key: '8bd25d26c0ab',
930 | _type: 'block',
931 | children: [
932 | {
933 | _type: 'span',
934 | marks: [],
935 | text: 'Lorem ipsum',
936 | },
937 | ],
938 | markDefs: [],
939 | style: 'normal',
940 | },
941 | {
942 | _key: '58fc3993c18c',
943 | _type: 'block',
944 | children: [
945 | {
946 | _type: 'span',
947 | marks: [],
948 | text: 'Lorem ipsum',
949 | },
950 | {
951 | _type: 'span',
952 | marks: ['em'],
953 | text: 'Lorem ipsum',
954 | },
955 | ],
956 | markDefs: [],
957 | style: 'normal',
958 | },
959 | {
960 | _key: '7845e645190f',
961 | _type: 'block',
962 | children: [
963 | {
964 | _type: 'span',
965 | marks: [],
966 | text: 'Lorem ipsum',
967 | },
968 | ],
969 | markDefs: [],
970 | style: 'h2',
971 | },
972 | {
973 | _key: '26e1555ec20c',
974 | _type: 'block',
975 | children: [
976 | {
977 | _type: 'span',
978 | marks: [],
979 | text: 'Lorem ipsum',
980 | },
981 | ],
982 | markDefs: [],
983 | style: 'normal',
984 | },
985 | {
986 | _key: 'e90b29141e56',
987 | _type: 'block',
988 | children: [
989 | {
990 | _type: 'span',
991 | marks: [],
992 | text: 'Lorem ipsum',
993 | },
994 | ],
995 | markDefs: [],
996 | style: 'normal',
997 | },
998 | {
999 | _key: '7f9ac906a4bd',
1000 | _type: 'block',
1001 | children: [
1002 | {
1003 | _type: 'span',
1004 | marks: [],
1005 | text: 'Lorem ipsum',
1006 | },
1007 | ],
1008 | markDefs: [],
1009 | style: 'h2',
1010 | },
1011 | {
1012 | _key: '9259af58c8be',
1013 | _type: 'block',
1014 | children: [
1015 | {
1016 | _type: 'span',
1017 | marks: [],
1018 | text: 'Lorem ipsum',
1019 | },
1020 | ],
1021 | markDefs: [],
1022 | style: 'normal',
1023 | },
1024 | {
1025 | _key: 'd3343fe575d4',
1026 | _type: 'block',
1027 | children: [
1028 | {
1029 | _type: 'span',
1030 | marks: [],
1031 | text: 'Lorem ipsum',
1032 | },
1033 | ],
1034 | level: 1,
1035 | listItem: 'number',
1036 | markDefs: [],
1037 | style: 'normal',
1038 | },
1039 | {
1040 | _key: '14c57fd646e8',
1041 | _type: 'block',
1042 | children: [
1043 | {
1044 | _type: 'span',
1045 | marks: [],
1046 | text: 'Lorem ipsum',
1047 | },
1048 | ],
1049 | level: 1,
1050 | listItem: 'number',
1051 | markDefs: [],
1052 | style: 'normal',
1053 | },
1054 | {
1055 | _key: 'c8e8905dfe9e',
1056 | _type: 'block',
1057 | children: [
1058 | {
1059 | _type: 'span',
1060 | marks: [],
1061 | text: 'Lorem ipsum',
1062 | },
1063 | ],
1064 | level: 1,
1065 | listItem: 'number',
1066 | markDefs: [],
1067 | style: 'normal',
1068 | },
1069 | {
1070 | _key: '69c4fe9fa4ed',
1071 | _type: 'block',
1072 | children: [
1073 | {
1074 | _type: 'span',
1075 | marks: [],
1076 | text: 'Lorem ipsum',
1077 | },
1078 | ],
1079 | level: 1,
1080 | listItem: 'number',
1081 | markDefs: [],
1082 | style: 'normal',
1083 | },
1084 | {
1085 | _key: 'ae19d6d44753',
1086 | _type: 'block',
1087 | children: [
1088 | {
1089 | _type: 'span',
1090 | marks: [],
1091 | text: 'Lorem ipsum',
1092 | },
1093 | ],
1094 | level: 1,
1095 | listItem: 'number',
1096 | markDefs: [],
1097 | style: 'normal',
1098 | },
1099 | {
1100 | _key: '1136f698594f',
1101 | _type: 'block',
1102 | children: [
1103 | {
1104 | _type: 'span',
1105 | marks: [],
1106 | text: 'Lorem ipsum',
1107 | },
1108 | ],
1109 | markDefs: [],
1110 | style: 'normal',
1111 | },
1112 | {
1113 | _key: 'd94cdd676b75',
1114 | _type: 'block',
1115 | children: [
1116 | {
1117 | _type: 'span',
1118 | marks: [],
1119 | text: 'Lorem ipsum',
1120 | },
1121 | ],
1122 | markDefs: [],
1123 | style: 'h2',
1124 | },
1125 | {
1126 | _key: '660d22bd8f2a',
1127 | _type: 'block',
1128 | children: [
1129 | {
1130 | _type: 'span',
1131 | marks: [],
1132 | text: 'Lorem ipsum',
1133 | },
1134 | ],
1135 | markDefs: [],
1136 | style: 'normal',
1137 | },
1138 | {
1139 | _key: '55c6814da883',
1140 | _type: 'block',
1141 | children: [
1142 | {
1143 | _type: 'span',
1144 | marks: [],
1145 | text: 'Lorem ipsum',
1146 | },
1147 | {
1148 | _type: 'span',
1149 | marks: ['96b9a7384bb9'],
1150 | text: 'Lorem ipsum',
1151 | },
1152 | {
1153 | _type: 'span',
1154 | marks: [],
1155 | text: 'Lorem ipsum',
1156 | },
1157 | ],
1158 | level: 1,
1159 | listItem: 'bullet',
1160 | markDefs: [
1161 | {
1162 | _key: '96b9a7384bb9',
1163 | _type: 'link',
1164 | href: 'https://example.com',
1165 | },
1166 | ],
1167 | style: 'normal',
1168 | },
1169 | {
1170 | _key: '2baca0a20bca',
1171 | _type: 'block',
1172 | children: [
1173 | {
1174 | _type: 'span',
1175 | marks: [],
1176 | text: 'Lorem ipsum',
1177 | },
1178 | {
1179 | _type: 'span',
1180 | marks: ['99d77e03056c'],
1181 | text: 'Lorem ipsum',
1182 | },
1183 | {
1184 | _type: 'span',
1185 | marks: [],
1186 | text: 'Lorem ipsum',
1187 | },
1188 | ],
1189 | level: 1,
1190 | listItem: 'bullet',
1191 | markDefs: [
1192 | {
1193 | _key: '99d77e03056c',
1194 | _type: 'link',
1195 | href: 'https://example.com',
1196 | },
1197 | ],
1198 | style: 'normal',
1199 | },
1200 | {
1201 | _key: '512d2c9cc40d',
1202 | _type: 'block',
1203 | children: [
1204 | {
1205 | _type: 'span',
1206 | marks: [],
1207 | text: 'Lorem ipsum',
1208 | },
1209 | {
1210 | _type: 'span',
1211 | marks: ['a81f3f515e3e'],
1212 | text: 'Lorem ipsum',
1213 | },
1214 | {
1215 | _type: 'span',
1216 | marks: [],
1217 | text: 'Lorem ipsum',
1218 | },
1219 | ],
1220 | level: 1,
1221 | listItem: 'bullet',
1222 | markDefs: [
1223 | {
1224 | _key: 'a81f3f515e3e',
1225 | _type: 'link',
1226 | href: 'https://example.com',
1227 | },
1228 | ],
1229 | style: 'normal',
1230 | },
1231 | {
1232 | _key: '5e68d8b50d64',
1233 | _type: 'block',
1234 | children: [
1235 | {
1236 | _type: 'span',
1237 | marks: [],
1238 | text: 'Lorem ipsum',
1239 | },
1240 | {
1241 | _type: 'span',
1242 | marks: ['aedfb56c1761'],
1243 | text: 'Lorem ipsum',
1244 | },
1245 | {
1246 | _type: 'span',
1247 | marks: [],
1248 | text: 'Lorem ipsum',
1249 | },
1250 | ],
1251 | level: 1,
1252 | listItem: 'bullet',
1253 | markDefs: [
1254 | {
1255 | _key: 'aedfb56c1761',
1256 | _type: 'link',
1257 | href: 'https://example.com',
1258 | },
1259 | ],
1260 | style: 'normal',
1261 | },
1262 | {
1263 | _key: '8d339b91184a',
1264 | _type: 'block',
1265 | children: [
1266 | {
1267 | _type: 'span',
1268 | marks: [],
1269 | text: 'Lorem ipsum',
1270 | },
1271 | {
1272 | _type: 'span',
1273 | marks: ['beec3b2a0459'],
1274 | text: 'Lorem ipsum',
1275 | },
1276 | {
1277 | _type: 'span',
1278 | marks: [],
1279 | text: 'Lorem ipsum',
1280 | },
1281 | ],
1282 | level: 1,
1283 | listItem: 'bullet',
1284 | markDefs: [
1285 | {
1286 | _key: 'beec3b2a0459',
1287 | _type: 'link',
1288 | href: 'https://example.com',
1289 | },
1290 | ],
1291 | style: 'normal',
1292 | },
1293 | {
1294 | _key: '09d48934ea88',
1295 | _type: 'block',
1296 | children: [
1297 | {
1298 | _type: 'span',
1299 | marks: [],
1300 | text: 'Lorem ipsum',
1301 | },
1302 | {
1303 | _type: 'span',
1304 | marks: ['30559cd94434'],
1305 | text: 'Lorem ipsum',
1306 | },
1307 | {
1308 | _type: 'span',
1309 | marks: [],
1310 | text: 'Lorem ipsum',
1311 | },
1312 | ],
1313 | level: 1,
1314 | listItem: 'bullet',
1315 | markDefs: [
1316 | {
1317 | _key: '30559cd94434',
1318 | _type: 'link',
1319 | href: 'https://example.com',
1320 | },
1321 | ],
1322 | style: 'normal',
1323 | },
1324 | {
1325 | _key: '851a44421210',
1326 | _type: 'block',
1327 | children: [
1328 | {
1329 | _type: 'span',
1330 | marks: [],
1331 | text: 'Lorem ipsum',
1332 | },
1333 | {
1334 | _type: 'span',
1335 | marks: ['cf109fae377a'],
1336 | text: 'Lorem ipsum',
1337 | },
1338 | {
1339 | _type: 'span',
1340 | marks: [],
1341 | text: 'Lorem ipsum',
1342 | },
1343 | ],
1344 | level: 1,
1345 | listItem: 'bullet',
1346 | markDefs: [
1347 | {
1348 | _key: 'cf109fae377a',
1349 | _type: 'link',
1350 | href: 'https://example.com',
1351 | },
1352 | ],
1353 | style: 'normal',
1354 | },
1355 | {
1356 | _key: 'b584b7aee2be',
1357 | _type: 'block',
1358 | children: [
1359 | {
1360 | _type: 'span',
1361 | marks: [],
1362 | text: 'Lorem ipsum',
1363 | },
1364 | ],
1365 | markDefs: [],
1366 | style: 'h2',
1367 | },
1368 | {
1369 | _key: '23e9756111da',
1370 | _type: 'block',
1371 | children: [
1372 | {
1373 | _type: 'span',
1374 | marks: [],
1375 | text: 'Lorem ipsum',
1376 | },
1377 | ],
1378 | markDefs: [],
1379 | style: 'normal',
1380 | },
1381 | ];
1382 |
1383 | export default {
1384 | input,
1385 | };
1386 |
--------------------------------------------------------------------------------