├── .czrc ├── .editorconfig ├── .eslintignore ├── .github ├── renovate.json └── workflows │ ├── format-if-needed.yml │ ├── main.yml │ └── release-please.yml ├── .gitignore ├── .husky └── commit-msg ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.config.ts ├── package.json ├── pnpm-lock.yaml ├── src ├── asserters.ts ├── buildMarksTree.ts ├── index.ts ├── nestLists.ts ├── sortMarksByOccurences.ts ├── spanToPlainText.ts ├── toPlainText.ts └── types.ts ├── test ├── __snapshots__ │ ├── buildMarksTree.test.ts.snap │ └── nestLists.test.ts.snap ├── asserters.test.ts ├── buildMarksTree.test.ts ├── nestLists.test.ts ├── sortMarksByOccurences.test.ts ├── spanToPlainText.test.ts └── toPlainText.test.ts ├── tsconfig.dist.json ├── tsconfig.json ├── tsconfig.settings.json ├── typedoc.json └── vite.config.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 | /tap-snapshots 4 | /docs 5 | .nyc_output 6 | -------------------------------------------------------------------------------- /.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/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 --ignore-scripts 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 docs:build 57 | if: ${{ steps.release.outputs.releases_created }} 58 | - uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847 # v3 59 | if: ${{ steps.release.outputs.releases_created }} 60 | with: 61 | github_token: ${{ steps.generate-token.outputs.token }} 62 | publish_dir: ./docs 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 toolkit + docs 49 | /dist 50 | /docs 51 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm exec commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | docs 3 | pnpm-lock.yaml 4 | tap-snapshots 5 | -------------------------------------------------------------------------------- /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 | ## [2.0.17](https://github.com/portabletext/toolkit/compare/v2.0.16...v2.0.17) (2025-02-06) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * guard against `markDefs` being `null` ([#92](https://github.com/portabletext/toolkit/issues/92)) ([1f1644a](https://github.com/portabletext/toolkit/commit/1f1644a67d8e41f022d8626c193f7044ccc4469e)) 14 | * **typeError:** Marking `markDefs` as optional to resolve TS error when markDefs array is missing ([#96](https://github.com/portabletext/toolkit/issues/96)) ([eedd7c7](https://github.com/portabletext/toolkit/commit/eedd7c77c1551f79d883d115c77eaf1a2906011f)) 15 | 16 | ## [2.0.16](https://github.com/portabletext/toolkit/compare/v2.0.15...v2.0.16) (2024-11-02) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * prevent double spaces in `toPlainText` when span follows non-span ([#93](https://github.com/portabletext/toolkit/issues/93)) ([43b963f](https://github.com/portabletext/toolkit/commit/43b963fc0182c304564d2f460029609efabcb8c5)) 22 | 23 | ## [2.0.15](https://github.com/portabletext/toolkit/compare/v2.0.14...v2.0.15) (2024-04-11) 24 | 25 | 26 | ### Bug Fixes 27 | 28 | * **deps:** update dependency @portabletext/types to ^2.0.13 ([#85](https://github.com/portabletext/toolkit/issues/85)) ([e5a0247](https://github.com/portabletext/toolkit/commit/e5a024795279f43cbd6712af67d8eb686652fd16)) 29 | 30 | ## [2.0.14](https://github.com/portabletext/toolkit/compare/v2.0.13...v2.0.14) (2024-04-05) 31 | 32 | 33 | ### Bug Fixes 34 | 35 | * **deps:** update dependency @portabletext/types to ^2.0.12 ([#78](https://github.com/portabletext/toolkit/issues/78)) ([eaaa9a1](https://github.com/portabletext/toolkit/commit/eaaa9a1952c8200ad20c59e434d2ac53cfe19c45)) 36 | 37 | ## [2.0.13](https://github.com/portabletext/toolkit/compare/v2.0.12...v2.0.13) (2024-03-20) 38 | 39 | 40 | ### Bug Fixes 41 | 42 | * **deps:** update dependency @portabletext/types to ^2.0.11 ([#73](https://github.com/portabletext/toolkit/issues/73)) ([e85b103](https://github.com/portabletext/toolkit/commit/e85b10331997f24e60348b96b174df36fea69230)) 43 | 44 | ## [2.0.12](https://github.com/portabletext/toolkit/compare/v2.0.11...v2.0.12) (2024-03-18) 45 | 46 | 47 | ### Bug Fixes 48 | 49 | * **deps:** update dependency @portabletext/types to ^2.0.10 ([#70](https://github.com/portabletext/toolkit/issues/70)) ([2b8e7f6](https://github.com/portabletext/toolkit/commit/2b8e7f63f609f8917b72ee655fd0907e626dafe3)) 50 | 51 | ## [2.0.11](https://github.com/portabletext/toolkit/compare/v2.0.10...v2.0.11) (2024-03-16) 52 | 53 | 54 | ### Bug Fixes 55 | 56 | * **deps:** update dependency @portabletext/types to ^2.0.9 ([#65](https://github.com/portabletext/toolkit/issues/65)) ([83c5700](https://github.com/portabletext/toolkit/commit/83c5700720afa12c2856c60881931c05ec549741)) 57 | 58 | ## [2.0.10](https://github.com/portabletext/toolkit/compare/v2.0.9...v2.0.10) (2023-10-10) 59 | 60 | 61 | ### Bug Fixes 62 | 63 | * **deps:** update dependency @portabletext/types to ^2.0.8 ([#48](https://github.com/portabletext/toolkit/issues/48)) ([36cdd6f](https://github.com/portabletext/toolkit/commit/36cdd6f8786cc5474eb1a1f1306120691d852d8f)) 64 | 65 | ## [2.0.9](https://github.com/portabletext/toolkit/compare/v2.0.8...v2.0.9) (2023-09-28) 66 | 67 | 68 | ### Bug Fixes 69 | 70 | * **deps:** update dependency @portabletext/types to ^2.0.7 ([#40](https://github.com/portabletext/toolkit/issues/40)) ([16f8ee7](https://github.com/portabletext/toolkit/commit/16f8ee75322c1bb290dcff4d666f5e87a9c67f46)) 71 | 72 | ## [2.0.8](https://github.com/portabletext/toolkit/compare/v2.0.7...v2.0.8) (2023-08-23) 73 | 74 | ### Bug Fixes 75 | 76 | - **deps:** update dependency @portabletext/types to ^2.0.6 ([#35](https://github.com/portabletext/toolkit/issues/35)) ([0eadb67](https://github.com/portabletext/toolkit/commit/0eadb67f0c85736d2e64e37186ec2224f92399e9)) 77 | 78 | ## [2.0.7](https://github.com/portabletext/toolkit/compare/v2.0.6...v2.0.7) (2023-08-23) 79 | 80 | ### Bug Fixes 81 | 82 | - setup provenance ([94ec5cf](https://github.com/portabletext/toolkit/commit/94ec5cf0f3e83d0df3ba0649339fb58195686a45)) 83 | 84 | ## [2.0.6](https://github.com/portabletext/toolkit/compare/v2.0.5...v2.0.6) (2023-08-23) 85 | 86 | ### Bug Fixes 87 | 88 | - isPortableTextBlock not accounting for `markDefs: undefined` ([#25](https://github.com/portabletext/toolkit/issues/25)) ([db076c8](https://github.com/portabletext/toolkit/commit/db076c869e816c151308c47ce50858ef80d4eb76)) 89 | 90 | ## [2.0.5](https://github.com/portabletext/toolkit/compare/v2.0.4...v2.0.5) (2023-08-23) 91 | 92 | ### Bug Fixes 93 | 94 | - add `node.module` condition ([1b369ba](https://github.com/portabletext/toolkit/commit/1b369bac105ccdb78df28f1b95b2cbbdf0e7ee74)) 95 | 96 | ## [2.0.4](https://github.com/portabletext/toolkit/compare/v2.0.3...v2.0.4) (2023-06-26) 97 | 98 | ### Bug Fixes 99 | 100 | - **deps:** update non-major ([6a9347a](https://github.com/portabletext/toolkit/commit/6a9347ad8ad08400f3eb2284e072997bf4067d59)) 101 | 102 | ## [2.0.3](https://github.com/portabletext/toolkit/compare/v2.0.2...v2.0.3) (2023-06-26) 103 | 104 | ### Bug Fixes 105 | 106 | - refactor to `type: module` ([1dc6579](https://github.com/portabletext/toolkit/commit/1dc6579053980f6191007985bc9ca9a9d4532f7b)) 107 | 108 | ## [2.0.2](https://github.com/portabletext/toolkit/compare/v2.0.1...v2.0.2) (2023-06-23) 109 | 110 | ### Bug Fixes 111 | 112 | - remove ESM wrapper (not needed) ([be72cb2](https://github.com/portabletext/toolkit/commit/be72cb258481d95343bda35e20b3c063ca30e0e2)) 113 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @portabletext/toolkit 2 | 3 | [![npm version](https://img.shields.io/npm/v/@portabletext/toolkit.svg?style=flat-square)](https://www.npmjs.com/package/@portabletext/toolkit)[![npm bundle size](https://img.shields.io/bundlephobia/minzip/@portabletext/toolkit?style=flat-square)](https://bundlephobia.com/result?p=@portabletext/toolkit)[![Build Status](https://img.shields.io/github/actions/workflow/status/portabletext/toolkit/main.yml?branch=main&style=flat-square)](https://github.com/portabletext/toolkit/actions?query=workflow%3Atest) 4 | 5 | Javascript toolkit of handy utility functions for dealing with Portable Text. 6 | 7 | Particularly useful for building rendering libraries for Portable Text that outputs HTML. 8 | 9 | ## Installation 10 | 11 | ``` 12 | npm install --save @portabletext/toolkit 13 | ``` 14 | 15 | ## Documentation 16 | 17 | See [https://portabletext.github.io/toolkit/](https://portabletext.github.io/toolkit/) 18 | 19 | ## Usage 20 | 21 | ```ts 22 | import {toPlainText} from '@portabletext/toolkit' 23 | 24 | console.log(toPlainText(myPortableTextBlocks)) 25 | ``` 26 | 27 | ## License 28 | 29 | MIT © [Sanity.io](https://www.sanity.io/) 30 | -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from '@sanity/pkg-utils' 2 | 3 | export default defineConfig({ 4 | extract: { 5 | rules: { 6 | 'ae-missing-release-tag': 'off', 7 | 'tsdoc-undefined-tag': 'off', 8 | }, 9 | }, 10 | 11 | tsconfig: 'tsconfig.dist.json', 12 | 13 | babel: { 14 | plugins: ['@babel/plugin-transform-object-rest-spread'], 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@portabletext/toolkit", 3 | "version": "2.0.17", 4 | "description": "Toolkit of handy utility functions for dealing with Portable Text", 5 | "keywords": [ 6 | "sanity", 7 | "cms", 8 | "headless", 9 | "realtime", 10 | "content", 11 | "portable-text-toolkit" 12 | ], 13 | "homepage": "https://github.com/portabletext/toolkit#readme", 14 | "bugs": { 15 | "url": "https://github.com/portabletext/toolkit/issues" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+ssh://git@github.com/portabletext/toolkit.git" 20 | }, 21 | "license": "MIT", 22 | "author": "Sanity.io ", 23 | "sideEffects": false, 24 | "type": "module", 25 | "exports": { 26 | ".": { 27 | "source": "./src/index.ts", 28 | "import": "./dist/index.js", 29 | "require": "./dist/index.cjs", 30 | "default": "./dist/index.js" 31 | }, 32 | "./package.json": "./package.json" 33 | }, 34 | "main": "./dist/index.cjs", 35 | "module": "./dist/index.js", 36 | "types": "./dist/index.d.ts", 37 | "files": [ 38 | "dist", 39 | "src", 40 | "README.md" 41 | ], 42 | "scripts": { 43 | "build": "run-s clean pkg:build pkg:check", 44 | "clean": "rimraf dist coverage", 45 | "coverage": "vitest run --coverage", 46 | "docs:build": "typedoc", 47 | "format": "prettier --write --cache --ignore-unknown .", 48 | "lint": "eslint .", 49 | "pkg:build": "pkg-utils build --strict", 50 | "pkg:check": "pkg-utils --strict", 51 | "prepare": "husky install", 52 | "prepublishOnly": "run-s build lint type-check", 53 | "test": "vitest", 54 | "type-check": "tsc --noEmit" 55 | }, 56 | "commitlint": { 57 | "extends": [ 58 | "@commitlint/config-conventional" 59 | ] 60 | }, 61 | "lint-staged": { 62 | "*": [ 63 | "prettier --write --cache --ignore-unknown" 64 | ] 65 | }, 66 | "browserslist": "extends @sanity/browserslist-config", 67 | "prettier": { 68 | "bracketSpacing": false, 69 | "plugins": [ 70 | "prettier-plugin-packagejson" 71 | ], 72 | "printWidth": 100, 73 | "semi": false, 74 | "singleQuote": true 75 | }, 76 | "eslintConfig": { 77 | "parserOptions": { 78 | "ecmaFeatures": { 79 | "modules": true 80 | }, 81 | "ecmaVersion": 9, 82 | "sourceType": "module" 83 | }, 84 | "extends": [ 85 | "sanity", 86 | "sanity/typescript", 87 | "prettier" 88 | ], 89 | "ignorePatterns": [ 90 | "dist/**/" 91 | ] 92 | }, 93 | "dependencies": { 94 | "@portabletext/types": "^2.0.13" 95 | }, 96 | "devDependencies": { 97 | "@babel/core": "^7.24.7", 98 | "@babel/plugin-transform-object-rest-spread": "^7.24.7", 99 | "@commitlint/cli": "^19.3.0", 100 | "@commitlint/config-conventional": "^19.2.2", 101 | "@sanity/pkg-utils": "^6.10.0", 102 | "@types/node": "^20.8.7", 103 | "@typescript-eslint/eslint-plugin": "^7.13.1", 104 | "@typescript-eslint/parser": "^7.13.1", 105 | "@vitest/coverage-v8": "^1.6.0", 106 | "commitizen": "^4.3.0", 107 | "cz-conventional-changelog": "^3.3.0", 108 | "eslint": "^8.57.0", 109 | "eslint-config-prettier": "^9.1.0", 110 | "eslint-config-sanity": "^7.1.2", 111 | "husky": "^8.0.3", 112 | "npm-run-all2": "^5.0.2", 113 | "prettier": "^3.3.2", 114 | "prettier-plugin-packagejson": "^2.5.0", 115 | "rimraf": "^4.4.1", 116 | "typedoc": "^0.25.13", 117 | "typescript": "^5.4.5", 118 | "vitest": "^1.6.0", 119 | "vitest-github-actions-reporter": "^0.11.1" 120 | }, 121 | "packageManager": "pnpm@9.4.0", 122 | "engines": { 123 | "node": "^14.13.1 || >=16.0.0" 124 | }, 125 | "publishConfig": { 126 | "access": "public" 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/asserters.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ArbitraryTypedObject, 3 | PortableTextBlock, 4 | PortableTextListItemBlock, 5 | PortableTextSpan, 6 | TypedObject, 7 | } from '@portabletext/types' 8 | 9 | import type {ToolkitNestedPortableTextSpan, ToolkitPortableTextList, ToolkitTextNode} from './types' 10 | 11 | /** 12 | * Strict check to determine if node is a correctly formatted Portable Text span. 13 | * 14 | * @param node - Node to check 15 | * @returns True if valid Portable Text span, otherwise false 16 | */ 17 | export function isPortableTextSpan( 18 | node: ArbitraryTypedObject | PortableTextSpan, 19 | ): node is PortableTextSpan { 20 | return ( 21 | node._type === 'span' && 22 | 'text' in node && 23 | typeof node.text === 'string' && 24 | (typeof node.marks === 'undefined' || 25 | (Array.isArray(node.marks) && node.marks.every((mark) => typeof mark === 'string'))) 26 | ) 27 | } 28 | 29 | /** 30 | * Strict check to determine if node is a correctly formatted Portable Text block. 31 | * 32 | * @param node - Node to check 33 | * @returns True if valid Portable Text block, otherwise false 34 | */ 35 | export function isPortableTextBlock( 36 | node: PortableTextBlock | TypedObject, 37 | ): node is PortableTextBlock { 38 | return ( 39 | // A block doesn't _have_ to be named 'block' - to differentiate between 40 | // allowed child types and marks, one might name them differently 41 | typeof node._type === 'string' && 42 | // Toolkit-types like nested spans are @-prefixed 43 | node._type[0] !== '@' && 44 | // `markDefs` isn't _required_ per say, but if it's there, it needs to be an array 45 | (!('markDefs' in node) || 46 | !node.markDefs || 47 | (Array.isArray(node.markDefs) && 48 | // Every mark definition needs to have an `_key` to be mappable in child spans 49 | node.markDefs.every((def) => typeof def._key === 'string'))) && 50 | // `children` is required and needs to be an array 51 | 'children' in node && 52 | Array.isArray(node.children) && 53 | // All children are objects with `_type` (usually spans, but can contain other stuff) 54 | node.children.every((child) => typeof child === 'object' && '_type' in child) 55 | ) 56 | } 57 | 58 | /** 59 | * Strict check to determine if node is a correctly formatted portable list item block. 60 | * 61 | * @param block - Block to check 62 | * @returns True if valid Portable Text list item block, otherwise false 63 | */ 64 | export function isPortableTextListItemBlock( 65 | block: PortableTextBlock | TypedObject, 66 | ): block is PortableTextListItemBlock { 67 | return ( 68 | isPortableTextBlock(block) && 69 | 'listItem' in block && 70 | typeof block.listItem === 'string' && 71 | (typeof block.level === 'undefined' || typeof block.level === 'number') 72 | ) 73 | } 74 | 75 | /** 76 | * Loose check to determine if block is a toolkit list node. 77 | * Only checks `_type`, assumes correct structure. 78 | * 79 | * @param block - Block to check 80 | * @returns True if toolkit list, otherwise false 81 | */ 82 | export function isPortableTextToolkitList( 83 | block: TypedObject | ToolkitPortableTextList, 84 | ): block is ToolkitPortableTextList { 85 | return block._type === '@list' 86 | } 87 | 88 | /** 89 | * Loose check to determine if span is a toolkit span node. 90 | * Only checks `_type`, assumes correct structure. 91 | * 92 | * @param span - Span to check 93 | * @returns True if toolkit span, otherwise false 94 | */ 95 | export function isPortableTextToolkitSpan( 96 | span: TypedObject | ToolkitNestedPortableTextSpan, 97 | ): span is ToolkitNestedPortableTextSpan { 98 | return span._type === '@span' 99 | } 100 | 101 | /** 102 | * Loose check to determine if node is a toolkit text node. 103 | * Only checks `_type`, assumes correct structure. 104 | * 105 | * @param node - Node to check 106 | * @returns True if toolkit text node, otherwise false 107 | */ 108 | export function isPortableTextToolkitTextNode( 109 | node: TypedObject | ToolkitTextNode, 110 | ): node is ToolkitTextNode { 111 | return node._type === '@text' 112 | } 113 | -------------------------------------------------------------------------------- /src/buildMarksTree.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ArbitraryTypedObject, 3 | PortableTextBlock, 4 | PortableTextMarkDefinition, 5 | } from '@portabletext/types' 6 | 7 | import {isPortableTextSpan} from './asserters' 8 | import {sortMarksByOccurences} from './sortMarksByOccurences' 9 | import type {ToolkitNestedPortableTextSpan, ToolkitTextNode} from './types' 10 | 11 | /** 12 | * Takes a Portable Text block and returns a nested tree of nodes optimized for rendering 13 | * in HTML-like environments where you want marks/annotations to be nested inside of eachother. 14 | * For instance, a naive span-by-span rendering might yield: 15 | * 16 | * ```html 17 | * This block contains 18 | * a link 19 | * and some bolded and 20 | * italicized text 21 | * ``` 22 | * 23 | * ...whereas an optimal order would be: 24 | * 25 | * ```html 26 | * 27 | * This block contains a link 28 | * and some bolded and italicized text 29 | * 30 | * ``` 31 | * 32 | * Note that since "native" Portable Text spans cannot be nested, 33 | * this function returns an array of "toolkit specific" types: 34 | * {@link ToolkitTextNode | `@text`} and {@link ToolkitNestedPortableTextSpan | `@span` }. 35 | * 36 | * The toolkit-specific type can hold both types, as well as any arbitrary inline objects, 37 | * creating an actual tree. 38 | * 39 | * @param block - The Portable Text block to create a tree of nodes from 40 | * @returns Array of (potentially) nested spans, text nodes and/or arbitrary inline objects 41 | */ 42 | export function buildMarksTree( 43 | block: PortableTextBlock, 44 | ): (ToolkitNestedPortableTextSpan | ToolkitTextNode | ArbitraryTypedObject)[] { 45 | const {children} = block 46 | const markDefs = block.markDefs ?? [] 47 | if (!children || !children.length) { 48 | return [] 49 | } 50 | 51 | const sortedMarks = children.map(sortMarksByOccurences) 52 | 53 | const rootNode: ToolkitNestedPortableTextSpan = { 54 | _type: '@span', 55 | children: [], 56 | markType: '', 57 | } 58 | 59 | let nodeStack: ToolkitNestedPortableTextSpan[] = [rootNode] 60 | 61 | for (let i = 0; i < children.length; i++) { 62 | const span = children[i] 63 | if (!span) { 64 | continue 65 | } 66 | 67 | const marksNeeded = sortedMarks[i] || [] 68 | let pos = 1 69 | 70 | // Start at position one. Root is always plain and should never be removed 71 | if (nodeStack.length > 1) { 72 | for (pos; pos < nodeStack.length; pos++) { 73 | const mark = nodeStack[pos]?.markKey || '' 74 | const index = marksNeeded.indexOf(mark) 75 | 76 | if (index === -1) { 77 | break 78 | } 79 | 80 | marksNeeded.splice(index, 1) 81 | } 82 | } 83 | 84 | // Keep from beginning to first miss 85 | nodeStack = nodeStack.slice(0, pos) 86 | 87 | // Add needed nodes 88 | let currentNode = nodeStack[nodeStack.length - 1] 89 | if (!currentNode) { 90 | continue 91 | } 92 | 93 | for (const markKey of marksNeeded) { 94 | const markDef = markDefs?.find((def) => def._key === markKey) 95 | const markType = markDef ? markDef._type : markKey 96 | const node: ToolkitNestedPortableTextSpan = { 97 | _type: '@span', 98 | _key: span._key, 99 | children: [], 100 | markDef, 101 | markType, 102 | markKey, 103 | } 104 | 105 | currentNode.children.push(node) 106 | nodeStack.push(node) 107 | currentNode = node 108 | } 109 | 110 | // Split at newlines to make individual line chunks, but keep newline 111 | // characters as individual elements in the array. We use these characters 112 | // in the span serializer to trigger hard-break rendering 113 | if (isPortableTextSpan(span)) { 114 | const lines = span.text.split('\n') 115 | for (let line = lines.length; line-- > 1; ) { 116 | lines.splice(line, 0, '\n') 117 | } 118 | 119 | currentNode.children = currentNode.children.concat( 120 | lines.map((text) => ({_type: '@text', text})), 121 | ) 122 | } else { 123 | // This is some other inline object, not a text span 124 | currentNode.children = currentNode.children.concat(span) 125 | } 126 | } 127 | 128 | return rootNode.children 129 | } 130 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './asserters' 2 | export * from './buildMarksTree' 3 | export * from './nestLists' 4 | export * from './sortMarksByOccurences' 5 | export * from './spanToPlainText' 6 | export * from './toPlainText' 7 | export * from './types' 8 | -------------------------------------------------------------------------------- /src/nestLists.ts: -------------------------------------------------------------------------------- 1 | import type {PortableTextBlock, PortableTextListItemBlock, TypedObject} from '@portabletext/types' 2 | 3 | import { 4 | isPortableTextListItemBlock, 5 | isPortableTextSpan, 6 | isPortableTextToolkitList, 7 | } from './asserters' 8 | import type { 9 | ToolkitListNestMode, 10 | ToolkitPortableTextDirectList, 11 | ToolkitPortableTextHtmlList, 12 | ToolkitPortableTextList, 13 | ToolkitPortableTextListItem, 14 | } from './types' 15 | 16 | export type ToolkitNestListsOutputNode = 17 | | T 18 | | ToolkitPortableTextHtmlList 19 | | ToolkitPortableTextDirectList 20 | 21 | /** 22 | * Takes an array of blocks and returns an array of nodes optimized for rendering in HTML-like 23 | * environment, where lists are nested inside of eachother instead of appearing "flat" as in 24 | * native Portable Text data structures. 25 | * 26 | * Note that the list node is not a native Portable Text node type, and thus is represented 27 | * using the {@link ToolkitPortableTextList | `@list`} type name (`{_type: '@list'}`). 28 | * 29 | * The nesting can be configured in two modes: 30 | * 31 | * - `direct`: deeper list nodes will appear as a direct child of the parent list 32 | * - `html`, deeper list nodes will appear as a child of the last _list item_ in the parent list 33 | * 34 | * When using `direct`, all list nodes will be of type {@link ToolkitPortableTextDirectList}, 35 | * while with `html` they will be of type {@link ToolkitPortableTextHtmlList} 36 | * 37 | * These modes are available as {@link LIST_NEST_MODE_HTML} and {@link LIST_NEST_MODE_DIRECT}. 38 | * 39 | * @param blocks - Array of Portable Text blocks and other arbitrary types 40 | * @param mode - Mode to use for nesting, `direct` or `html` 41 | * @returns Array of potentially nested nodes optimized for rendering 42 | */ 43 | export function nestLists( 44 | blocks: T[], 45 | mode: 'direct', 46 | ): (T | ToolkitPortableTextDirectList)[] 47 | export function nestLists( 48 | blocks: T[], 49 | mode: 'html', 50 | ): (T | ToolkitPortableTextHtmlList)[] 51 | export function nestLists( 52 | blocks: T[], 53 | mode: 'direct' | 'html', 54 | ): (T | ToolkitPortableTextHtmlList | ToolkitPortableTextDirectList)[] 55 | export function nestLists( 56 | blocks: T[], 57 | mode: ToolkitListNestMode, 58 | ): ToolkitNestListsOutputNode[] { 59 | const tree: ToolkitNestListsOutputNode[] = [] 60 | let currentList: ToolkitPortableTextList | undefined 61 | 62 | for (let i = 0; i < blocks.length; i++) { 63 | const block = blocks[i] 64 | if (!block) { 65 | continue 66 | } 67 | 68 | if (!isPortableTextListItemBlock(block)) { 69 | tree.push(block) 70 | currentList = undefined 71 | continue 72 | } 73 | 74 | // Start of a new list? 75 | if (!currentList) { 76 | currentList = listFromBlock(block, i, mode) 77 | tree.push(currentList) 78 | continue 79 | } 80 | 81 | // New list item within same list? 82 | if (blockMatchesList(block, currentList)) { 83 | currentList.children.push(block) 84 | continue 85 | } 86 | 87 | // Different list props, are we going deeper? 88 | if ((block.level || 1) > currentList.level) { 89 | const newList = listFromBlock(block, i, mode) 90 | 91 | if (mode === 'html') { 92 | // Because HTML is kinda weird, nested lists needs to be nested within list items. 93 | // So while you would think that we could populate the parent list with a new sub-list, 94 | // we actually have to target the last list element (child) of the parent. 95 | // However, at this point we need to be very careful - simply pushing to the list of children 96 | // will mutate the input, and we don't want to blindly clone the entire tree. 97 | 98 | // Clone the last child while adding our new list as the last child of it 99 | const lastListItem = currentList.children[ 100 | currentList.children.length - 1 101 | ] as ToolkitPortableTextListItem 102 | 103 | const newLastChild: ToolkitPortableTextListItem = { 104 | ...lastListItem, 105 | children: [...lastListItem.children, newList], 106 | } 107 | 108 | // Swap the last child 109 | currentList.children[currentList.children.length - 1] = newLastChild 110 | } else { 111 | ;(currentList as ToolkitPortableTextDirectList).children.push( 112 | newList as ToolkitPortableTextDirectList, 113 | ) 114 | } 115 | 116 | // Set the newly created, deeper list as the current 117 | currentList = newList 118 | continue 119 | } 120 | 121 | // Different list props, are we going back up the tree? 122 | if ((block.level || 1) < currentList.level) { 123 | // Current list has ended, and we need to hook up with a parent of the same level and type 124 | const matchingBranch = tree[tree.length - 1] 125 | const match = matchingBranch && findListMatching(matchingBranch, block) 126 | if (match) { 127 | currentList = match 128 | currentList.children.push(block) 129 | continue 130 | } 131 | 132 | // Similar parent can't be found, assume new list 133 | currentList = listFromBlock(block, i, mode) 134 | tree.push(currentList) 135 | continue 136 | } 137 | 138 | // Different list props, different list style? 139 | if (block.listItem !== currentList.listItem) { 140 | const matchingBranch = tree[tree.length - 1] 141 | const match = matchingBranch && findListMatching(matchingBranch, {level: block.level || 1}) 142 | if (match && match.listItem === block.listItem) { 143 | currentList = match 144 | currentList.children.push(block) 145 | continue 146 | } else { 147 | currentList = listFromBlock(block, i, mode) 148 | tree.push(currentList) 149 | continue 150 | } 151 | } 152 | 153 | // eslint-disable-next-line no-console 154 | console.warn('Unknown state encountered for block', block) 155 | tree.push(block) 156 | } 157 | 158 | return tree 159 | } 160 | 161 | function blockMatchesList(block: PortableTextBlock, list: ToolkitPortableTextList) { 162 | return (block.level || 1) === list.level && block.listItem === list.listItem 163 | } 164 | 165 | function listFromBlock( 166 | block: PortableTextListItemBlock, 167 | index: number, 168 | mode: ToolkitListNestMode, 169 | ): ToolkitPortableTextList { 170 | return { 171 | _type: '@list', 172 | _key: `${block._key || `${index}`}-parent`, 173 | mode, 174 | level: block.level || 1, 175 | listItem: block.listItem, 176 | children: [block], 177 | } 178 | } 179 | 180 | function findListMatching( 181 | rootNode: T, 182 | matching: Partial, 183 | ): ToolkitPortableTextList | undefined { 184 | const level = matching.level || 1 185 | const style = matching.listItem || 'normal' 186 | const filterOnType = typeof matching.listItem === 'string' 187 | if ( 188 | isPortableTextToolkitList(rootNode) && 189 | (rootNode.level || 1) === level && 190 | filterOnType && 191 | (rootNode.listItem || 'normal') === style 192 | ) { 193 | return rootNode 194 | } 195 | 196 | if (!('children' in rootNode)) { 197 | return undefined 198 | } 199 | 200 | const node = rootNode.children[rootNode.children.length - 1] 201 | return node && !isPortableTextSpan(node) ? findListMatching(node, matching) : undefined 202 | } 203 | -------------------------------------------------------------------------------- /src/sortMarksByOccurences.ts: -------------------------------------------------------------------------------- 1 | import type {PortableTextSpan, TypedObject} from '@portabletext/types' 2 | 3 | import {isPortableTextSpan} from './asserters' 4 | 5 | const knownDecorators = ['strong', 'em', 'code', 'underline', 'strike-through'] 6 | 7 | /** 8 | * Figures out the optimal order of marks, in order to minimize the amount of 9 | * nesting/repeated elements in environments such as HTML. For instance, a naive 10 | * implementation might render something like: 11 | * 12 | * ```html 13 | * This block contains 14 | * a link 15 | * and some bolded text 16 | * ``` 17 | * 18 | * ...whereas an optimal order would be: 19 | * 20 | * ```html 21 | * 22 | * This block contains a link and some bolded text 23 | * 24 | * ``` 25 | * 26 | * This is particularly necessary for cases like links, where you don't want multiple 27 | * individual links for different segments of the link text, even if parts of it are 28 | * bolded/italicized. 29 | * 30 | * This function is meant to be used like: `block.children.map(sortMarksByOccurences)`, 31 | * and is used internally in {@link buildMarksTree | `buildMarksTree()`}. 32 | * 33 | * The marks are sorted in the following order: 34 | * 35 | * 1. Marks that are shared amongst the most adjacent siblings 36 | * 2. Non-default marks (links, custom metadata) 37 | * 3. Decorators (bold, emphasis, code etc), in a predefined, preferred order 38 | * 39 | * @param span - The current span to sort 40 | * @param index - The index of the current span within the block 41 | * @param blockChildren - All children of the block being sorted 42 | * @returns Array of decorators and annotations, sorted by "most adjacent siblings" 43 | */ 44 | export function sortMarksByOccurences( 45 | span: PortableTextSpan | TypedObject, 46 | index: number, 47 | blockChildren: (PortableTextSpan | TypedObject)[], 48 | ): string[] { 49 | if (!isPortableTextSpan(span) || !span.marks) { 50 | return [] 51 | } 52 | 53 | if (!span.marks.length) { 54 | return [] 55 | } 56 | 57 | // Slicing because we'll be sorting with `sort()`, which mutates 58 | const marks = span.marks.slice() 59 | const occurences: Record = {} 60 | marks.forEach((mark) => { 61 | occurences[mark] = 1 62 | 63 | for (let siblingIndex = index + 1; siblingIndex < blockChildren.length; siblingIndex++) { 64 | const sibling = blockChildren[siblingIndex] 65 | 66 | if ( 67 | sibling && 68 | isPortableTextSpan(sibling) && 69 | Array.isArray(sibling.marks) && 70 | sibling.marks.indexOf(mark) !== -1 71 | ) { 72 | occurences[mark]++ 73 | } else { 74 | break 75 | } 76 | } 77 | }) 78 | 79 | return marks.sort((markA, markB) => sortMarks(occurences, markA, markB)) 80 | } 81 | 82 | function sortMarks>( 83 | occurences: T, 84 | markA: U, 85 | markB: U, 86 | ): number { 87 | const aOccurences = occurences[markA] 88 | const bOccurences = occurences[markB] 89 | 90 | if (aOccurences !== bOccurences) { 91 | return bOccurences - aOccurences 92 | } 93 | 94 | const aKnownPos = knownDecorators.indexOf(markA) 95 | const bKnownPos = knownDecorators.indexOf(markB) 96 | 97 | // Sort known decorators last 98 | if (aKnownPos !== bKnownPos) { 99 | return aKnownPos - bKnownPos 100 | } 101 | 102 | // Sort other marks simply by key 103 | return markA.localeCompare(markB) 104 | } 105 | -------------------------------------------------------------------------------- /src/spanToPlainText.ts: -------------------------------------------------------------------------------- 1 | import {isPortableTextToolkitSpan, isPortableTextToolkitTextNode} from './asserters' 2 | import type {ToolkitNestedPortableTextSpan} from './types' 3 | 4 | /** 5 | * Returns the plain-text representation of a 6 | * {@link ToolkitNestedPortableTextSpan | toolkit-specific Portable Text span}. 7 | * 8 | * Useful if you have a subset of nested nodes and want the text from just those, 9 | * instead of for the entire Portable Text block. 10 | * 11 | * @param span - Span node to get text from (Portable Text toolkit specific type) 12 | * @returns The plain-text version of the span 13 | */ 14 | export function spanToPlainText(span: ToolkitNestedPortableTextSpan): string { 15 | let text = '' 16 | span.children.forEach((current) => { 17 | if (isPortableTextToolkitTextNode(current)) { 18 | text += current.text 19 | } else if (isPortableTextToolkitSpan(current)) { 20 | text += spanToPlainText(current) 21 | } 22 | }) 23 | return text 24 | } 25 | -------------------------------------------------------------------------------- /src/toPlainText.ts: -------------------------------------------------------------------------------- 1 | import type {ArbitraryTypedObject, PortableTextBlock} from '@portabletext/types' 2 | 3 | import {isPortableTextBlock, isPortableTextSpan} from './asserters' 4 | 5 | const leadingSpace = /^\s/ 6 | const trailingSpace = /\s$/ 7 | 8 | /** 9 | * Takes a Portable Text block (or an array of them) and returns the text value 10 | * of all the Portable Text span nodes. Adds whitespace when encountering inline, 11 | * non-span nodes to ensure text flow is optimal. 12 | * 13 | * Note that this only accounts for regular Portable Text blocks - any text inside 14 | * custom content types are not included in the output. 15 | * 16 | * @param block - Single block or an array of blocks to extract text from 17 | * @returns The plain-text content of the blocks 18 | */ 19 | export function toPlainText( 20 | block: PortableTextBlock | ArbitraryTypedObject[] | PortableTextBlock[], 21 | ): string { 22 | const blocks = Array.isArray(block) ? block : [block] 23 | let text = '' 24 | 25 | blocks.forEach((current, index) => { 26 | if (!isPortableTextBlock(current)) { 27 | return 28 | } 29 | 30 | let pad = false 31 | current.children.forEach((span) => { 32 | if (isPortableTextSpan(span)) { 33 | // If the previous element was a non-span, and we have no natural whitespace 34 | // between the previous and the next span, insert it to give the spans some 35 | // room to breathe. However, don't do so if this is the first span. 36 | text += pad && text && !trailingSpace.test(text) && !leadingSpace.test(span.text) ? ' ' : '' 37 | text += span.text 38 | pad = false 39 | } else { 40 | pad = true 41 | } 42 | }) 43 | 44 | if (index !== blocks.length - 1) { 45 | text += '\n\n' 46 | } 47 | }) 48 | 49 | return text 50 | } 51 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ArbitraryTypedObject, 3 | PortableTextListItemBlock, 4 | PortableTextMarkDefinition, 5 | PortableTextSpan, 6 | } from '@portabletext/types' 7 | 8 | /** 9 | * List nesting mode for HTML, see the {@link nestLists | `nestLists()` function} 10 | */ 11 | export const LIST_NEST_MODE_HTML = 'html' 12 | 13 | /** 14 | * List nesting mode for direct, nested lists, see the {@link nestLists | `nestLists()` function} 15 | */ 16 | export const LIST_NEST_MODE_DIRECT = 'direct' 17 | 18 | /** 19 | * List nesting mode, see the {@link nestLists | `nestLists()` function} 20 | */ 21 | export type ToolkitListNestMode = 'html' | 'direct' 22 | 23 | /** 24 | * Toolkit-specific type representing a nested list 25 | * 26 | * See the `nestLists()` function for more info 27 | */ 28 | export type ToolkitPortableTextList = ToolkitPortableTextHtmlList | ToolkitPortableTextDirectList 29 | 30 | /** 31 | * Toolkit-specific type representing a nested list in HTML mode, where deeper lists are nested 32 | * inside of the _list items_, eg `
  • Some text
    • Deeper
` 33 | */ 34 | export interface ToolkitPortableTextHtmlList { 35 | /** 36 | * Type name, prefixed with `@` to signal that this is a toolkit-specific node. 37 | */ 38 | _type: '@list' 39 | 40 | /** 41 | * Unique key for this list (within its parent) 42 | */ 43 | _key: string 44 | 45 | /** 46 | * List mode, signaling that list nodes will appear as children of the _list items_ 47 | */ 48 | mode: 'html' 49 | 50 | /** 51 | * Level/depth of this list node (starts at `1`) 52 | */ 53 | level: number 54 | 55 | /** 56 | * Style of this list item (`bullet`, `number` are common values, but can be customized) 57 | */ 58 | listItem: string 59 | 60 | /** 61 | * Child nodes of this list - toolkit-specific list items which can themselves hold deeper lists 62 | */ 63 | children: ToolkitPortableTextListItem[] 64 | } 65 | 66 | /** 67 | * Toolkit-specific type representing a nested list in "direct" mode, where deeper lists are nested 68 | * inside of the lists children, alongside other blocks. 69 | */ 70 | export interface ToolkitPortableTextDirectList { 71 | /** 72 | * Type name, prefixed with `@` to signal that this is a toolkit-specific node. 73 | */ 74 | _type: '@list' 75 | 76 | /** 77 | * Unique key for this list (within its parent) 78 | */ 79 | _key: string 80 | 81 | /** 82 | * List mode, signaling that list nodes can appear as direct children 83 | */ 84 | mode: 'direct' 85 | 86 | /** 87 | * Level/depth of this list node (starts at `1`) 88 | */ 89 | level: number 90 | 91 | /** 92 | * Style of this list item (`bullet`, `number` are common values, but can be customized) 93 | */ 94 | listItem: string 95 | 96 | /** 97 | * Child nodes of this list - either portable text list items, or another, deeper list 98 | */ 99 | children: (PortableTextListItemBlock | ToolkitPortableTextDirectList)[] 100 | } 101 | 102 | /** 103 | * Toolkit-specific type representing a list item block, but where the children can be another list 104 | */ 105 | export interface ToolkitPortableTextListItem 106 | extends PortableTextListItemBlock< 107 | PortableTextMarkDefinition, 108 | PortableTextSpan | ToolkitPortableTextList 109 | > {} 110 | 111 | /** 112 | * Toolkit-specific type representing a text node, used when nesting spans. 113 | * 114 | * See the {@link buildMarksTree | `buildMarksTree()` function} 115 | */ 116 | export interface ToolkitTextNode { 117 | /** 118 | * Type name, prefixed with `@` to signal that this is a toolkit-specific node. 119 | */ 120 | _type: '@text' 121 | 122 | /** 123 | * The actual string value of the text node 124 | */ 125 | text: string 126 | } 127 | 128 | /** 129 | * Toolkit-specific type representing a portable text span that can hold other spans. 130 | * In this type, each span only has a single mark, instead of an array of them. 131 | */ 132 | export interface ToolkitNestedPortableTextSpan< 133 | M extends PortableTextMarkDefinition = PortableTextMarkDefinition, 134 | > { 135 | /** 136 | * Type name, prefixed with `@` to signal that this is a toolkit-specific node. 137 | */ 138 | _type: '@span' 139 | 140 | /** 141 | * Unique key for this span 142 | */ 143 | _key?: string 144 | 145 | /** 146 | * Holds the value (definition) of the mark in the case of annotations. 147 | * `undefined` if the mark is a decorator (strong, em or similar). 148 | */ 149 | markDef?: M 150 | 151 | /** 152 | * The key of the mark definition (in the case of annotations). 153 | * `undefined` if the mark is a decorator (strong, em or similar). 154 | */ 155 | markKey?: string 156 | 157 | /** 158 | * Type of the mark. For annotations, this is the `_type` property of the value. 159 | * For decorators, it will hold the name of the decorator (strong, em or similar). 160 | */ 161 | markType: string 162 | 163 | /** 164 | * Child nodes of this span. Can be toolkit-specific text nodes, nested spans 165 | * or any inline object type. 166 | */ 167 | children: ( 168 | | ToolkitTextNode 169 | | ToolkitNestedPortableTextSpan 170 | | ArbitraryTypedObject 171 | )[] 172 | } 173 | -------------------------------------------------------------------------------- /test/__snapshots__/buildMarksTree.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`buildMarksTree: handles leading inline objects in tree 1`] = ` 4 | [ 5 | { 6 | "_key": "a", 7 | "_type": "image", 8 | "src": "/some/image.png", 9 | }, 10 | { 11 | "_type": "@text", 12 | "text": "Include", 13 | }, 14 | { 15 | "_type": "@text", 16 | "text": " the image.", 17 | }, 18 | ] 19 | `; 20 | 21 | exports[`buildMarksTree: handles trailing inline objects in tree 1`] = ` 22 | [ 23 | { 24 | "_type": "@text", 25 | "text": "Include", 26 | }, 27 | { 28 | "_type": "@text", 29 | "text": " the image.", 30 | }, 31 | { 32 | "_key": "c", 33 | "_type": "image", 34 | "src": "/some/image.png", 35 | }, 36 | ] 37 | `; 38 | 39 | exports[`buildMarksTree: includes inline objects in tree 1`] = ` 40 | [ 41 | { 42 | "_type": "@text", 43 | "text": "Include", 44 | }, 45 | { 46 | "_key": "h", 47 | "_type": "image", 48 | "src": "/some/image.png", 49 | }, 50 | { 51 | "_type": "@text", 52 | "text": " the image.", 53 | }, 54 | ] 55 | `; 56 | 57 | exports[`buildMarksTree: includes inline objects in tree, with surrounding marks 1`] = ` 58 | [ 59 | { 60 | "_key": "a", 61 | "_type": "@span", 62 | "children": [ 63 | { 64 | "_key": "a", 65 | "_type": "@span", 66 | "children": [ 67 | { 68 | "_type": "@text", 69 | "text": "Include", 70 | }, 71 | ], 72 | "markDef": undefined, 73 | "markKey": "em", 74 | "markType": "em", 75 | }, 76 | ], 77 | "markDef": undefined, 78 | "markKey": "strong", 79 | "markType": "strong", 80 | }, 81 | { 82 | "_key": "h", 83 | "_type": "image", 84 | "src": "/some/image.png", 85 | }, 86 | { 87 | "_key": "z", 88 | "_type": "@span", 89 | "children": [ 90 | { 91 | "_key": "z", 92 | "_type": "@span", 93 | "children": [ 94 | { 95 | "_type": "@text", 96 | "text": " the image.", 97 | }, 98 | ], 99 | "markDef": undefined, 100 | "markKey": "em", 101 | "markType": "em", 102 | }, 103 | ], 104 | "markDef": undefined, 105 | "markKey": "strong", 106 | "markType": "strong", 107 | }, 108 | ] 109 | `; 110 | 111 | exports[`buildMarksTree: joins on adjacent spans with same annotation 1`] = ` 112 | [ 113 | { 114 | "_key": "a", 115 | "_type": "@span", 116 | "children": [ 117 | { 118 | "_type": "@text", 119 | "text": "Portable", 120 | }, 121 | { 122 | "_type": "@text", 123 | "text": " Text!", 124 | }, 125 | ], 126 | "markDef": { 127 | "_key": "link", 128 | "_type": "link", 129 | "href": "https://portabletext.org", 130 | }, 131 | "markKey": "link", 132 | "markType": "link", 133 | }, 134 | ] 135 | `; 136 | 137 | exports[`buildMarksTree: nests correctly, extracts correct \`markDef\` 1`] = ` 138 | [ 139 | { 140 | "_type": "@text", 141 | "text": "This block ", 142 | }, 143 | { 144 | "_type": "@text", 145 | "text": "", 146 | }, 147 | { 148 | "_key": undefined, 149 | "_type": "@span", 150 | "children": [ 151 | { 152 | "_type": "@text", 153 | "text": "contains", 154 | }, 155 | { 156 | "_key": undefined, 157 | "_type": "@span", 158 | "children": [ 159 | { 160 | "_type": "@text", 161 | "text": "a link", 162 | }, 163 | ], 164 | "markDef": { 165 | "_key": "s0m3l1nk", 166 | "_type": "link", 167 | "href": "https://some.example/", 168 | }, 169 | "markKey": "s0m3l1nk", 170 | "markType": "link", 171 | }, 172 | { 173 | "_type": "@text", 174 | "text": " and some bolded text", 175 | }, 176 | ], 177 | "markDef": undefined, 178 | "markKey": "strong", 179 | "markType": "strong", 180 | }, 181 | ] 182 | `; 183 | 184 | exports[`buildMarksTree: nests decorators and annotations correctly, extracts correct \`markDef\` 1`] = ` 185 | [ 186 | { 187 | "_type": "@text", 188 | "text": "This block ", 189 | }, 190 | { 191 | "_key": undefined, 192 | "_type": "@span", 193 | "children": [ 194 | { 195 | "_key": undefined, 196 | "_type": "@span", 197 | "children": [ 198 | { 199 | "_type": "@text", 200 | "text": "contains", 201 | }, 202 | ], 203 | "markDef": undefined, 204 | "markKey": "em", 205 | "markType": "em", 206 | }, 207 | { 208 | "_key": undefined, 209 | "_type": "@span", 210 | "children": [ 211 | { 212 | "_type": "@text", 213 | "text": "a link", 214 | }, 215 | ], 216 | "markDef": undefined, 217 | "markKey": "strong", 218 | "markType": "strong", 219 | }, 220 | ], 221 | "markDef": { 222 | "_key": "s0m3l1nk", 223 | "_type": "link", 224 | "href": "https://some.example/", 225 | }, 226 | "markKey": "s0m3l1nk", 227 | "markType": "link", 228 | }, 229 | { 230 | "_key": undefined, 231 | "_type": "@span", 232 | "children": [ 233 | { 234 | "_type": "@text", 235 | "text": " and some bolded text", 236 | }, 237 | ], 238 | "markDef": undefined, 239 | "markKey": "strong", 240 | "markType": "strong", 241 | }, 242 | ] 243 | `; 244 | -------------------------------------------------------------------------------- /test/__snapshots__/nestLists.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`nestLists: assumes level is 1 if not set 1`] = ` 4 | [ 5 | { 6 | "_key": "A0-parent", 7 | "_type": "@list", 8 | "children": [ 9 | { 10 | "_key": "A0", 11 | "_type": "block", 12 | "children": [ 13 | { 14 | "_type": "span", 15 | "text": "Bullet 1", 16 | }, 17 | ], 18 | "listItem": "bullet", 19 | }, 20 | ], 21 | "level": 1, 22 | "listItem": "bullet", 23 | "mode": "html", 24 | }, 25 | { 26 | "_key": "B0-parent", 27 | "_type": "@list", 28 | "children": [ 29 | { 30 | "_key": "B0", 31 | "_type": "block", 32 | "children": [ 33 | { 34 | "_type": "span", 35 | "text": "Bullet 2", 36 | }, 37 | ], 38 | "listItem": "number", 39 | }, 40 | ], 41 | "level": 1, 42 | "listItem": "number", 43 | "mode": "html", 44 | }, 45 | ] 46 | `; 47 | 48 | exports[`nestLists: ends lists when non-list item occurs 1`] = ` 49 | [ 50 | { 51 | "_key": "A0-parent", 52 | "_type": "@list", 53 | "children": [ 54 | { 55 | "_key": "A0", 56 | "_type": "block", 57 | "children": [ 58 | { 59 | "_type": "span", 60 | "text": "Bullet 1", 61 | }, 62 | ], 63 | "level": 1, 64 | "listItem": "bullet", 65 | }, 66 | { 67 | "_key": "B1", 68 | "_type": "block", 69 | "children": [ 70 | { 71 | "_type": "span", 72 | "text": "Bullet 2", 73 | }, 74 | ], 75 | "level": 1, 76 | "listItem": "bullet", 77 | }, 78 | ], 79 | "level": 1, 80 | "listItem": "bullet", 81 | "mode": "html", 82 | }, 83 | { 84 | "_type": "map", 85 | }, 86 | { 87 | "_key": "C0-parent", 88 | "_type": "@list", 89 | "children": [ 90 | { 91 | "_key": "C0", 92 | "_type": "block", 93 | "children": [ 94 | { 95 | "_type": "span", 96 | "text": "Number 1", 97 | }, 98 | ], 99 | "level": 1, 100 | "listItem": "number", 101 | }, 102 | { 103 | "_key": "D1", 104 | "_type": "block", 105 | "children": [ 106 | { 107 | "_type": "span", 108 | "text": "Number 2", 109 | }, 110 | ], 111 | "level": 1, 112 | "listItem": "number", 113 | }, 114 | ], 115 | "level": 1, 116 | "listItem": "number", 117 | "mode": "html", 118 | }, 119 | ] 120 | `; 121 | 122 | exports[`nestLists: handles deeper/shallower transitions correctly in direct mode 1`] = ` 123 | [ 124 | { 125 | "_key": "A0-parent", 126 | "_type": "@list", 127 | "children": [ 128 | { 129 | "_key": "A0", 130 | "_type": "block", 131 | "children": [ 132 | { 133 | "_type": "span", 134 | "text": "Level 1, A", 135 | }, 136 | ], 137 | "level": 1, 138 | "listItem": "bullet", 139 | }, 140 | { 141 | "_key": "B1", 142 | "_type": "block", 143 | "children": [ 144 | { 145 | "_type": "span", 146 | "text": "Level 1, B", 147 | }, 148 | ], 149 | "level": 1, 150 | "listItem": "bullet", 151 | }, 152 | { 153 | "_key": "C0-parent", 154 | "_type": "@list", 155 | "children": [ 156 | { 157 | "_key": "C0", 158 | "_type": "block", 159 | "children": [ 160 | { 161 | "_type": "span", 162 | "text": "Level 2, C", 163 | }, 164 | ], 165 | "level": 2, 166 | "listItem": "bullet", 167 | }, 168 | { 169 | "_key": "D1", 170 | "_type": "block", 171 | "children": [ 172 | { 173 | "_type": "span", 174 | "text": "Level 2, D", 175 | }, 176 | ], 177 | "level": 2, 178 | "listItem": "bullet", 179 | }, 180 | { 181 | "_key": "E0-parent", 182 | "_type": "@list", 183 | "children": [ 184 | { 185 | "_key": "E0", 186 | "_type": "block", 187 | "children": [ 188 | { 189 | "_type": "span", 190 | "text": "Level 3, E", 191 | }, 192 | ], 193 | "level": 3, 194 | "listItem": "bullet", 195 | }, 196 | { 197 | "_key": "F1", 198 | "_type": "block", 199 | "children": [ 200 | { 201 | "_type": "span", 202 | "text": "Level 3, F", 203 | }, 204 | ], 205 | "level": 3, 206 | "listItem": "bullet", 207 | }, 208 | ], 209 | "level": 3, 210 | "listItem": "bullet", 211 | "mode": "direct", 212 | }, 213 | { 214 | "_key": "G0", 215 | "_type": "block", 216 | "children": [ 217 | { 218 | "_type": "span", 219 | "text": "Level 2, G", 220 | }, 221 | ], 222 | "level": 2, 223 | "listItem": "bullet", 224 | }, 225 | { 226 | "_key": "H1", 227 | "_type": "block", 228 | "children": [ 229 | { 230 | "_type": "span", 231 | "text": "Level 2, H", 232 | }, 233 | ], 234 | "level": 2, 235 | "listItem": "bullet", 236 | }, 237 | { 238 | "_key": "I0-parent", 239 | "_type": "@list", 240 | "children": [ 241 | { 242 | "_key": "I0", 243 | "_type": "block", 244 | "children": [ 245 | { 246 | "_type": "span", 247 | "text": "Level 3, I", 248 | }, 249 | ], 250 | "level": 3, 251 | "listItem": "bullet", 252 | }, 253 | { 254 | "_key": "J1", 255 | "_type": "block", 256 | "children": [ 257 | { 258 | "_type": "span", 259 | "text": "Level 3, J", 260 | }, 261 | ], 262 | "level": 3, 263 | "listItem": "bullet", 264 | }, 265 | ], 266 | "level": 3, 267 | "listItem": "bullet", 268 | "mode": "direct", 269 | }, 270 | ], 271 | "level": 2, 272 | "listItem": "bullet", 273 | "mode": "direct", 274 | }, 275 | ], 276 | "level": 1, 277 | "listItem": "bullet", 278 | "mode": "direct", 279 | }, 280 | { 281 | "_key": "K0-parent", 282 | "_type": "@list", 283 | "children": [ 284 | { 285 | "_key": "K0", 286 | "_type": "block", 287 | "children": [ 288 | { 289 | "_type": "span", 290 | "text": "Level 1, K", 291 | }, 292 | ], 293 | "level": 1, 294 | "listItem": "number", 295 | }, 296 | { 297 | "_key": "L1", 298 | "_type": "block", 299 | "children": [ 300 | { 301 | "_type": "span", 302 | "text": "Level 1, L", 303 | }, 304 | ], 305 | "level": 1, 306 | "listItem": "number", 307 | }, 308 | ], 309 | "level": 1, 310 | "listItem": "number", 311 | "mode": "direct", 312 | }, 313 | ] 314 | `; 315 | 316 | exports[`nestLists: handles deeper/shallower transitions correctly in html mode 1`] = ` 317 | [ 318 | { 319 | "_key": "A0-parent", 320 | "_type": "@list", 321 | "children": [ 322 | { 323 | "_key": "A0", 324 | "_type": "block", 325 | "children": [ 326 | { 327 | "_type": "span", 328 | "text": "Level 1, A", 329 | }, 330 | ], 331 | "level": 1, 332 | "listItem": "bullet", 333 | }, 334 | { 335 | "_key": "B1", 336 | "_type": "block", 337 | "children": [ 338 | { 339 | "_type": "span", 340 | "text": "Level 1, B", 341 | }, 342 | { 343 | "_key": "C0-parent", 344 | "_type": "@list", 345 | "children": [ 346 | { 347 | "_key": "C0", 348 | "_type": "block", 349 | "children": [ 350 | { 351 | "_type": "span", 352 | "text": "Level 2, C", 353 | }, 354 | ], 355 | "level": 2, 356 | "listItem": "bullet", 357 | }, 358 | { 359 | "_key": "D1", 360 | "_type": "block", 361 | "children": [ 362 | { 363 | "_type": "span", 364 | "text": "Level 2, D", 365 | }, 366 | { 367 | "_key": "E0-parent", 368 | "_type": "@list", 369 | "children": [ 370 | { 371 | "_key": "E0", 372 | "_type": "block", 373 | "children": [ 374 | { 375 | "_type": "span", 376 | "text": "Level 3, E", 377 | }, 378 | ], 379 | "level": 3, 380 | "listItem": "bullet", 381 | }, 382 | { 383 | "_key": "F1", 384 | "_type": "block", 385 | "children": [ 386 | { 387 | "_type": "span", 388 | "text": "Level 3, F", 389 | }, 390 | ], 391 | "level": 3, 392 | "listItem": "bullet", 393 | }, 394 | ], 395 | "level": 3, 396 | "listItem": "bullet", 397 | "mode": "html", 398 | }, 399 | ], 400 | "level": 2, 401 | "listItem": "bullet", 402 | }, 403 | { 404 | "_key": "G0", 405 | "_type": "block", 406 | "children": [ 407 | { 408 | "_type": "span", 409 | "text": "Level 2, G", 410 | }, 411 | ], 412 | "level": 2, 413 | "listItem": "bullet", 414 | }, 415 | { 416 | "_key": "H1", 417 | "_type": "block", 418 | "children": [ 419 | { 420 | "_type": "span", 421 | "text": "Level 2, H", 422 | }, 423 | { 424 | "_key": "I0-parent", 425 | "_type": "@list", 426 | "children": [ 427 | { 428 | "_key": "I0", 429 | "_type": "block", 430 | "children": [ 431 | { 432 | "_type": "span", 433 | "text": "Level 3, I", 434 | }, 435 | ], 436 | "level": 3, 437 | "listItem": "bullet", 438 | }, 439 | { 440 | "_key": "J1", 441 | "_type": "block", 442 | "children": [ 443 | { 444 | "_type": "span", 445 | "text": "Level 3, J", 446 | }, 447 | ], 448 | "level": 3, 449 | "listItem": "bullet", 450 | }, 451 | ], 452 | "level": 3, 453 | "listItem": "bullet", 454 | "mode": "html", 455 | }, 456 | ], 457 | "level": 2, 458 | "listItem": "bullet", 459 | }, 460 | ], 461 | "level": 2, 462 | "listItem": "bullet", 463 | "mode": "html", 464 | }, 465 | ], 466 | "level": 1, 467 | "listItem": "bullet", 468 | }, 469 | ], 470 | "level": 1, 471 | "listItem": "bullet", 472 | "mode": "html", 473 | }, 474 | { 475 | "_key": "K0-parent", 476 | "_type": "@list", 477 | "children": [ 478 | { 479 | "_key": "K0", 480 | "_type": "block", 481 | "children": [ 482 | { 483 | "_type": "span", 484 | "text": "Level 1, K", 485 | }, 486 | ], 487 | "level": 1, 488 | "listItem": "number", 489 | }, 490 | { 491 | "_key": "L1", 492 | "_type": "block", 493 | "children": [ 494 | { 495 | "_type": "span", 496 | "text": "Level 1, L", 497 | }, 498 | ], 499 | "level": 1, 500 | "listItem": "number", 501 | }, 502 | ], 503 | "level": 1, 504 | "listItem": "number", 505 | "mode": "html", 506 | }, 507 | ] 508 | `; 509 | 510 | exports[`nestLists: nests deeper lists inside of parent list in direct mode 1`] = ` 511 | [ 512 | { 513 | "_key": "A0-parent", 514 | "_type": "@list", 515 | "children": [ 516 | { 517 | "_key": "A0", 518 | "_type": "block", 519 | "children": [ 520 | { 521 | "_type": "span", 522 | "text": "Bullet 1", 523 | }, 524 | ], 525 | "level": 1, 526 | "listItem": "bullet", 527 | }, 528 | { 529 | "_key": "B1", 530 | "_type": "block", 531 | "children": [ 532 | { 533 | "_type": "span", 534 | "text": "Bullet 2", 535 | }, 536 | ], 537 | "level": 1, 538 | "listItem": "bullet", 539 | }, 540 | { 541 | "_key": "C0-parent", 542 | "_type": "@list", 543 | "children": [ 544 | { 545 | "_key": "C0", 546 | "_type": "block", 547 | "children": [ 548 | { 549 | "_type": "span", 550 | "text": "Number 1", 551 | }, 552 | ], 553 | "level": 2, 554 | "listItem": "number", 555 | }, 556 | { 557 | "_key": "D1", 558 | "_type": "block", 559 | "children": [ 560 | { 561 | "_type": "span", 562 | "text": "Number 2", 563 | }, 564 | ], 565 | "level": 2, 566 | "listItem": "number", 567 | }, 568 | ], 569 | "level": 2, 570 | "listItem": "number", 571 | "mode": "direct", 572 | }, 573 | ], 574 | "level": 1, 575 | "listItem": "bullet", 576 | "mode": "direct", 577 | }, 578 | ] 579 | `; 580 | 581 | exports[`nestLists: wraps adjacent list items of different types in separate list nodes 1`] = ` 582 | [ 583 | { 584 | "_key": "A0-parent", 585 | "_type": "@list", 586 | "children": [ 587 | { 588 | "_key": "A0", 589 | "_type": "block", 590 | "children": [ 591 | { 592 | "_type": "span", 593 | "text": "Bullet 1", 594 | }, 595 | ], 596 | "level": 1, 597 | "listItem": "bullet", 598 | }, 599 | { 600 | "_key": "B1", 601 | "_type": "block", 602 | "children": [ 603 | { 604 | "_type": "span", 605 | "text": "Bullet 2", 606 | }, 607 | ], 608 | "level": 1, 609 | "listItem": "bullet", 610 | }, 611 | ], 612 | "level": 1, 613 | "listItem": "bullet", 614 | "mode": "html", 615 | }, 616 | { 617 | "_key": "C0-parent", 618 | "_type": "@list", 619 | "children": [ 620 | { 621 | "_key": "C0", 622 | "_type": "block", 623 | "children": [ 624 | { 625 | "_type": "span", 626 | "text": "Number 1", 627 | }, 628 | ], 629 | "level": 1, 630 | "listItem": "number", 631 | }, 632 | { 633 | "_key": "D1", 634 | "_type": "block", 635 | "children": [ 636 | { 637 | "_type": "span", 638 | "text": "Number 2", 639 | }, 640 | ], 641 | "level": 1, 642 | "listItem": "number", 643 | }, 644 | ], 645 | "level": 1, 646 | "listItem": "number", 647 | "mode": "html", 648 | }, 649 | ] 650 | `; 651 | 652 | exports[`nestLists: wraps adjacent list items of different types in separate list nodes 2`] = ` 653 | [ 654 | { 655 | "_key": "A0-parent", 656 | "_type": "@list", 657 | "children": [ 658 | { 659 | "_key": "A0", 660 | "_type": "block", 661 | "children": [ 662 | { 663 | "_type": "span", 664 | "text": "Bullet 1", 665 | }, 666 | ], 667 | "level": 1, 668 | "listItem": "bullet", 669 | }, 670 | { 671 | "_key": "B1", 672 | "_type": "block", 673 | "children": [ 674 | { 675 | "_type": "span", 676 | "text": "Bullet 2", 677 | }, 678 | ], 679 | "level": 1, 680 | "listItem": "bullet", 681 | }, 682 | ], 683 | "level": 1, 684 | "listItem": "bullet", 685 | "mode": "html", 686 | }, 687 | { 688 | "_key": "C0-parent", 689 | "_type": "@list", 690 | "children": [ 691 | { 692 | "_key": "C0", 693 | "_type": "block", 694 | "children": [ 695 | { 696 | "_type": "span", 697 | "text": "Number 1", 698 | }, 699 | ], 700 | "level": 1, 701 | "listItem": "number", 702 | }, 703 | { 704 | "_key": "D1", 705 | "_type": "block", 706 | "children": [ 707 | { 708 | "_type": "span", 709 | "text": "Number 2", 710 | }, 711 | ], 712 | "level": 1, 713 | "listItem": "number", 714 | }, 715 | ], 716 | "level": 1, 717 | "listItem": "number", 718 | "mode": "html", 719 | }, 720 | ] 721 | `; 722 | 723 | exports[`nestLists: wraps adjacent list items of same type/level in toolkit list node 1`] = ` 724 | [ 725 | { 726 | "_key": "A0-parent", 727 | "_type": "@list", 728 | "children": [ 729 | { 730 | "_key": "A0", 731 | "_type": "block", 732 | "children": [ 733 | { 734 | "_type": "span", 735 | "text": "First", 736 | }, 737 | ], 738 | "level": 1, 739 | "listItem": "bullet", 740 | }, 741 | { 742 | "_key": "B1", 743 | "_type": "block", 744 | "children": [ 745 | { 746 | "_type": "span", 747 | "text": "Second", 748 | }, 749 | ], 750 | "level": 1, 751 | "listItem": "bullet", 752 | }, 753 | { 754 | "_key": "C2", 755 | "_type": "block", 756 | "children": [ 757 | { 758 | "_type": "span", 759 | "text": "Third", 760 | }, 761 | ], 762 | "level": 1, 763 | "listItem": "bullet", 764 | }, 765 | ], 766 | "level": 1, 767 | "listItem": "bullet", 768 | "mode": "html", 769 | }, 770 | ] 771 | `; 772 | 773 | exports[`nestLists: wraps deeper lists inside of last list item in html mode 1`] = ` 774 | [ 775 | { 776 | "_key": "A0-parent", 777 | "_type": "@list", 778 | "children": [ 779 | { 780 | "_key": "A0", 781 | "_type": "block", 782 | "children": [ 783 | { 784 | "_type": "span", 785 | "text": "Bullet 1", 786 | }, 787 | ], 788 | "level": 1, 789 | "listItem": "bullet", 790 | }, 791 | { 792 | "_key": "B1", 793 | "_type": "block", 794 | "children": [ 795 | { 796 | "_type": "span", 797 | "text": "Bullet 2", 798 | }, 799 | { 800 | "_key": "C0-parent", 801 | "_type": "@list", 802 | "children": [ 803 | { 804 | "_key": "C0", 805 | "_type": "block", 806 | "children": [ 807 | { 808 | "_type": "span", 809 | "text": "Number 1", 810 | }, 811 | ], 812 | "level": 2, 813 | "listItem": "number", 814 | }, 815 | { 816 | "_key": "D1", 817 | "_type": "block", 818 | "children": [ 819 | { 820 | "_type": "span", 821 | "text": "Number 2", 822 | }, 823 | ], 824 | "level": 2, 825 | "listItem": "number", 826 | }, 827 | ], 828 | "level": 2, 829 | "listItem": "number", 830 | "mode": "html", 831 | }, 832 | ], 833 | "level": 1, 834 | "listItem": "bullet", 835 | }, 836 | ], 837 | "level": 1, 838 | "listItem": "bullet", 839 | "mode": "html", 840 | }, 841 | ] 842 | `; 843 | -------------------------------------------------------------------------------- /test/asserters.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import {expect, test} from 'vitest' 3 | 4 | import { 5 | isPortableTextBlock, 6 | isPortableTextListItemBlock, 7 | isPortableTextSpan, 8 | isPortableTextToolkitList, 9 | isPortableTextToolkitSpan, 10 | isPortableTextToolkitTextNode, 11 | } from '../src' 12 | 13 | test('isPortableTextBlock: all possible non-list properties', () => { 14 | expect( 15 | isPortableTextBlock({ 16 | _type: 'block', 17 | _key: 'a', 18 | style: 'normal', 19 | children: [{_type: 'span', _key: 's', text: 'Portable Text', marks: ['l']}], 20 | markDefs: [{_key: 'l', _type: 'link', href: 'https://portabletext.org/'}], 21 | }), 22 | '`true` if all possible non-list properties are present', 23 | ).toBe(true) 24 | }) 25 | 26 | test('isPortableTextBlock: all possible list properties', () => { 27 | expect( 28 | isPortableTextBlock({ 29 | _type: 'block', 30 | _key: 'a', 31 | style: 'normal', 32 | children: [{_type: 'span', _key: 's', text: 'Portable Text', marks: ['l']}], 33 | markDefs: [{_key: 'l', _type: 'link', href: 'https://portabletext.org/'}], 34 | listItem: 'bullet', 35 | level: 1, 36 | }), 37 | '`true` if all possible list properties are present', 38 | ).toBe(true) 39 | }) 40 | 41 | test('isPortableTextBlock: absolute minimum properties', () => { 42 | expect( 43 | isPortableTextBlock({ 44 | _type: 'any-type', 45 | children: [], 46 | }), 47 | '`true` on stripped to the bone block', 48 | ).toBe(true) 49 | }) 50 | 51 | test('isPortableTextBlock: minimum properties (with span)', () => { 52 | expect( 53 | isPortableTextBlock({ 54 | _type: 'any-type', 55 | children: [{_type: 'span', text: 'Portable Text'}], 56 | }), 57 | '`true` on single span child', 58 | ).toBe(true) 59 | }) 60 | 61 | test('isPortableTextBlock: minimum properties (non-span child)', () => { 62 | expect( 63 | isPortableTextBlock({ 64 | _type: 'any-type', 65 | children: [{_type: 'other', arb: 'itrary'}], 66 | }), 67 | '`true` on single non-span child', 68 | ).toBe(true) 69 | }) 70 | 71 | test('isPortableTextBlock: false on markDefs without a `_key`', () => { 72 | expect( 73 | isPortableTextBlock({ 74 | _type: 'block', 75 | _key: 'a', 76 | style: 'normal', 77 | children: [{_type: 'span', _key: 's', text: 'Portable Text', marks: ['l']}], 78 | markDefs: [{_type: 'link', href: 'https://portabletext.org/'} as any], 79 | }), 80 | '`false` on mark def with no `_type`', 81 | ).toBe(false) 82 | }) 83 | 84 | test('isPortableTextBlock: false on non-string `_type`', () => { 85 | expect( 86 | isPortableTextBlock({ 87 | _type: 123 as any, 88 | children: [], 89 | }), 90 | '`false` on non-string `_type`', 91 | ).toBe(false) 92 | }) 93 | 94 | test('isPortableTextBlock: false on non-array `markDefs`', () => { 95 | expect( 96 | isPortableTextBlock({ 97 | _type: 'block', 98 | children: [], 99 | markDefs: 123 as any, 100 | }), 101 | '`false` on non-array `markDefs`', 102 | ).toBe(false) 103 | }) 104 | 105 | test('isPortableTextBlock: false on missing `children`', () => { 106 | expect( 107 | isPortableTextBlock({ 108 | _type: 'block', 109 | _key: 'a', 110 | style: 'normal', 111 | markDefs: [{_key: 'l', _type: 'link', href: 'https://portabletext.org/'}], 112 | }), 113 | '`false` on missing `children`', 114 | ).toBe(false) 115 | }) 116 | 117 | test('isPortableTextBlock: false on `children` without `_type`', () => { 118 | expect( 119 | isPortableTextBlock({ 120 | _type: 'block', 121 | _key: 'a', 122 | style: 'normal', 123 | children: [{yep: ''} as any], 124 | }), 125 | '`false` on children missing `_type`', 126 | ).toBe(false) 127 | }) 128 | 129 | test('isPortableTextListItemBlock: true on all properties present', () => { 130 | expect( 131 | isPortableTextListItemBlock({ 132 | _type: 'block', 133 | _key: 'a', 134 | style: 'normal', 135 | children: [{_type: 'span', _key: 's', text: 'Portable Text', marks: ['l']}], 136 | markDefs: [{_key: 'l', _type: 'link', href: 'https://portabletext.org/'}], 137 | level: 3, 138 | listItem: 'bullet', 139 | }), 140 | '`true` if all list properties are present', 141 | ).toBe(true) 142 | }) 143 | 144 | test('isPortableTextListItemBlock: true on `level` missing', () => { 145 | expect( 146 | isPortableTextListItemBlock({ 147 | _type: 'block', 148 | _key: 'a', 149 | style: 'normal', 150 | children: [{_type: 'span', _key: 's', text: 'Portable Text', marks: ['l']}], 151 | markDefs: [{_key: 'l', _type: 'link', href: 'https://portabletext.org/'}], 152 | listItem: 'bullet', 153 | }), 154 | '`true` if all block properties + listItem are present', 155 | ).toBe(true) 156 | }) 157 | 158 | test('isPortableTextListItemBlock: false on `level` of incorrect type', () => { 159 | expect( 160 | isPortableTextListItemBlock({ 161 | _type: 'block', 162 | _key: 'a', 163 | style: 'normal', 164 | children: [{_type: 'span', _key: 's', text: 'Portable Text', marks: ['l']}], 165 | markDefs: [{_key: 'l', _type: 'link', href: 'https://portabletext.org/'}], 166 | listItem: 'bullet', 167 | level: 'nope' as any, 168 | }), 169 | '`false` if `level` is not a number', 170 | ).toBe(false) 171 | }) 172 | 173 | test('isPortableTextListItemBlock: false on `listItem` of incorrect type', () => { 174 | expect( 175 | isPortableTextListItemBlock({ 176 | _type: 'block', 177 | _key: 'a', 178 | style: 'normal', 179 | children: [{_type: 'span', _key: 's', text: 'Portable Text', marks: ['l']}], 180 | markDefs: [{_key: 'l', _type: 'link', href: 'https://portabletext.org/'}], 181 | listItem: 13 as any, 182 | }), 183 | '`false` if `listItem` is not a string', 184 | ).toBe(false) 185 | }) 186 | 187 | test('isPortableTextListItemBlock: false if no `listItem`', () => { 188 | expect( 189 | isPortableTextListItemBlock({ 190 | _type: 'block', 191 | _key: 'a', 192 | style: 'normal', 193 | children: [{_type: 'span', _key: 's', text: 'Portable Text', marks: ['l']}], 194 | markDefs: [{_key: 'l', _type: 'link', href: 'https://portabletext.org/'}], 195 | }), 196 | '`false` if `listItem` is missing', 197 | ).toBe(false) 198 | }) 199 | 200 | test('isPortableTextSpan: true on all valid span properties', () => { 201 | expect( 202 | isPortableTextSpan({ 203 | _type: 'span', 204 | _key: 'a', 205 | text: 'Portable Text', 206 | marks: ['l'], 207 | }), 208 | '`true` if all properties are present', 209 | ).toBe(true) 210 | }) 211 | 212 | test('isPortableTextSpan: true on all required span properties', () => { 213 | expect( 214 | isPortableTextSpan({ 215 | _type: 'span', 216 | text: 'Portable Text', 217 | }), 218 | '`true` if all required properties are present', 219 | ).toBe(true) 220 | }) 221 | 222 | test('isPortableTextSpan: false on non-`span` type', () => { 223 | expect( 224 | isPortableTextSpan({ 225 | _type: 'nonSpan', 226 | text: 'Portable Text', 227 | }), 228 | '`false` if `_type` is not `span`', 229 | ).toBe(false) 230 | }) 231 | 232 | test('isPortableTextSpan: false on missing `text`', () => { 233 | expect( 234 | isPortableTextSpan({ 235 | _type: 'span', 236 | foo: 'bar', 237 | }), 238 | '`false` if `text` is missing', 239 | ).toBe(false) 240 | }) 241 | 242 | test('isPortableTextSpan: false on non-string `text`', () => { 243 | expect( 244 | isPortableTextSpan({ 245 | _type: 'span', 246 | text: 123, 247 | }), 248 | '`false` if `text` is not a string', 249 | ).toBe(false) 250 | }) 251 | 252 | test('isPortableTextSpan: false on non-array `marks`', () => { 253 | expect( 254 | isPortableTextSpan({ 255 | _type: 'span', 256 | text: 'yes', 257 | marks: 'also yes', 258 | }), 259 | '`false` if `marks` is not an array', 260 | ).toBe(false) 261 | }) 262 | 263 | test('isPortableTextSpan: false on non-string `marks` item', () => { 264 | expect( 265 | isPortableTextSpan({ 266 | _type: 'span', 267 | text: 'yes', 268 | marks: ['yep', 123], 269 | }), 270 | '`false` if `marks` contains non-strings', 271 | ).toBe(false) 272 | }) 273 | 274 | /** 275 | * WEAK ASSERTERS FOLLOWS - THESE ARE NOT THOROUGH, ONLY SURFACE-LEVEL 276 | */ 277 | test('isPortableTextToolkitList: true on correct _type', () => { 278 | expect(isPortableTextToolkitList({_type: '@list'}), '`true` if `_type` is `@list`').toBe(true) 279 | }) 280 | 281 | test('isPortableTextToolkitList: false on incorrect _type', () => { 282 | expect(isPortableTextToolkitList({_type: 'list'}), '`false` if `_type` is not `@list`').toBe( 283 | false, 284 | ) 285 | }) 286 | 287 | test('isPortableTextToolkitSpan: true on correct _type', () => { 288 | expect(isPortableTextToolkitSpan({_type: '@span'}), '`true` if `_type` is `@span`').toBe(true) 289 | }) 290 | 291 | test('isPortableTextToolkitSpan: false on incorrect _type', () => { 292 | expect(isPortableTextToolkitSpan({_type: 'span'}), '`false` if `_type` is not `@span`').toBe( 293 | false, 294 | ) 295 | }) 296 | 297 | test('isPortableTextToolkitTextNode: true on correct _type', () => { 298 | expect(isPortableTextToolkitTextNode({_type: '@text'}), '`true` if `_type` is `@text`').toBe(true) 299 | }) 300 | 301 | test('isPortableTextToolkitTextNode: false on incorrect _type', () => { 302 | expect(isPortableTextToolkitTextNode({_type: 'text'}), '`false` if `_type` is not `@text`').toBe( 303 | false, 304 | ) 305 | }) 306 | -------------------------------------------------------------------------------- /test/buildMarksTree.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type {PortableTextBlock} from '@portabletext/types' 3 | import {expect, test} from 'vitest' 4 | 5 | import {buildMarksTree} from '../src' 6 | 7 | test('buildMarksTree: returns empty tree on empty blocks', () => { 8 | expect(buildMarksTree({_type: 'block', children: []})).toEqual([]) 9 | expect(buildMarksTree({_type: 'block'} as any)).toEqual([]) 10 | }) 11 | 12 | test('buildMarksTree: returns newlines as individual text nodes', () => { 13 | expect( 14 | buildMarksTree({_type: 'block', children: [{_type: 'span', text: 'Portable\nText'}]}), 15 | ).toEqual([ 16 | {_type: '@text', text: 'Portable'}, 17 | {_type: '@text', text: '\n'}, 18 | {_type: '@text', text: 'Text'}, 19 | ]) 20 | }) 21 | 22 | test('buildMarksTree: nests correctly, extracts correct `markDef`', () => { 23 | const block: PortableTextBlock = { 24 | _type: 'block', 25 | children: [ 26 | {_type: 'span', text: 'This block '}, 27 | {_type: 'span', text: '', marks: []}, 28 | {_type: 'span', text: 'contains', marks: ['strong']}, 29 | {_type: 'span', text: 'a link', marks: ['s0m3l1nk', 'strong']}, 30 | {_type: 'span', text: ' and some bolded text', marks: ['strong']}, 31 | ], 32 | markDefs: [ 33 | { 34 | _key: 's0m3l1nk', 35 | _type: 'link', 36 | href: 'https://some.example/', 37 | }, 38 | ], 39 | } 40 | 41 | expect(buildMarksTree(block)).toMatchSnapshot() 42 | }) 43 | 44 | test('buildMarksTree: joins on adjacent spans with same annotation', () => { 45 | const block: PortableTextBlock = { 46 | _type: 'block', 47 | _key: 'a', 48 | style: 'normal', 49 | children: [ 50 | {_type: 'span', _key: 'a', text: 'Portable', marks: ['link']}, 51 | {_type: 'span', _key: 'z', text: ' Text!', marks: ['link']}, 52 | ], 53 | markDefs: [{_key: 'link', _type: 'link', href: 'https://portabletext.org'}], 54 | } 55 | 56 | expect(buildMarksTree(block)).toMatchSnapshot() 57 | }) 58 | 59 | test('buildMarksTree: nests decorators and annotations correctly, extracts correct `markDef`', () => { 60 | const block: PortableTextBlock = { 61 | _type: 'block', 62 | children: [ 63 | {_type: 'span', text: 'This block '}, 64 | {_type: 'span', text: 'contains', marks: ['em', 's0m3l1nk']}, 65 | {_type: 'span', text: 'a link', marks: ['s0m3l1nk', 'strong']}, 66 | {_type: 'span', text: ' and some bolded text', marks: ['strong']}, 67 | ], 68 | markDefs: [ 69 | { 70 | _key: 's0m3l1nk', 71 | _type: 'link', 72 | href: 'https://some.example/', 73 | }, 74 | ], 75 | } 76 | 77 | expect(buildMarksTree(block)).toMatchSnapshot() 78 | }) 79 | 80 | test('buildMarksTree: includes inline objects in tree', () => { 81 | const block: PortableTextBlock = { 82 | _type: 'block', 83 | _key: 'a', 84 | style: 'normal', 85 | children: [ 86 | {_type: 'span', _key: 'a', text: 'Include'}, 87 | {_type: 'image', _key: 'h', src: '/some/image.png'}, 88 | {_type: 'span', _key: 'z', text: ' the image.'}, 89 | ], 90 | markDefs: [], 91 | } 92 | 93 | expect(buildMarksTree(block)).toMatchSnapshot() 94 | }) 95 | 96 | test('buildMarksTree: handles leading inline objects in tree', () => { 97 | const block: PortableTextBlock = { 98 | _type: 'block', 99 | _key: 'a', 100 | style: 'normal', 101 | children: [ 102 | {_type: 'image', _key: 'a', src: '/some/image.png'}, 103 | {_type: 'span', _key: 'b', text: 'Include'}, 104 | {_type: 'span', _key: 'c', text: ' the image.'}, 105 | ], 106 | markDefs: [], 107 | } 108 | 109 | expect(buildMarksTree(block)).toMatchSnapshot() 110 | }) 111 | 112 | test('buildMarksTree: handles trailing inline objects in tree', () => { 113 | const block: PortableTextBlock = { 114 | _type: 'block', 115 | _key: 'a', 116 | style: 'normal', 117 | children: [ 118 | {_type: 'span', _key: 'a', text: 'Include'}, 119 | {_type: 'span', _key: 'b', text: ' the image.'}, 120 | {_type: 'image', _key: 'c', src: '/some/image.png'}, 121 | ], 122 | markDefs: [], 123 | } 124 | 125 | expect(buildMarksTree(block)).toMatchSnapshot() 126 | }) 127 | 128 | test('buildMarksTree: includes inline objects in tree, with surrounding marks', () => { 129 | const block: PortableTextBlock = { 130 | _type: 'block', 131 | _key: 'a', 132 | style: 'normal', 133 | children: [ 134 | {_type: 'span', _key: 'a', text: 'Include', marks: ['em', 'strong']}, 135 | {_type: 'image', _key: 'h', src: '/some/image.png'}, 136 | {_type: 'span', _key: 'z', text: ' the image.', marks: ['strong', 'em']}, 137 | ], 138 | markDefs: [], 139 | } 140 | 141 | expect(buildMarksTree(block)).toMatchSnapshot() 142 | }) 143 | -------------------------------------------------------------------------------- /test/nestLists.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import type {PortableTextListItemBlock} from '@portabletext/types' 3 | import {expect, test} from 'vitest' 4 | 5 | import {LIST_NEST_MODE_DIRECT, LIST_NEST_MODE_HTML, nestLists} from '../src' 6 | 7 | test('nestLists: returns empty tree on no blocks', () => { 8 | expect(nestLists([], LIST_NEST_MODE_HTML)).toEqual([]) 9 | }) 10 | 11 | test('nestLists: returns non-list blocks verbatim', () => { 12 | const block = {_type: 'block', children: [{_type: 'span', text: 'Verbatim, please'}]} 13 | expect(nestLists([block], LIST_NEST_MODE_HTML)).toEqual([block]) 14 | }) 15 | 16 | test('nestLists: wraps list items in toolkit list node', () => { 17 | const block = { 18 | _type: 'block', 19 | _key: 'a', 20 | children: [{_type: 'span', text: 'Verbatim, please'}], 21 | listItem: 'bullet', 22 | } 23 | expect(nestLists([block], LIST_NEST_MODE_HTML)).toEqual([ 24 | { 25 | _type: '@list', 26 | _key: 'a-parent', 27 | mode: 'html', 28 | level: 1, 29 | listItem: 'bullet', 30 | children: [block], 31 | }, 32 | ]) 33 | 34 | // Uses index as key if no _key is present 35 | expect(nestLists([{...block, _key: undefined}], LIST_NEST_MODE_HTML)[0]?._key).toBe('0-parent') 36 | }) 37 | 38 | test('nestLists: wraps adjacent list items of same type/level in toolkit list node', () => { 39 | const blocks = createBlocks(['First', 'Second', 'Third']) 40 | expect(nestLists(blocks, LIST_NEST_MODE_HTML)).toMatchSnapshot() 41 | }) 42 | 43 | test('nestLists: wraps adjacent list items of different types in separate list nodes', () => { 44 | const blocks = [ 45 | ...createBlocks(['Bullet 1', 'Bullet 2']), 46 | ...createBlocks(['Number 1', 'Number 2'], {type: 'number', startIndex: 2}), 47 | ] 48 | expect(nestLists(blocks, LIST_NEST_MODE_HTML)).toMatchSnapshot() 49 | }) 50 | 51 | test('nestLists: ends lists when non-list item occurs', () => { 52 | const blocks = [ 53 | ...createBlocks(['Bullet 1', 'Bullet 2']), 54 | {_type: 'map'}, 55 | ...createBlocks(['Number 1', 'Number 2'], {type: 'number', startIndex: 2}), 56 | ] 57 | expect(nestLists(blocks, LIST_NEST_MODE_HTML)).toMatchSnapshot() 58 | }) 59 | 60 | test('nestLists: wraps deeper lists inside of last list item in html mode', () => { 61 | const blocks = [ 62 | ...createBlocks(['Bullet 1', 'Bullet 2']), 63 | ...createBlocks(['Number 1', 'Number 2'], {type: 'number', level: 2, startIndex: 2}), 64 | ] 65 | expect(nestLists(blocks, LIST_NEST_MODE_HTML)).toMatchSnapshot() 66 | }) 67 | 68 | test('nestLists: nests deeper lists inside of parent list in direct mode', () => { 69 | const blocks = [ 70 | ...createBlocks(['Bullet 1', 'Bullet 2']), 71 | ...createBlocks(['Number 1', 'Number 2'], {type: 'number', level: 2, startIndex: 2}), 72 | ] 73 | expect(nestLists(blocks, LIST_NEST_MODE_DIRECT)).toMatchSnapshot() 74 | }) 75 | 76 | test('nestLists: assumes level is 1 if not set', () => { 77 | const blocks = [ 78 | ...createBlocks(['Bullet 1']).map(({level, ...block}) => block), 79 | ...createBlocks(['Bullet 2'], {startIndex: 1, type: 'number'}).map( 80 | ({level, ...block}) => block, 81 | ), 82 | ] 83 | expect(nestLists(blocks, LIST_NEST_MODE_HTML)).toMatchSnapshot() 84 | }) 85 | 86 | test('nestLists: handles deeper/shallower transitions correctly in html mode', () => { 87 | const blocks = [ 88 | ...createBlocks(['Level 1, A', 'Level 1, B']), 89 | ...createBlocks(['Level 2, C', 'Level 2, D'], {level: 2, startIndex: 2}), 90 | ...createBlocks(['Level 3, E', 'Level 3, F'], {level: 3, startIndex: 4}), 91 | ...createBlocks(['Level 2, G', 'Level 2, H'], {level: 2, startIndex: 6}), 92 | ...createBlocks(['Level 3, I', 'Level 3, J'], {level: 3, startIndex: 8}), 93 | ...createBlocks(['Level 1, K', 'Level 1, L'], {level: 1, startIndex: 10, type: 'number'}), 94 | ] 95 | expect(nestLists(blocks, LIST_NEST_MODE_HTML)).toMatchSnapshot() 96 | }) 97 | 98 | test('nestLists: handles deeper/shallower transitions correctly in direct mode', () => { 99 | const blocks = [ 100 | ...createBlocks(['Level 1, A', 'Level 1, B']), 101 | ...createBlocks(['Level 2, C', 'Level 2, D'], {level: 2, startIndex: 2}), 102 | ...createBlocks(['Level 3, E', 'Level 3, F'], {level: 3, startIndex: 4}), 103 | ...createBlocks(['Level 2, G', 'Level 2, H'], {level: 2, startIndex: 6}), 104 | ...createBlocks(['Level 3, I', 'Level 3, J'], {level: 3, startIndex: 8}), 105 | ...createBlocks(['Level 1, K', 'Level 1, L'], {level: 1, startIndex: 10, type: 'number'}), 106 | ] 107 | expect(nestLists(blocks, LIST_NEST_MODE_DIRECT)).toMatchSnapshot() 108 | }) 109 | 110 | test('nestLists: wraps adjacent list items of different types in separate list nodes', () => { 111 | const blocks = [ 112 | ...createBlocks(['Bullet 1', 'Bullet 2'], {type: 'bullet', startIndex: 0}), 113 | ...createBlocks(['Number 1', 'Number 2'], {type: 'number', startIndex: 2}), 114 | ] 115 | expect(nestLists(blocks, LIST_NEST_MODE_HTML)).toMatchSnapshot() 116 | }) 117 | 118 | function createBlocks( 119 | spans: string[], 120 | options: {level?: number; type?: string; startIndex?: number} = {}, 121 | ): PortableTextListItemBlock[] { 122 | const {level = 1, type = 'bullet', startIndex = 0} = options 123 | return spans.map((span, i) => ({ 124 | _type: 'block', 125 | _key: `${String.fromCharCode(65 + startIndex + i)}${i}`, 126 | children: [{_type: 'span', text: span}], 127 | listItem: type, 128 | level, 129 | })) 130 | } 131 | -------------------------------------------------------------------------------- /test/sortMarksByOccurences.test.ts: -------------------------------------------------------------------------------- 1 | import type {PortableTextBlock} from '@portabletext/types' 2 | import {expect, test} from 'vitest' 3 | 4 | import {sortMarksByOccurences} from '../src' 5 | 6 | test('sortMarksByOccurences: sorts correctly', () => { 7 | const block: PortableTextBlock = { 8 | _type: 'block', 9 | children: [ 10 | {_type: 'span', text: 'This block '}, 11 | {_type: 'span', text: '', marks: []}, 12 | {_type: 'span', text: 'contains', marks: ['strong']}, 13 | {_type: 'span', text: 'a link', marks: ['s0m3l1nk', 'strong']}, 14 | {_type: 'span', text: ' and some bolded text', marks: ['strong']}, 15 | ], 16 | } 17 | 18 | expect(block.children.map(sortMarksByOccurences)).toEqual([ 19 | [], 20 | [], 21 | ['strong'], 22 | ['strong', 's0m3l1nk'], 23 | ['strong'], 24 | ]) 25 | }) 26 | 27 | test('sortMarksByOccurences: sorts correctly on tied decorator usage', () => { 28 | const block: PortableTextBlock = { 29 | _type: 'block', 30 | children: [ 31 | {_type: 'span', text: 'Some ', marks: ['em', 'strong']}, 32 | {_type: 'span', text: 'marks ', marks: ['strong', 'em']}, 33 | {_type: 'span', text: 'might be tied.', marks: []}, 34 | ], 35 | } 36 | 37 | expect(block.children.map(sortMarksByOccurences)).toEqual([ 38 | ['strong', 'em'], 39 | ['strong', 'em'], 40 | [], 41 | ]) 42 | }) 43 | 44 | test('sortMarksByOccurences: sorts correctly on tied decorator usage with annotations', () => { 45 | const block: PortableTextBlock = { 46 | _type: 'block', 47 | children: [ 48 | {_type: 'span', text: 'Some ', marks: ['em', 'a', 'strong', 'b']}, 49 | {_type: 'span', text: 'marks ', marks: ['b', 'strong', 'em', 'a']}, 50 | {_type: 'span', text: 'might be tied.', marks: []}, 51 | ], 52 | } 53 | 54 | expect(block.children.map(sortMarksByOccurences)).toEqual([ 55 | ['a', 'b', 'strong', 'em'], 56 | ['a', 'b', 'strong', 'em'], 57 | [], 58 | ]) 59 | }) 60 | 61 | test('sortMarksByOccurences: returns empty array on invalid marks', () => { 62 | const block: PortableTextBlock = { 63 | _type: 'block', 64 | children: [ 65 | {_type: 'span', text: 'Some ', marks: ['em', 'a', undefined, '', 'b', true]}, 66 | {_type: 'span', text: 'marks ', marks: ['b', '', 'em', 'a', false]}, 67 | {_type: 'span', text: 'might be tied.', marks: [null]}, 68 | ], 69 | } 70 | 71 | expect(block.children.map(sortMarksByOccurences)).toEqual([[], [], []]) 72 | }) 73 | -------------------------------------------------------------------------------- /test/spanToPlainText.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from 'vitest' 2 | 3 | import {spanToPlainText} from '../src' 4 | 5 | test('spanToPlainText: converts single-span correctly', () => { 6 | expect( 7 | spanToPlainText({ 8 | _type: '@span', 9 | _key: 'a', 10 | children: [{_type: '@text', text: 'Just a plain text thing'}], 11 | markType: 'em', 12 | }), 13 | ).toEqual('Just a plain text thing') 14 | }) 15 | 16 | test('spanToPlainText: converts nested spans correctly', () => { 17 | expect( 18 | spanToPlainText({ 19 | _type: '@span', 20 | children: [ 21 | {_type: '@text', text: 'Just a '}, 22 | { 23 | _type: '@span', 24 | markType: 'strong', 25 | children: [ 26 | {_type: '@text', text: 'very'}, 27 | {_type: '@span', markType: 'code', children: [{_type: '@text', text: ' nested'}]}, 28 | {_type: '@text', text: ' span'}, 29 | ], 30 | }, 31 | {_type: '@text', text: ' of marks.'}, 32 | ], 33 | markType: 'em', 34 | }), 35 | ).toEqual('Just a very nested span of marks.') 36 | }) 37 | -------------------------------------------------------------------------------- /test/toPlainText.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from 'vitest' 2 | 3 | import {toPlainText} from '../src' 4 | 5 | test('toPlainText: converts single-block, single-span with no formatting correctly', () => { 6 | expect( 7 | toPlainText({ 8 | _type: 'block', 9 | _key: 'a', 10 | style: 'normal', 11 | children: [{_type: 'span', _key: 's', text: 'Portable Text'}], 12 | markDefs: [], 13 | }), 14 | ).toEqual('Portable Text') 15 | }) 16 | 17 | test('toPlainText: converts single-block, multi-span with no formatting correctly', () => { 18 | expect( 19 | toPlainText({ 20 | _type: 'block', 21 | _key: 'a', 22 | style: 'normal', 23 | children: [ 24 | {_type: 'span', _key: 'a', text: 'Portable '}, 25 | {_type: 'span', _key: 'z', text: 'Text'}, 26 | ], 27 | markDefs: [], 28 | }), 29 | ).toEqual('Portable Text') 30 | }) 31 | 32 | test('toPlainText: converts single-block, multi-span with formatting correctly', () => { 33 | expect( 34 | toPlainText({ 35 | _type: 'block', 36 | _key: 'a', 37 | style: 'normal', 38 | children: [ 39 | {_type: 'span', _key: 'a', text: 'Portable', marks: ['em']}, 40 | {_type: 'span', _key: 'z', text: ' Text!'}, 41 | ], 42 | markDefs: [], 43 | }), 44 | ).toEqual('Portable Text!') 45 | }) 46 | 47 | test('toPlainText: converts multi-block, multi-span with formatting correctly', () => { 48 | expect( 49 | toPlainText([ 50 | { 51 | _type: 'block', 52 | _key: 'a', 53 | style: 'normal', 54 | children: [ 55 | {_type: 'span', _key: 'a', text: 'Portable', marks: ['link']}, 56 | {_type: 'span', _key: 'z', text: ' Text!', marks: ['link']}, 57 | ], 58 | markDefs: [{_key: 'link', _type: 'link', href: 'https://portabletext.org'}], 59 | }, 60 | { 61 | _type: 'block', 62 | _key: 'b', 63 | style: 'normal', 64 | children: [{_type: 'span', _key: 'a', text: 'Use it!', marks: []}], 65 | }, 66 | ]), 67 | ).toEqual('Portable Text!\n\nUse it!') 68 | }) 69 | 70 | test('toPlainText: ignores non-blocks, non-spans', () => { 71 | expect( 72 | toPlainText([ 73 | { 74 | _type: 'block', 75 | _key: 'a', 76 | style: 'normal', 77 | children: [ 78 | {_type: 'span', _key: 'a', text: 'Ignore'}, 79 | {_type: 'image', _key: 'h', src: '/some/image.png'}, 80 | {_type: 'span', _key: 'z', text: ' the image.'}, 81 | ], 82 | markDefs: [], 83 | }, 84 | { 85 | _type: 'map', 86 | lat: 59, 87 | lng: 13, 88 | }, 89 | { 90 | _type: 'block', 91 | _key: 'b', 92 | style: 'normal', 93 | children: [{_type: 'span', _key: 'a', text: '...and the map!', marks: []}], 94 | }, 95 | ]), 96 | ).toEqual('Ignore the image.\n\n...and the map!') 97 | }) 98 | 99 | test('toPlainText: does not add unnecessary whitespace on non-spans', () => { 100 | expect( 101 | toPlainText({ 102 | _type: 'block', 103 | children: [ 104 | {_type: 'span', text: 'Ignore'}, 105 | {_type: 'image', src: '/some/image.png'}, 106 | {_type: 'span', text: ' the image.'}, 107 | ], 108 | }), 109 | 'Ignore the image.', 110 | ) 111 | }) 112 | 113 | test('toPlainText: adds whitespace on span-hugging non-spans', () => { 114 | expect( 115 | toPlainText({ 116 | _type: 'block', 117 | children: [ 118 | {_type: 'span', text: 'Ignore'}, 119 | {_type: 'image', src: '/some/image.png'}, 120 | {_type: 'span', text: 'the image.'}, 121 | ], 122 | }), 123 | ).toEqual('Ignore the image.') 124 | }) 125 | 126 | test('toPlainText: does not add leading whitespace on span-hugging non-span', () => { 127 | expect( 128 | toPlainText({ 129 | _type: 'block', 130 | children: [ 131 | {_type: 'image', src: '/some/image.png'}, 132 | {_type: 'span', text: 'Now that is an image.'}, 133 | ], 134 | }), 135 | ).toEqual('Now that is an image.') 136 | }) 137 | 138 | test('toPlainText: does not add leading whitespace on span-hugging non-span (trailing)', () => { 139 | expect( 140 | toPlainText({ 141 | _type: 'block', 142 | children: [ 143 | {_type: 'span', text: 'Now that is a '}, 144 | {_type: 'image', src: '/some/image.png'}, 145 | {_type: 'span', text: 'beautiful image.'}, 146 | ], 147 | }), 148 | ).toEqual('Now that is a beautiful image.') 149 | }) 150 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "compilerOptions": { 5 | "rootDir": ".", 6 | "outDir": "./dist", 7 | 8 | "lib": ["ES2016", "DOM"], 9 | "noUncheckedIndexedAccess": true, 10 | "forceConsistentCasingInFileNames": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./package.config.ts", "./src"], 4 | "compilerOptions": { 5 | "rootDir": ".", 6 | "outDir": "./dist", 7 | 8 | "lib": ["ES2016", "DOM"], 9 | "noUncheckedIndexedAccess": true, 10 | "forceConsistentCasingInFileNames": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES2020", 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 | "moduleResolution": "node", 27 | "allowSyntheticDefaultImports": true, 28 | "esModuleInterop": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["./src/index.ts"], 3 | "includeVersion": true, 4 | "disableSources": true, 5 | "sort": ["required-first", "source-order"] 6 | } 7 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | // This is the default config, runs with Node.js globals and doesn't require `npm run build` before executing tests 2 | 3 | import {defineConfig} from 'vitest/config' 4 | import GithubActionsReporter from 'vitest-github-actions-reporter' 5 | 6 | export default defineConfig({ 7 | test: { 8 | // Enable rich PR failed test annotation on the CI 9 | // eslint-disable-next-line no-process-env 10 | reporters: process.env.GITHUB_ACTIONS ? ['default', new GithubActionsReporter()] : 'default', 11 | }, 12 | }) 13 | --------------------------------------------------------------------------------