├── .czrc ├── .editorconfig ├── .eslintignore ├── .github ├── renovate.json └── workflows │ ├── format-if-needed.yml │ ├── main.yml │ └── release-please.yml ├── .gitignore ├── .husky └── commit-msg ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── MIGRATING.md ├── README.md ├── demo ├── components │ ├── AnnotatedMap.tsx │ ├── CharacterReference.tsx │ ├── Code.css │ ├── Code.tsx │ ├── CurrencyAmount.tsx │ ├── Leaflet.tsx │ ├── Link.tsx │ ├── LinkableHeader.tsx │ ├── SchnauzerList.tsx │ ├── SpeechSynthesis.tsx │ └── TermDefinition.tsx ├── demo.css ├── demo.tsx ├── external.d.ts ├── fixture.ts └── index.html ├── package.config.ts ├── package.json ├── pnpm-lock.yaml ├── src ├── components │ ├── defaults.tsx │ ├── list.tsx │ ├── marks.tsx │ ├── merge.ts │ └── unknown.tsx ├── index.ts ├── react-portable-text.tsx ├── types.ts └── warnings.ts ├── test ├── components.test.tsx ├── fixtures │ ├── 001-empty-block.ts │ ├── 002-single-span.ts │ ├── 003-multiple-spans.ts │ ├── 004-basic-mark-single-span.ts │ ├── 005-basic-mark-multiple-adjacent-spans.ts │ ├── 006-basic-mark-nested-marks.ts │ ├── 007-link-mark-def.ts │ ├── 008-plain-header-block.ts │ ├── 009-messy-link-text.ts │ ├── 010-basic-bullet-list.ts │ ├── 011-basic-numbered-list.ts │ ├── 014-nested-lists.ts │ ├── 015-all-basic-marks.ts │ ├── 016-deep-weird-lists.ts │ ├── 017-all-default-block-styles.ts │ ├── 018-marks-all-the-way-down.ts │ ├── 019-keyless.ts │ ├── 020-empty-array.ts │ ├── 021-list-without-level.ts │ ├── 022-inline-nodes.ts │ ├── 023-hard-breaks.ts │ ├── 024-inline-objects.ts │ ├── 026-inline-block-with-text.ts │ ├── 027-styled-list-items.ts │ ├── 028-custom-list-item-type.ts │ ├── 050-custom-block-type.ts │ ├── 052-custom-marks.ts │ ├── 053-override-default-marks.ts │ ├── 060-list-issue.ts │ ├── 061-missing-mark-component.ts │ ├── 062-custom-block-type-with-children.ts │ └── index.ts ├── mutations.test.tsx ├── portable-text.test.tsx └── toPlainText.test.ts ├── tsconfig.dist.json ├── tsconfig.json ├── tsconfig.settings.json └── vite.config.demo.ts /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "cz-conventional-changelog" 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; editorconfig.org 2 | root = true 3 | charset= utf8 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /coverage 3 | /demo/dist 4 | .nyc_output 5 | -------------------------------------------------------------------------------- /.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 | } 15 | -------------------------------------------------------------------------------- /.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@c5a7806660adbe173f04e3e038b0ccdcd758773c # 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 | -------------------------------------------------------------------------------- /.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 type-check 32 | - run: pnpm lint 33 | - run: pnpm build 34 | 35 | test: 36 | runs-on: ${{ matrix.platform }} 37 | name: Node.js ${{ matrix.node-version }} / ${{ matrix.platform }} 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | platform: [macos-latest, ubuntu-latest, windows-latest] 42 | node-version: [lts/*] 43 | include: 44 | - platform: ubuntu-latest 45 | node-version: current 46 | steps: 47 | - uses: actions/checkout@v4 48 | - uses: pnpm/action-setup@v4 49 | - uses: actions/setup-node@v4 50 | with: 51 | cache: pnpm 52 | node-version: ${{ matrix.node-version }} 53 | - run: pnpm install 54 | - run: pnpm test 55 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Please 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | release-please: 14 | permissions: 15 | id-token: write # to enable use of OIDC for npm provenance 16 | # permissions for pushing commits and opening PRs are handled by the `generate-token` step 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/create-github-app-token@v1 20 | id: generate-token 21 | with: 22 | app-id: ${{ secrets.ECOSCRIPT_APP_ID }} 23 | private-key: ${{ secrets.ECOSCRIPT_APP_PRIVATE_KEY }} 24 | # This action will create a release PR when regular conventional commits are pushed to main, it'll also detect if a release PR is merged and npm publish should happen 25 | - uses: google-github-actions/release-please-action@v3 26 | id: release 27 | with: 28 | release-type: node 29 | token: ${{ steps.generate-token.outputs.token }} 30 | 31 | # Publish to NPM on new releases 32 | - uses: actions/checkout@v4 33 | if: ${{ steps.release.outputs.releases_created }} 34 | - uses: pnpm/action-setup@v4 35 | if: ${{ steps.release.outputs.releases_created }} 36 | - uses: actions/setup-node@v4 37 | if: ${{ steps.release.outputs.releases_created }} 38 | with: 39 | cache: pnpm 40 | node-version: lts/* 41 | registry-url: 'https://registry.npmjs.org' 42 | - run: pnpm install 43 | if: ${{ steps.release.outputs.releases_created }} 44 | - name: Set publishing config 45 | run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}" 46 | if: ${{ steps.release.outputs.releases_created }} 47 | env: 48 | NODE_AUTH_TOKEN: ${{secrets.NPM_PUBLISH_TOKEN}} 49 | # Release Please has already incremented versions and published tags, so we just 50 | # need to publish the new version to npm here 51 | - run: pnpm publish 52 | if: ${{ steps.release.outputs.releases_created }} 53 | env: 54 | NPM_CONFIG_PROVENANCE: true 55 | # Publish to GitHub Pages 56 | - run: pnpm build:demo 57 | if: ${{ steps.release.outputs.releases_created }} 58 | - uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4 59 | if: ${{ steps.release.outputs.releases_created }} 60 | with: 61 | github_token: ${{ steps.generate-token.outputs.token }} 62 | publish_dir: ./demo/dist 63 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm exec commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | dist 3 | pnpm-lock.yaml 4 | tap-snapshots 5 | demo/dist 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 📓 Changelog 4 | 5 | All notable changes to this project will be documented in this file. See 6 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 7 | 8 | ## [3.2.1](https://github.com/portabletext/react-portabletext/compare/v3.2.0...v3.2.1) (2025-02-06) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * **deps:** update dependency @portabletext/toolkit to ^2.0.17 ([#193](https://github.com/portabletext/react-portabletext/issues/193)) ([34e8a5a](https://github.com/portabletext/react-portabletext/commit/34e8a5ad98bbb07a6c85d77e906e5eb00cb63758)) 14 | 15 | ## [3.2.0](https://github.com/portabletext/react-portabletext/compare/v3.1.0...v3.2.0) (2024-12-06) 16 | 17 | 18 | ### Features 19 | 20 | * support react 19 ([#185](https://github.com/portabletext/react-portabletext/issues/185)) ([61f7620](https://github.com/portabletext/react-portabletext/commit/61f76200f17cd1aaf28f251c036a809feee2cc9e)) 21 | 22 | 23 | ### Bug Fixes 24 | 25 | * **deps:** update dependency @portabletext/toolkit to ^2.0.16 ([#180](https://github.com/portabletext/react-portabletext/issues/180)) ([14237ac](https://github.com/portabletext/react-portabletext/commit/14237ac99334b36232cad8c43c8574b7dc4d0085)) 26 | 27 | ## [3.1.0](https://github.com/portabletext/react-portabletext/compare/v3.0.18...v3.1.0) (2024-05-28) 28 | 29 | 30 | ### Features 31 | 32 | * support react 19 ([#170](https://github.com/portabletext/react-portabletext/issues/170)) ([4d3cce4](https://github.com/portabletext/react-portabletext/commit/4d3cce4bd70269df788e83d9c410d6377c453e00)) 33 | 34 | ## [3.0.18](https://github.com/portabletext/react-portabletext/compare/v3.0.17...v3.0.18) (2024-04-11) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * **deps:** update dependency @portabletext/toolkit to ^2.0.15 ([#165](https://github.com/portabletext/react-portabletext/issues/165)) ([677ff11](https://github.com/portabletext/react-portabletext/commit/677ff11ca664b4e74b3a978e44f7502c9d4b1766)) 40 | * **deps:** update dependency @portabletext/types to ^2.0.13 ([#163](https://github.com/portabletext/react-portabletext/issues/163)) ([4d14c07](https://github.com/portabletext/react-portabletext/commit/4d14c0782f70a291c81e39bbe9a93ac582ea902b)) 41 | 42 | ## [3.0.17](https://github.com/portabletext/react-portabletext/compare/v3.0.16...v3.0.17) (2024-04-05) 43 | 44 | 45 | ### Bug Fixes 46 | 47 | * **deps:** update dependency @portabletext/toolkit to ^2.0.14 ([#157](https://github.com/portabletext/react-portabletext/issues/157)) ([6f180af](https://github.com/portabletext/react-portabletext/commit/6f180af2ea26895c9b86c9cf6706b2381fc57ef8)) 48 | 49 | ## [3.0.16](https://github.com/portabletext/react-portabletext/compare/v3.0.15...v3.0.16) (2024-04-05) 50 | 51 | 52 | ### Bug Fixes 53 | 54 | * **deps:** update dependency @portabletext/types to ^2.0.12 ([#154](https://github.com/portabletext/react-portabletext/issues/154)) ([a745844](https://github.com/portabletext/react-portabletext/commit/a7458446845e798155e6b5fd686da3199ccf32f8)) 55 | 56 | ## [3.0.15](https://github.com/portabletext/react-portabletext/compare/v3.0.14...v3.0.15) (2024-03-20) 57 | 58 | 59 | ### Bug Fixes 60 | 61 | * **deps:** update dependency @portabletext/toolkit to ^2.0.13 ([#148](https://github.com/portabletext/react-portabletext/issues/148)) ([c631eeb](https://github.com/portabletext/react-portabletext/commit/c631eebecbe230cc0c8589eda69013d0201cc0dc)) 62 | 63 | ## [3.0.14](https://github.com/portabletext/react-portabletext/compare/v3.0.13...v3.0.14) (2024-03-20) 64 | 65 | 66 | ### Bug Fixes 67 | 68 | * **deps:** update dependency @portabletext/types to ^2.0.11 ([#146](https://github.com/portabletext/react-portabletext/issues/146)) ([22e3259](https://github.com/portabletext/react-portabletext/commit/22e325951a5e73a976dd4c4c1dfd790de32473b3)) 69 | 70 | ## [3.0.13](https://github.com/portabletext/react-portabletext/compare/v3.0.12...v3.0.13) (2024-03-18) 71 | 72 | 73 | ### Bug Fixes 74 | 75 | * **deps:** update dependency @portabletext/toolkit to ^2.0.12 ([#144](https://github.com/portabletext/react-portabletext/issues/144)) ([8b5cd00](https://github.com/portabletext/react-portabletext/commit/8b5cd00305330e1589ff4c2d307b2304debfa74b)) 76 | * **deps:** update dependency @portabletext/types to ^2.0.10 ([#134](https://github.com/portabletext/react-portabletext/issues/134)) ([143b21f](https://github.com/portabletext/react-portabletext/commit/143b21f2a645ae98cdf3688e79c2366f77f92471)) 77 | 78 | ## [3.0.12](https://github.com/portabletext/react-portabletext/compare/v3.0.11...v3.0.12) (2024-03-16) 79 | 80 | 81 | ### Bug Fixes 82 | 83 | * add `PortableTextBlock` export ([2a41d9f](https://github.com/portabletext/react-portabletext/commit/2a41d9f1fe17fdeab79309384ae367a69e999657)) 84 | * **deps:** update dependency @portabletext/toolkit to ^2.0.11 ([#139](https://github.com/portabletext/react-portabletext/issues/139)) ([b430be3](https://github.com/portabletext/react-portabletext/commit/b430be352623bacc3453e154e494b4736bab2659)) 85 | * **deps:** update dependency @portabletext/types to ^2.0.9 ([df552d8](https://github.com/portabletext/react-portabletext/commit/df552d8f530df6a9337e8b78e92ccdfb4440f7d4)) 86 | 87 | ## [3.0.11](https://github.com/portabletext/react-portabletext/compare/v3.0.10...v3.0.11) (2023-10-10) 88 | 89 | 90 | ### Bug Fixes 91 | 92 | * **regression:** output valid jsx ([#113](https://github.com/portabletext/react-portabletext/issues/113)) ([f5a0285](https://github.com/portabletext/react-portabletext/commit/f5a02858ae206693a01bee8fcf0cd466743bcf2c)) 93 | 94 | ## [3.0.10](https://github.com/portabletext/react-portabletext/compare/v3.0.9...v3.0.10) (2023-10-10) 95 | 96 | 97 | ### Bug Fixes 98 | 99 | * **deps:** update dependency @portabletext/toolkit to ^2.0.10 ([#111](https://github.com/portabletext/react-portabletext/issues/111)) ([9315e31](https://github.com/portabletext/react-portabletext/commit/9315e31a95d1d93ca2251272217263cb39504e6a)) 100 | * **deps:** update dependency @portabletext/types to ^2.0.8 ([#108](https://github.com/portabletext/react-portabletext/issues/108)) ([983acc7](https://github.com/portabletext/react-portabletext/commit/983acc7df8b2045aa8d3b2c4e1da00d17a79e096)) 101 | 102 | ## [3.0.9](https://github.com/portabletext/react-portabletext/compare/v3.0.8...v3.0.9) (2023-09-28) 103 | 104 | 105 | ### Bug Fixes 106 | 107 | * **deps:** update dependency @commitlint/cli to ^17.7.2 ([#100](https://github.com/portabletext/react-portabletext/issues/100)) ([064001e](https://github.com/portabletext/react-portabletext/commit/064001e188adc59dd309523e6e1b88375635890a)) 108 | 109 | ## [3.0.8](https://github.com/portabletext/react-portabletext/compare/v3.0.7...v3.0.8) (2023-09-28) 110 | 111 | 112 | ### Bug Fixes 113 | 114 | * **deps:** update dependency @portabletext/toolkit to ^2.0.9 ([fb1bad6](https://github.com/portabletext/react-portabletext/commit/fb1bad63acc4f589962093c6a10650efc8e78ae9)) 115 | * **deps:** update dependency @portabletext/types to ^2.0.7 ([05eb011](https://github.com/portabletext/react-portabletext/commit/05eb01144bd1c69d89344cea4d2380bef45ad24c)) 116 | 117 | ## [3.0.7](https://github.com/portabletext/react-portabletext/compare/v3.0.6...v3.0.7) (2023-08-23) 118 | 119 | ### Bug Fixes 120 | 121 | - **deps:** update dependency @portabletext/toolkit to ^2.0.8 ([#84](https://github.com/portabletext/react-portabletext/issues/84)) ([850a5d8](https://github.com/portabletext/react-portabletext/commit/850a5d8228a74751ce6df3ac9cc610bb1e56cae3)) 122 | - **deps:** update dependency @portabletext/types to ^2.0.6 ([#85](https://github.com/portabletext/react-portabletext/issues/85)) ([2eef233](https://github.com/portabletext/react-portabletext/commit/2eef23348598e88c5a8584c2fd2df75fbf45dd7c)) 123 | 124 | ## [3.0.6](https://github.com/portabletext/react-portabletext/compare/v3.0.5...v3.0.6) (2023-08-23) 125 | 126 | ### Bug Fixes 127 | 128 | - add provenance ([ef756f8](https://github.com/portabletext/react-portabletext/commit/ef756f8ded3e86d17d06e780d26e047d3aa68177)) 129 | 130 | ## [3.0.5](https://github.com/portabletext/react-portabletext/compare/v3.0.4...v3.0.5) (2023-08-23) 131 | 132 | ### Bug Fixes 133 | 134 | - add `node.module` export condition ([#81](https://github.com/portabletext/react-portabletext/issues/81)) ([aab74d6](https://github.com/portabletext/react-portabletext/commit/aab74d6c790c4dc778f56b8c802e4cc1ce153fb2)) 135 | 136 | ## [3.0.4](https://github.com/portabletext/react-portabletext/compare/v3.0.3...v3.0.4) (2023-06-26) 137 | 138 | ### Bug Fixes 139 | 140 | - **deps:** update non-major ([6d724a5](https://github.com/portabletext/react-portabletext/commit/6d724a5249e936bb5c544d0654e6c2a338f1c5a3)) 141 | 142 | ## [3.0.3](https://github.com/portabletext/react-portabletext/compare/v3.0.2...v3.0.3) (2023-06-23) 143 | 144 | ### Bug Fixes 145 | 146 | - update deps ([9bddc53](https://github.com/portabletext/react-portabletext/commit/9bddc53c544d449a3944cef65a45467da152948d)) 147 | 148 | ## [3.0.2](https://github.com/portabletext/react-portabletext/compare/v3.0.1...v3.0.2) (2023-05-31) 149 | 150 | ### Bug Fixes 151 | 152 | - package should now work with Sanity Studio v2 ([bee2567](https://github.com/portabletext/react-portabletext/commit/bee25677374e05748a15439c492cbe69b6e9ee75)) 153 | 154 | ## [3.0.1](https://github.com/portabletext/react-portabletext/compare/v3.0.0...v3.0.1) (2023-05-31) 155 | 156 | ### Bug Fixes 157 | 158 | - **docs:** added Typescript typing example ([631b3dc](https://github.com/portabletext/react-portabletext/commit/631b3dc1a08cd069ecaefa99962492c471ecfbdc)) 159 | 160 | ## [3.0.0](https://github.com/portabletext/react-portabletext/compare/v2.0.3...v3.0.0) (2023-04-26) 161 | 162 | ### ⚠ BREAKING CHANGES 163 | 164 | - Components defined in `components.types` will now be used even 165 | if the data shape appears to be a portable text block. In past versions, data 166 | shapes that appeared to be portable text blocks would always be rendered using 167 | the default block renderer, with no way of overriding how they would be 168 | rendered. 169 | 170 | ### Features 171 | 172 | - allow specifying custom component for block-like types ([6407839](https://github.com/portabletext/react-portabletext/commit/6407839fd9042bec6b77d21e62833ecd5b88bcc5)) 173 | 174 | ### Bug Fixes 175 | 176 | - confirm a \_type of "block" when using the basic block renderer ([75f1ec4](https://github.com/portabletext/react-portabletext/commit/75f1ec4dcbdd6f9a5c80cfcd6872bb27c57d9770)) 177 | 178 | ## [2.0.3](https://github.com/portabletext/react-portabletext/compare/v2.0.2...v2.0.3) (2023-04-20) 179 | 180 | ### Bug Fixes 181 | 182 | - set list child index correctly ([#61](https://github.com/portabletext/react-portabletext/issues/61)) ([5552d6f](https://github.com/portabletext/react-portabletext/commit/5552d6fa9bf367957cbaa4a658cd1f005060398f)) 183 | 184 | ## [2.0.2](https://github.com/portabletext/react-portabletext/compare/v2.0.1...v2.0.2) (2023-02-27) 185 | 186 | ### Bug Fixes 187 | 188 | - **deps:** update dependencies (non-major) ([#49](https://github.com/portabletext/react-portabletext/issues/49)) ([034b49b](https://github.com/portabletext/react-portabletext/commit/034b49b31a9346a790e6c196be7342fc509a8d53)) 189 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sanity.io 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MIGRATING.md: -------------------------------------------------------------------------------- 1 | # Migrating to @portabletext/react v2 2 | 3 | `@portabletext/react@1` allowed configuring custom components through React context. In v2, this functionality has been removed in order to allow using the component with [React Server Components](https://reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html) (RSC). 4 | 5 | If you were previously using the context provider, we now suggest creating a "wrapper component" (eg predefines which React components to use), or defining your own context that holds the components. 6 | 7 | # Migrating from @sanity/block-content-to-react to @portabletext/react 8 | 9 | This document outlines the differences between [@portabletext/react](https://www.npmjs.com/package/@portabletext/react) and [@sanity/block-content-to-react](https://www.npmjs.com/package/@sanity/block-content-to-react) so you can adjust your code to use the newer @portabletext/react. 10 | 11 | The goal of the new package is to make the module more ergonomic to use, and more in line with what you'd expect from a React component. 12 | 13 | ## `BlockContent` renamed to `PortableText` 14 | 15 | PortableText is an [open-source specification](https://portabletext.org/), and as such we're giving it more prominence through the library and component renaming. 16 | 17 | Also note that the component is now a named export, not the default export as in @sanity/block-content-to-react: 18 | 19 | ```jsx 20 | // From: 21 | import BlockContent from '@sanity/block-content-to-react' 22 | 23 | 24 | // ✅ To: 25 | // Not the default export anymore 26 | import { PortableText } from '@portabletext/react' 27 | 28 | ``` 29 | 30 | ## `blocks` renamed to `value` 31 | 32 | 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`. 33 | 34 | ```jsx 35 | // From: 36 | 37 | 38 | // ✅ To: 39 | 40 | ``` 41 | 42 | ## `serializers` renamed to `components` 43 | 44 | "Serializers" are now named "Components", which should make their role as custom renderers of content more understandable for React developers. 45 | 46 | ```jsx 47 | // From: 48 | 54 | 55 | // ✅ To: 56 | 62 | ``` 63 | 64 | ## New component properties 65 | 66 | The properties for custom components (previously "serializers") have changed slightly: 67 | 68 | - Blocks: `node` has been renamed to `value` 69 | - Marks: `mark` has been renamed to `value` 70 | 71 | ## Easier customization of individual block styles 72 | 73 | Previously, if you wanted to override you'd need to override the rendering of headings, blockquotes, or other block styles, you'd need to re-define the entire block renderer (`serializers.types.block`): 74 | 75 | ```jsx 76 | // From: 77 | const BlockRenderer = (props) => { 78 | const {style = 'normal'} = props.node 79 | 80 | if (/^h\d/.test(style)) { 81 | const level = style.replace(/[^\d]/g, '') 82 | return React.createElement(style, {className: `heading-${level}`}, props.children) 83 | } 84 | 85 | if (style === 'blockquote') { 86 | return
- {props.children}
87 | } 88 | 89 | // Fall back to default handling 90 | return BlockContent.defaultSerializers.types.block(props) 91 | } 92 | 93 | ; 94 | ``` 95 | 96 | You are now able to provide different React components for different block styles - handy if you just want to override the rendering of headings, but not other styles, for instance. 97 | 98 | ```jsx 99 | // ✅ To: 100 |

{children}

, 106 | 107 | // Same applies to custom styles 108 | customHeading: ({children}) => ( 109 |

{children}

110 | ), 111 | }, 112 | }} 113 | /> 114 | ``` 115 | 116 | ## No container rendered 117 | 118 | Previously the component would render a container element around the rendered blocks, unless a single block was given as the input. This was done because at the time the module was written, React did not support returning fragments (eg multiple children without a parent container element). This component requires React >= 17, which means we can use `` when multiple blocks are present. 119 | 120 | ## Images aren't handled by default anymore 121 | 122 | 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. 123 | 124 | 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. 125 | 126 | ```jsx 127 | import urlBuilder from '@sanity/image-url' 128 | import {getImageDimensions} from '@sanity/asset-utils' 129 | 130 | // Barebones lazy-loaded image component 131 | const SampleImageComponent = ({value}) => { 132 | const {width, height} = getImageDimensions(value) 133 | return ( 134 | {value.alt 143 | ) 144 | } 145 | 146 | // You'll now need to define your own image component 147 | ; 156 | ``` 157 | 158 | ## Written in Typescript 159 | 160 | The new module is written in TypeScript - which means a better experience when you're building with TypeScript yourself, but also with editors/IDEs which provide auto-completing the available props and warnings about mistypes. 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @portabletext/react 2 | 3 | [![npm version](https://img.shields.io/npm/v/@portabletext/react.svg?style=flat-square)](https://www.npmjs.com/package/@portabletext/react)[![npm bundle size](https://img.shields.io/bundlephobia/minzip/@portabletext/react?style=flat-square)](https://bundlephobia.com/result?p=@portabletext/react)[![Build Status](https://img.shields.io/github/actions/workflow/status/portabletext/react-portabletext/main.yml?branch=main&style=flat-square)](https://github.com/portabletext/react-portabletext/actions?query=workflow%3Atest) 4 | 5 | Render [Portable Text](https://portabletext.org/) with React. 6 | 7 | Migrating from [@sanity/block-content-to-react](https://www.npmjs.com/package/@sanity/block-content-to-react)? Refer to the [migration docs](https://github.com/portabletext/react-portabletext/blob/main/MIGRATING.md). 8 | 9 | ## Table of contents 10 | 11 | - [Installation](#installation) 12 | - [Basic usage](#basic-usage) 13 | - [Styling](#styling-the-output) 14 | - [Customizing components](#customizing-components) 15 | - [Available components](#available-components) 16 | - [types](#types) 17 | - [marks](#marks) 18 | - [block](#block) 19 | - [list](#list) 20 | - [listItem](#listItem) 21 | - [hardBreak](#hardBreak) 22 | - [unknown components](#unknownMark) 23 | - [Disable warnings / Handling unknown types](#disabling-warnings--handling-unknown-types) 24 | - [Rendering Plain Text](#rendering-plain-text) 25 | - [Typing Portable Text](#typing-portable-text) 26 | 27 | ## Installation 28 | 29 | ``` 30 | npm install --save @portabletext/react 31 | ``` 32 | 33 | ## Basic usage 34 | 35 | ```js 36 | import {PortableText} from '@portabletext/react' 37 | 38 | 42 | ``` 43 | 44 | ## Styling the output 45 | 46 | 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. 47 | 48 | ## Customizing components 49 | 50 | 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. 51 | 52 | Provided components will be merged with the defaults. In other words, you only need to provide the things you want to override. 53 | 54 | **Note**: Make sure the object does not change on every render - eg do not create the object within a React component, or if you do, use `useMemo` to ensure referential identity between renders for better performance. 55 | 56 | ```js 57 | const myPortableTextComponents = { 58 | types: { 59 | image: ({value}) => , 60 | callToAction: ({value, isInline}) => 61 | isInline ? ( 62 | {value.text} 63 | ) : ( 64 |
{value.text}
65 | ), 66 | }, 67 | 68 | marks: { 69 | link: ({children, value}) => { 70 | const rel = !value.href.startsWith('/') ? 'noreferrer noopener' : undefined 71 | return ( 72 | 73 | {children} 74 | 75 | ) 76 | }, 77 | }, 78 | } 79 | 80 | const YourComponent = (props) => { 81 | return 82 | } 83 | ``` 84 | 85 | ## Available components 86 | 87 | These are the overridable/implementable keys: 88 | 89 | ### `types` 90 | 91 | An object of React 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. 92 | 93 | Use the `isInline` property to check whether or not this is an inline object or a block. 94 | 95 | The object has the shape `{typeName: ReactComponent}`, where `typeName` is the value set in individual `_type` attributes. 96 | 97 | Example of rendering a custom `image` object: 98 | 99 | ```jsx 100 | import {PortableText} from '@portabletext/react' 101 | import urlBuilder from '@sanity/image-url' 102 | import {getImageDimensions} from '@sanity/asset-utils' 103 | 104 | // Barebones lazy-loaded image component 105 | const SampleImageComponent = ({value, isInline}) => { 106 | const {width, height} = getImageDimensions(value) 107 | return ( 108 | {value.alt 125 | ) 126 | } 127 | 128 | const components = { 129 | types: { 130 | image: SampleImageComponent, 131 | // Any other custom types you have in your content 132 | // Examples: mapLocation, contactForm, code, featuredProjects, latestNews, etc. 133 | }, 134 | } 135 | 136 | const YourComponent = (props) => { 137 | return 138 | } 139 | ``` 140 | 141 | ### `marks` 142 | 143 | Object of React components that renders different types of marks that might appear in spans. Marks can either be simple "decorators" (eg emphasis, underline, italic) or full "annotations" which include associated data (eg links, references, descriptions). 144 | 145 | 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. 146 | 147 | The component also receives a `children` prop that should (usually) be returned in whatever parent container component makes sense for this mark (eg ``, ``). 148 | 149 | ```tsx 150 | // `components` object you'll pass to PortableText w/ optional TS definition 151 | import {PortableTextComponents} from '@portabletext/react' 152 | 153 | const components: PortableTextComponents = { 154 | marks: { 155 | // Ex. 1: custom renderer for the em / italics decorator 156 | em: ({children}) => {children}, 157 | 158 | // Ex. 2: rendering a custom `link` annotation 159 | link: ({value, children}) => { 160 | const target = (value?.href || '').startsWith('http') ? '_blank' : undefined 161 | return ( 162 | 163 | {children} 164 | 165 | ) 166 | }, 167 | }, 168 | } 169 | ``` 170 | 171 | ### `block` 172 | 173 | An object of React components that renders portable text blocks with different `style` properties. The object has the shape `{styleName: ReactComponent}`, where `styleName` is the value set in individual `style` attributes on blocks (`normal` being the default). 174 | 175 | ```jsx 176 | // `components` object you'll pass to PortableText 177 | const components = { 178 | block: { 179 | // Ex. 1: customizing common block types 180 | h1: ({children}) =>

{children}

, 181 | blockquote: ({children}) =>
{children}
, 182 | 183 | // Ex. 2: rendering custom styles 184 | customHeading: ({children}) => ( 185 |

{children}

186 | ), 187 | }, 188 | } 189 | ``` 190 | 191 | The `block` object can also be set to a single React component, which would handle block styles of _any_ type. 192 | 193 | ### `list` 194 | 195 | Object of React components used to render lists of different types (`bullet` vs `number`, for instance, which by default is `
    ` and `
      `, respectively). 196 | 197 | 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. 198 | 199 | ```jsx 200 | const components = { 201 | list: { 202 | // Ex. 1: customizing common list types 203 | bullet: ({children}) =>
        {children}
      , 204 | number: ({children}) =>
        {children}
      , 205 | 206 | // Ex. 2: rendering custom lists 207 | checkmarks: ({children}) =>
        {children}
      , 208 | }, 209 | } 210 | ``` 211 | 212 | The `list` property can also be set to a single React component, which would handle lists of _any_ type. 213 | 214 | ### `listItem` 215 | 216 | Object of React components used to render different list item styles. The object has the shape `{listItemType: ReactComponent}`, where `listItemType` is the value set in individual `listItem` attributes on blocks. 217 | 218 | ```jsx 219 | const components = { 220 | listItem: { 221 | // Ex. 1: customizing common list types 222 | bullet: ({children}) =>
    1. {children}
    2. , 223 | 224 | // Ex. 2: rendering custom list items 225 | checkmarks: ({children}) =>
    3. ✅ {children}
    4. , 226 | }, 227 | } 228 | ``` 229 | 230 | The `listItem` property can also be set to a single React component, which would handle list items of _any_ type. 231 | 232 | ### `hardBreak` 233 | 234 | Component to use for rendering "hard breaks", eg `\n` inside of text spans. 235 | 236 | Will by default render a `
      `. Pass `false` to render as-is (`\n`) 237 | 238 | ### `unknownMark` 239 | 240 | React component used when encountering a mark type there is no registered component for in the `components.marks` prop. 241 | 242 | ### `unknownType` 243 | 244 | React component used when encountering an object type there is no registered component for in the `components.types` prop. 245 | 246 | ### `unknownBlockStyle` 247 | 248 | React 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. 249 | 250 | ### `unknownList` 251 | 252 | React 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. 253 | 254 | ### `unknownListItem` 255 | 256 | React 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. 257 | 258 | ## Disabling warnings / handling unknown types 259 | 260 | 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. 261 | 262 | 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: 263 | 264 | ```tsx 265 | import {PortableText} from '@portabletext/react' 266 | 267 | 271 | 272 | // or, pass it a function: 273 | 274 | { 277 | myErrorLogger.report(message, { 278 | // eg `someUnknownType` 279 | type: options.type, 280 | 281 | // 'block' | 'mark' | 'blockStyle' | 'listStyle' | 'listItemStyle' 282 | nodeType: options.nodeType 283 | }) 284 | }} 285 | /> 286 | ``` 287 | 288 | ## Rendering Plain Text 289 | 290 | 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. 291 | 292 | For instance, to render an OpenGraph meta description for a page: 293 | 294 | ```tsx 295 | import {toPlainText} from '@portabletext/react' 296 | 297 | const MetaDescription = (myPortableTextData) => { 298 | return 299 | } 300 | ``` 301 | 302 | Or to generate element IDs for headers, in order for them to be linkable: 303 | 304 | ```tsx 305 | import {PortableText, toPlainText, PortableTextComponents} from '@portabletext/react' 306 | import slugify from 'slugify' 307 | 308 | const LinkableHeader = ({children, value}) => { 309 | // `value` is the single Portable Text block of this header 310 | const slug = slugify(toPlainText(value)) 311 | return

      {children}

      312 | } 313 | 314 | const components: PortableTextComponents = { 315 | block: { 316 | h2: LinkableHeader, 317 | }, 318 | } 319 | ``` 320 | 321 | ## Typing Portable Text 322 | 323 | Portable Text data can be typed using the `@portabletext/types` package. 324 | 325 | ### Basic usage 326 | 327 | Use `PortableTextBlock` without generics for loosely typed defaults. 328 | 329 | ```ts 330 | import {PortableTextBlock} from '@portabletext/types' 331 | 332 | interface MySanityDocument { 333 | portableTextField: (PortableTextBlock | SomeBlockType)[] 334 | } 335 | ``` 336 | 337 | ### Narrow types, marks, inline-blocks and lists 338 | 339 | `PortableTextBlock` supports generics, and has the following signature: 340 | 341 | ```ts 342 | interface PortableTextBlock< 343 | M extends PortableTextMarkDefinition = PortableTextMarkDefinition, 344 | C extends TypedObject = ArbitraryTypedObject | PortableTextSpan, 345 | S extends string = PortableTextBlockStyle, 346 | L extends string = PortableTextListItemType, 347 | > {} 348 | ``` 349 | 350 | Create your own, narrowed Portable text type: 351 | 352 | ```ts 353 | import {PortableTextBlock, PortableTextMarkDefinition, PortableTextSpan} from '@portabletext/types' 354 | 355 | // MARKS 356 | interface FirstMark extends PortableTextMarkDefinition { 357 | _type: 'firstMark' 358 | // ...other fields 359 | } 360 | 361 | interface SecondMark extends PortableTextMarkDefinition { 362 | _type: 'secondMark' 363 | // ...other fields 364 | } 365 | 366 | type CustomMarks = FirstMark | SecondMark 367 | 368 | // INLINE BLOCKS 369 | 370 | interface MyInlineBlock { 371 | _type: 'myInlineBlock' 372 | // ...other fields 373 | } 374 | 375 | type InlineBlocks = PortableTextSpan | MyInlineBlock 376 | 377 | // STYLES 378 | 379 | type TextStyles = 'normal' | 'h1' | 'myCustomStyle' 380 | 381 | // LISTS 382 | 383 | type ListStyles = 'bullet' | 'myCustomList' 384 | 385 | // CUSTOM PORTABLE TEXT BLOCK 386 | 387 | // Putting it all together by specifying generics 388 | // all of these are valid: 389 | // type CustomPortableTextBlock = PortableTextBlock 390 | // type CustomPortableTextBlock = PortableTextBlock 391 | // type CustomPortableTextBlock = PortableTextBlock 392 | type CustomPortableTextBlock = PortableTextBlock 393 | 394 | // Other BLOCKS that can appear inbetween text 395 | 396 | interface MyCustomBlock { 397 | _type: 'myCustomBlock' 398 | // ...other fields 399 | } 400 | 401 | // TYPE FOR PORTABLE TEXT FIELD ITEMS 402 | type PortableTextFieldType = CustomPortableTextBlock | MyCustomBlock 403 | 404 | // Using it in your document type 405 | interface MyDocumentType { 406 | portableTextField: PortableTextFieldType[] 407 | } 408 | ``` 409 | 410 | ## License 411 | 412 | MIT © [Sanity.io](https://www.sanity.io/) 413 | -------------------------------------------------------------------------------- /demo/components/AnnotatedMap.tsx: -------------------------------------------------------------------------------- 1 | import 'leaflet/dist/leaflet.css' 2 | 3 | import {useEffect, useState} from 'react' 4 | 5 | import {PortableTextTypeComponent} from '../../src' 6 | import type {ReducedLeafletApi} from './Leaflet' 7 | 8 | export interface Geopoint { 9 | _type: 'geopoint' 10 | lat: number 11 | lng: number 12 | } 13 | 14 | export interface MapMarker { 15 | _type: 'mapMarker' 16 | _key: string 17 | position: Geopoint 18 | title: string 19 | description?: string 20 | } 21 | 22 | export interface AnnotatedMapBlock { 23 | _type: 'annotatedMap' 24 | center?: Geopoint 25 | markers?: MapMarker[] 26 | } 27 | 28 | export const AnnotatedMap: PortableTextTypeComponent = ({value}) => { 29 | const [Leaflet, setLeaflet] = useState(undefined) 30 | 31 | useEffect(() => { 32 | import('./Leaflet').then((leafletApi) => setLeaflet(leafletApi.default)) 33 | }, [Leaflet, setLeaflet]) 34 | 35 | if (!Leaflet) { 36 | return ( 37 |
      38 |
      Loading map…
      39 |
      40 | ) 41 | } 42 | 43 | return ( 44 | 50 | 54 | {value.markers?.map((marker) => ( 55 | 56 | {marker.title} 57 | 58 | ))} 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /demo/components/CharacterReference.tsx: -------------------------------------------------------------------------------- 1 | import {Avatar, Box, Flex, Stack, Text, Tooltip} from '@sanity/ui' 2 | import {MouseEventHandler} from 'react' 3 | 4 | import type {PortableTextMarkComponent} from '../../src' 5 | 6 | interface CharacterDefinition { 7 | name: string 8 | image: string 9 | description: string 10 | } 11 | 12 | interface CharacterReferenceMark { 13 | _type: 'characterReference' 14 | characterId: string 15 | } 16 | 17 | // Obviously you'd want to do this async against some API in a real world scenario 18 | const characters: Record = { 19 | nedStark: { 20 | name: 'Eddark Stark', 21 | image: 22 | 'data:image/jpeg;base64,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', 23 | description: 24 | 'The head of House Stark, the Lord of Winterfell, Lord Paramount and Warden of the North.', 25 | }, 26 | } 27 | 28 | function CharacterCard({name, image, description}: CharacterDefinition) { 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | 36 | {name} 37 | {description} 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | const handleClick: MouseEventHandler = (evt) => evt.preventDefault() 45 | 46 | export const CharacterReference: PortableTextMarkComponent = ({ 47 | children, 48 | value, 49 | }) => { 50 | const id = value?.characterId || '' 51 | const data = characters[id] 52 | if (!data) { 53 | return <>{children} 54 | } 55 | 56 | return ( 57 | } open portal> 58 | 59 | {children} 60 | 61 | 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /demo/components/Code.css: -------------------------------------------------------------------------------- 1 | /** 2 | * prism.js tomorrow night eighties for JavaScript, CoffeeScript, CSS and HTML 3 | * Based on https://github.com/chriskempson/tomorrow-theme 4 | * @author Rose Pritchard 5 | */ 6 | 7 | code[class*='language-'], 8 | pre[class*='language-'] { 9 | color: #ccc; 10 | background: none; 11 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 12 | text-align: left; 13 | white-space: pre; 14 | word-spacing: normal; 15 | word-break: normal; 16 | word-wrap: normal; 17 | line-height: 1.5; 18 | 19 | -moz-tab-size: 4; 20 | -o-tab-size: 4; 21 | tab-size: 4; 22 | 23 | -webkit-hyphens: none; 24 | -moz-hyphens: none; 25 | -ms-hyphens: none; 26 | hyphens: none; 27 | } 28 | 29 | /* Code blocks */ 30 | pre[class*='language-'] { 31 | padding: 1em; 32 | margin: 0.5em 0; 33 | overflow: auto; 34 | } 35 | 36 | :not(pre) > code[class*='language-'], 37 | pre[class*='language-'] { 38 | background: #2d2d2d; 39 | } 40 | 41 | /* Inline code */ 42 | :not(pre) > code[class*='language-'] { 43 | padding: 0.1em; 44 | border-radius: 0.3em; 45 | white-space: normal; 46 | } 47 | 48 | .token.comment, 49 | .token.block-comment, 50 | .token.prolog, 51 | .token.doctype, 52 | .token.cdata { 53 | color: #999; 54 | } 55 | 56 | .token.punctuation { 57 | color: #ccc; 58 | } 59 | 60 | .token.tag, 61 | .token.attr-name, 62 | .token.namespace, 63 | .token.deleted { 64 | color: #e2777a; 65 | } 66 | 67 | .token.function-name { 68 | color: #6196cc; 69 | } 70 | 71 | .token.boolean, 72 | .token.number, 73 | .token.function { 74 | color: #f08d49; 75 | } 76 | 77 | .token.property, 78 | .token.class-name, 79 | .token.constant, 80 | .token.symbol { 81 | color: #f8c555; 82 | } 83 | 84 | .token.selector, 85 | .token.important, 86 | .token.atrule, 87 | .token.keyword, 88 | .token.builtin { 89 | color: #cc99cd; 90 | } 91 | 92 | .token.string, 93 | .token.char, 94 | .token.attr-value, 95 | .token.regex, 96 | .token.variable { 97 | color: #7ec699; 98 | } 99 | 100 | .token.operator, 101 | .token.entity, 102 | .token.url { 103 | color: #67cdcc; 104 | } 105 | 106 | .token.important, 107 | .token.bold { 108 | font-weight: bold; 109 | } 110 | .token.italic { 111 | font-style: italic; 112 | } 113 | 114 | .token.entity { 115 | cursor: help; 116 | } 117 | 118 | .token.inserted { 119 | color: green; 120 | } 121 | -------------------------------------------------------------------------------- /demo/components/Code.tsx: -------------------------------------------------------------------------------- 1 | import './Code.css' 2 | 3 | import Refractor from 'react-refractor' 4 | import typescript from 'refractor/lang/typescript' 5 | 6 | import type {PortableTextComponent} from '../../src' 7 | 8 | // Prism auto-highlights, but we only want the API, so we need to set it to manual mode 9 | if (typeof window !== 'undefined') { 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | const prismWindow = window as any 12 | prismWindow.Prism = prismWindow.Prism || {} 13 | prismWindow.Prism.manual = true 14 | } 15 | 16 | Refractor.registerLanguage(typescript) 17 | 18 | export interface CodeBlock { 19 | _type: 'code' 20 | code: string 21 | language?: string 22 | } 23 | 24 | export const Code: PortableTextComponent = ({value}) => { 25 | return 26 | } 27 | -------------------------------------------------------------------------------- /demo/components/CurrencyAmount.tsx: -------------------------------------------------------------------------------- 1 | import type {PortableTextTypeComponent} from '../../src' 2 | 3 | const style = {background: '#eee'} 4 | 5 | export interface CurrencyAmountDef { 6 | _type: 'currencyAmount' 7 | currency: string 8 | amount: number 9 | } 10 | 11 | interface CurrencySnapshotValue { 12 | flag: string 13 | currency: string 14 | rate: number 15 | } 16 | 17 | export const CurrencyAmount: PortableTextTypeComponent = ({value}) => { 18 | const hasLanguages = typeof navigator !== 'undefined' && Array.isArray(navigator.languages) 19 | const languages: readonly string[] = hasLanguages ? navigator.languages : ['es'] 20 | const normalized = languages.map((code) => code.toUpperCase()) 21 | const withCurrency = normalized.find((lang) => lang.toUpperCase() in snapshot) 22 | const currency = withCurrency ? snapshot[withCurrency] : undefined 23 | 24 | if (!currency) { 25 | return ( 26 | <> 27 | {value.amount} {value.currency} 28 | 29 | ) 30 | } 31 | 32 | return ( 33 | <> 34 | {value.amount} {value.currency}{' '} 35 | 36 | (~ {(currency.rate * value.amount).toFixed(2)} {currency.currency} {currency.flag}) 37 | 38 | 39 | ) 40 | } 41 | 42 | /** 43 | * Just a snapshot in time, don't use this for anything 44 | */ 45 | const snapshot: Record = { 46 | AD: {flag: '🇦🇩', currency: 'EUR', rate: 0.879428}, 47 | AE: {flag: '🇦🇪', currency: 'AED', rate: 3.670509}, 48 | AF: {flag: '🇦🇫', currency: 'AFN', rate: 105.457582}, 49 | AG: {flag: '🇦🇬', currency: 'XCD', rate: 2.701017}, 50 | AI: {flag: '🇦🇮', currency: 'XCD', rate: 2.701017}, 51 | AL: {flag: '🇦🇱', currency: 'ALL', rate: 107.184626}, 52 | AM: {flag: '🇦🇲', currency: 'AMD', rate: 482.308839}, 53 | AO: {flag: '🇦🇴', currency: 'AOA', rate: 534.205705}, 54 | AR: {flag: '🇦🇷', currency: 'ARS', rate: 103.440786}, 55 | AS: {flag: '🇦🇸', currency: 'EUR', rate: 0.879428}, 56 | AT: {flag: '🇦🇹', currency: 'EUR', rate: 0.879428}, 57 | AU: {flag: '🇦🇺', currency: 'AUD', rate: 1.386631}, 58 | AW: {flag: '🇦🇼', currency: 'ANG', rate: 1.801544}, 59 | AX: {flag: '🇦🇽', currency: 'EUR', rate: 0.879428}, 60 | AZ: {flag: '🇦🇿', currency: 'AZN', rate: 1.698531}, 61 | BA: {flag: '🇧🇦', currency: 'BAM', rate: 1.723603}, 62 | BB: {flag: '🇧🇧', currency: 'BBD', rate: 1.999171}, 63 | BD: {flag: '🇧🇩', currency: 'BDT', rate: 85.896826}, 64 | BE: {flag: '🇧🇪', currency: 'EUR', rate: 0.879428}, 65 | BF: {flag: '🇧🇫', currency: 'XOF', rate: 576.513956}, 66 | BG: {flag: '🇧🇬', currency: 'BGN', rate: 1.720308}, 67 | BH: {flag: '🇧🇭', currency: 'BHD', rate: 0.377398}, 68 | BI: {flag: '🇧🇮', currency: 'BIF', rate: 1998.466669}, 69 | BJ: {flag: '🇧🇯', currency: 'XOF', rate: 576.513956}, 70 | BL: {flag: '🇧🇱', currency: 'EUR', rate: 0.879428}, 71 | BM: {flag: '🇧🇲', currency: 'BMD', rate: 0.99994}, 72 | BN: {flag: '🇧🇳', currency: 'BND', rate: 1.352127}, 73 | BO: {flag: '🇧🇴', currency: 'BOB', rate: 6.87981}, 74 | BR: {flag: '🇧🇷', currency: 'BRL', rate: 5.565261}, 75 | BS: {flag: '🇧🇸', currency: 'BSD', rate: 0.999704}, 76 | BT: {flag: '🇧🇹', currency: 'INR', rate: 73.762797}, 77 | BV: {flag: '🇧🇻', currency: 'NOK', rate: 8.765649}, 78 | BW: {flag: '🇧🇼', currency: 'BWP', rate: 11.617826}, 79 | BZ: {flag: '🇧🇿', currency: 'BZD', rate: 2.014251}, 80 | CA: {flag: '🇨🇦', currency: 'CAD', rate: 1.257282}, 81 | CC: {flag: '🇨🇨', currency: 'AUD', rate: 1.386631}, 82 | CD: {flag: '🇨🇩', currency: 'CDF', rate: 2004.501987}, 83 | CF: {flag: '🇨🇫', currency: 'XAF', rate: 576.513877}, 84 | CG: {flag: '🇨🇬', currency: 'XAF', rate: 576.513877}, 85 | CH: {flag: '🇨🇭', currency: 'CHF', rate: 0.92356}, 86 | CI: {flag: '🇨🇮', currency: 'XOF', rate: 576.513956}, 87 | CK: {flag: '🇨🇰', currency: 'NZD', rate: 1.473513}, 88 | CL: {flag: '🇨🇱', currency: 'CLP', rate: 827.631981}, 89 | CM: {flag: '🇨🇲', currency: 'XAF', rate: 576.513877}, 90 | CN: {flag: '🇨🇳', currency: 'CNY', rate: 6.369804}, 91 | CO: {flag: '🇨🇴', currency: 'COP', rate: 4036.967078}, 92 | CR: {flag: '🇨🇷', currency: 'CRC', rate: 641.164635}, 93 | CU: {flag: '🇨🇺', currency: 'CUP', rate: 25.730751}, 94 | CV: {flag: '🇨🇻', currency: 'CVE', rate: 98.027048}, 95 | CX: {flag: '🇨🇽', currency: 'AUD', rate: 1.386631}, 96 | CZ: {flag: '🇨🇿', currency: 'CZK', rate: 21.456661}, 97 | DE: {flag: '🇩🇪', currency: 'EUR', rate: 0.879428}, 98 | DJ: {flag: '🇩🇯', currency: 'DJF', rate: 177.863705}, 99 | DK: {flag: '🇩🇰', currency: 'DKK', rate: 6.542304}, 100 | DM: {flag: '🇩🇲', currency: 'XCD', rate: 2.701017}, 101 | DO: {flag: '🇩🇴', currency: 'DOP', rate: 57.588082}, 102 | DZ: {flag: '🇩🇿', currency: 'DZD', rate: 139.49189}, 103 | EG: {flag: '🇪🇬', currency: 'EGP', rate: 15.698314}, 104 | EH: {flag: '🇪🇭', currency: 'MAD', rate: 9.255006}, 105 | ER: {flag: '🇪🇷', currency: 'ETB', rate: 49.579387}, 106 | ES: {flag: '🇪🇸', currency: 'EUR', rate: 0.879428}, 107 | ET: {flag: '🇪🇹', currency: 'ETB', rate: 49.579387}, 108 | FI: {flag: '🇫🇮', currency: 'EUR', rate: 0.879428}, 109 | FJ: {flag: '🇫🇯', currency: 'FJD', rate: 2.124592}, 110 | FK: {flag: '🇫🇰', currency: 'FKP', rate: 0.733525}, 111 | FO: {flag: '🇫🇴', currency: 'DKK', rate: 6.542304}, 112 | FR: {flag: '🇫🇷', currency: 'EUR', rate: 0.879428}, 113 | GA: {flag: '🇬🇦', currency: 'XAF', rate: 576.513877}, 114 | GB: {flag: '🇬🇧', currency: 'GBP', rate: 0.733308}, 115 | GD: {flag: '🇬🇩', currency: 'XCD', rate: 2.701017}, 116 | GE: {flag: '🇬🇪', currency: 'GEL', rate: 3.082704}, 117 | GF: {flag: '🇬🇫', currency: 'EUR', rate: 0.879428}, 118 | GG: {flag: '🇬🇬', currency: 'GGP', rate: 0.733218}, 119 | GH: {flag: '🇬🇭', currency: 'GHS', rate: 6.169922}, 120 | GI: {flag: '🇬🇮', currency: 'GIP', rate: 0.733314}, 121 | GL: {flag: '🇬🇱', currency: 'DKK', rate: 6.542304}, 122 | GM: {flag: '🇬🇲', currency: 'GMD', rate: 52.835519}, 123 | GN: {flag: '🇬🇳', currency: 'GNF', rate: 9096.796426}, 124 | GP: {flag: '🇬🇵', currency: 'EUR', rate: 0.879428}, 125 | GQ: {flag: '🇬🇶', currency: 'XAF', rate: 576.513877}, 126 | GR: {flag: '🇬🇷', currency: 'EUR', rate: 0.879428}, 127 | GS: {flag: '🇬🇸', currency: 'GBP', rate: 0.733308}, 128 | GT: {flag: '🇬🇹', currency: 'GTQ', rate: 7.7106}, 129 | GW: {flag: '🇬🇼', currency: 'XOF', rate: 576.513956}, 130 | GY: {flag: '🇬🇾', currency: 'GYD', rate: 209.028352}, 131 | HK: {flag: '🇭🇰', currency: 'HKD', rate: 7.790894}, 132 | HM: {flag: '🇭🇲', currency: 'AUD', rate: 1.386631}, 133 | HN: {flag: '🇭🇳', currency: 'HNL', rate: 24.45758}, 134 | HR: {flag: '🇭🇷', currency: 'HRK', rate: 6.614072}, 135 | HT: {flag: '🇭🇹', currency: 'HTG', rate: 103.236208}, 136 | HU: {flag: '🇭🇺', currency: 'HUF', rate: 313.677546}, 137 | ID: {flag: '🇮🇩', currency: 'IDR', rate: 14277.716563}, 138 | IE: {flag: '🇮🇪', currency: 'EUR', rate: 0.879428}, 139 | IL: {flag: '🇮🇱', currency: 'ILS', rate: 3.115951}, 140 | IM: {flag: '🇮🇲', currency: 'GBP', rate: 0.733308}, 141 | IN: {flag: '🇮🇳', currency: 'INR', rate: 73.762797}, 142 | IQ: {flag: '🇮🇶', currency: 'IQD', rate: 1459.909443}, 143 | IR: {flag: '🇮🇷', currency: 'IRR', rate: 42218.437072}, 144 | IS: {flag: '🇮🇸', currency: 'ISK', rate: 129.204095}, 145 | IT: {flag: '🇮🇹', currency: 'EUR', rate: 0.879428}, 146 | JE: {flag: '🇯🇪', currency: 'GBP', rate: 0.733308}, 147 | JM: {flag: '🇯🇲', currency: 'JMD', rate: 154.164956}, 148 | JO: {flag: '🇯🇴', currency: 'JOD', rate: 0.70895}, 149 | JP: {flag: '🇯🇵', currency: 'JPY', rate: 115.232254}, 150 | KE: {flag: '🇰🇪', currency: 'KES', rate: 113.265475}, 151 | KG: {flag: '🇰🇬', currency: 'KGS', rate: 84.739786}, 152 | KH: {flag: '🇰🇭', currency: 'KHR', rate: 4071.613838}, 153 | KI: {flag: '🇰🇮', currency: 'AUD', rate: 1.386631}, 154 | KM: {flag: '🇰🇲', currency: 'KMF', rate: 433.30185}, 155 | KN: {flag: '🇰🇳', currency: 'XCD', rate: 2.701017}, 156 | KP: {flag: '🇰🇵', currency: 'KPW', rate: 899.328502}, 157 | KR: {flag: '🇰🇷', currency: 'KRW', rate: 1188.158584}, 158 | KW: {flag: '🇰🇼', currency: 'KWD', rate: 0.303165}, 159 | KY: {flag: '🇰🇾', currency: 'KYD', rate: 0.833045}, 160 | KZ: {flag: '🇰🇿', currency: 'KZT', rate: 434.984596}, 161 | LA: {flag: '🇱🇦', currency: 'LAK', rate: 11271.573551}, 162 | LB: {flag: '🇱🇧', currency: 'LBP', rate: 1512.86911}, 163 | LC: {flag: '🇱🇨', currency: 'XCD', rate: 2.701017}, 164 | LI: {flag: '🇱🇮', currency: 'CHF', rate: 0.92356}, 165 | LK: {flag: '🇱🇰', currency: 'LKR', rate: 202.690445}, 166 | LR: {flag: '🇱🇷', currency: 'LRD', rate: 148.389662}, 167 | LS: {flag: '🇱🇸', currency: 'LSL', rate: 15.623427}, 168 | LU: {flag: '🇱🇺', currency: 'EUR', rate: 0.879428}, 169 | LY: {flag: '🇱🇾', currency: 'LYD', rate: 4.590183}, 170 | MA: {flag: '🇲🇦', currency: 'MAD', rate: 9.255006}, 171 | MC: {flag: '🇲🇨', currency: 'EUR', rate: 0.879428}, 172 | MD: {flag: '🇲🇩', currency: 'MDL', rate: 17.919316}, 173 | ME: {flag: '🇲🇪', currency: 'EUR', rate: 0.879428}, 174 | MF: {flag: '🇲🇫', currency: 'ANG', rate: 1.801544}, 175 | MG: {flag: '🇲🇬', currency: 'MGA', rate: 3971.376644}, 176 | MK: {flag: '🇲🇰', currency: 'MKD', rate: 54.159482}, 177 | ML: {flag: '🇲🇱', currency: 'XOF', rate: 576.513956}, 178 | MM: {flag: '🇲🇲', currency: 'MMK', rate: 1776.381082}, 179 | MN: {flag: '🇲🇳', currency: 'MNT', rate: 2856.547565}, 180 | MO: {flag: '🇲🇴', currency: 'MOP', rate: 8.023872}, 181 | MQ: {flag: '🇲🇶', currency: 'EUR', rate: 0.879428}, 182 | MS: {flag: '🇲🇸', currency: 'XCD', rate: 2.701017}, 183 | MU: {flag: '🇲🇺', currency: 'MUR', rate: 43.823574}, 184 | MV: {flag: '🇲🇻', currency: 'MVR', rate: 15.438917}, 185 | MW: {flag: '🇲🇼', currency: 'MWK', rate: 813.076218}, 186 | MX: {flag: '🇲🇽', currency: 'MXN', rate: 20.372189}, 187 | MY: {flag: '🇲🇾', currency: 'MYR', rate: 4.185751}, 188 | MZ: {flag: '🇲🇿', currency: 'MZN', rate: 63.796128}, 189 | NA: {flag: '🇳🇦', currency: 'NAD', rate: 15.528589}, 190 | NC: {flag: '🇳🇨', currency: 'XPF', rate: 104.880296}, 191 | NE: {flag: '🇳🇪', currency: 'XOF', rate: 576.513956}, 192 | NF: {flag: '🇳🇫', currency: 'AUD', rate: 1.386631}, 193 | NG: {flag: '🇳🇬', currency: 'NGN', rate: 413.292007}, 194 | NI: {flag: '🇳🇮', currency: 'NIO', rate: 35.380823}, 195 | NL: {flag: '🇳🇱', currency: 'EUR', rate: 0.879428}, 196 | NO: {flag: '🇳🇴', currency: 'NOK', rate: 8.765649}, 197 | NP: {flag: '🇳🇵', currency: 'NPR', rate: 118.127544}, 198 | NR: {flag: '🇳🇷', currency: 'AUD', rate: 1.386631}, 199 | NU: {flag: '🇳🇺', currency: 'NZD', rate: 1.473513}, 200 | NZ: {flag: '🇳🇿', currency: 'NZD', rate: 1.473513}, 201 | OM: {flag: '🇴🇲', currency: 'OMR', rate: 0.385317}, 202 | PA: {flag: '🇵🇦', currency: 'PAB', rate: 0.999395}, 203 | PE: {flag: '🇵🇪', currency: 'PEN', rate: 3.90621}, 204 | PF: {flag: '🇵🇫', currency: 'XPF', rate: 104.880296}, 205 | PG: {flag: '🇵🇬', currency: 'PGK', rate: 3.508148}, 206 | PH: {flag: '🇵🇭', currency: 'PHP', rate: 51.034302}, 207 | PK: {flag: '🇵🇰', currency: 'PKR', rate: 176.516726}, 208 | PL: {flag: '🇵🇱', currency: 'PLN', rate: 3.989182}, 209 | PM: {flag: '🇵🇲', currency: 'EUR', rate: 0.879428}, 210 | PN: {flag: '🇵🇳', currency: 'NZD', rate: 1.473513}, 211 | PS: {flag: '🇵🇸', currency: 'JOD', rate: 0.70895}, 212 | PT: {flag: '🇵🇹', currency: 'EUR', rate: 0.879428}, 213 | PY: {flag: '🇵🇾', currency: 'PYG', rate: 6934.511334}, 214 | QA: {flag: '🇶🇦', currency: 'QAR', rate: 3.638712}, 215 | RE: {flag: '🇷🇪', currency: 'EUR', rate: 0.879428}, 216 | RO: {flag: '🇷🇴', currency: 'RON', rate: 4.346365}, 217 | RS: {flag: '🇷🇸', currency: 'RSD', rate: 103.383672}, 218 | RU: {flag: '🇷🇺', currency: 'RUB', rate: 74.452973}, 219 | RW: {flag: '🇷🇼', currency: 'RWF', rate: 1025.118306}, 220 | SA: {flag: '🇸🇦', currency: 'SAR', rate: 3.751513}, 221 | SB: {flag: '🇸🇧', currency: 'SBD', rate: 8.075467}, 222 | SC: {flag: '🇸🇨', currency: 'SCR', rate: 14.231278}, 223 | SD: {flag: '🇸🇩', currency: 'SDG', rate: 437.1739}, 224 | SE: {flag: '🇸🇪', currency: 'SEK', rate: 9.030481}, 225 | SG: {flag: '🇸🇬', currency: 'SGD', rate: 1.349307}, 226 | SH: {flag: '🇸🇭', currency: 'GBP', rate: 0.733308}, 227 | SI: {flag: '🇸🇮', currency: 'EUR', rate: 0.879428}, 228 | SJ: {flag: '🇸🇯', currency: 'NOK', rate: 8.765649}, 229 | SL: {flag: '🇸🇱', currency: 'SLL', rate: 11337.524974}, 230 | SM: {flag: '🇸🇲', currency: 'EUR', rate: 0.879428}, 231 | SN: {flag: '🇸🇳', currency: 'XOF', rate: 576.513956}, 232 | SO: {flag: '🇸🇴', currency: 'SOS', rate: 581.289628}, 233 | SR: {flag: '🇸🇷', currency: 'SRD', rate: 21.217253}, 234 | ST: {flag: '🇸🇹', currency: 'STD', rate: 21278.282153}, 235 | SV: {flag: '🇸🇻', currency: 'SVC', rate: 8.742406}, 236 | SY: {flag: '🇸🇾', currency: 'SYP', rate: 2510.123912}, 237 | SZ: {flag: '🇸🇿', currency: 'SZL', rate: 15.623297}, 238 | TD: {flag: '🇹🇩', currency: 'XAF', rate: 576.513877}, 239 | TF: {flag: '🇹🇫', currency: 'EUR', rate: 0.879428}, 240 | TG: {flag: '🇹🇬', currency: 'XOF', rate: 576.513956}, 241 | TH: {flag: '🇹🇭', currency: 'THB', rate: 33.268561}, 242 | TJ: {flag: '🇹🇯', currency: 'TJS', rate: 11.280423}, 243 | TK: {flag: '🇹🇰', currency: 'NZD', rate: 1.473513}, 244 | TM: {flag: '🇹🇲', currency: 'TMT', rate: 3.497363}, 245 | TN: {flag: '🇹🇳', currency: 'TND', rate: 2.878461}, 246 | TO: {flag: '🇹🇴', currency: 'TOP', rate: 2.282774}, 247 | TR: {flag: '🇹🇷', currency: 'TRY', rate: 13.759517}, 248 | TT: {flag: '🇹🇹', currency: 'TTD', rate: 6.781753}, 249 | TV: {flag: '🇹🇻', currency: 'AUD', rate: 1.386631}, 250 | TW: {flag: '🇹🇼', currency: 'TWD', rate: 27.655737}, 251 | TZ: {flag: '🇹🇿', currency: 'TZS', rate: 2298.282318}, 252 | UA: {flag: '🇺🇦', currency: 'UAH', rate: 27.508699}, 253 | UG: {flag: '🇺🇬', currency: 'UGX', rate: 3526.801242}, 254 | UY: {flag: '🇺🇾', currency: 'UYU', rate: 44.655349}, 255 | UZ: {flag: '🇺🇿', currency: 'UZS', rate: 10851.887304}, 256 | VA: {flag: '🇻🇦', currency: 'EUR', rate: 0.879428}, 257 | VC: {flag: '🇻🇨', currency: 'XCD', rate: 2.701017}, 258 | VN: {flag: '🇻🇳', currency: 'VND', rate: 22681.941729}, 259 | VU: {flag: '🇻🇺', currency: 'VUV', rate: 113.447726}, 260 | WF: {flag: '🇼🇫', currency: 'XPF', rate: 104.880296}, 261 | WS: {flag: '🇼🇸', currency: 'EUR', rate: 0.879428}, 262 | YE: {flag: '🇾🇪', currency: 'YER', rate: 250.113775}, 263 | YT: {flag: '🇾🇹', currency: 'EUR', rate: 0.879428}, 264 | ZA: {flag: '🇿🇦', currency: 'ZAR', rate: 15.502048}, 265 | } 266 | -------------------------------------------------------------------------------- /demo/components/Leaflet.tsx: -------------------------------------------------------------------------------- 1 | import L from 'leaflet' 2 | import markerIcon from 'leaflet/dist/images/marker-icon.png' 3 | import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png' 4 | import markerShadow from 'leaflet/dist/images/marker-shadow.png' 5 | import {MapContainer, Marker, Popup, TileLayer} from 'react-leaflet' 6 | 7 | // Yes, this is unfortunately required, and an intentional side-effect :/ 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | delete (L.Icon.Default.prototype as any)._getIconUrl 10 | L.Icon.Default.mergeOptions({ 11 | iconUrl: markerIcon, 12 | iconRetinaUrl: markerIcon2x, 13 | shadowUrl: markerShadow, 14 | }) 15 | 16 | export interface ReducedLeafletApi { 17 | MapContainer: typeof MapContainer 18 | TileLayer: typeof TileLayer 19 | Marker: typeof Marker 20 | Popup: typeof Popup 21 | } 22 | 23 | const LeafletApi: ReducedLeafletApi = { 24 | MapContainer, 25 | TileLayer, 26 | Marker, 27 | Popup, 28 | } 29 | 30 | export default LeafletApi 31 | -------------------------------------------------------------------------------- /demo/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import {PortableTextMarkComponent} from '../../src' 2 | 3 | interface LinkMark { 4 | _type: 'link' 5 | href: string 6 | } 7 | 8 | export const Link: PortableTextMarkComponent = ({value, children}) => { 9 | const target = (value?.href || '').startsWith('http') ? '_blank' : undefined 10 | return ( 11 | 12 | {children} 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /demo/components/LinkableHeader.tsx: -------------------------------------------------------------------------------- 1 | import {PortableTextBlockComponent, toPlainText} from '../../src' 2 | 3 | /** 4 | * This is obviously extremely simplistic, you'd want to use something "proper" 5 | */ 6 | function slugify(text: string): string { 7 | return text.toLowerCase().replace(/[^a-z0-9]+/g, '-') 8 | } 9 | 10 | export const LinkableHeader: PortableTextBlockComponent = ({value, children}) => { 11 | const slug = slugify(toPlainText(value)) 12 | return ( 13 |

      14 | {children}{' '} 15 | 16 | # 17 | 18 |

      19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /demo/components/SchnauzerList.tsx: -------------------------------------------------------------------------------- 1 | import type {PortableTextListComponent} from '../../src' 2 | 3 | const size = 14 4 | const schnauzerImage = `` 5 | const schnauzerImageEncoded = `data:image/svg+xml;base64,${btoa(schnauzerImage)}` 6 | 7 | export const SchnauzerList: PortableTextListComponent = ({children}) => { 8 | return
        {children}
      9 | } 10 | -------------------------------------------------------------------------------- /demo/components/SpeechSynthesis.tsx: -------------------------------------------------------------------------------- 1 | import {useCallback} from 'react' 2 | 3 | import type {PortableTextMarkComponent} from '../../src' 4 | 5 | interface SpeechSynthesisMark { 6 | _type: 'speech' 7 | pitch?: number 8 | } 9 | 10 | export const hasSpeechApi = typeof window !== 'undefined' && 'speechSynthesis' in window 11 | 12 | export const SpeechSynthesis: PortableTextMarkComponent = ({ 13 | children, 14 | text, 15 | value, 16 | }) => { 17 | const pitch = value?.pitch || 1 18 | const handleSynthesis = useCallback(() => { 19 | const msg = new SpeechSynthesisUtterance() 20 | msg.text = text 21 | msg.pitch = pitch 22 | window.speechSynthesis.speak(msg) 23 | }, [text, pitch]) 24 | 25 | return ( 26 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /demo/components/TermDefinition.tsx: -------------------------------------------------------------------------------- 1 | import {Popover, Text} from '@sanity/ui' 2 | import {useCallback, useState} from 'react' 3 | 4 | import type {PortableTextMarkComponent} from '../../src' 5 | 6 | interface DefinitionMark { 7 | _type: 'definition' 8 | details: string 9 | } 10 | 11 | export const TermDefinition: PortableTextMarkComponent = ({value, children}) => { 12 | const [isOpen, setOpen] = useState(false) 13 | const handleOpen = useCallback(() => setOpen(true), [setOpen]) 14 | const handleClose = useCallback(() => setOpen(false), [setOpen]) 15 | return ( 16 | 23 | {value?.details} 24 | 25 | } 26 | > 27 | 28 | {children} 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /demo/demo.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | } 4 | 5 | #demo-root { 6 | max-width: 700px; 7 | margin: 0 auto; 8 | } 9 | 10 | code { 11 | background: #eee; 12 | } 13 | 14 | blockquote { 15 | border-left: 3px solid #ccc; 16 | margin-left: 0.6rem; 17 | padding-left: 0.6rem; 18 | } 19 | 20 | .slug-anchor { 21 | opacity: 0; 22 | text-decoration: none; 23 | color: #888; 24 | display: inline-block; 25 | padding: 5px 10px; 26 | } 27 | 28 | .slug-anchor:hover { 29 | background: #eee; 30 | } 31 | 32 | h2:hover .slug-anchor { 33 | opacity: 1; 34 | } 35 | 36 | li { 37 | margin: 5px; 38 | } 39 | 40 | .annotated-map { 41 | min-height: 400px; 42 | border: 1px solid transparent; 43 | } 44 | 45 | .annotated-map.loading { 46 | border: 1px solid #000; 47 | background: #eee; 48 | display: flex; 49 | flex-direction: row; 50 | align-items: center; 51 | text-align: center; 52 | } 53 | 54 | .annotated-map.loading > div { 55 | flex: 1; 56 | } 57 | -------------------------------------------------------------------------------- /demo/demo.tsx: -------------------------------------------------------------------------------- 1 | import {studioTheme, ThemeProvider} from '@sanity/ui' 2 | import {StrictMode} from 'react' 3 | import {createRoot} from 'react-dom/client' 4 | 5 | import {PortableTextComponents} from '../src' 6 | import {PortableText} from '../src/react-portable-text' 7 | import {AnnotatedMap} from './components/AnnotatedMap' 8 | import {CharacterReference} from './components/CharacterReference' 9 | import {Code} from './components/Code' 10 | import {CurrencyAmount} from './components/CurrencyAmount' 11 | import {Link} from './components/Link' 12 | import {LinkableHeader} from './components/LinkableHeader' 13 | import {SchnauzerList} from './components/SchnauzerList' 14 | import {hasSpeechApi, SpeechSynthesis} from './components/SpeechSynthesis' 15 | import {TermDefinition} from './components/TermDefinition' 16 | import {blocks} from './fixture' 17 | 18 | /** 19 | * Note that these are statically defined (outside the scope of a function), 20 | * which ensures that unnecessary rerenders does not happen because of a new 21 | * components object being generated on every render. The alternative is to 22 | * `useMemo()`, but if you can get away with this approach it is _better_. 23 | **/ 24 | const ptComponents: PortableTextComponents = { 25 | // Components for totally custom types outside the scope of Portable Text 26 | types: { 27 | code: Code, 28 | currencyAmount: CurrencyAmount, 29 | annotatedMap: AnnotatedMap, 30 | }, 31 | 32 | // Overrides for specific block styles - in this case just the `h2` style 33 | block: { 34 | h2: LinkableHeader, 35 | }, 36 | 37 | // Implements a custom component to handle the `schnauzer` list item type 38 | list: { 39 | schnauzer: SchnauzerList, 40 | }, 41 | 42 | // Custom components for marks - note that `link` overrides the default component, 43 | // while the others define components for totally custom types. 44 | marks: { 45 | link: Link, 46 | characterReference: CharacterReference, 47 | speech: hasSpeechApi ? SpeechSynthesis : undefined, 48 | definition: TermDefinition, 49 | }, 50 | } 51 | 52 | function Demo() { 53 | return 54 | } 55 | 56 | const root = createRoot(document.getElementById('demo-root')!) 57 | root.render( 58 | 59 | 60 | 61 | 62 | , 63 | ) 64 | -------------------------------------------------------------------------------- /demo/external.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-refractor' { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | const Lowlight: any 4 | export default Lowlight 5 | } 6 | 7 | declare module 'refractor/lang/typescript' { 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | const lang: any 10 | export default lang 11 | } 12 | 13 | declare module 'leaflet/dist/images/*' { 14 | const path: string 15 | export default path 16 | } 17 | -------------------------------------------------------------------------------- /demo/fixture.ts: -------------------------------------------------------------------------------- 1 | import type {PortableTextBlock} from '@portabletext/types' 2 | 3 | import type {AnnotatedMapBlock} from './components/AnnotatedMap' 4 | import type {CodeBlock} from './components/Code' 5 | 6 | const exampleCode = ` 7 | import {PortableText, PortableTextTypeComponent} from '@portabletext/react' 8 | 9 | interface CodeBlock { 10 | _type: 'code' 11 | code: string 12 | language?: string 13 | } 14 | 15 | const Code: PortableTextComponent = ({value}) => { 16 | return 17 | } 18 | `.trim() 19 | 20 | export const blocks: (PortableTextBlock | CodeBlock | AnnotatedMapBlock)[] = [ 21 | { 22 | _type: 'block', 23 | _key: 'head', 24 | style: 'h1', 25 | markDefs: [], 26 | children: [{_type: 'span', text: '@portabletext/react demo'}], 27 | }, 28 | { 29 | _type: 'block', 30 | _key: 'text-format-header', 31 | style: 'h2', 32 | children: [{_type: 'span', _key: 'a', text: 'Text formatting'}], 33 | }, 34 | { 35 | _type: 'block', 36 | _key: 'text-formatting', 37 | markDefs: [{_type: 'link', _key: 'd4tl1nk', href: 'https://portabletext.org/'}], 38 | children: [ 39 | {_type: 'span', _key: 'a', text: 'Plain, '}, 40 | {_type: 'span', _key: 'b', text: 'emphasized, ', marks: ['em']}, 41 | {_type: 'span', _key: 'c', text: 'linked', marks: ['d4tl1nk']}, 42 | {_type: 'span', _key: 'd', text: ' and ', marks: ['em']}, 43 | {_type: 'span', _key: 'e', text: 'strong', marks: ['strong']}, 44 | {_type: 'span', _key: 'f', text: ' text, that can also be ', marks: []}, 45 | {_type: 'span', _key: 'g', text: 'combined', marks: ['em', 'strong', 'd4tl1nk']}, 46 | {_type: 'span', _key: 'g', text: '. Obviously it also supports ', marks: []}, 47 | {_type: 'span', _key: 'h', text: 'inline code', marks: ['code']}, 48 | {_type: 'span', _key: 'i', text: ', '}, 49 | {_type: 'span', _key: 'j', text: 'underlined text', marks: ['underline']}, 50 | {_type: 'span', _key: 'k', text: ' and '}, 51 | {_type: 'span', _key: 'l', text: 'strike-through', marks: ['strike-through']}, 52 | {_type: 'span', _key: 'm', text: '.'}, 53 | ], 54 | }, 55 | { 56 | _type: 'block', 57 | _key: 'text-annotations', 58 | markDefs: [ 59 | { 60 | _type: 'definition', 61 | _key: 'd3f', 62 | details: 'a statement of the exact meaning of a word, especially in a dictionary.', 63 | }, 64 | {_type: 'characterReference', _key: 'b34n', characterId: 'nedStark'}, 65 | {_type: 'speech', _key: 'sp33ch', pitch: 1}, 66 | {_type: 'speech', _key: 'p17ch', pitch: 1.5}, 67 | ], 68 | children: [ 69 | { 70 | _type: 'span', 71 | _key: 'a', 72 | text: 'But aside from that, it also supports completely custom annotations - be it structured references to ', 73 | }, 74 | {_type: 'span', _key: 'b', text: 'book/movie characters', marks: ['b34n']}, 75 | {_type: 'span', _key: 'c', text: ', term '}, 76 | {_type: 'span', _key: 'd', text: 'definitions', marks: ['d3f']}, 77 | {_type: 'span', _key: 'e', text: ', '}, 78 | {_type: 'span', _key: 'f', text: 'speech synthesis controls', marks: ['sp33ch']}, 79 | {_type: 'span', _key: 'g', text: ' '}, 80 | {_type: 'span', _key: 'h', text: '(configurable)', marks: ['p17ch']}, 81 | {_type: 'span', _key: 'i', text: ' or some other fun and creative use case.'}, 82 | ], 83 | }, 84 | { 85 | _type: 'block', 86 | _key: 'inline-objects', 87 | style: 'normal', 88 | children: [ 89 | {_type: 'span', text: 'Blocks can also contain "'}, 90 | {_type: 'span', text: 'inline objects', marks: ['em']}, 91 | { 92 | _type: 'span', 93 | text: '", which contain user-defined data. Maybe you want to represent a price in a given currency, but be able to get a live exchange rate in the users local currency? In January 2022, a Whopper was about ', 94 | }, 95 | { 96 | _type: 'currencyAmount', 97 | currency: 'USD', 98 | amount: 5, 99 | }, 100 | {_type: 'span', text: ' at your local Burger King restaurant in Miami.'}, 101 | ], 102 | }, 103 | { 104 | _type: 'block', 105 | _key: 'blocks-expl-header', 106 | style: 'h2', 107 | children: [{_type: 'span', _key: 'z', text: 'Blocks'}], 108 | }, 109 | { 110 | _type: 'block', 111 | _key: 'block-intro', 112 | children: [ 113 | { 114 | _type: 'span', 115 | _key: 'z', 116 | text: '"Blocks" in Portable Text are... well, block-level items.\nBy default, you will see things like blockquotes, headings and regular paragraphs.', 117 | }, 118 | ], 119 | }, 120 | { 121 | _type: 'block', 122 | _key: 'cool-custom', 123 | style: 'normal', 124 | markDefs: [{_type: 'link', _key: 'lllink', href: 'https://github.com/rexxars/react-refractor'}], 125 | children: [ 126 | { 127 | _type: 'span', 128 | text: 'Aside from that, you can drop in pretty much any data you want, as long as you define a React component to render it. Here is a code block, highlighted by ', 129 | }, 130 | {_type: 'span', text: 'react-refractor', marks: ['lllink']}, 131 | {_type: 'span', text: ', for instance:'}, 132 | ], 133 | }, 134 | { 135 | _type: 'code', 136 | code: exampleCode, 137 | language: 'typescript', 138 | }, 139 | { 140 | _type: 'block', 141 | _key: 'cool-custom-map', 142 | style: 'normal', 143 | children: [ 144 | { 145 | _type: 'span', 146 | text: 'But it can really be anything - like a map of annotated markers, for instance - all represented by structured JSON:', 147 | }, 148 | ], 149 | }, 150 | { 151 | _type: 'annotatedMap', 152 | _key: 'bar-map', 153 | center: {_type: 'geopoint', lat: 37.778225, lng: -122.427743}, 154 | markers: [ 155 | { 156 | _key: '8e63881c-50b3-41ae-afbe-5d546e961c45', 157 | _type: 'mapMarker', 158 | position: { 159 | _type: 'geopoint', 160 | lat: 37.77006534953728, 161 | lng: -122.47415458826903, 162 | }, 163 | title: 'Golden Gate Park', 164 | }, 165 | { 166 | _key: '48d621c4-ead9-4343-b1be-4cda4942e40c', 167 | _type: 'mapMarker', 168 | position: { 169 | _type: 'geopoint', 170 | lat: 37.8199286, 171 | lng: -122.4782551, 172 | }, 173 | title: 'Golden Gate Bridge', 174 | }, 175 | { 176 | _key: '4a07e89c-be09-43da-9541-ed37c74c65a3', 177 | _type: 'mapMarker', 178 | position: { 179 | _type: 'geopoint', 180 | lat: 37.8590937, 181 | lng: -122.4852507, 182 | }, 183 | title: 'Sausalito', 184 | }, 185 | { 186 | _key: '4908563a-89c8-4fc4-baf2-21fff1f83def', 187 | _type: 'mapMarker', 188 | position: { 189 | _type: 'geopoint', 190 | lat: 37.7935724, 191 | lng: -122.483638, 192 | }, 193 | title: 'Baker Beach', 194 | }, 195 | { 196 | _key: 'bde29009-7f5f-4ff9-808d-a26e930e3952', 197 | _type: 'mapMarker', 198 | position: { 199 | _type: 'geopoint', 200 | lat: 37.78044439999999, 201 | lng: -122.51365, 202 | }, 203 | title: 'Lands End / Sutro Baths', 204 | }, 205 | { 206 | _key: '14e3a0e9-375c-4692-8757-b2e2576c73ec', 207 | _type: 'mapMarker', 208 | position: { 209 | _type: 'geopoint', 210 | lat: 37.7925153, 211 | lng: -122.4382307, 212 | }, 213 | title: 'Pacific Heights', 214 | }, 215 | { 216 | _key: 'adb4463e-4934-45d9-bde0-bfd1108ffc89', 217 | _type: 'mapMarker', 218 | position: { 219 | _type: 'geopoint', 220 | lat: 37.8029308, 221 | lng: -122.4484231, 222 | }, 223 | title: 'Palace of Fine Arts', 224 | }, 225 | { 226 | _key: '2e7088ad-bb10-465a-a953-16b96f112379', 227 | _type: 'mapMarker', 228 | position: { 229 | _type: 'geopoint', 230 | lat: 37.8036667, 231 | lng: -122.4368151, 232 | }, 233 | title: 'Marina', 234 | }, 235 | { 236 | _key: '872c6817-0482-44a0-8be6-a56a8c4c9a06', 237 | _type: 'mapMarker', 238 | position: { 239 | _type: 'geopoint', 240 | lat: 37.80105959999999, 241 | lng: -122.4194486, 242 | }, 243 | title: 'Russian Hill', 244 | }, 245 | { 246 | _key: '1a583dac-80dc-44e8-99cc-c50f627bfd31', 247 | _type: 'mapMarker', 248 | position: { 249 | _type: 'geopoint', 250 | lat: 37.8013407, 251 | lng: -122.4056674, 252 | }, 253 | title: 'Telegraph Hill', 254 | }, 255 | { 256 | _key: '7a557e3c-8920-4d0f-99a5-161b041c593c', 257 | _type: 'mapMarker', 258 | position: { 259 | _type: 'geopoint', 260 | lat: 37.7857182, 261 | lng: -122.4010508, 262 | }, 263 | title: 'SF MoMA', 264 | }, 265 | { 266 | _key: '4944a4a0-2404-41f3-8f0b-8139fd292486', 267 | _type: 'mapMarker', 268 | position: { 269 | _type: 'geopoint', 270 | lat: 37.7692204, 271 | lng: -122.4481393, 272 | }, 273 | title: 'Haight/Ashbury', 274 | }, 275 | { 276 | _key: 'e2435821-ed8e-44f8-8420-9a77f4628b48', 277 | _type: 'mapMarker', 278 | position: { 279 | _type: 'geopoint', 280 | lat: 37.75939210000001, 281 | lng: -122.510734, 282 | }, 283 | title: 'Ocean Beach', 284 | }, 285 | { 286 | _key: 'f291d51f-6c0e-4966-85f1-30a24fc824fe', 287 | _type: 'mapMarker', 288 | position: { 289 | _type: 'geopoint', 290 | lat: 37.7614531211161, 291 | lng: -122.42172718048096, 292 | }, 293 | title: 'Mission/Dolores', 294 | }, 295 | { 296 | _key: '6883eb7a-df7b-425d-baf9-857c527146f7', 297 | _type: 'mapMarker', 298 | position: { 299 | _type: 'geopoint', 300 | lat: 37.7544066, 301 | lng: -122.4476845, 302 | }, 303 | title: 'Twin Peaks', 304 | }, 305 | { 306 | _key: '32d53c0e-308e-4f6f-a7fc-88d42ce5e8b0', 307 | _type: 'mapMarker', 308 | position: { 309 | _type: 'geopoint', 310 | lat: 37.7552213, 311 | lng: -122.4527624, 312 | }, 313 | title: 'Sutro Tower', 314 | }, 315 | { 316 | _key: 'b4e8b5d5-65e7-4def-92a1-09928964acdb', 317 | _type: 'mapMarker', 318 | position: { 319 | _type: 'geopoint', 320 | lat: 37.7906226, 321 | lng: -122.4172174, 322 | }, 323 | title: 'Liquid Gold', 324 | }, 325 | { 326 | _key: 'b6460976-3750-444d-b656-0d6cbc34fc00', 327 | _type: 'mapMarker', 328 | position: { 329 | _type: 'geopoint', 330 | lat: 37.7998951, 331 | lng: -122.407345, 332 | }, 333 | title: 'Church Key', 334 | }, 335 | { 336 | _key: '0a72c257-6536-4172-bb05-24459a9b0538', 337 | _type: 'mapMarker', 338 | position: { 339 | _type: 'geopoint', 340 | lat: 37.7589398, 341 | lng: -122.4122858, 342 | }, 343 | title: 'Flour + Water', 344 | }, 345 | { 346 | _key: 'cf824249-1842-4f14-9ae2-308df3755962', 347 | _type: 'mapMarker', 348 | position: { 349 | _type: 'geopoint', 350 | lat: 37.7719042, 351 | lng: -122.4312295, 352 | }, 353 | title: 'Toronado', 354 | }, 355 | { 356 | _key: 'a5706d60-9d3b-4c77-ac1d-1c64958f196d', 357 | _type: 'mapMarker', 358 | position: { 359 | _type: 'geopoint', 360 | lat: 37.7771765, 361 | lng: -122.4107242, 362 | }, 363 | title: 'Cellarmaker', 364 | }, 365 | { 366 | _key: '9d294f43-415c-4ed9-865f-17eb748f448b', 367 | _type: 'mapMarker', 368 | position: { 369 | _type: 'geopoint', 370 | lat: 37.7785719, 371 | lng: -122.4122471, 372 | }, 373 | title: 'City Beer Store', 374 | }, 375 | { 376 | _key: '48a249f0-3df6-408f-9761-ef5dcc5e4816', 377 | _type: 'mapMarker', 378 | position: { 379 | _type: 'geopoint', 380 | lat: 37.76985570000001, 381 | lng: -122.420367, 382 | }, 383 | title: 'The Crafty Fox', 384 | }, 385 | { 386 | _key: '95f2d308-c87d-4edc-ab51-ddb081032378', 387 | _type: 'mapMarker', 388 | position: { 389 | _type: 'geopoint', 390 | lat: 37.77002299999999, 391 | lng: -122.4221172, 392 | }, 393 | title: 'Zeitgeist', 394 | }, 395 | { 396 | _key: '4a0b5ae8-9eca-45c4-b955-3c3e3296b6de', 397 | _type: 'mapMarker', 398 | position: { 399 | _type: 'geopoint', 400 | lat: 37.7647206, 401 | lng: -122.422986, 402 | }, 403 | title: "The Monk's Kettle", 404 | }, 405 | { 406 | _key: 'ec2ea212-02d7-486f-a663-b797bbd630d1', 407 | _type: 'mapMarker', 408 | position: { 409 | _type: 'geopoint', 410 | lat: 37.739413, 411 | lng: -122.4182559, 412 | }, 413 | title: 'Holy Water', 414 | }, 415 | { 416 | _key: '552d23bd-8e1c-4139-8695-57758c9a7d6d', 417 | _type: 'mapMarker', 418 | position: { 419 | _type: 'geopoint', 420 | lat: 37.757149, 421 | lng: -122.4213259, 422 | }, 423 | title: 'Senor Sisig', 424 | }, 425 | { 426 | _key: '60edf5c3-9f4f-4229-ae16-f491be6c804f', 427 | _type: 'mapMarker', 428 | position: { 429 | _type: 'geopoint', 430 | lat: 37.76869349999999, 431 | lng: -122.4148318, 432 | }, 433 | title: 'Pink Onion', 434 | }, 435 | { 436 | _key: '0b02c5e8-e0b4-416a-9f8c-6d8d3a802a04', 437 | _type: 'mapMarker', 438 | position: { 439 | _type: 'geopoint', 440 | lat: 37.8266636, 441 | lng: -122.4230122, 442 | }, 443 | title: 'Alcatraz Island', 444 | }, 445 | { 446 | _key: '35d9f82e-3d5d-43d6-8fb9-121028d9ffa9', 447 | _type: 'mapMarker', 448 | position: { 449 | _type: 'geopoint', 450 | lat: 37.7770619, 451 | lng: -122.4123798, 452 | }, 453 | title: 'Sanity HQ', 454 | }, 455 | { 456 | _key: '96afa0c1-a72a-427b-a9e5-db1ca70eb5e5', 457 | _type: 'mapMarker', 458 | position: { 459 | _type: 'geopoint', 460 | lat: 37.7398597, 461 | lng: -122.4091179, 462 | }, 463 | title: 'Barebottle Brewing Company', 464 | }, 465 | { 466 | _key: '5416b5c1-20c3-410c-b95f-cd59831e3794', 467 | _type: 'mapMarker', 468 | position: { 469 | _type: 'geopoint', 470 | lat: 37.7955917, 471 | lng: -122.3935498, 472 | }, 473 | title: 'Ferry Building', 474 | }, 475 | { 476 | _key: '37207e40-4f00-4aa6-b0c1-664549f3a30e', 477 | _type: 'mapMarker', 478 | position: { 479 | _type: 'geopoint', 480 | lat: 37.7988737, 481 | lng: -122.4661937, 482 | }, 483 | title: 'Presidio', 484 | }, 485 | ], 486 | }, 487 | { 488 | _type: 'block', 489 | _key: 'list-head', 490 | style: 'h2', 491 | markDefs: [], 492 | children: [{_type: 'span', text: 'Lists'}], 493 | }, 494 | { 495 | _type: 'block', 496 | _key: 'list-intrp', 497 | style: 'normal', 498 | markDefs: [], 499 | children: [{_type: 'span', text: 'Of course, you will want lists!'}], 500 | }, 501 | { 502 | _type: 'block', 503 | listItem: 'bullet', 504 | children: [{_type: 'span', text: 'They are fully supported'}], 505 | }, 506 | { 507 | _type: 'block', 508 | listItem: 'bullet', 509 | children: [{_type: 'span', text: 'They can be unordered or ordered'}], 510 | }, 511 | { 512 | _type: 'block', 513 | listItem: 'bullet', 514 | children: [{_type: 'span', text: 'Reasons why ordered lists might be better:'}], 515 | }, 516 | { 517 | _type: 'block', 518 | listItem: 'number', 519 | level: 2, 520 | children: [ 521 | {_type: 'span', text: 'Most lists have '}, 522 | {_type: 'span', text: 'some', marks: ['em']}, 523 | {_type: 'span', text: ' priority'}, 524 | ], 525 | }, 526 | { 527 | _type: 'block', 528 | listItem: 'number', 529 | level: 2, 530 | children: [{_type: 'span', text: 'Not conveying importance/priority is lazy'}], 531 | }, 532 | { 533 | _type: 'block', 534 | _key: 'bb', 535 | children: [ 536 | { 537 | _type: 'span', 538 | text: 'You can also go beyond ordered/unordered...', 539 | }, 540 | ], 541 | }, 542 | { 543 | _type: 'block', 544 | children: [ 545 | { 546 | _type: 'span', 547 | text: 'Here is a list of schnauzers, as indicated by the schnauzer list type and icon:', 548 | }, 549 | ], 550 | }, 551 | { 552 | _type: 'block', 553 | listItem: 'schnauzer', 554 | children: [{_type: 'span', text: 'Kokos'}], 555 | }, 556 | { 557 | _type: 'block', 558 | listItem: 'schnauzer', 559 | children: [{_type: 'span', text: 'Pippi'}], 560 | }, 561 | ] 562 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @portabletext/react demo 7 | 8 | 9 | 10 | 11 |
      12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from '@sanity/pkg-utils' 2 | import {visualizer} from 'rollup-plugin-visualizer' 3 | 4 | import {name, version} from './package.json' 5 | 6 | export default defineConfig({ 7 | extract: { 8 | rules: { 9 | 'ae-missing-release-tag': 'off', 10 | 'tsdoc-undefined-tag': 'off', 11 | }, 12 | }, 13 | 14 | rollup: { 15 | plugins: [ 16 | visualizer({ 17 | emitFile: true, 18 | filename: 'stats.html', 19 | gzipSize: true, 20 | title: `${name}@${version} bundle analysis`, 21 | }), 22 | ], 23 | }, 24 | 25 | babel: { 26 | plugins: ['@babel/plugin-proposal-object-rest-spread'], 27 | }, 28 | 29 | tsconfig: 'tsconfig.dist.json', 30 | }) 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@portabletext/react", 3 | "version": "3.2.1", 4 | "description": "Render Portable Text with React", 5 | "keywords": [ 6 | "portable-text" 7 | ], 8 | "homepage": "https://github.com/portabletext/react-portabletext#readme", 9 | "bugs": { 10 | "url": "https://github.com/portabletext/react-portabletext/issues" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+ssh://git@github.com/portabletext/react-portabletext.git" 15 | }, 16 | "license": "MIT", 17 | "author": "Sanity.io ", 18 | "sideEffects": false, 19 | "type": "module", 20 | "exports": { 21 | ".": { 22 | "source": "./src/index.ts", 23 | "import": "./dist/index.js", 24 | "require": "./dist/index.cjs", 25 | "default": "./dist/index.js" 26 | }, 27 | "./package.json": "./package.json" 28 | }, 29 | "main": "./dist/index.cjs", 30 | "module": "./dist/index.js", 31 | "types": "./dist/index.d.ts", 32 | "files": [ 33 | "dist", 34 | "!dist/stats.html", 35 | "src" 36 | ], 37 | "scripts": { 38 | "build": "pkg-utils build --strict --check --clean", 39 | "build:demo": "vite build demo --config=./vite.config.demo.ts --base=/react-portabletext/", 40 | "clean": "rimraf dist coverage demo/dist .nyc_output", 41 | "dev": "vite demo", 42 | "format": "prettier --write --cache --ignore-unknown .", 43 | "lint": "eslint .", 44 | "prepare": "husky install", 45 | "prepublishOnly": "run-s build lint type-check", 46 | "start": "vite demo", 47 | "test": "vitest", 48 | "type-check": "tsc --noEmit" 49 | }, 50 | "commitlint": { 51 | "extends": [ 52 | "@commitlint/config-conventional" 53 | ] 54 | }, 55 | "lint-staged": { 56 | "*": [ 57 | "prettier --write --cache --ignore-unknown" 58 | ] 59 | }, 60 | "browserslist": "extends @sanity/browserslist-config", 61 | "prettier": { 62 | "bracketSpacing": false, 63 | "plugins": [ 64 | "prettier-plugin-packagejson" 65 | ], 66 | "printWidth": 100, 67 | "semi": false, 68 | "singleQuote": true 69 | }, 70 | "eslintConfig": { 71 | "parserOptions": { 72 | "ecmaFeatures": { 73 | "modules": true 74 | }, 75 | "ecmaVersion": 9, 76 | "sourceType": "module" 77 | }, 78 | "plugins": [ 79 | "react-compiler" 80 | ], 81 | "extends": [ 82 | "sanity", 83 | "sanity/react", 84 | "sanity/typescript", 85 | "prettier" 86 | ], 87 | "rules": { 88 | "react-compiler/react-compiler": "error", 89 | "react/prop-types": "off" 90 | }, 91 | "ignorePatterns": [ 92 | "dist/**/" 93 | ] 94 | }, 95 | "dependencies": { 96 | "@portabletext/toolkit": "^2.0.17", 97 | "@portabletext/types": "^2.0.13" 98 | }, 99 | "devDependencies": { 100 | "@babel/plugin-proposal-object-rest-spread": "^7.20.7", 101 | "@commitlint/cli": "^19.7.1", 102 | "@commitlint/config-conventional": "^19.7.1", 103 | "@sanity/pkg-utils": "^6.13.4", 104 | "@sanity/ui": "^2.13.0", 105 | "@types/leaflet": "^1.9.16", 106 | "@types/react": "^19.0.8", 107 | "@types/react-dom": "^19.0.3", 108 | "@types/refractor": "^3.4.1", 109 | "@types/ws": "^8.5.14", 110 | "@typescript-eslint/eslint-plugin": "^7.18.0", 111 | "@typescript-eslint/parser": "^7.18.0", 112 | "@vitejs/plugin-react": "^4.3.4", 113 | "babel-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124", 114 | "commitizen": "^4.3.1", 115 | "cz-conventional-changelog": "^3.3.0", 116 | "esbuild": "^0.25.0", 117 | "esbuild-register": "^3.6.0", 118 | "eslint": "^8.57.1", 119 | "eslint-config-prettier": "^9.1.0", 120 | "eslint-config-sanity": "^7.1.4", 121 | "eslint-plugin-react": "^7.37.4", 122 | "eslint-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124", 123 | "eslint-plugin-react-hooks": "^5.1.0", 124 | "husky": "^8.0.3", 125 | "leaflet": "^1.9.4", 126 | "npm-run-all2": "^5.0.2", 127 | "prettier": "^3.5.1", 128 | "prettier-plugin-packagejson": "^2.5.8", 129 | "react": "^19.0.0", 130 | "react-dom": "^19.0.0", 131 | "react-is": "^19.0.0", 132 | "react-leaflet": "^4.2.1", 133 | "react-refractor": "^2.2.0", 134 | "refractor": "^4.8.1", 135 | "rimraf": "^5.0.1", 136 | "rollup-plugin-visualizer": "^5.14.0", 137 | "styled-components": "^6.1.15", 138 | "typescript": "^5.7.3", 139 | "vite": "^5.4.14", 140 | "vitest": "^1.6.1" 141 | }, 142 | "peerDependencies": { 143 | "react": "^17 || ^18 || >=19.0.0-0" 144 | }, 145 | "packageManager": "pnpm@9.4.0", 146 | "engines": { 147 | "node": "^14.13.1 || >=16.0.0" 148 | }, 149 | "publishConfig": { 150 | "access": "public" 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/components/defaults.tsx: -------------------------------------------------------------------------------- 1 | import type {PortableTextBlockStyle} from '@portabletext/types' 2 | import type {JSX} from 'react' 3 | 4 | import type {PortableTextBlockComponent, PortableTextReactComponents} from '../types' 5 | import {DefaultListItem, defaultLists} from './list' 6 | import {defaultMarks} from './marks' 7 | import { 8 | DefaultUnknownBlockStyle, 9 | DefaultUnknownList, 10 | DefaultUnknownListItem, 11 | DefaultUnknownMark, 12 | DefaultUnknownType, 13 | } from './unknown' 14 | 15 | export const DefaultHardBreak = (): JSX.Element =>
      16 | 17 | export const defaultBlockStyles: Record< 18 | PortableTextBlockStyle, 19 | PortableTextBlockComponent | undefined 20 | > = { 21 | normal: ({children}) =>

      {children}

      , 22 | blockquote: ({children}) =>
      {children}
      , 23 | h1: ({children}) =>

      {children}

      , 24 | h2: ({children}) =>

      {children}

      , 25 | h3: ({children}) =>

      {children}

      , 26 | h4: ({children}) =>

      {children}

      , 27 | h5: ({children}) =>
      {children}
      , 28 | h6: ({children}) =>
      {children}
      , 29 | } 30 | 31 | export const defaultComponents: PortableTextReactComponents = { 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 | -------------------------------------------------------------------------------- /src/components/list.tsx: -------------------------------------------------------------------------------- 1 | import type {PortableTextListComponent, PortableTextListItemComponent} from '../types' 2 | 3 | export const defaultLists: Record<'number' | 'bullet', PortableTextListComponent> = { 4 | number: ({children}) =>
        {children}
      , 5 | bullet: ({children}) =>
        {children}
      , 6 | } 7 | 8 | export const DefaultListItem: PortableTextListItemComponent = ({children}) =>
    5. {children}
    6. 9 | -------------------------------------------------------------------------------- /src/components/marks.tsx: -------------------------------------------------------------------------------- 1 | import type {TypedObject} from '@portabletext/types' 2 | 3 | import type {PortableTextMarkComponent} from '../types' 4 | 5 | interface DefaultLink extends TypedObject { 6 | _type: 'link' 7 | href: string 8 | } 9 | 10 | const link: PortableTextMarkComponent = ({children, value}) => ( 11 | {children} 12 | ) 13 | 14 | const underlineStyle = {textDecoration: 'underline'} 15 | 16 | export const defaultMarks: Record = { 17 | em: ({children}) => {children}, 18 | strong: ({children}) => {children}, 19 | code: ({children}) => {children}, 20 | underline: ({children}) => {children}, 21 | 'strike-through': ({children}) => {children}, 22 | link, 23 | } 24 | -------------------------------------------------------------------------------- /src/components/merge.ts: -------------------------------------------------------------------------------- 1 | import type {PortableTextComponents, PortableTextReactComponents} from '../types' 2 | 3 | export function mergeComponents( 4 | parent: PortableTextReactComponents, 5 | overrides: PortableTextComponents, 6 | ): PortableTextReactComponents { 7 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 8 | const {block, list, listItem, marks, types, ...rest} = overrides 9 | // @todo figure out how to not `as ...` these 10 | return { 11 | ...parent, 12 | block: mergeDeeply(parent, overrides, 'block') as PortableTextReactComponents['block'], 13 | list: mergeDeeply(parent, overrides, 'list') as PortableTextReactComponents['list'], 14 | listItem: mergeDeeply(parent, overrides, 'listItem') as PortableTextReactComponents['listItem'], 15 | marks: mergeDeeply(parent, overrides, 'marks') as PortableTextReactComponents['marks'], 16 | types: mergeDeeply(parent, overrides, 'types') as PortableTextReactComponents['types'], 17 | ...rest, 18 | } 19 | } 20 | 21 | function mergeDeeply( 22 | parent: PortableTextReactComponents, 23 | overrides: PortableTextComponents, 24 | key: 'block' | 'list' | 'listItem' | 'marks' | 'types', 25 | ): PortableTextReactComponents[typeof key] { 26 | const override = overrides[key] 27 | const parentVal = parent[key] 28 | 29 | if (typeof override === 'function') { 30 | return override 31 | } 32 | 33 | if (override && typeof parentVal === 'function') { 34 | return override 35 | } 36 | 37 | if (override) { 38 | return {...parentVal, ...override} as PortableTextReactComponents[typeof key] 39 | } 40 | 41 | return parentVal 42 | } 43 | -------------------------------------------------------------------------------- /src/components/unknown.tsx: -------------------------------------------------------------------------------- 1 | import type {PortableTextReactComponents} from '../types' 2 | import {unknownTypeWarning} from '../warnings' 3 | 4 | const hidden = {display: 'none'} 5 | 6 | export const DefaultUnknownType: PortableTextReactComponents['unknownType'] = ({ 7 | value, 8 | isInline, 9 | }) => { 10 | const warning = unknownTypeWarning(value._type) 11 | return isInline ? {warning} :
      {warning}
      12 | } 13 | 14 | export const DefaultUnknownMark: PortableTextReactComponents['unknownMark'] = ({ 15 | markType, 16 | children, 17 | }) => { 18 | return {children} 19 | } 20 | 21 | export const DefaultUnknownBlockStyle: PortableTextReactComponents['unknownBlockStyle'] = ({ 22 | children, 23 | }) => { 24 | return

      {children}

      25 | } 26 | 27 | export const DefaultUnknownList: PortableTextReactComponents['unknownList'] = ({children}) => { 28 | return
        {children}
      29 | } 30 | 31 | export const DefaultUnknownListItem: PortableTextReactComponents['unknownListItem'] = ({ 32 | children, 33 | }) => { 34 | return
    7. {children}
    8. 35 | } 36 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {defaultComponents} from './components/defaults' 2 | export {mergeComponents} from './components/merge' 3 | export {PortableText} from './react-portable-text' 4 | export * from './types' 5 | export type {ToolkitListNestMode as ListNestMode} from '@portabletext/toolkit' 6 | export {toPlainText} from '@portabletext/toolkit' 7 | export type {PortableTextBlock} from '@portabletext/types' 8 | -------------------------------------------------------------------------------- /src/react-portable-text.tsx: -------------------------------------------------------------------------------- 1 | import type {ToolkitNestedPortableTextSpan, ToolkitTextNode} from '@portabletext/toolkit' 2 | import { 3 | buildMarksTree, 4 | isPortableTextBlock, 5 | isPortableTextListItemBlock, 6 | isPortableTextToolkitList, 7 | isPortableTextToolkitSpan, 8 | isPortableTextToolkitTextNode, 9 | LIST_NEST_MODE_HTML, 10 | nestLists, 11 | spanToPlainText, 12 | } from '@portabletext/toolkit' 13 | import type { 14 | PortableTextBlock, 15 | PortableTextListItemBlock, 16 | PortableTextMarkDefinition, 17 | PortableTextSpan, 18 | TypedObject, 19 | } from '@portabletext/types' 20 | import {type JSX, type ReactNode, useMemo} from 'react' 21 | 22 | import {defaultComponents} from './components/defaults' 23 | import {mergeComponents} from './components/merge' 24 | import type { 25 | MissingComponentHandler, 26 | NodeRenderer, 27 | PortableTextProps, 28 | PortableTextReactComponents, 29 | ReactPortableTextList, 30 | Serializable, 31 | SerializedBlock, 32 | } from './types' 33 | import { 34 | printWarning, 35 | unknownBlockStyleWarning, 36 | unknownListItemStyleWarning, 37 | unknownListStyleWarning, 38 | unknownMarkWarning, 39 | unknownTypeWarning, 40 | } from './warnings' 41 | 42 | export function PortableText({ 43 | value: input, 44 | components: componentOverrides, 45 | listNestingMode, 46 | onMissingComponent: missingComponentHandler = printWarning, 47 | }: PortableTextProps): JSX.Element { 48 | const handleMissingComponent = missingComponentHandler || noop 49 | const blocks = Array.isArray(input) ? input : [input] 50 | const nested = nestLists(blocks, listNestingMode || LIST_NEST_MODE_HTML) 51 | 52 | const components = useMemo(() => { 53 | return componentOverrides 54 | ? mergeComponents(defaultComponents, componentOverrides) 55 | : defaultComponents 56 | }, [componentOverrides]) 57 | 58 | const renderNode = useMemo( 59 | () => getNodeRenderer(components, handleMissingComponent), 60 | [components, handleMissingComponent], 61 | ) 62 | const rendered = nested.map((node, index) => 63 | renderNode({node: node, index, isInline: false, renderNode}), 64 | ) 65 | 66 | return <>{rendered} 67 | } 68 | 69 | const getNodeRenderer = ( 70 | components: PortableTextReactComponents, 71 | handleMissingComponent: MissingComponentHandler, 72 | ): NodeRenderer => { 73 | function renderNode(options: Serializable): ReactNode { 74 | const {node, index, isInline} = options 75 | const key = node._key || `node-${index}` 76 | 77 | if (isPortableTextToolkitList(node)) { 78 | return renderList(node, index, key) 79 | } 80 | 81 | if (isPortableTextListItemBlock(node)) { 82 | return renderListItem(node, index, key) 83 | } 84 | 85 | if (isPortableTextToolkitSpan(node)) { 86 | return renderSpan(node, index, key) 87 | } 88 | 89 | if (hasCustomComponentForNode(node)) { 90 | return renderCustomBlock(node, index, key, isInline) 91 | } 92 | 93 | if (isPortableTextBlock(node)) { 94 | return renderBlock(node, index, key, isInline) 95 | } 96 | 97 | if (isPortableTextToolkitTextNode(node)) { 98 | return renderText(node, key) 99 | } 100 | 101 | return renderUnknownType(node, index, key, isInline) 102 | } 103 | 104 | function hasCustomComponentForNode(node: TypedObject): boolean { 105 | return node._type in components.types 106 | } 107 | 108 | /* eslint-disable react/jsx-no-bind */ 109 | function renderListItem( 110 | node: PortableTextListItemBlock, 111 | index: number, 112 | key: string, 113 | ) { 114 | const tree = serializeBlock({node, index, isInline: false, renderNode}) 115 | const renderer = components.listItem 116 | const handler = typeof renderer === 'function' ? renderer : renderer[node.listItem] 117 | const Li = handler || components.unknownListItem 118 | 119 | if (Li === components.unknownListItem) { 120 | const style = node.listItem || 'bullet' 121 | handleMissingComponent(unknownListItemStyleWarning(style), { 122 | type: style, 123 | nodeType: 'listItemStyle', 124 | }) 125 | } 126 | 127 | let children = tree.children 128 | if (node.style && node.style !== 'normal') { 129 | // Wrap any other style in whatever the block serializer says to use 130 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 131 | const {listItem, ...blockNode} = node 132 | children = renderNode({node: blockNode, index, isInline: false, renderNode}) 133 | } 134 | 135 | return ( 136 |
    9. 137 | {children} 138 |
    10. 139 | ) 140 | } 141 | 142 | function renderList(node: ReactPortableTextList, index: number, key: string) { 143 | const children = node.children.map((child, childIndex) => 144 | renderNode({ 145 | node: child._key ? child : {...child, _key: `li-${index}-${childIndex}`}, 146 | index: childIndex, 147 | isInline: false, 148 | renderNode, 149 | }), 150 | ) 151 | 152 | const component = components.list 153 | const handler = typeof component === 'function' ? component : component[node.listItem] 154 | const List = handler || components.unknownList 155 | 156 | if (List === components.unknownList) { 157 | const style = node.listItem || 'bullet' 158 | handleMissingComponent(unknownListStyleWarning(style), {nodeType: 'listStyle', type: style}) 159 | } 160 | 161 | return ( 162 | 163 | {children} 164 | 165 | ) 166 | } 167 | 168 | function renderSpan(node: ToolkitNestedPortableTextSpan, _index: number, key: string) { 169 | const {markDef, markType, markKey} = node 170 | const Span = components.marks[markType] || components.unknownMark 171 | const children = node.children.map((child, childIndex) => 172 | renderNode({node: child, index: childIndex, isInline: true, renderNode}), 173 | ) 174 | 175 | if (Span === components.unknownMark) { 176 | handleMissingComponent(unknownMarkWarning(markType), {nodeType: 'mark', type: markType}) 177 | } 178 | 179 | return ( 180 | 188 | {children} 189 | 190 | ) 191 | } 192 | 193 | function renderBlock(node: PortableTextBlock, index: number, key: string, isInline: boolean) { 194 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 195 | const {_key, ...props} = serializeBlock({node, index, isInline, renderNode}) 196 | const style = props.node.style || 'normal' 197 | const handler = 198 | typeof components.block === 'function' ? components.block : components.block[style] 199 | const Block = handler || components.unknownBlockStyle 200 | 201 | if (Block === components.unknownBlockStyle) { 202 | handleMissingComponent(unknownBlockStyleWarning(style), { 203 | nodeType: 'blockStyle', 204 | type: style, 205 | }) 206 | } 207 | 208 | return 209 | } 210 | 211 | function renderText(node: ToolkitTextNode, key: string) { 212 | if (node.text === '\n') { 213 | const HardBreak = components.hardBreak 214 | return HardBreak ? : '\n' 215 | } 216 | 217 | return node.text 218 | } 219 | 220 | function renderUnknownType(node: TypedObject, index: number, key: string, isInline: boolean) { 221 | const nodeOptions = { 222 | value: node, 223 | isInline, 224 | index, 225 | renderNode, 226 | } 227 | 228 | handleMissingComponent(unknownTypeWarning(node._type), {nodeType: 'block', type: node._type}) 229 | 230 | const UnknownType = components.unknownType 231 | return 232 | } 233 | 234 | function renderCustomBlock(node: TypedObject, index: number, key: string, isInline: boolean) { 235 | const nodeOptions = { 236 | value: node, 237 | isInline, 238 | index, 239 | renderNode, 240 | } 241 | 242 | const Node = components.types[node._type] 243 | return Node ? : null 244 | } 245 | /* eslint-enable react/jsx-no-bind */ 246 | 247 | return renderNode 248 | } 249 | 250 | function serializeBlock(options: Serializable): SerializedBlock { 251 | const {node, index, isInline, renderNode} = options 252 | const tree = buildMarksTree(node) 253 | const children = tree.map((child, i) => 254 | renderNode({node: child, isInline: true, index: i, renderNode}), 255 | ) 256 | 257 | return { 258 | _key: node._key || `block-${index}`, 259 | children, 260 | index, 261 | isInline, 262 | node, 263 | } 264 | } 265 | 266 | function noop() { 267 | // Intentional noop 268 | } 269 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ToolkitListNestMode, 3 | ToolkitPortableTextList, 4 | ToolkitPortableTextListItem, 5 | } from '@portabletext/toolkit' 6 | import type { 7 | ArbitraryTypedObject, 8 | PortableTextBlock, 9 | PortableTextBlockStyle, 10 | PortableTextListItemBlock, 11 | PortableTextListItemType, 12 | TypedObject, 13 | } from '@portabletext/types' 14 | import type {ComponentType, ReactNode} from 'react' 15 | 16 | /** 17 | * Properties for the Portable Text react component 18 | * 19 | * @template B Types that can appear in the array of blocks 20 | */ 21 | export interface PortableTextProps< 22 | B extends TypedObject = PortableTextBlock | ArbitraryTypedObject, 23 | > { 24 | /** 25 | * One or more blocks to render 26 | */ 27 | value: B | B[] 28 | 29 | /** 30 | * React components to use for rendering 31 | */ 32 | components?: Partial 33 | 34 | /** 35 | * Function to call when encountering unknown unknown types, eg blocks, marks, 36 | * block style, list styles without an associated React component. 37 | * 38 | * Will print a warning message to the console by default. 39 | * Pass `false` to disable. 40 | */ 41 | onMissingComponent?: MissingComponentHandler | false 42 | 43 | /** 44 | * Determines whether or not lists are nested inside of list items (`html`) 45 | * or as a direct child of another list (`direct` - for React Native) 46 | * 47 | * You rarely (if ever) need/want to customize this 48 | */ 49 | listNestingMode?: ToolkitListNestMode 50 | } 51 | 52 | /** 53 | * Generic type for portable text rendering components that takes blocks/inline blocks 54 | * 55 | * @template N Node types we expect to be rendering (`PortableTextBlock` should usually be part of this) 56 | */ 57 | export type PortableTextComponent = ComponentType> 58 | 59 | /** 60 | * React component type for rendering portable text blocks (paragraphs, headings, blockquotes etc) 61 | */ 62 | export type PortableTextBlockComponent = PortableTextComponent 63 | 64 | /** 65 | * React component type for rendering (virtual, not part of the spec) portable text lists 66 | */ 67 | export type PortableTextListComponent = PortableTextComponent 68 | 69 | /** 70 | * React component type for rendering portable text list items 71 | */ 72 | export type PortableTextListItemComponent = PortableTextComponent 73 | 74 | /** 75 | * React component type for rendering portable text marks and/or decorators 76 | * 77 | * @template M The mark type we expect 78 | */ 79 | export type PortableTextMarkComponent< 80 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 81 | M extends TypedObject = any, 82 | > = ComponentType> 83 | 84 | export type PortableTextTypeComponent< 85 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 86 | V extends TypedObject = any, 87 | > = ComponentType> 88 | 89 | /** 90 | * Object defining the different React 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 React components to use for rendering various aspects 97 | * of Portable Text and user-provided types. 98 | */ 99 | export interface PortableTextReactComponents { 100 | /** 101 | * Object of React 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: ReactComponent}`, where `typeName` is the value set 108 | * in individual `_type` attributes. 109 | */ 110 | types: Record 111 | 112 | /** 113 | * Object of React components that renders different types of marks that might appear in spans. 114 | * 115 | * The object has the shape `{markName: ReactComponent}`, 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 React components that renders blocks with different `style` properties. 122 | * 123 | * The object has the shape `{styleName: ReactComponent}`, where `styleName` is the value set 124 | * in individual `style` attributes on blocks. 125 | * 126 | * Can also be set to a single React component, which would handle block styles of _any_ type. 127 | */ 128 | block: 129 | | Record 130 | | PortableTextBlockComponent 131 | 132 | /** 133 | * Object of React 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 React component, which would handle lists of _any_ type. 141 | */ 142 | list: 143 | | Record 144 | | PortableTextListComponent 145 | 146 | /** 147 | * Object of React components used to render different list item styles. 148 | * 149 | * The object has the shape `{listItemType: ReactComponent}`, where `listItemType` is the value 150 | * set in individual `listItem` attributes on blocks. 151 | * 152 | * Can also be set to a single React 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 | // @TODO find a better way to handle this 163 | // eslint-disable-next-line @typescript-eslint/ban-types 164 | hardBreak: ComponentType<{}> | false 165 | 166 | /** 167 | * React component used when encountering a mark type there is no registered component for 168 | * in the `components.marks` prop. 169 | */ 170 | unknownMark: PortableTextMarkComponent 171 | 172 | /** 173 | * React component used when encountering an object type there is no registered component for 174 | * in the `components.types` prop. 175 | */ 176 | unknownType: PortableTextComponent 177 | 178 | /** 179 | * React component used when encountering a block style there is no registered component for 180 | * in the `components.block` prop. Only used if `components.block` is an object. 181 | */ 182 | unknownBlockStyle: PortableTextComponent 183 | 184 | /** 185 | * React component used when encountering a list style there is no registered component for 186 | * in the `components.list` prop. Only used if `components.list` is an object. 187 | */ 188 | unknownList: PortableTextComponent 189 | 190 | /** 191 | * React component used when encountering a list item style there is no registered component for 192 | * in the `components.listItem` prop. Only used if `components.listItem` is an object. 193 | */ 194 | unknownListItem: PortableTextComponent 195 | } 196 | 197 | /** 198 | * Props received by most Portable Text components 199 | * 200 | * @template T Type of data this component will receive in its `value` property 201 | */ 202 | export interface PortableTextComponentProps { 203 | /** 204 | * Data associated with this portable text node, eg the raw JSON value of a block/type 205 | */ 206 | value: T 207 | 208 | /** 209 | * Index within its parent 210 | */ 211 | index: number 212 | 213 | /** 214 | * Whether or not this node is "inline" - ie as a child of a text block, 215 | * alongside text spans, or a block in and of itself. 216 | */ 217 | isInline: boolean 218 | 219 | /** 220 | * React child nodes of this block/component 221 | */ 222 | children?: ReactNode 223 | 224 | /** 225 | * Function used to render any node that might appear in a portable text array or block, 226 | * including virtual "toolkit"-nodes like lists and nested spans. You will rarely need 227 | * to use this. 228 | */ 229 | renderNode: NodeRenderer 230 | } 231 | 232 | /** 233 | * Props received by any user-defined type in the input array that is not a text block 234 | * 235 | * @template T Type of data this component will receive in its `value` property 236 | */ 237 | export type PortableTextTypeComponentProps = Omit, 'children'> 238 | 239 | /** 240 | * Props received by Portable Text mark rendering components 241 | * 242 | * @template M Shape describing the data associated with this mark, if it is an annotation 243 | */ 244 | export interface PortableTextMarkComponentProps { 245 | /** 246 | * Mark definition, eg the actual data of the annotation. If the mark is a simple decorator, this will be `undefined` 247 | */ 248 | value?: M 249 | 250 | /** 251 | * Text content of this mark 252 | */ 253 | text: string 254 | 255 | /** 256 | * 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 React keys. 257 | */ 258 | markKey?: string 259 | 260 | /** 261 | * Type of mark - ie value of `_type` in the case of annotations, or the name of the decorator otherwise - eg `em`, `italic`. 262 | */ 263 | markType: string 264 | 265 | /** 266 | * React child nodes of this mark 267 | */ 268 | children: ReactNode 269 | 270 | /** 271 | * Function used to render any node that might appear in a portable text array or block, 272 | * including virtual "toolkit"-nodes like lists and nested spans. You will rarely need 273 | * to use this. 274 | */ 275 | renderNode: NodeRenderer 276 | } 277 | 278 | /** 279 | * Any node type that we can't identify - eg it has an `_type`, 280 | * but we don't know anything about its other properties 281 | */ 282 | export type UnknownNodeType = {[key: string]: unknown; _type: string} | TypedObject 283 | 284 | /** 285 | * Function that renders any node that might appear in a portable text array or block, 286 | * including virtual "toolkit"-nodes like lists and nested spans 287 | */ 288 | export type NodeRenderer = (options: Serializable) => ReactNode 289 | 290 | export type NodeType = 'block' | 'mark' | 'blockStyle' | 'listStyle' | 'listItemStyle' 291 | 292 | export type MissingComponentHandler = ( 293 | message: string, 294 | options: {type: string; nodeType: NodeType}, 295 | ) => void 296 | 297 | export interface Serializable { 298 | node: T 299 | index: number 300 | isInline: boolean 301 | renderNode: NodeRenderer 302 | } 303 | 304 | export interface SerializedBlock { 305 | _key: string 306 | children: ReactNode 307 | index: number 308 | isInline: boolean 309 | node: PortableTextBlock | PortableTextListItemBlock 310 | } 311 | 312 | // Re-exporting these as we don't want to refer to "toolkit" outside of this module 313 | 314 | /** 315 | * A virtual "list" node for Portable Text - not strictly part of Portable Text, 316 | * but generated by this library to ease the rendering of lists in HTML etc 317 | */ 318 | export type ReactPortableTextList = ToolkitPortableTextList 319 | 320 | /** 321 | * A virtual "list item" node for Portable Text - not strictly any different from a 322 | * regular Portable Text Block, but we can guarantee that it has a `listItem` property. 323 | */ 324 | export type ReactPortableTextListItem = ToolkitPortableTextListItem 325 | -------------------------------------------------------------------------------- /src/warnings.ts: -------------------------------------------------------------------------------- 1 | const getTemplate = (type: string, prop: string): string => 2 | `[@portabletext/react] 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/components.test.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/server' 2 | import {test} from 'vitest' 3 | 4 | import {PortableTextProps} from '../src' 5 | import {PortableText} from '../src/react-portable-text' 6 | 7 | const render = (props: PortableTextProps) => 8 | ReactDOM.renderToStaticMarkup() 9 | 10 | test('can override unknown mark component', ({expect}) => { 11 | const result = render({ 12 | value: { 13 | _type: 'block', 14 | markDefs: [{_key: 'unknown-mark', _type: 'unknown-mark'}], 15 | children: [ 16 | {_type: 'span', marks: ['unknown-deco'], text: 'simple'}, 17 | {_type: 'span', marks: ['unknown-mark'], text: 'advanced'}, 18 | ], 19 | }, 20 | components: { 21 | unknownMark: ({children, markType}) => ( 22 | 23 | Unknown ({markType}): {children} 24 | 25 | ), 26 | }, 27 | }) 28 | expect(result).toBe( 29 | '

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

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

          Let's test some of these lists!

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

          Let's test some of these lists!

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

          Span

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

          Just a block

          ', 140 | ] 141 | .map((line) => line.trim()) 142 | .join(''), 143 | } 144 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/fixtures/016-deep-weird-lists.ts: -------------------------------------------------------------------------------- 1 | import type {PortableTextBlock} from '@portabletext/types' 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | listItem: 'bullet', 6 | style: 'normal', 7 | level: 1, 8 | _type: 'block', 9 | _key: 'fde2e840a29c', 10 | markDefs: [], 11 | children: [ 12 | { 13 | _type: 'span', 14 | text: 'Item a', 15 | marks: [], 16 | }, 17 | ], 18 | }, 19 | { 20 | listItem: 'bullet', 21 | style: 'normal', 22 | level: 1, 23 | _type: 'block', 24 | _key: 'c16f11c71638', 25 | markDefs: [], 26 | children: [ 27 | { 28 | _type: 'span', 29 | text: 'Item b', 30 | marks: [], 31 | }, 32 | ], 33 | }, 34 | { 35 | listItem: 'number', 36 | style: 'normal', 37 | level: 1, 38 | _type: 'block', 39 | _key: 'e92f55b185ae', 40 | markDefs: [], 41 | children: [ 42 | { 43 | _type: 'span', 44 | text: 'Item 1', 45 | marks: [], 46 | }, 47 | ], 48 | }, 49 | { 50 | listItem: 'number', 51 | style: 'normal', 52 | level: 1, 53 | _type: 'block', 54 | _key: 'a77e71209aff', 55 | markDefs: [], 56 | children: [ 57 | { 58 | _type: 'span', 59 | text: 'Item 2', 60 | marks: [], 61 | }, 62 | ], 63 | }, 64 | { 65 | listItem: 'number', 66 | style: 'normal', 67 | level: 2, 68 | _type: 'block', 69 | _key: 'da1f863df265', 70 | markDefs: [], 71 | children: [ 72 | { 73 | _type: 'span', 74 | text: 'Item 2, a', 75 | marks: [], 76 | }, 77 | ], 78 | }, 79 | { 80 | listItem: 'number', 81 | style: 'normal', 82 | level: 2, 83 | _type: 'block', 84 | _key: '60d8c92bed0d', 85 | markDefs: [], 86 | children: [ 87 | { 88 | _type: 'span', 89 | text: 'Item 2, b', 90 | marks: [], 91 | }, 92 | ], 93 | }, 94 | { 95 | listItem: 'number', 96 | style: 'normal', 97 | level: 1, 98 | _type: 'block', 99 | _key: '6dbc061d5d36', 100 | markDefs: [], 101 | children: [ 102 | { 103 | _type: 'span', 104 | text: 'Item 3', 105 | marks: [], 106 | }, 107 | ], 108 | }, 109 | { 110 | style: 'normal', 111 | _type: 'block', 112 | _key: 'bb89bd1ef2c9', 113 | markDefs: [], 114 | children: [ 115 | { 116 | _type: 'span', 117 | text: '', 118 | marks: [], 119 | }, 120 | ], 121 | }, 122 | { 123 | listItem: 'bullet', 124 | style: 'normal', 125 | level: 1, 126 | _type: 'block', 127 | _key: '289c1f176eab', 128 | markDefs: [], 129 | children: [ 130 | { 131 | _type: 'span', 132 | text: 'In', 133 | marks: [], 134 | }, 135 | ], 136 | }, 137 | { 138 | listItem: 'bullet', 139 | style: 'normal', 140 | level: 2, 141 | _type: 'block', 142 | _key: '011f8cc6d19b', 143 | markDefs: [], 144 | children: [ 145 | { 146 | _type: 'span', 147 | text: 'Out', 148 | marks: [], 149 | }, 150 | ], 151 | }, 152 | { 153 | listItem: 'bullet', 154 | style: 'normal', 155 | level: 1, 156 | _type: 'block', 157 | _key: 'ccfb4e37b798', 158 | markDefs: [], 159 | children: [ 160 | { 161 | _type: 'span', 162 | text: 'In', 163 | marks: [], 164 | }, 165 | ], 166 | }, 167 | { 168 | listItem: 'bullet', 169 | style: 'normal', 170 | level: 2, 171 | _type: 'block', 172 | _key: 'bd0102405e5c', 173 | markDefs: [], 174 | children: [ 175 | { 176 | _type: 'span', 177 | text: 'Out', 178 | marks: [], 179 | }, 180 | ], 181 | }, 182 | { 183 | listItem: 'bullet', 184 | style: 'normal', 185 | level: 3, 186 | _type: 'block', 187 | _key: '030fda546030', 188 | markDefs: [], 189 | children: [ 190 | { 191 | _type: 'span', 192 | text: 'Even More', 193 | marks: [], 194 | }, 195 | ], 196 | }, 197 | { 198 | listItem: 'bullet', 199 | style: 'normal', 200 | level: 4, 201 | _type: 'block', 202 | _key: '80369435aed0', 203 | markDefs: [], 204 | children: [ 205 | { 206 | _type: 'span', 207 | text: 'Even deeper', 208 | marks: [], 209 | }, 210 | ], 211 | }, 212 | { 213 | listItem: 'bullet', 214 | style: 'normal', 215 | level: 2, 216 | _type: 'block', 217 | _key: '3b36919a8914', 218 | markDefs: [], 219 | children: [ 220 | { 221 | _type: 'span', 222 | text: 'Two steps back', 223 | marks: [], 224 | }, 225 | ], 226 | }, 227 | { 228 | listItem: 'bullet', 229 | style: 'normal', 230 | level: 1, 231 | _type: 'block', 232 | _key: '9193cbc6ba54', 233 | markDefs: [], 234 | children: [ 235 | { 236 | _type: 'span', 237 | text: 'All the way back', 238 | marks: [], 239 | }, 240 | ], 241 | }, 242 | { 243 | listItem: 'bullet', 244 | style: 'normal', 245 | level: 3, 246 | _type: 'block', 247 | _key: '256fe8487d7a', 248 | markDefs: [], 249 | children: [ 250 | { 251 | _type: 'span', 252 | text: 'Skip a step', 253 | marks: [], 254 | }, 255 | ], 256 | }, 257 | { 258 | listItem: 'number', 259 | style: 'normal', 260 | level: 1, 261 | _type: 'block', 262 | _key: 'aaa', 263 | markDefs: [], 264 | children: [ 265 | { 266 | _type: 'span', 267 | text: 'New list', 268 | marks: [], 269 | }, 270 | ], 271 | }, 272 | { 273 | listItem: 'number', 274 | style: 'normal', 275 | level: 2, 276 | _type: 'block', 277 | _key: 'bbb', 278 | markDefs: [], 279 | children: [ 280 | { 281 | _type: 'span', 282 | text: 'Next level', 283 | marks: [], 284 | }, 285 | ], 286 | }, 287 | { 288 | listItem: 'bullet', 289 | style: 'normal', 290 | level: 1, 291 | _type: 'block', 292 | _key: 'ccc', 293 | markDefs: [], 294 | children: [ 295 | { 296 | _type: 'span', 297 | text: 'New bullet list', 298 | marks: [], 299 | }, 300 | ], 301 | }, 302 | ] 303 | 304 | export default { 305 | input, 306 | output: [ 307 | '
            ', 308 | '
          • Item a
          • ', 309 | '
          • Item b
          • ', 310 | '
          ', 311 | '
            ', 312 | '
          1. Item 1
          2. ', 313 | '
          3. ', 314 | 'Item 2', 315 | '
              ', 316 | '
            1. Item 2, a
            2. ', 317 | '
            3. Item 2, b
            4. ', 318 | '
            ', 319 | '
          4. ', 320 | '
          5. Item 3
          6. ', 321 | '
          ', 322 | '

          ', 323 | '
            ', 324 | '
          • ', 325 | 'In', 326 | '
              ', 327 | '
            • Out
            • ', 328 | '
            ', 329 | '
          • ', 330 | '
          • ', 331 | 'In', 332 | '
              ', 333 | '
            • ', 334 | 'Out', 335 | '
                ', 336 | '
              • ', 337 | 'Even More', 338 | '
                  ', 339 | '
                • Even deeper
                • ', 340 | '
                ', 341 | '
              • ', 342 | '
              ', 343 | '
            • ', 344 | '
            • Two steps back
            • ', 345 | '
            ', 346 | '
          • ', 347 | '
          • ', 348 | 'All the way back', 349 | '
              ', 350 | '
            • Skip a step
            • ', 351 | '
            ', 352 | '
          • ', 353 | '
          ', 354 | '
            ', 355 | '
          1. New list
            1. Next level
          2. ', 356 | '
          ', 357 | '
          • New bullet list
          ', 358 | ].join(''), 359 | } 360 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/020-empty-array.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | input: [], 3 | output: '', 4 | } 5 | -------------------------------------------------------------------------------- /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/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/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/024-inline-objects.ts: -------------------------------------------------------------------------------- 1 | import type {PortableTextBlock} from '@portabletext/types' 2 | 3 | const input: PortableTextBlock[] = [ 4 | { 5 | _key: '08707ed2945b', 6 | _type: 'block', 7 | style: 'normal', 8 | children: [ 9 | { 10 | _key: '08707ed2945b0', 11 | text: 'Foo! Bar!', 12 | _type: 'span', 13 | marks: ['code'], 14 | }, 15 | { 16 | _key: 'a862cadb584f', 17 | _type: 'localCurrency', 18 | sourceCurrency: 'USD', 19 | sourceAmount: 13.5, 20 | }, 21 | {_key: '08707ed2945b1', text: 'Neat', _type: 'span', marks: []}, 22 | ], 23 | markDefs: [], 24 | }, 25 | 26 | { 27 | _key: 'abc', 28 | _type: 'block', 29 | style: 'normal', 30 | children: [ 31 | { 32 | _key: '08707ed2945b0', 33 | text: 'Foo! Bar! ', 34 | _type: 'span', 35 | marks: ['code'], 36 | }, 37 | { 38 | _key: 'a862cadb584f', 39 | _type: 'localCurrency', 40 | sourceCurrency: 'DKK', 41 | sourceAmount: 200, 42 | }, 43 | {_key: '08707ed2945b1', text: ' Baz!', _type: 'span', marks: ['code']}, 44 | ], 45 | markDefs: [], 46 | }, 47 | 48 | { 49 | _key: 'def', 50 | _type: 'block', 51 | style: 'normal', 52 | children: [ 53 | { 54 | _key: '08707ed2945b0', 55 | text: 'Foo! Bar! ', 56 | _type: 'span', 57 | marks: [], 58 | }, 59 | { 60 | _key: 'a862cadb584f', 61 | _type: 'localCurrency', 62 | sourceCurrency: 'EUR', 63 | sourceAmount: 25, 64 | }, 65 | {_key: '08707ed2945b1', text: ' Baz!', _type: 'span', marks: ['code']}, 66 | ], 67 | markDefs: [], 68 | }, 69 | ] 70 | 71 | export default { 72 | input, 73 | output: 74 | '

          Foo! Bar!~119 NOKNeat

          Foo! Bar! ~270 NOK Baz!

          Foo! Bar! ~251 NOK Baz!

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

          Men, , da!

          ', 19 | } 20 | -------------------------------------------------------------------------------- /test/fixtures/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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 | allBasicMarks, 35 | allDefaultBlockStyles, 36 | basicBulletList, 37 | basicMarkMultipleAdjacentSpans, 38 | basicMarkNestedMarks, 39 | basicMarkSingleSpan, 40 | basicNumberedList, 41 | customBlockType, 42 | customBlockTypeWithChildren, 43 | customListItemType, 44 | customMarks, 45 | deepWeirdLists, 46 | emptyArray, 47 | emptyBlock, 48 | hardBreaks, 49 | inlineBlockWithText, 50 | inlineNodes, 51 | inlineObjects, 52 | keyless, 53 | linkMarkDef, 54 | listIssue, 55 | listWithoutLevel, 56 | marksAllTheWayDown, 57 | messyLinkText, 58 | missingMarkComponent, 59 | multipleSpans, 60 | nestedLists, 61 | overrideDefaultMarks, 62 | plainHeaderBlock, 63 | singleSpan, 64 | styledListItems, 65 | } 66 | -------------------------------------------------------------------------------- /test/mutations.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import ReactDOM from 'react-dom/server' 3 | import {test} from 'vitest' 4 | 5 | import {PortableText} from '../src/react-portable-text' 6 | import type {PortableTextProps, PortableTextReactComponents} from '../src/types' 7 | import * as fixtures from './fixtures' 8 | 9 | const render = (props: PortableTextProps) => 10 | ReactDOM.renderToStaticMarkup() 11 | 12 | test('never mutates input', ({expect}) => { 13 | for (const [key, fixture] of Object.entries(fixtures)) { 14 | if (key === 'default') { 15 | continue 16 | } 17 | 18 | const highlight = () => 19 | const components: Partial = { 20 | marks: {highlight}, 21 | unknownMark: ({children}) => {children}, 22 | unknownType: ({children}) =>
          {children}
          , 23 | } 24 | const originalInput = JSON.parse(JSON.stringify(fixture.input)) 25 | const passedInput = fixture.input 26 | try { 27 | render({ 28 | value: passedInput as any, 29 | components, 30 | }) 31 | } catch (error) { 32 | // ignore 33 | } 34 | expect(originalInput).toStrictEqual(passedInput) 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /test/portable-text.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/no-unused-vars */ 3 | import {Fragment} from 'react' 4 | import ReactDOM from 'react-dom/server' 5 | import {test} from 'vitest' 6 | 7 | import {PortableText} from '../src/react-portable-text' 8 | import { 9 | MissingComponentHandler, 10 | PortableTextMarkComponent, 11 | PortableTextProps, 12 | PortableTextReactComponents, 13 | } from '../src/types' 14 | import * as fixtures from './fixtures' 15 | 16 | const render = (props: PortableTextProps) => 17 | ReactDOM.renderToStaticMarkup() 18 | 19 | test('builds empty tree on empty block', ({expect}) => { 20 | const {input, output} = fixtures.emptyBlock 21 | const result = render({value: input}) 22 | expect(result).toEqual(output) 23 | }) 24 | 25 | test('builds simple one-node tree on single, markless span', ({expect}) => { 26 | const {input, output} = fixtures.singleSpan 27 | const result = render({value: input}) 28 | expect(result).toEqual(output) 29 | }) 30 | 31 | test('builds simple multi-node tree on markless spans', ({expect}) => { 32 | const {input, output} = fixtures.multipleSpans 33 | const result = render({value: input}) 34 | expect(result).toEqual(output) 35 | }) 36 | 37 | test('builds annotated span on simple mark', ({expect}) => { 38 | const {input, output} = fixtures.basicMarkSingleSpan 39 | const result = render({value: input}) 40 | expect(result).toEqual(output) 41 | }) 42 | 43 | test('builds annotated, joined span on adjacent, equal marks', ({expect}) => { 44 | const {input, output} = fixtures.basicMarkMultipleAdjacentSpans 45 | const result = render({value: input}) 46 | expect(result).toEqual(output) 47 | }) 48 | 49 | test('builds annotated, nested spans in tree format', ({expect}) => { 50 | const {input, output} = fixtures.basicMarkNestedMarks 51 | const result = render({value: input}) 52 | expect(result).toEqual(output) 53 | }) 54 | 55 | test('builds annotated spans with expanded marks on object-style marks', ({expect}) => { 56 | const {input, output} = fixtures.linkMarkDef 57 | const result = render({value: input}) 58 | expect(result).toEqual(output) 59 | }) 60 | 61 | test('builds correct structure from advanced, nested mark structure', ({expect}) => { 62 | const {input, output} = fixtures.messyLinkText 63 | const result = render({value: input}) 64 | expect(result).toEqual(output) 65 | }) 66 | 67 | test('builds bullet lists in parent container', ({expect}) => { 68 | const {input, output} = fixtures.basicBulletList 69 | const result = render({value: input}) 70 | expect(result).toEqual(output) 71 | }) 72 | 73 | test('builds numbered lists in parent container', ({expect}) => { 74 | const {input, output} = fixtures.basicNumberedList 75 | const result = render({value: input}) 76 | expect(result).toEqual(output) 77 | }) 78 | 79 | test('builds nested lists', ({expect}) => { 80 | const {input, output} = fixtures.nestedLists 81 | const result = render({value: input}) 82 | expect(result).toEqual(output) 83 | }) 84 | 85 | test('builds all basic marks as expected', ({expect}) => { 86 | const {input, output} = fixtures.allBasicMarks 87 | const result = render({value: input}) 88 | expect(result).toEqual(output) 89 | }) 90 | 91 | test('builds weirdly complex lists without any issues', ({expect}) => { 92 | const {input, output} = fixtures.deepWeirdLists 93 | const result = render({value: input}) 94 | expect(result).toEqual(output) 95 | }) 96 | 97 | test('renders all default block styles', ({expect}) => { 98 | const {input, output} = fixtures.allDefaultBlockStyles 99 | const result = render({value: input}) 100 | expect(result).toEqual(output) 101 | }) 102 | 103 | test('sorts marks correctly on equal number of occurences', ({expect}) => { 104 | const {input, output} = fixtures.marksAllTheWayDown 105 | const marks: PortableTextReactComponents['marks'] = { 106 | highlight: ({value, children}) => ( 107 | {children} 108 | ), 109 | } 110 | const result = render({value: input, components: {marks}}) 111 | expect(result).toEqual(output) 112 | }) 113 | 114 | test('handles keyless blocks/spans', ({expect}) => { 115 | const {input, output} = fixtures.keyless 116 | const result = render({value: input}) 117 | expect(result).toEqual(output) 118 | }) 119 | 120 | test('handles empty arrays', ({expect}) => { 121 | const {input, output} = fixtures.emptyArray 122 | const result = render({value: input}) 123 | expect(result).toEqual(output) 124 | }) 125 | 126 | test('handles lists without level', ({expect}) => { 127 | const {input, output} = fixtures.listWithoutLevel 128 | const result = render({value: input}) 129 | expect(result).toEqual(output) 130 | }) 131 | 132 | test('handles inline non-span nodes', ({expect}) => { 133 | const {input, output} = fixtures.inlineNodes 134 | const result = render({ 135 | value: input, 136 | components: { 137 | types: { 138 | rating: ({value}) => { 139 | return 140 | }, 141 | }, 142 | }, 143 | }) 144 | expect(result).toEqual(output) 145 | }) 146 | 147 | test('handles hardbreaks', ({expect}) => { 148 | const {input, output} = fixtures.hardBreaks 149 | const result = render({value: input}) 150 | expect(result).toEqual(output) 151 | }) 152 | 153 | test('can disable hardbreak component', ({expect}) => { 154 | const {input, output} = fixtures.hardBreaks 155 | const result = render({value: input, components: {hardBreak: false}}) 156 | expect(result).toEqual(output.replace(//g, '\n')) 157 | }) 158 | 159 | test('can customize hardbreak component', ({expect}) => { 160 | const {input, output} = fixtures.hardBreaks 161 | const hardBreak = () =>
          162 | const result = render({value: input, components: {hardBreak}}) 163 | expect(result).toEqual(output.replace(//g, '
          ')) 164 | }) 165 | 166 | test('can nest marks correctly in block/marks context', ({expect}) => { 167 | const {input, output} = fixtures.inlineObjects 168 | const result = render({ 169 | value: input, 170 | components: { 171 | types: { 172 | localCurrency: ({value}) => { 173 | // in the real world we'd look up the users local currency, 174 | // do some rate calculations and render the result. Obviously. 175 | const rates: Record = {USD: 8.82, DKK: 1.35, EUR: 10.04} 176 | const rate = rates[value.sourceCurrency] || 1 177 | return ~{Math.round(value.sourceAmount * rate)} NOK 178 | }, 179 | }, 180 | }, 181 | }) 182 | 183 | expect(result).toEqual(output) 184 | }) 185 | 186 | test('can render inline block with text property', ({expect}) => { 187 | const {input, output} = fixtures.inlineBlockWithText 188 | const result = render({ 189 | value: input, 190 | components: {types: {button: (props) => }}, 191 | }) 192 | expect(result).toEqual(output) 193 | }) 194 | 195 | test('can render styled list items', ({expect}) => { 196 | const {input, output} = fixtures.styledListItems 197 | const result = render({value: input}) 198 | expect(result).toEqual(output) 199 | }) 200 | 201 | test('can render custom list item styles with fallback', ({expect}) => { 202 | const {input, output} = fixtures.customListItemType 203 | const result = render({value: input}) 204 | expect(result).toEqual(output) 205 | }) 206 | 207 | test('can render custom list item styles with provided list style component', ({expect}) => { 208 | const {input} = fixtures.customListItemType 209 | const result = render({ 210 | value: input, 211 | components: {list: {square: ({children}) =>
            {children}
          }}, 212 | }) 213 | expect(result).toBe( 214 | '
          • Square 1
          • Square 2
            • Dat disc
          • Square 3
          ', 215 | ) 216 | }) 217 | 218 | test('can render custom list item styles with provided list style component', ({expect}) => { 219 | const {input} = fixtures.customListItemType 220 | const result = render({ 221 | value: input, 222 | components: { 223 | listItem: { 224 | square: ({children}) =>
        1. {children}
        2. , 225 | }, 226 | }, 227 | }) 228 | expect(result).toBe( 229 | '
          • Square 1
          • Square 2
            • Dat disc
          • Square 3
          ', 230 | ) 231 | }) 232 | 233 | test('warns on missing list style component', ({expect}) => { 234 | const {input} = fixtures.customListItemType 235 | const result = render({ 236 | value: input, 237 | components: {list: {}}, 238 | }) 239 | expect(result).toBe( 240 | '
          • Square 1
          • Square 2
            • Dat disc
          • Square 3
          ', 241 | ) 242 | }) 243 | 244 | test('can render styled list items with custom list item component', ({expect}) => { 245 | const {input, output} = fixtures.styledListItems 246 | const result = render({ 247 | value: input, 248 | components: { 249 | listItem: ({children}) => { 250 | return
        3. {children}
        4. 251 | }, 252 | }, 253 | }) 254 | expect(result).toEqual(output) 255 | }) 256 | 257 | test('can specify custom component for custom block types', ({expect}) => { 258 | const {input, output} = fixtures.customBlockType 259 | const types: Partial['types'] = { 260 | code: ({renderNode, ...props}) => { 261 | expect(props).toEqual({ 262 | value: { 263 | _key: '9a15ea2ed8a2', 264 | _type: 'code', 265 | code: input[0]?.code, 266 | language: 'javascript', 267 | }, 268 | index: 0, 269 | isInline: false, 270 | }) 271 | return ( 272 |
          273 |           {props.value.code}
          274 |         
          275 | ) 276 | }, 277 | } 278 | const result = render({value: input, components: {types}}) 279 | expect(result).toEqual(output) 280 | }) 281 | 282 | test('can specify custom component for custom block types with children', ({expect}) => { 283 | const {input, output} = fixtures.customBlockTypeWithChildren 284 | const types: Partial['types'] = { 285 | quote: ({renderNode, ...props}) => { 286 | expect(props).toEqual({ 287 | value: { 288 | _type: 'quote', 289 | _key: '9a15ea2ed8a2', 290 | background: 'blue', 291 | children: [ 292 | { 293 | _type: 'span', 294 | _key: '9a15ea2ed8a2', 295 | text: 'This is an inspirational quote', 296 | }, 297 | ], 298 | }, 299 | index: 0, 300 | isInline: false, 301 | }) 302 | 303 | return ( 304 |

          305 | {props.value.children.map(({text}: any) => ( 306 | Customers say: {text} 307 | ))} 308 |

          309 | ) 310 | }, 311 | } 312 | const result = render({value: input, components: {types}}) 313 | expect(result).toEqual(output) 314 | }) 315 | 316 | test('can specify custom components for custom marks', ({expect}) => { 317 | const {input, output} = fixtures.customMarks 318 | const highlight: PortableTextMarkComponent<{_type: 'highlight'; thickness: number}> = ({ 319 | value, 320 | children, 321 | }) => {children} 322 | 323 | const result = render({value: input, components: {marks: {highlight}}}) 324 | expect(result).toEqual(output) 325 | }) 326 | 327 | test('can specify custom components for defaults marks', ({expect}) => { 328 | const {input, output} = fixtures.overrideDefaultMarks 329 | const link: PortableTextMarkComponent<{_type: 'link'; href: string}> = ({value, children}) => ( 330 | 331 | {children} 332 | 333 | ) 334 | 335 | const result = render({value: input, components: {marks: {link}}}) 336 | expect(result).toEqual(output) 337 | }) 338 | 339 | test('falls back to default component for missing mark components', ({expect}) => { 340 | const {input, output} = fixtures.missingMarkComponent 341 | const result = render({value: input}) 342 | expect(result).toEqual(output) 343 | }) 344 | 345 | test('can register custom `missing component` handler', ({expect}) => { 346 | let warning = '' 347 | const onMissingComponent: MissingComponentHandler = (message) => { 348 | warning = message 349 | } 350 | 351 | const {input} = fixtures.missingMarkComponent 352 | render({value: input, onMissingComponent}) 353 | expect(warning).toBe( 354 | '[@portabletext/react] Unknown mark type "abc", specify a component for it in the `components.marks` prop', 355 | ) 356 | }) 357 | -------------------------------------------------------------------------------- /test/toPlainText.test.ts: -------------------------------------------------------------------------------- 1 | import {test} from 'vitest' 2 | 3 | import {toPlainText} from '../src' 4 | import * as fixtures from './fixtures' 5 | 6 | test('can extract text from all fixtures without crashing', ({expect}) => { 7 | for (const [key, fixture] of Object.entries(fixtures)) { 8 | if (key === 'default') { 9 | continue 10 | } 11 | 12 | const output = toPlainText(fixture.input) 13 | expect(output).toBeTypeOf('string') 14 | } 15 | }) 16 | 17 | test('can extract text from a properly formatted block', ({expect}) => { 18 | const text = toPlainText([ 19 | { 20 | _type: 'block', 21 | markDefs: [{_type: 'link', _key: 'a1b', href: 'https://some.url/'}], 22 | children: [ 23 | {_type: 'span', text: 'Plain '}, 24 | {_type: 'span', text: 'text', marks: ['em']}, 25 | {_type: 'span', text: ', even with '}, 26 | {_type: 'span', text: 'annotated value', marks: ['a1b']}, 27 | {_type: 'span', text: '.'}, 28 | ], 29 | }, 30 | { 31 | _type: 'otherBlockType', 32 | children: [{_type: 'span', text: 'Should work?'}], 33 | }, 34 | ]) 35 | 36 | expect(text).toBe('Plain text, even with annotated value.\n\nShould work?') 37 | }) 38 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "compilerOptions": { 5 | "rootDir": ".", 6 | "outDir": "./dist", 7 | 8 | "jsx": "preserve", 9 | "lib": ["ES2016", "DOM"], 10 | "noUncheckedIndexedAccess": true, 11 | "forceConsistentCasingInFileNames": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./demo", "./package.config.ts", "./package.json", "./src", "./test"], 4 | "compilerOptions": { 5 | "rootDir": ".", 6 | "outDir": "./dist", 7 | 8 | "jsx": "react-jsx", 9 | "lib": ["ES2016", "DOM"], 10 | "noUncheckedIndexedAccess": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Preserve", 4 | "target": "ES2020", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "sourceMap": true, 8 | 9 | // Strict type-checking 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "strictPropertyInitialization": true, 15 | "noImplicitThis": true, 16 | "alwaysStrict": true, 17 | 18 | // Additional checks 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noImplicitReturns": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "skipLibCheck": true, 24 | 25 | // Module resolution 26 | "allowSyntheticDefaultImports": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /vite.config.demo.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import path from 'path' 3 | import {visualizer} from 'rollup-plugin-visualizer' 4 | import {defineConfig} from 'vite' 5 | 6 | const pkg = require('./package.json') 7 | 8 | export default defineConfig({ 9 | plugins: [ 10 | react({babel: {plugins: [['babel-plugin-react-compiler', {target: '19'}]]}}), 11 | visualizer({ 12 | filename: path.join(__dirname, 'demo', 'dist', 'stats.html'), 13 | gzipSize: true, 14 | title: `${pkg.name}@${pkg.version} demo bundle analysis`, 15 | }), 16 | ], 17 | }) 18 | --------------------------------------------------------------------------------