├── .codecov.yml ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── poetry.lock ├── portabletext_html ├── __init__.py ├── constants.py ├── logger.py ├── marker_definitions.py ├── py.typed ├── renderer.py ├── types.py └── utils.py ├── pyproject.toml ├── setup.cfg └── tests ├── __init__.py ├── fixtures ├── basic_mark.json ├── custom_serializer_node_after_list.json ├── invalid_node.json ├── invalid_type.json ├── multiple_adjecent_marks.json ├── multiple_simple_spans.json ├── nested_marks.json ├── simple_span.json ├── simple_xss.json └── upstream │ ├── 001-empty-block.json │ ├── 002-single-span.json │ ├── 003-multiple-spans.json │ ├── 004-basic-mark-single-span.json │ ├── 005-basic-mark-multiple-adjacent-spans.json │ ├── 006-basic-mark-nested-marks.json │ ├── 007-link-mark-def.json │ ├── 008-plain-header-block.json │ ├── 009-messy-link-text.json │ ├── 010-basic-bullet-list.json │ ├── 011-basic-numbered-list.json │ ├── 012-image-support.json │ ├── 013-materialized-image-support.json │ ├── 014-nested-lists.json │ ├── 015-all-basic-marks.json │ ├── 016-deep-weird-lists.json │ ├── 017-all-default-block-styles.json │ ├── 018-marks-all-the-way-down.json │ ├── 019-keyless.json │ ├── 020-empty-array.json │ ├── 021-list-without-level.json │ ├── 022-inline-nodes.json │ ├── 023-hard-breaks.json │ ├── 024-inline-images.json │ ├── 025-image-with-hotspot.json │ ├── 026-inline-block-with-text.json │ ├── 027-styled-list-items.json │ ├── 050-custom-block-type.json │ ├── 051-override-defaults.json │ ├── 052-custom-marks.json │ ├── 053-override-default-marks.json │ ├── 060-list-issue.json │ └── 061-missing-mark-serializer.json ├── test_marker_definitions.py ├── test_module_loading.py ├── test_rendering.py └── test_upstream_suite.py /.codecov.yml: -------------------------------------------------------------------------------- 1 | # Docs: https://docs.codecov.io/docs/codecovyml-reference 2 | 3 | codecov: 4 | require_ci_to_pass: yes 5 | 6 | coverage: 7 | precision: 1 8 | round: down 9 | status: 10 | project: 11 | default: 12 | target: auto 13 | patch: no 14 | changes: no 15 | 16 | comment: 17 | layout: "diff,files" 18 | require_changes: yes 19 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish package to pypi 2 | 3 | on: 4 | release: 5 | types: [published, edited] 6 | 7 | jobs: 8 | build-and-publish-test: 9 | name: Build and publish 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | with: 15 | python-version: 3.9 16 | - uses: snok/install-poetry@v1.1.6 17 | - name: Publish to test-pypi 18 | run: | 19 | poetry config repositories.test https://test.pypi.org/legacy/ 20 | poetry config pypi-token.test ${{ secrets.TEST_PYPI_TOKEN }} 21 | poetry publish --build --no-interaction --repository test 22 | build-and-publish: 23 | needs: build-and-publish-test 24 | name: Build and publish 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions/setup-python@v2 29 | with: 30 | python-version: 3.9 31 | - uses: snok/install-poetry@v1.1.6 32 | - name: Publish to pypi 33 | run: | 34 | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} 35 | poetry publish --build --no-interaction 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | linting: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-python@v2 15 | with: 16 | python-version: "3.10" 17 | - uses: actions/cache@v2 18 | id: cache-venv 19 | with: 20 | path: .venv 21 | key: venv-1 22 | - run: | 23 | python -m venv .venv --upgrade-deps 24 | source .venv/bin/activate 25 | pip install pre-commit 26 | if: steps.cache-venv.outputs.cache-hit != 'true' 27 | - uses: actions/cache@v2 28 | id: pre-commit-cache 29 | with: 30 | path: ~/.cache/pre-commit 31 | key: key-1 32 | - run: | 33 | source .venv/bin/activate 34 | pre-commit run --all-files 35 | 36 | test: 37 | runs-on: ubuntu-latest 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | python-version: [ "3.7.14", "3.8.14", "3.9.15", "3.10.8", "3.11.0", "3.12.0-alpha.1" ] 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: actions/setup-python@v2 45 | with: 46 | python-version: "${{ matrix.python-version }}" 47 | - uses: actions/cache@v2 48 | id: poetry-cache 49 | with: 50 | path: ~/.local 51 | key: key-2 52 | - uses: snok/install-poetry@v1 53 | with: 54 | virtualenvs-create: false 55 | - uses: actions/cache@v2 56 | id: cache-venv 57 | with: 58 | path: .venv 59 | key: ${{ hashFiles('**/poetry.lock') }}-1 60 | - run: | 61 | python -m venv .venv 62 | source .venv/bin/activate 63 | pip install -U pip wheel 64 | poetry install --no-interaction --no-root 65 | if: steps.cache-venv.outputs.cache-hit != 'true' 66 | - name: Run tests 67 | run: | 68 | source .venv/bin/activate 69 | pytest -m "not unsupported" --cov-report=xml 70 | coverage report 71 | - uses: codecov/codecov-action@v2 72 | with: 73 | file: ./coverage.xml 74 | fail_ci_if_error: true 75 | if: matrix.python-version == '3.10' 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .venv 3 | .pytest_cache 4 | .env 5 | .idea 6 | .coverage 7 | coverage.xml 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | test.json 14 | # C extensions 15 | *.so 16 | dumps/ 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | pip-wheel-metadata/ 32 | share/python-wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | MANIFEST 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .nox/ 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | *.iml 135 | .vscode 136 | .DS_Store 137 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 22.10.0 4 | hooks: 5 | - id: black 6 | args: [ "--quiet" ] 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v4.3.0 9 | hooks: 10 | - id: check-ast 11 | - id: check-merge-conflict 12 | - id: check-case-conflict 13 | - id: check-docstring-first 14 | - id: check-json 15 | - id: check-yaml 16 | - id: double-quote-string-fixer 17 | - id: end-of-file-fixer 18 | - id: trailing-whitespace 19 | - id: mixed-line-ending 20 | - id: trailing-whitespace 21 | - repo: https://github.com/pycqa/flake8 22 | rev: 5.0.4 23 | hooks: 24 | - id: flake8 25 | additional_dependencies: [ 26 | 'flake8-bugbear', 27 | 'flake8-comprehensions', 28 | 'flake8-print', 29 | 'flake8-mutable', 30 | 'flake8-simplify', 31 | 'flake8-pytest-style', 32 | 'flake8-docstrings', 33 | 'flake8-annotations', 34 | 'flake8-printf-formatting', 35 | 'flake8-type-checking', 36 | ] 37 | args: 38 | - '--allow-star-arg-any' 39 | - repo: https://github.com/asottile/pyupgrade 40 | rev: v3.2.2 41 | hooks: 42 | - id: pyupgrade 43 | args: [ "--py36-plus", "--py37-plus",'--keep-runtime-typing' ] 44 | - repo: https://github.com/pycqa/isort 45 | rev: 5.10.1 46 | hooks: 47 | - id: isort 48 | - repo: https://github.com/pre-commit/mirrors-mypy 49 | rev: v0.991 50 | hooks: 51 | - id: mypy 52 | additional_dependencies: 53 | - types-requests 54 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This package is open to contributions. To contribute, please follow these steps: 4 | 5 | 1. Fork the upstream repository into your personal account. 6 | 2. Install [poetry](https://python-poetry.org/), and install project dependencies using `poetry install` 7 | 3. Install [pre-commit](https://pre-commit.com/) (for project linting) by running `pre-commit install` 8 | 4. Create a new branch for your changes, and make sure to add tests 9 | 5. Push the topic branch to your personal fork 10 | 6. Run `pre-commit run --all-files` locally to ensure proper linting 11 | 7. Create a pull request to the this repository with a detailed summary of your changes and what motivated the change 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://pypi.org/project/portabletext-html/) 2 | [](https://github.com/otovo/python-portabletext-html/actions/workflows/test.yml) 3 | [](https://codecov.io/gh/otovo/python-portabletext-html) 4 | [](https://pypi.org/project/python-portabletext-html/) 5 | 6 | # Portable Text HTML Renderer for Python 7 | 8 | This package generates HTML from [Portable Text](https://github.com/portabletext/portabletext). 9 | 10 | For the most part, it mirrors [Sanity's](https://www.sanity.io/) own [block-content-to-html](https://www.npmjs.com/package/%40sanity/block-content-to-html) NPM library. 11 | 12 | ## Installation 13 | 14 | ``` 15 | pip install portabletext-html 16 | ``` 17 | 18 | ## Usage 19 | 20 | Instantiate the `PortableTextRenderer` class with your content and call the `render` method. 21 | 22 | The following content 23 | 24 | ```python 25 | from portabletext_html import PortableTextRenderer 26 | 27 | renderer = PortableTextRenderer({ 28 | "_key": "R5FvMrjo", 29 | "_type": "block", 30 | "children": [ 31 | {"_key": "cZUQGmh4", "_type": "span", "marks": ["strong"], "text": "A word of"}, 32 | {"_key": "toaiCqIK", "_type": "span", "marks": ["strong"], "text": " warning;"}, 33 | {"_key": "gaZingsA", "_type": "span", "marks": [], "text": " Sanity is addictive."} 34 | ], 35 | "markDefs": [], 36 | "style": "normal" 37 | }) 38 | renderer.render() 39 | ``` 40 | 41 | Generates this HTML 42 | ```html 43 |
A word of warning; Sanity is addictive.
44 | ``` 45 | 46 | ### Supported types 47 | 48 | The `block` and `span` types are supported out of the box. 49 | 50 | ### Custom types 51 | 52 | Beyond the built-in types, you have the freedom to provide 53 | your own serializers to render any custom `_type` the way you 54 | would like to. 55 | 56 | To illustrate, if you passed this data to the renderer class: 57 | 58 | ```python 59 | from portabletext_html import PortableTextRenderer 60 | 61 | renderer = PortableTextRenderer({ 62 | "_type": "block", 63 | "_key": "foo", 64 | "style": "normal", 65 | "children": [ 66 | { 67 | "_type": "span", 68 | "text": "Press, " 69 | }, 70 | { 71 | "_type": "button", 72 | "text": "here" 73 | }, 74 | { 75 | "_type": "span", 76 | "text": ", now!" 77 | } 78 | ] 79 | }) 80 | renderer.render() 81 | ``` 82 | 83 | The renderer would actually throw an error here, since `button` 84 | does not have a corresponding built-in type serializer by default. 85 | 86 | To render this text you must provide your own serializer, like this: 87 | 88 | ```python 89 | from portabletext_html import PortableTextRenderer 90 | 91 | 92 | def button_serializer(node: dict, context: Optional[Block], list_item: bool): 93 | return f'' 94 | 95 | 96 | renderer = PortableTextRenderer( 97 | ..., 98 | custom_serializers={'button': button_serializer} 99 | ) 100 | output = renderer.render() 101 | ``` 102 | 103 | With the custom serializer provided, the renderer would now successfully 104 | output the following HTML: 105 | 106 | ```html 107 |Press , now!
108 | ``` 109 | 110 | ### Supported mark definitions 111 | 112 | The package provides several built-in marker definitions and styles: 113 | 114 | **decorator marker definitions** 115 | 116 | - `em` 117 | - `strong` 118 | - `code` 119 | - `underline` 120 | - `strike-through` 121 | 122 | **annotation marker definitions** 123 | 124 | - `link` 125 | - `comment` 126 | 127 | ### Custom mark definitions 128 | 129 | Like with custom type serializers, additional serializers for 130 | marker definitions and styles can be passed in like this: 131 | 132 | ```python 133 | from portabletext_html import PortableTextRenderer 134 | 135 | renderer = PortableTextRenderer( 136 | ..., 137 | custom_marker_definitions={'em': ComicSansEmphasis} 138 | ) 139 | renderer.render() 140 | ``` 141 | 142 | The primary difference between a type serializer and a mark definition serializer 143 | is that the latter uses a class structure, and has three required methods. 144 | 145 | Here's an example of a custom style, adding an extra font 146 | to the built-in equivalent serializer: 147 | 148 | ```python 149 | from portabletext_html.marker_definitions import MarkerDefinition 150 | 151 | 152 | class ComicSansEmphasis(MarkerDefinition): 153 | tag = 'em' 154 | 155 | @classmethod 156 | def render_prefix(cls, span: Span, marker: str, context: Block) -> str: 157 | return f'<{cls.tag} style="font-family: "Comic Sans MS", "Comic Sans", cursive;">' 158 | 159 | @classmethod 160 | def render_suffix(cls, span: Span, marker: str, context: Block) -> str: 161 | return f'{cls.tag}>' 162 | 163 | @classmethod 164 | def render_text(cls, span: Span, marker: str, context: Block) -> str: 165 | # custom rendering logic can be placed here 166 | return str(span.text) 167 | 168 | @classmethod 169 | def render(cls, span: Span, marker: str, context: Block) -> str: 170 | result = cls.render_prefix(span, marker, context) 171 | result += str(span.text) 172 | result += cls.render_suffix(span, marker, context) 173 | return result 174 | ``` 175 | 176 | Since the `render_suffix` and `render` methods here are actually identical to the base class, 177 | they do not need to be specified, and the whole example can be reduced to: 178 | 179 | ```python 180 | from portabletext_html.marker_definitions import MarkerDefinition # base 181 | from portabletext_html import PortableTextRenderer 182 | 183 | 184 | class ComicSansEmphasis(MarkerDefinition): 185 | tag = 'em' 186 | 187 | @classmethod 188 | def render_prefix(cls, span: Span, marker: str, context: Block) -> str: 189 | return f'<{cls.tag} style="font-family: "Comic Sans MS", "Comic Sans", cursive;">' 190 | 191 | 192 | renderer = PortableTextRenderer( 193 | ..., 194 | custom_marker_definitions={'em': ComicSansEmphasis} 195 | ) 196 | renderer.render() 197 | ``` 198 | 199 | 200 | ### Supported styles 201 | 202 | Blocks can optionally define a `style` tag. These styles are supported: 203 | 204 | - `h1` 205 | - `h2` 206 | - `h3` 207 | - `h4` 208 | - `h5` 209 | - `h6` 210 | - `blockquote` 211 | - `normal` 212 | 213 | ## Missing features 214 | 215 | For anyone interested, we would be happy to see a 216 | default built-in serializer for the `image` type added. 217 | In the meantime, users should be able to serialize image types by passing a custom serializer. 218 | 219 | ## Contributing 220 | 221 | Contributions are always appreciated 👏 222 | 223 | For details, see the [CONTRIBUTING.md](https://github.com/otovo/python-portabletext-html/blob/main/CONTRIBUTING.md). 224 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "atomicwrites" 3 | version = "1.4.1" 4 | description = "Atomic file writes." 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 8 | 9 | [[package]] 10 | name = "attrs" 11 | version = "22.1.0" 12 | description = "Classes Without Boilerplate" 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=3.5" 16 | 17 | [package.extras] 18 | dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] 19 | docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] 20 | tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] 21 | tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] 22 | 23 | [[package]] 24 | name = "colorama" 25 | version = "0.4.6" 26 | description = "Cross-platform colored terminal text." 27 | category = "dev" 28 | optional = false 29 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 30 | 31 | [[package]] 32 | name = "coverage" 33 | version = "5.5" 34 | description = "Code coverage measurement for Python" 35 | category = "dev" 36 | optional = false 37 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 38 | 39 | [package.extras] 40 | toml = ["toml"] 41 | 42 | [[package]] 43 | name = "flake8" 44 | version = "3.9.2" 45 | description = "the modular source code checker: pep8 pyflakes and co" 46 | category = "dev" 47 | optional = false 48 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 49 | 50 | [package.dependencies] 51 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 52 | mccabe = ">=0.6.0,<0.7.0" 53 | pycodestyle = ">=2.7.0,<2.8.0" 54 | pyflakes = ">=2.3.0,<2.4.0" 55 | 56 | [[package]] 57 | name = "importlib-metadata" 58 | version = "5.0.0" 59 | description = "Read metadata from Python packages" 60 | category = "dev" 61 | optional = false 62 | python-versions = ">=3.7" 63 | 64 | [package.dependencies] 65 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 66 | zipp = ">=0.5" 67 | 68 | [package.extras] 69 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] 70 | perf = ["ipython"] 71 | testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] 72 | 73 | [[package]] 74 | name = "iniconfig" 75 | version = "1.1.1" 76 | description = "iniconfig: brain-dead simple config-ini parsing" 77 | category = "dev" 78 | optional = false 79 | python-versions = "*" 80 | 81 | [[package]] 82 | name = "mccabe" 83 | version = "0.6.1" 84 | description = "McCabe checker, plugin for flake8" 85 | category = "dev" 86 | optional = false 87 | python-versions = "*" 88 | 89 | [[package]] 90 | name = "packaging" 91 | version = "21.3" 92 | description = "Core utilities for Python packages" 93 | category = "dev" 94 | optional = false 95 | python-versions = ">=3.6" 96 | 97 | [package.dependencies] 98 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 99 | 100 | [[package]] 101 | name = "pluggy" 102 | version = "1.0.0" 103 | description = "plugin and hook calling mechanisms for python" 104 | category = "dev" 105 | optional = false 106 | python-versions = ">=3.6" 107 | 108 | [package.dependencies] 109 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 110 | 111 | [package.extras] 112 | dev = ["pre-commit", "tox"] 113 | testing = ["pytest", "pytest-benchmark"] 114 | 115 | [[package]] 116 | name = "py" 117 | version = "1.11.0" 118 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 119 | category = "dev" 120 | optional = false 121 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 122 | 123 | [[package]] 124 | name = "pycodestyle" 125 | version = "2.7.0" 126 | description = "Python style guide checker" 127 | category = "dev" 128 | optional = false 129 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 130 | 131 | [[package]] 132 | name = "pyflakes" 133 | version = "2.3.1" 134 | description = "passive checker of Python programs" 135 | category = "dev" 136 | optional = false 137 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 138 | 139 | [[package]] 140 | name = "pyparsing" 141 | version = "3.0.9" 142 | description = "pyparsing module - Classes and methods to define and execute parsing grammars" 143 | category = "dev" 144 | optional = false 145 | python-versions = ">=3.6.8" 146 | 147 | [package.extras] 148 | diagrams = ["jinja2", "railroad-diagrams"] 149 | 150 | [[package]] 151 | name = "pytest" 152 | version = "6.2.5" 153 | description = "pytest: simple powerful testing with Python" 154 | category = "dev" 155 | optional = false 156 | python-versions = ">=3.6" 157 | 158 | [package.dependencies] 159 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 160 | attrs = ">=19.2.0" 161 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 162 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 163 | iniconfig = "*" 164 | packaging = "*" 165 | pluggy = ">=0.12,<2.0" 166 | py = ">=1.8.2" 167 | toml = "*" 168 | 169 | [package.extras] 170 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 171 | 172 | [[package]] 173 | name = "pytest-cov" 174 | version = "2.12.1" 175 | description = "Pytest plugin for measuring coverage." 176 | category = "dev" 177 | optional = false 178 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 179 | 180 | [package.dependencies] 181 | coverage = ">=5.2.1" 182 | pytest = ">=4.6" 183 | toml = "*" 184 | 185 | [package.extras] 186 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 187 | 188 | [[package]] 189 | name = "toml" 190 | version = "0.10.2" 191 | description = "Python Library for Tom's Obvious, Minimal Language" 192 | category = "dev" 193 | optional = false 194 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 195 | 196 | [[package]] 197 | name = "typing-extensions" 198 | version = "4.4.0" 199 | description = "Backported and Experimental Type Hints for Python 3.7+" 200 | category = "dev" 201 | optional = false 202 | python-versions = ">=3.7" 203 | 204 | [[package]] 205 | name = "zipp" 206 | version = "3.10.0" 207 | description = "Backport of pathlib-compatible object wrapper for zip files" 208 | category = "dev" 209 | optional = false 210 | python-versions = ">=3.7" 211 | 212 | [package.extras] 213 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] 214 | testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] 215 | 216 | [metadata] 217 | lock-version = "1.1" 218 | python-versions = '^3.7' 219 | content-hash = "c641d950bccb6ffac52cf3fcd3571b51f5e31d4864c03e763fe2748919bf855b" 220 | 221 | [metadata.files] 222 | atomicwrites = [ 223 | {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, 224 | ] 225 | attrs = [ 226 | {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, 227 | {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, 228 | ] 229 | colorama = [ 230 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 231 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 232 | ] 233 | coverage = [ 234 | {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, 235 | {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, 236 | {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, 237 | {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, 238 | {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, 239 | {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, 240 | {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, 241 | {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, 242 | {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, 243 | {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, 244 | {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, 245 | {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, 246 | {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, 247 | {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, 248 | {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, 249 | {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, 250 | {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, 251 | {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, 252 | {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, 253 | {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, 254 | {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, 255 | {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, 256 | {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, 257 | {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, 258 | {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, 259 | {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, 260 | {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, 261 | {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, 262 | {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, 263 | {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, 264 | {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, 265 | {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, 266 | {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, 267 | {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, 268 | {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, 269 | {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, 270 | {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, 271 | {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, 272 | {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, 273 | {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, 274 | {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, 275 | {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, 276 | {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, 277 | {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, 278 | {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, 279 | {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, 280 | {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, 281 | {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, 282 | {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, 283 | {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, 284 | {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, 285 | {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, 286 | ] 287 | flake8 = [ 288 | {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, 289 | {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, 290 | ] 291 | importlib-metadata = [ 292 | {file = "importlib_metadata-5.0.0-py3-none-any.whl", hash = "sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43"}, 293 | {file = "importlib_metadata-5.0.0.tar.gz", hash = "sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab"}, 294 | ] 295 | iniconfig = [ 296 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 297 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 298 | ] 299 | mccabe = [ 300 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 301 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 302 | ] 303 | packaging = [ 304 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 305 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 306 | ] 307 | pluggy = [ 308 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 309 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 310 | ] 311 | py = [ 312 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 313 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 314 | ] 315 | pycodestyle = [ 316 | {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, 317 | {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, 318 | ] 319 | pyflakes = [ 320 | {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, 321 | {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, 322 | ] 323 | pyparsing = [ 324 | {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, 325 | {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, 326 | ] 327 | pytest = [ 328 | {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, 329 | {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, 330 | ] 331 | pytest-cov = [ 332 | {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, 333 | {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, 334 | ] 335 | toml = [ 336 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 337 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 338 | ] 339 | typing-extensions = [ 340 | {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, 341 | {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, 342 | ] 343 | zipp = [ 344 | {file = "zipp-3.10.0-py3-none-any.whl", hash = "sha256:4fcb6f278987a6605757302a6e40e896257570d11c51628968ccb2a47e80c6c1"}, 345 | {file = "zipp-3.10.0.tar.gz", hash = "sha256:7a7262fd930bd3e36c50b9a64897aec3fafff3dfdeec9623ae22b40e93f99bb8"}, 346 | ] 347 | -------------------------------------------------------------------------------- /portabletext_html/__init__.py: -------------------------------------------------------------------------------- 1 | from portabletext_html.renderer import PortableTextRenderer, render 2 | 3 | __all__ = ['PortableTextRenderer', 'render'] 4 | -------------------------------------------------------------------------------- /portabletext_html/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from portabletext_html.marker_definitions import ( 6 | CodeMarkerDefinition, 7 | CommentMarkerDefinition, 8 | EmphasisMarkerDefinition, 9 | LinkMarkerDefinition, 10 | StrikeThroughMarkerDefinition, 11 | StrongMarkerDefinition, 12 | UnderlineMarkerDefinition, 13 | ) 14 | 15 | if TYPE_CHECKING: 16 | from typing import Dict, Type 17 | 18 | from portabletext_html.marker_definitions import MarkerDefinition 19 | 20 | STYLE_MAP = { 21 | 'h1': 'h1', 22 | 'h2': 'h2', 23 | 'h3': 'h3', 24 | 'h4': 'h4', 25 | 'h5': 'h5', 26 | 'h6': 'h6', 27 | 'blockquote': 'blockquote', 28 | 'normal': 'p', 29 | } 30 | 31 | DECORATOR_MARKER_DEFINITIONS: Dict[str, Type[MarkerDefinition]] = { 32 | 'em': EmphasisMarkerDefinition, 33 | 'strong': StrongMarkerDefinition, 34 | 'code': CodeMarkerDefinition, 35 | 'underline': UnderlineMarkerDefinition, 36 | 'strike-through': StrikeThroughMarkerDefinition, 37 | } 38 | 39 | ANNOTATION_MARKER_DEFINITIONS: Dict[str, Type[MarkerDefinition]] = { 40 | 'link': LinkMarkerDefinition, 41 | 'comment': CommentMarkerDefinition, 42 | } 43 | -------------------------------------------------------------------------------- /portabletext_html/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Logging setup. 3 | 4 | The rest of the code gets the logger through this module rather than 5 | `logging.getLogger` to make sure that it is configured. 6 | """ 7 | import logging 8 | 9 | logger = logging.getLogger('portabletext_html') 10 | 11 | if not logger.handlers: # pragma: no cover 12 | logger.setLevel(logging.WARNING) 13 | logger.addHandler(logging.NullHandler()) 14 | -------------------------------------------------------------------------------- /portabletext_html/marker_definitions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from portabletext_html.logger import logger 6 | 7 | if TYPE_CHECKING: 8 | from typing import Type 9 | 10 | from portabletext_html.types import Block, Span 11 | 12 | 13 | class MarkerDefinition: 14 | """Base class for marker definition handlers.""" 15 | 16 | tag: str 17 | 18 | @classmethod 19 | def render_prefix(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str: 20 | """Render the prefix for the marked span. 21 | 22 | Usually this this the opening of the HTML tag. 23 | """ 24 | logger.debug('Rendering %s prefix', cls.tag) 25 | return f'<{cls.tag}>' 26 | 27 | @classmethod 28 | def render_suffix(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str: 29 | """Render the suffix for the marked span. 30 | 31 | Usually this this the closing of the HTML tag. 32 | """ 33 | logger.debug('Rendering %s suffix', cls.tag) 34 | return f'{cls.tag}>' 35 | 36 | @classmethod 37 | def render(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str: 38 | """Render the marked span directly with prefix and suffix.""" 39 | result = cls.render_prefix(span, marker, context) 40 | result += cls.render_text(span, marker, context) 41 | result += cls.render_suffix(span, marker, context) 42 | return result 43 | 44 | @classmethod 45 | def render_text(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str: 46 | """Render the content part for a marked span.""" 47 | return str(span.text) 48 | 49 | 50 | # Decorators 51 | 52 | 53 | class DefaultMarkerDefinition(MarkerDefinition): 54 | """Marker used for unknown definitions.""" 55 | 56 | tag = 'span' 57 | 58 | 59 | class EmphasisMarkerDefinition(MarkerDefinition): 60 | """Marker definition for rendering.""" 61 | 62 | tag = 'em' 63 | 64 | 65 | class StrongMarkerDefinition(MarkerDefinition): 66 | """Marker definition for rendering.""" 67 | 68 | tag = 'strong' 69 | 70 | 71 | class CodeMarkerDefinition(MarkerDefinition): 72 | """Marker definition for rendering."""
73 |
74 | tag = 'code'
75 |
76 |
77 | class UnderlineMarkerDefinition(MarkerDefinition):
78 | """Marker definition for rendering."""
79 |
80 | tag = 'span'
81 |
82 | @classmethod
83 | def render_prefix(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str:
84 | """Render the span with the appropriate style for underline."""
85 | return ''
86 |
87 |
88 | class StrikeThroughMarkerDefinition(MarkerDefinition):
89 | """Marker definition for rendering."""
90 |
91 | tag = 'del'
92 |
93 |
94 | # Annotations
95 |
96 |
97 | class LinkMarkerDefinition(MarkerDefinition):
98 | """Marker definition for link rendering."""
99 |
100 | tag = 'a'
101 |
102 | @classmethod
103 | def render_prefix(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str:
104 | """Render the opening anchor tag with the href attribute set.
105 |
106 | The href attribute is fetched from the provided block context using
107 | the provided marker key.
108 | """
109 | marker_definition = next((md for md in context.markDefs if md['_key'] == marker), None)
110 | if not marker_definition:
111 | raise ValueError(f'Marker definition for key: {marker} not found in parent block context')
112 | href = marker_definition.get('href', '')
113 | return f''
114 |
115 |
116 | class CommentMarkerDefinition(MarkerDefinition):
117 | """Marker definition for HTML comment rendering."""
118 |
119 | tag = '!--'
120 |
121 | @classmethod
122 | def render_prefix(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str:
123 | """Render the opening of the HTML comment block."""
124 | return ''
130 |
--------------------------------------------------------------------------------
/portabletext_html/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/otovo/python-portabletext-html/eb5595c2393b8881a510c4d7e0ae05907525f293/portabletext_html/py.typed
--------------------------------------------------------------------------------
/portabletext_html/renderer.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import html
4 | from typing import TYPE_CHECKING, cast
5 |
6 | from portabletext_html.constants import STYLE_MAP
7 | from portabletext_html.logger import logger
8 | from portabletext_html.marker_definitions import DefaultMarkerDefinition
9 | from portabletext_html.types import Block, Span
10 | from portabletext_html.utils import get_list_tags, is_block, is_list, is_span
11 |
12 | if TYPE_CHECKING:
13 | from typing import Any, Callable, Dict, List, Optional, Type, Union
14 |
15 | from portabletext_html.marker_definitions import MarkerDefinition
16 |
17 |
18 | class UnhandledNodeError(Exception):
19 | """Raised when we receive a node that we cannot parse."""
20 |
21 | pass
22 |
23 |
24 | class MissingSerializerError(UnhandledNodeError):
25 | """
26 | Raised when an unrecognized node _type value is found.
27 |
28 | This usually means that you need to pass a custom serializer
29 | to handle the custom type.
30 | """
31 |
32 | pass
33 |
34 |
35 | class PortableTextRenderer:
36 | """HTML renderer for Sanity's portable text format."""
37 |
38 | def __init__(
39 | self,
40 | blocks: Union[list[dict], dict],
41 | custom_marker_definitions: dict[str, Type[MarkerDefinition]] | None = None,
42 | custom_serializers: dict[str, Callable[[dict, Optional[Block], bool], str]] | None = None,
43 | ) -> None:
44 | logger.debug('Initializing block renderer')
45 | self._wrapper_element: Optional[str] = None
46 | self._custom_marker_definitions = custom_marker_definitions or {}
47 | self._custom_serializers = custom_serializers or {}
48 |
49 | if isinstance(blocks, dict):
50 | self._blocks = [blocks]
51 | elif isinstance(blocks, list):
52 | self._blocks = blocks
53 | self._wrapper_element = 'div' if len(blocks) > 1 else ''
54 |
55 | def render(self) -> str:
56 | """Render HTML from self._blocks."""
57 | logger.debug('Rendering HTML')
58 |
59 | if not self._blocks:
60 | return ''
61 |
62 | result = ''
63 | list_nodes: List[Dict] = []
64 |
65 | for node in self._blocks:
66 |
67 | if list_nodes and not is_list(node):
68 | tree = self._normalize_list_tree(list_nodes)
69 | result += ''.join([self._render_node(n, list_item=True) for n in tree])
70 | list_nodes = [] # reset list_nodes
71 |
72 | if is_list(node):
73 | list_nodes.append(node)
74 | continue # handle all elements ^ when the list ends
75 |
76 | result += self._render_node(node) # render non-list nodes immediately
77 |
78 | if list_nodes:
79 | tree = self._normalize_list_tree(list_nodes)
80 | result += ''.join(self._render_node(n, Block(**node), list_item=True) for n in tree)
81 |
82 | result = result.strip()
83 |
84 | if self._wrapper_element:
85 | return f'<{self._wrapper_element}>{result}{self._wrapper_element}>'
86 | return result
87 |
88 | def _render_node(self, node: dict, context: Optional[Block] = None, list_item: bool = False) -> str:
89 | """
90 | Call the correct render method depending on the node type.
91 |
92 | :param node: Block content node - can be block, span, or list (block).
93 | :param context: Optional context. Spans are passed with a Block instance as context for mark lookups.
94 | :param list_item: Whether we are handling a list upstream (impacts block handling).
95 | """
96 | if is_list(node):
97 | logger.debug('Rendering node as list')
98 | block = Block(**node, marker_definitions=self._custom_marker_definitions)
99 | return self._render_list(block, context)
100 |
101 | elif is_block(node):
102 | logger.debug('Rendering node as block')
103 | block = Block(**node, marker_definitions=self._custom_marker_definitions)
104 | return self._render_block(block, list_item=list_item)
105 |
106 | elif is_span(node):
107 | logger.debug('Rendering node as span')
108 | span = Span(**node)
109 | context = cast('Block', context) # context should always be a Block here
110 | return self._render_span(span, block=context)
111 |
112 | elif self._custom_serializers.get(node.get('_type', '')):
113 | return self._custom_serializers.get(node.get('_type', ''))(node, context, list_item) # type: ignore
114 |
115 | else:
116 | if '_type' in node:
117 | raise MissingSerializerError(
118 | f'Found unhandled node type: {node["_type"]}. ' 'Most likely this requires a custom serializer.'
119 | )
120 | else:
121 | raise UnhandledNodeError(f'Received node that we cannot handle: {node}')
122 |
123 | def _render_block(self, block: Block, list_item: bool = False) -> str:
124 | text, tag = '', STYLE_MAP[block.style]
125 |
126 | if not list_item or tag != 'p':
127 | text += f'<{tag}>'
128 |
129 | for child_node in block.children:
130 | text += self._render_node(child_node, context=block)
131 |
132 | if not list_item or tag != 'p':
133 | text += f'{tag}>'
134 |
135 | return text
136 |
137 | def _render_span(self, span: Span, block: Block) -> str:
138 | logger.debug('Rendering span')
139 | result: str = ''
140 | prev_node, next_node = block.get_node_siblings(span)
141 |
142 | prev_marks = prev_node.get('marks', []) if prev_node else []
143 | next_marks = next_node.get('marks', []) if next_node else []
144 |
145 | sorted_marks = sorted(span.marks, key=lambda x: -block.marker_frequencies[x])
146 | for mark in sorted_marks:
147 | if mark in prev_marks:
148 | continue
149 |
150 | marker_callable = block.marker_definitions.get(mark, DefaultMarkerDefinition)()
151 | result += marker_callable.render_prefix(span, mark, block)
152 |
153 | # to avoid rendering the text multiple times,
154 | # only the first custom mark will be used
155 | custom_mark_text_rendered = False
156 | if sorted_marks:
157 | for mark in sorted_marks:
158 | if custom_mark_text_rendered or mark in prev_marks:
159 | continue
160 | marker_callable = block.marker_definitions.get(mark, DefaultMarkerDefinition)()
161 | result += marker_callable.render_text(span, mark, block)
162 | custom_mark_text_rendered = True
163 |
164 | if not custom_mark_text_rendered:
165 | result += html.escape(span.text).replace('\n', '
')
166 |
167 | for mark in reversed(sorted_marks):
168 | if mark in next_marks:
169 | continue
170 |
171 | marker_callable = block.marker_definitions.get(mark, DefaultMarkerDefinition)()
172 | result += marker_callable.render_suffix(span, mark, block)
173 |
174 | return result
175 |
176 | def _render_list(self, node: Block, context: Optional[Block]) -> str:
177 | assert node.listItem
178 | head, tail = get_list_tags(node.listItem)
179 | result = head
180 | for child in node.children:
181 | result += f'{self._render_block(Block(**child), True)} '
182 | result += tail
183 | return result
184 |
185 | def _normalize_list_tree(self, nodes: list) -> list[dict]:
186 | tree = []
187 |
188 | current_list = None
189 | for node in nodes:
190 | if not is_block(node):
191 | tree.append(node)
192 | current_list = None
193 | continue
194 |
195 | if current_list is None:
196 | current_list = self._list_from_block(node)
197 | tree.append(current_list)
198 | continue
199 |
200 | if node.get('level') == current_list['level'] and node.get('listItem') == current_list['listItem']:
201 | current_list['children'].append(node)
202 | continue
203 |
204 | if node.get('level') > current_list['level']:
205 | new_list = self._list_from_block(node)
206 | current_list['children'][-1]['children'].append(new_list)
207 | current_list = new_list
208 | continue
209 |
210 | if node.get('level') < current_list['level']:
211 | parent = self._find_list(tree[-1], level=node.get('level'), list_item=node.get('listItem'))
212 | if parent:
213 | current_list = parent
214 | current_list['children'].append(node)
215 | continue
216 | current_list = self._list_from_block(node)
217 | tree.append(current_list)
218 | continue
219 |
220 | if node.get('listItem') != current_list['listItem']:
221 | match = self._find_list(tree[-1], level=node.get('level'))
222 | if match and match['listItem'] == node.get('listItem'):
223 | current_list = match
224 | current_list['children'].append(node)
225 | continue
226 | current_list = self._list_from_block(node)
227 | tree.append(current_list)
228 | continue
229 | # TODO: Warn
230 | tree.append(node)
231 |
232 | return tree
233 |
234 | def _find_list(self, root_node: dict, level: int, list_item: Optional[str] = None) -> Optional[dict]:
235 | filter_on_type = isinstance(list_item, str)
236 | if (
237 | root_node.get('_type') == 'list'
238 | and root_node.get('level') == level
239 | and (filter_on_type and root_node.get('listItem') == list_item)
240 | ):
241 | return root_node
242 |
243 | children = root_node.get('children')
244 | if children:
245 | return self._find_list(children[-1], level, list_item)
246 |
247 | return None
248 |
249 | def _list_from_block(self, block: dict) -> dict:
250 | return {
251 | '_type': 'list',
252 | '_key': f'${block["_key"]}-parent',
253 | 'level': block.get('level'),
254 | 'listItem': block['listItem'],
255 | 'children': [block],
256 | }
257 |
258 |
259 | def render(blocks: List[Dict], *args: Any, **kwargs: Any) -> str:
260 | """Shortcut function inspired by Sanity's own blocksToHtml.h callable."""
261 | renderer = PortableTextRenderer(blocks, *args, **kwargs)
262 | return renderer.render()
263 |
--------------------------------------------------------------------------------
/portabletext_html/types.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass, field
4 | from typing import TYPE_CHECKING
5 |
6 | from portabletext_html.utils import get_default_marker_definitions
7 |
8 | if TYPE_CHECKING:
9 | from typing import Literal, Optional, Tuple, Type, Union
10 |
11 | from portabletext_html.marker_definitions import MarkerDefinition
12 |
13 |
14 | @dataclass(frozen=True)
15 | class Span:
16 | """Class representation of a Portable Text span.
17 |
18 | A span is the standard way to express inline text within a block.
19 | """
20 |
21 | _type: Literal['span']
22 | text: str
23 |
24 | _key: Optional[str] = None
25 | marks: list[str] = field(default_factory=list) # keys that correspond with block.mark_definitions
26 | style: Literal['normal'] = 'normal'
27 |
28 |
29 | @dataclass
30 | class Block:
31 | """Class representation of a Portable Text block.
32 |
33 | A block is what's typically recognized as a section of a text, e.g. a paragraph or a heading.
34 |
35 | listItem and markDefs are camelCased to support dictionary unpacking.
36 | """
37 |
38 | _type: Literal['block']
39 |
40 | _key: Optional[str] = None
41 | style: Literal['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'normal'] = 'normal'
42 | level: Optional[int] = None
43 | listItem: Optional[Literal['bullet', 'number', 'square']] = None
44 | children: list[dict] = field(default_factory=list)
45 | markDefs: list[dict] = field(default_factory=list)
46 | marker_definitions: dict[str, Type[MarkerDefinition]] = field(default_factory=dict)
47 | marker_frequencies: dict[str, int] = field(init=False)
48 |
49 | def __post_init__(self) -> None:
50 | """
51 | Add custom fields after init.
52 |
53 | To make handling of span `marks` simpler, we define marker_definitions as a dict, from which
54 | we can directly look up both annotation marks or decorator marks.
55 | """
56 | self.marker_definitions = self._add_custom_marker_definitions()
57 | self.marker_frequencies = self._compute_marker_frequencies()
58 |
59 | def _compute_marker_frequencies(self) -> dict[str, int]:
60 | counts: dict[str, int] = {}
61 | for child in self.children:
62 | for mark in child.get('marks', []):
63 | if mark in counts:
64 | counts[mark] += 1
65 | else:
66 | counts[mark] = 0
67 | return counts
68 |
69 | def _add_custom_marker_definitions(self) -> dict[str, Type[MarkerDefinition]]:
70 | marker_definitions = get_default_marker_definitions(self.markDefs)
71 | marker_definitions.update(self.marker_definitions)
72 | for definition in self.markDefs:
73 | if definition['_type'] in self.marker_definitions:
74 | marker = self.marker_definitions[definition['_type']]
75 | marker_definitions[definition['_key']] = marker
76 | # del marker_definitions[definition['_type']]
77 | return marker_definitions
78 |
79 | def get_node_siblings(self, node: Union[dict, Span]) -> Tuple[Optional[dict], Optional[dict]]:
80 | """Return the sibling nodes (prev, next) to the given node."""
81 | if not self.children:
82 | return None, None
83 | try:
84 | if type(node) == dict:
85 | node_idx = self.children.index(node)
86 | elif type(node) == Span:
87 | for index, item in enumerate(self.children):
88 | if 'text' in item and node.text == item['text']:
89 | # Is it possible to handle several identical texts?
90 | node_idx = index
91 | break
92 | else:
93 | raise ValueError(f'Expected dict or Span but received {type(node)}')
94 | except ValueError:
95 | return None, None
96 |
97 | next_node = None
98 |
99 | prev_node = self.children[node_idx - 1] if node_idx != 0 else None
100 | if node_idx != len(self.children) - 1:
101 | next_node = self.children[node_idx + 1]
102 |
103 | return prev_node, next_node
104 |
--------------------------------------------------------------------------------
/portabletext_html/utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | from portabletext_html.constants import ANNOTATION_MARKER_DEFINITIONS, DECORATOR_MARKER_DEFINITIONS
6 |
7 | if TYPE_CHECKING:
8 | from typing import Type
9 |
10 | from portabletext_html.marker_definitions import MarkerDefinition
11 |
12 |
13 | def get_default_marker_definitions(mark_defs: list[dict]) -> dict[str, Type[MarkerDefinition]]:
14 | """
15 | Convert JSON definitions to a map of marker definition renderers.
16 |
17 | There are two types of markers: decorators and annotations. Decorators are accessed
18 | by string (`em` or `strong`), while annotations are accessed by a key.
19 | """
20 | marker_definitions = {}
21 |
22 | for definition in mark_defs:
23 | if definition['_type'] in ANNOTATION_MARKER_DEFINITIONS:
24 | marker = ANNOTATION_MARKER_DEFINITIONS[definition['_type']]
25 | marker_definitions[definition['_key']] = marker
26 |
27 | return {**marker_definitions, **DECORATOR_MARKER_DEFINITIONS}
28 |
29 |
30 | def is_list(node: dict) -> bool:
31 | """Check whether a node is a list node."""
32 | return 'listItem' in node
33 |
34 |
35 | def is_span(node: dict) -> bool:
36 | """Check whether a node is a span node."""
37 | return node.get('_type', '') == 'span' or isinstance(node, str) or hasattr(node, 'marks')
38 |
39 |
40 | def is_block(node: dict) -> bool:
41 | """Check whether a node is a block node."""
42 | return node.get('_type') == 'block'
43 |
44 |
45 | def get_list_tags(list_item: str) -> tuple[str, str]:
46 | """Return the appropriate list tags for a given list item."""
47 | # TODO: Make it possible for users to pass their own maps, perhaps by adding this to the class
48 | # and checking optional class context variables defined on initialization.
49 | return {
50 | 'bullet': ('', '
'),
51 | 'square': ('', '
'),
52 | 'number': ('', '
'),
53 | }[list_item]
54 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = 'portabletext-html'
3 | version = '1.1.3'
4 | description = "HTML renderer for Sanity's Portable Text format"
5 | homepage = 'https://github.com/otovo/python-sanity-html'
6 | repository = 'https://github.com/otovo/python-sanity-html'
7 | authors = ['Kristian Klette ']
8 | maintainers = ['Sondre Lillebø Gundersen ']
9 | license = 'Apache2'
10 | readme = 'README.md'
11 | keywords = ['sanity', 'portable', 'text', 'html', 'parsing']
12 | include = ['CHANGELOG.md']
13 | packages = [{ include = 'portabletext_html' }]
14 | classifiers = [
15 | 'Development Status :: 5 - Production/Stable',
16 | 'Intended Audience :: Developers',
17 | 'Environment :: Web Environment',
18 | 'Operating System :: OS Independent',
19 | 'License :: OSI Approved :: Apache Software License',
20 | 'Topic :: Text Processing',
21 | 'Topic :: Text Processing :: Markup',
22 | 'Topic :: Text Processing :: Markup :: HTML',
23 | 'Programming Language :: Python',
24 | 'Programming Language :: Python :: 3.7',
25 | 'Programming Language :: Python :: 3.8',
26 | 'Programming Language :: Python :: 3.9',
27 | 'Programming Language :: Python :: 3.10',
28 | 'Programming Language :: Python :: 3.11',
29 | 'Typing :: Typed',
30 | ]
31 |
32 | [tool.poetry.dependencies]
33 | python = '^3.7'
34 |
35 | [tool.poetry.dev-dependencies]
36 | pytest = '^6.2.3'
37 | flake8 = '^3.9.0'
38 | pytest-cov = '^2.11.1'
39 | coverage = '^5.5'
40 |
41 | [build-system]
42 | requires = ['poetry-core>=1.0.0']
43 | build-backend = 'poetry.core.masonry.api'
44 |
45 | [tool.black]
46 | line-length = 120
47 | include = '\.pyi?$'
48 | skip-string-normalization = true
49 |
50 | [tool.isort]
51 | profile = 'black'
52 | multi_line_output = 3
53 | include_trailing_comma = true
54 | line_length = 120
55 |
56 | [tool.pytest.ini_options]
57 | addopts = ['--cov=portabletext_html','--cov-report', 'term-missing']
58 | markers = ['unsupported']
59 |
60 | [tool.coverage.run]
61 | source = ['portabletext_html/*']
62 | omit = []
63 | branch = true
64 |
65 | [tool.coverage.report]
66 | show_missing = true
67 | skip_covered = true
68 | exclude_lines = [
69 | 'if TYPE_CHECKING:',
70 | ]
71 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [flake8]
2 | ignore=
3 | # D104 Missing docstring in public package
4 | D104,
5 | # D100 Missing docstring in public module
6 | D100,
7 | # D107 Missing docstring in __init__
8 | D107,
9 | # ANN101 Missing type annotation for self in method
10 | ANN101,
11 | # W503 line break before binary operator
12 | W503,
13 | # ANN002 and ANN003 missing type annotation for *args and **kwargs
14 | ANN002, ANN003
15 | # SM106 - Handle error cases first
16 | SIM106
17 |
18 | select =
19 | E, F, N, W
20 | # Bugbear
21 | B, B950,
22 | # Comprehensions
23 | C,
24 | # Print
25 | T,
26 | # Mutable
27 | M,
28 | # Simplify
29 | SIM,
30 | # Pytest-style
31 | PT,
32 | # Docstrings
33 | D,
34 | # Annotations
35 | ANN,
36 | # Type-checking
37 | TC, TC1,
38 | # printf-formatting
39 | MOD
40 |
41 | exclude =
42 | .git,
43 | .venv
44 | .idea,
45 | __pycache__,
46 | tests/*
47 |
48 | max-complexity = 15
49 | max-line-length = 120
50 |
51 | [mypy]
52 | show_error_codes = True
53 | warn_unused_ignores = True
54 | strict_optional = True
55 | incremental = True
56 | ignore_missing_imports = True
57 | warn_redundant_casts = True
58 | warn_unused_configs = True
59 | disallow_untyped_defs = True
60 | disallow_untyped_calls = True
61 | local_partial_types = True
62 | show_traceback = True
63 | exclude =
64 | .venv/
65 |
66 | [mypy-tests.*]
67 | ignore_errors = True
68 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/otovo/python-portabletext-html/eb5595c2393b8881a510c4d7e0ae05907525f293/tests/__init__.py
--------------------------------------------------------------------------------
/tests/fixtures/basic_mark.json:
--------------------------------------------------------------------------------
1 | {
2 | "_key": "R5FvMrjo",
3 | "_type": "block",
4 | "children": [
5 | {
6 | "_key": "cZUQGmh4",
7 | "_type": "span",
8 | "marks": ["code"],
9 | "text": "sanity"
10 | },
11 | {
12 | "_key": "toaiCqIK",
13 | "_type": "span",
14 | "marks": [],
15 | "text": " is the name of the CLI tool."
16 | }
17 | ],
18 | "markDefs": [],
19 | "style": "normal"
20 | }
21 |
--------------------------------------------------------------------------------
/tests/fixtures/custom_serializer_node_after_list.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "_key": "e5b6e416e6e9",
4 | "_type": "block",
5 | "children": [
6 | { "_key": "3bbbff0f158b", "_type": "span", "marks": [], "text": "resers" }
7 | ],
8 | "level": 1,
9 | "listItem": "bullet",
10 | "markDefs": [],
11 | "style": "normal"
12 | },
13 | {
14 | "_key": "73405dda68e0",
15 | "_type": "extraInfoBlock",
16 | "extraInfo": "This informations is not supported by Block",
17 | "markDefs": [],
18 | "style": "normal"
19 | }
20 | ]
21 |
--------------------------------------------------------------------------------
/tests/fixtures/invalid_node.json:
--------------------------------------------------------------------------------
1 | {
2 | "_key": "73405dda68e7",
3 | "children": [
4 | {
5 | "_key": "25a09c61d80a",
6 | "_type": "span",
7 | "marks": [],
8 | "text": "Otovo guarantee is good"
9 | }
10 | ],
11 | "markDefs": [],
12 | "style": "normal"
13 | }
14 |
--------------------------------------------------------------------------------
/tests/fixtures/invalid_type.json:
--------------------------------------------------------------------------------
1 | {
2 | "_key": "73405dda68e7",
3 | "_type": "invalid_type",
4 | "children": [
5 | {
6 | "_key": "25a09c61d80a",
7 | "_type": "span",
8 | "marks": [],
9 | "text": "Otovo guarantee is good"
10 | }
11 | ],
12 | "markDefs": [],
13 | "style": "normal"
14 | }
15 |
--------------------------------------------------------------------------------
/tests/fixtures/multiple_adjecent_marks.json:
--------------------------------------------------------------------------------
1 | {
2 | "_key": "R5FvMrjo",
3 | "_type": "block",
4 | "children": [
5 | {
6 | "_key": "cZUQGmh4",
7 | "_type": "span",
8 | "marks": ["strong"],
9 | "text": "A word of"
10 | },
11 | {
12 | "_key": "toaiCqIK",
13 | "_type": "span",
14 | "marks": ["strong"],
15 | "text": " warning;"
16 | },
17 | {
18 | "_key": "gaZingA",
19 | "_type": "span",
20 | "marks": [],
21 | "text": " Sanity is addictive."
22 | }
23 | ],
24 | "markDefs": [],
25 | "style": "normal"
26 | }
27 |
--------------------------------------------------------------------------------
/tests/fixtures/multiple_simple_spans.json:
--------------------------------------------------------------------------------
1 | {
2 | "_key": "73405dda68e7",
3 | "_type": "block",
4 | "children": [
5 | {
6 | "_key": "25a09c61d80a",
7 | "_type": "span",
8 | "marks": [],
9 | "text": "Otovo guarantee is good "
10 | },
11 | {
12 | "_key": "25a09c61d80b",
13 | "_type": "span",
14 | "marks": [],
15 | "text": "for all"
16 | }
17 | ],
18 | "markDefs": [],
19 | "style": "normal"
20 | }
21 |
--------------------------------------------------------------------------------
/tests/fixtures/nested_marks.json:
--------------------------------------------------------------------------------
1 | {
2 | "_key": "R5FvMrjo",
3 | "_type": "block",
4 | "children": [
5 | {
6 | "_key": "cZUQGmh4",
7 | "_type": "span",
8 | "marks": ["strong"],
9 | "text": "A word of "
10 | },
11 | {
12 | "_key": "toaiCqIK",
13 | "_type": "span",
14 | "marks": ["strong", "em"],
15 | "text": "warning;"
16 | },
17 | {
18 | "_key": "gaZingA",
19 | "_type": "span",
20 | "marks": [],
21 | "text": " Sanity is addictive."
22 | }
23 | ],
24 | "markDefs": [],
25 | "style": "normal"
26 | }
27 |
--------------------------------------------------------------------------------
/tests/fixtures/simple_span.json:
--------------------------------------------------------------------------------
1 | {
2 | "_key": "73405dda68e7",
3 | "_type": "block",
4 | "children": [
5 | {
6 | "_key": "25a09c61d80a",
7 | "_type": "span",
8 | "marks": [],
9 | "text": "Otovo guarantee is good"
10 | }
11 | ],
12 | "markDefs": [],
13 | "style": "normal"
14 | }
15 |
--------------------------------------------------------------------------------
/tests/fixtures/simple_xss.json:
--------------------------------------------------------------------------------
1 | {
2 | "_key": "73405dda68e7",
3 | "_type": "block",
4 | "children": [
5 | {
6 | "_key": "25a09c61d80a",
7 | "_type": "span",
8 | "marks": [],
9 | "text": "Otovo guarantee is good"
10 | }
11 | ],
12 | "markDefs": [],
13 | "style": "normal"
14 | }
15 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/001-empty-block.json:
--------------------------------------------------------------------------------
1 | {"input":{"_key":"R5FvMrjo","_type":"block","children":[],"markDefs":[],"style":"normal"},"output":""}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/002-single-span.json:
--------------------------------------------------------------------------------
1 | {"input":{"_key":"R5FvMrjo","_type":"block","children":[{"_key":"cZUQGmh4","_type":"span","marks":[],"text":"Plain text."}],"markDefs":[],"style":"normal"},"output":"Plain text.
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/003-multiple-spans.json:
--------------------------------------------------------------------------------
1 | {"input":{"_key":"R5FvMrjo","_type":"block","children":[{"_key":"cZUQGmh4","_type":"span","marks":[],"text":"Span number one. "},{"_key":"toaiCqIK","_type":"span","marks":[],"text":"And span number two."}],"markDefs":[],"style":"normal"},"output":"Span number one. And span number two.
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/004-basic-mark-single-span.json:
--------------------------------------------------------------------------------
1 | {"input":{"_key":"R5FvMrjo","_type":"block","children":[{"_key":"cZUQGmh4","_type":"span","marks":["code"],"text":"sanity"},{"_key":"toaiCqIK","_type":"span","marks":[],"text":" is the name of the CLI tool."}],"markDefs":[],"style":"normal"},"output":"sanity
is the name of the CLI tool.
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/005-basic-mark-multiple-adjacent-spans.json:
--------------------------------------------------------------------------------
1 | {
2 | "input": {
3 | "_key": "R5FvMrjo",
4 | "_type": "block",
5 | "children": [
6 | {
7 | "_key": "cZUQGmh4",
8 | "_type": "span",
9 | "marks": [
10 | "strong"
11 | ],
12 | "text": "A word of"
13 | },
14 | {
15 | "_key": "toaiCqIK",
16 | "_type": "span",
17 | "marks": [
18 | "strong"
19 | ],
20 | "text": " warning;"
21 | },
22 | {
23 | "_key": "gaZingA",
24 | "_type": "span",
25 | "marks": [],
26 | "text": " Sanity is addictive."
27 | }
28 | ],
29 | "markDefs": [],
30 | "style": "normal"
31 | },
32 | "output": "A word of warning; Sanity is addictive.
"
33 | }
34 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/006-basic-mark-nested-marks.json:
--------------------------------------------------------------------------------
1 | {"input":{"_key":"R5FvMrjo","_type":"block","children":[{"_key":"cZUQGmh4","_type":"span","marks":["strong"],"text":"A word of "},{"_key":"toaiCqIK","_type":"span","marks":["strong","em"],"text":"warning;"},{"_key":"gaZingA","_type":"span","marks":[],"text":" Sanity is addictive."}],"markDefs":[],"style":"normal"},"output":"A word of warning; Sanity is addictive.
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/007-link-mark-def.json:
--------------------------------------------------------------------------------
1 | {"input":{"_key":"R5FvMrjo","_type":"block","children":[{"_key":"cZUQGmh4","_type":"span","marks":[],"text":"A word of warning; "},{"_key":"toaiCqIK","_type":"span","marks":["someLinkId"],"text":"Sanity"},{"_key":"gaZingA","_type":"span","marks":[],"text":" is addictive."}],"markDefs":[{"_type":"link","_key":"someLinkId","href":"https://sanity.io/"}],"style":"normal"},"output":"A word of warning; Sanity is addictive.
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/008-plain-header-block.json:
--------------------------------------------------------------------------------
1 | {"input":{"_key":"R5FvMrjo","_type":"block","children":[{"_key":"cZUQGmh4","_type":"span","marks":[],"text":"Dat heading"}],"markDefs":[],"style":"h2"},"output":"Dat heading
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/009-messy-link-text.json:
--------------------------------------------------------------------------------
1 | {
2 | "input": {
3 | "_type": "block",
4 | "children": [
5 | {
6 | "_key": "a1ph4",
7 | "_type": "span",
8 | "marks": ["zomgLink"],
9 | "text": "Sanity"
10 | },
11 | {
12 | "_key": "b374",
13 | "_type": "span",
14 | "marks": [],
15 | "text": " can be used to power almost any "
16 | },
17 | {
18 | "_key": "ch4r1i3",
19 | "_type": "span",
20 | "marks": ["zomgLink", "strong", "em"],
21 | "text": "app"
22 | },
23 | {
24 | "_key": "d3174",
25 | "_type": "span",
26 | "marks": ["em", "zomgLink"],
27 | "text": " or website"
28 | },
29 | { "_key": "ech0", "_type": "span", "marks": [], "text": "." }
30 | ],
31 | "markDefs": [
32 | { "_key": "zomgLink", "_type": "link", "href": "https://sanity.io/" }
33 | ],
34 | "style": "blockquote"
35 | },
36 | "output": "Sanity can be used to power almost any app or website.
"
37 | }
38 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/010-basic-bullet-list.json:
--------------------------------------------------------------------------------
1 | {
2 | "input": [
3 | {
4 | "style": "normal",
5 | "_type": "block",
6 | "_key": "f94596b05b41",
7 | "markDefs": [],
8 | "children": [
9 | {
10 | "_type": "span",
11 | "text": "Let's test some of these lists!",
12 | "marks": []
13 | }
14 | ]
15 | },
16 | {
17 | "listItem": "bullet",
18 | "style": "normal",
19 | "level": 1,
20 | "_type": "block",
21 | "_key": "937effb1cd06",
22 | "markDefs": [],
23 | "children": [
24 | {
25 | "_type": "span",
26 | "text": "Bullet 1",
27 | "marks": []
28 | }
29 | ]
30 | },
31 | {
32 | "listItem": "bullet",
33 | "style": "normal",
34 | "level": 1,
35 | "_type": "block",
36 | "_key": "bd2d22278b88",
37 | "markDefs": [],
38 | "children": [
39 | {
40 | "_type": "span",
41 | "text": "Bullet 2",
42 | "marks": []
43 | }
44 | ]
45 | },
46 | {
47 | "listItem": "bullet",
48 | "style": "normal",
49 | "level": 1,
50 | "_type": "block",
51 | "_key": "a97d32e9f747",
52 | "markDefs": [],
53 | "children": [
54 | {
55 | "_type": "span",
56 | "text": "Bullet 3",
57 | "marks": []
58 | }
59 | ]
60 | }
61 | ],
62 | "output": "Let's test some of these lists!
- Bullet 1
- Bullet 2
- Bullet 3
"
63 | }
64 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/011-basic-numbered-list.json:
--------------------------------------------------------------------------------
1 | {"input":[{"style":"normal","_type":"block","_key":"f94596b05b41","markDefs":[],"children":[{"_type":"span","text":"Let's test some of these lists!","marks":[]}]},{"listItem":"number","style":"normal","level":1,"_type":"block","_key":"937effb1cd06","markDefs":[],"children":[{"_type":"span","text":"Number 1","marks":[]}]},{"listItem":"number","style":"normal","level":1,"_type":"block","_key":"bd2d22278b88","markDefs":[],"children":[{"_type":"span","text":"Number 2","marks":[]}]},{"listItem":"number","style":"normal","level":1,"_type":"block","_key":"a97d32e9f747","markDefs":[],"children":[{"_type":"span","text":"Number 3","marks":[]}]}],"output":"Let's test some of these lists!
- Number 1
- Number 2
- Number 3
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/012-image-support.json:
--------------------------------------------------------------------------------
1 | {
2 | "input": [
3 | {
4 | "style": "normal",
5 | "_type": "block",
6 | "_key": "bd73ec5f61a1",
7 | "markDefs": [],
8 | "children": [
9 | {
10 | "_type": "span",
11 | "text": "Also, images are pretty common.",
12 | "marks": []
13 | }
14 | ]
15 | },
16 | {
17 | "_type": "image",
18 | "_key": "d234a4fa317a",
19 | "asset": {
20 | "_type": "reference",
21 | "_ref": "image-YiOKD0O6AdjKPaK24WtbOEv0-3456x2304-jpg"
22 | }
23 | }
24 | ],
25 | "output": "Also, images are pretty common.

"
26 | }
27 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/013-materialized-image-support.json:
--------------------------------------------------------------------------------
1 | {
2 | "input": [
3 | {
4 | "style": "normal",
5 | "_type": "block",
6 | "_key": "bd73ec5f61a1",
7 | "markDefs": [],
8 | "children": [
9 | {
10 | "_type": "span",
11 | "text": "Also, images are pretty common.",
12 | "marks": []
13 | }
14 | ]
15 | },
16 | {
17 | "_key": "bd45080bf448",
18 | "_type": "image",
19 | "asset": {
20 | "_createdAt": "2017-08-02T23:04:57Z",
21 | "_id": "image-caC3MscJLd3mNAbMdQ6-5748x3832-jpg",
22 | "_rev": "ch7HXy1Ux9jmVKZ6TKPoZ8",
23 | "_type": "sanity.imageAsset",
24 | "_updatedAt": "2017-09-19T18:05:06Z",
25 | "assetId": "caC3MscJLd3mNAbMdQ6",
26 | "extension": "jpg",
27 | "metadata": {
28 | "dimensions": {
29 | "aspectRatio": 1.5,
30 | "height": 3832,
31 | "width": 5748
32 | }
33 | },
34 | "mimeType": "image/jpeg",
35 | "path": "images/3do82whm/production/caC3MscJLd3mNAbMdQ6-5748x3832.jpg",
36 | "url": "https://cdn.sanity.io/images/3do82whm/production/caC3MscJLd3mNAbMdQ6-5748x3832.jpg"
37 | }
38 | }
39 | ],
40 | "output": "Also, images are pretty common.

"
41 | }
42 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/014-nested-lists.json:
--------------------------------------------------------------------------------
1 | {"input":[{"_type":"block","_key":"a","markDefs":[],"style":"normal","children":[{"_type":"span","marks":[],"text":"Span"}]},{"_type":"block","_key":"b","markDefs":[],"level":1,"children":[{"_type":"span","marks":[],"text":"Item 1, level 1"}],"listItem":"bullet"},{"_type":"block","_key":"c","markDefs":[],"level":1,"children":[{"_type":"span","marks":[],"text":"Item 2, level 1"}],"listItem":"bullet"},{"_type":"block","_key":"d","markDefs":[],"level":2,"children":[{"_type":"span","marks":[],"text":"Item 3, level 2"}],"listItem":"number"},{"_type":"block","_key":"e","markDefs":[],"level":3,"children":[{"_type":"span","marks":[],"text":"Item 4, level 3"}],"listItem":"number"},{"_type":"block","_key":"f","markDefs":[],"level":2,"children":[{"_type":"span","marks":[],"text":"Item 5, level 2"}],"listItem":"number"},{"_type":"block","_key":"g","markDefs":[],"level":2,"children":[{"_type":"span","marks":[],"text":"Item 6, level 2"}],"listItem":"number"},{"_type":"block","_key":"h","markDefs":[],"level":1,"children":[{"_type":"span","marks":[],"text":"Item 7, level 1"}],"listItem":"bullet"},{"_type":"block","_key":"i","markDefs":[],"level":1,"children":[{"_type":"span","marks":[],"text":"Item 8, level 1"}],"listItem":"bullet"},{"_type":"block","_key":"j","markDefs":[],"level":1,"children":[{"_type":"span","marks":[],"text":"Item 1 of list 2"}],"listItem":"number"},{"_type":"block","_key":"k","markDefs":[],"level":1,"children":[{"_type":"span","marks":[],"text":"Item 2 of list 2"}],"listItem":"number"},{"_type":"block","_key":"l","markDefs":[],"level":2,"children":[{"_type":"span","marks":[],"text":"Item 3 of list 2, level 2"}],"listItem":"number"},{"_type":"block","_key":"m","markDefs":[],"style":"normal","children":[{"_type":"span","marks":[],"text":"Just a block"}]}],"output":"Span
- Item 1, level 1
- Item 2, level 1
- Item 3, level 2
- Item 4, level 3
- Item 5, level 2
- Item 6, level 2
- Item 7, level 1
- Item 8, level 1
- Item 1 of list 2
- Item 2 of list 2
- Item 3 of list 2, level 2
Just a block
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/015-all-basic-marks.json:
--------------------------------------------------------------------------------
1 | {
2 | "input": {
3 | "_key": "R5FvMrjo",
4 | "_type": "block",
5 | "children": [
6 | {
7 | "_key": "a",
8 | "_type": "span",
9 | "marks": [
10 | "code"
11 | ],
12 | "text": "code"
13 | },
14 | {
15 | "_key": "b",
16 | "_type": "span",
17 | "marks": [
18 | "strong"
19 | ],
20 | "text": "strong"
21 | },
22 | {
23 | "_key": "c",
24 | "_type": "span",
25 | "marks": [
26 | "em"
27 | ],
28 | "text": "em"
29 | },
30 | {
31 | "_key": "d",
32 | "_type": "span",
33 | "marks": [
34 | "underline"
35 | ],
36 | "text": "underline"
37 | },
38 | {
39 | "_key": "e",
40 | "_type": "span",
41 | "marks": [
42 | "strike-through"
43 | ],
44 | "text": "strike-through"
45 | },
46 | {
47 | "_key": "f",
48 | "_type": "span",
49 | "marks": [
50 | "dat-link"
51 | ],
52 | "text": "link"
53 | }
54 | ],
55 | "markDefs": [
56 | {
57 | "_key": "dat-link",
58 | "_type": "link",
59 | "href": "https://www.sanity.io/"
60 | }
61 | ],
62 | "style": "normal"
63 | },
64 | "output": "code
strongemunderlinestrike-throughlink
"
65 | }
66 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/016-deep-weird-lists.json:
--------------------------------------------------------------------------------
1 | {"input":[{"listItem":"bullet","style":"normal","level":1,"_type":"block","_key":"fde2e840a29c","markDefs":[],"children":[{"_type":"span","text":"Item a","marks":[]}]},{"listItem":"bullet","style":"normal","level":1,"_type":"block","_key":"c16f11c71638","markDefs":[],"children":[{"_type":"span","text":"Item b","marks":[]}]},{"listItem":"number","style":"normal","level":1,"_type":"block","_key":"e92f55b185ae","markDefs":[],"children":[{"_type":"span","text":"Item 1","marks":[]}]},{"listItem":"number","style":"normal","level":1,"_type":"block","_key":"a77e71209aff","markDefs":[],"children":[{"_type":"span","text":"Item 2","marks":[]}]},{"listItem":"number","style":"normal","level":2,"_type":"block","_key":"da1f863df265","markDefs":[],"children":[{"_type":"span","text":"Item 2, a","marks":[]}]},{"listItem":"number","style":"normal","level":2,"_type":"block","_key":"60d8c92bed0d","markDefs":[],"children":[{"_type":"span","text":"Item 2, b","marks":[]}]},{"listItem":"number","style":"normal","level":1,"_type":"block","_key":"6dbc061d5d36","markDefs":[],"children":[{"_type":"span","text":"Item 3","marks":[]}]},{"style":"normal","_type":"block","_key":"bb89bd1ef2c9","markDefs":[],"children":[{"_type":"span","text":"","marks":[]}]},{"listItem":"bullet","style":"normal","level":1,"_type":"block","_key":"289c1f176eab","markDefs":[],"children":[{"_type":"span","text":"In","marks":[]}]},{"listItem":"bullet","style":"normal","level":2,"_type":"block","_key":"011f8cc6d19b","markDefs":[],"children":[{"_type":"span","text":"Out","marks":[]}]},{"listItem":"bullet","style":"normal","level":1,"_type":"block","_key":"ccfb4e37b798","markDefs":[],"children":[{"_type":"span","text":"In","marks":[]}]},{"listItem":"bullet","style":"normal","level":2,"_type":"block","_key":"bd0102405e5c","markDefs":[],"children":[{"_type":"span","text":"Out","marks":[]}]},{"listItem":"bullet","style":"normal","level":3,"_type":"block","_key":"030fda546030","markDefs":[],"children":[{"_type":"span","text":"Even More","marks":[]}]},{"listItem":"bullet","style":"normal","level":4,"_type":"block","_key":"80369435aed0","markDefs":[],"children":[{"_type":"span","text":"Even deeper","marks":[]}]},{"listItem":"bullet","style":"normal","level":2,"_type":"block","_key":"3b36919a8914","markDefs":[],"children":[{"_type":"span","text":"Two steps back","marks":[]}]},{"listItem":"bullet","style":"normal","level":1,"_type":"block","_key":"9193cbc6ba54","markDefs":[],"children":[{"_type":"span","text":"All the way back","marks":[]}]},{"listItem":"bullet","style":"normal","level":3,"_type":"block","_key":"256fe8487d7a","markDefs":[],"children":[{"_type":"span","text":"Skip a step","marks":[]}]},{"listItem":"number","style":"normal","level":1,"_type":"block","_key":"aaa","markDefs":[],"children":[{"_type":"span","text":"New list","marks":[]}]},{"listItem":"number","style":"normal","level":2,"_type":"block","_key":"bbb","markDefs":[],"children":[{"_type":"span","text":"Next level","marks":[]}]},{"listItem":"bullet","style":"normal","level":1,"_type":"block","_key":"ccc","markDefs":[],"children":[{"_type":"span","text":"New bullet list","marks":[]}]}],"output":"- Item a
- Item b
- Item 1
- Item 2
- Item 2, a
- Item 2, b
- Item 3
- In
- Out
- In
- Out
- Even More
- Even deeper
- Two steps back
- All the way back
- Skip a step
- New list
- Next level
- New bullet list
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/017-all-default-block-styles.json:
--------------------------------------------------------------------------------
1 | {"input":[{"style":"h1","_type":"block","_key":"b07278ae4e5a","markDefs":[],"children":[{"_type":"span","text":"Sanity","marks":[]}]},{"style":"h2","_type":"block","_key":"0546428bbac2","markDefs":[],"children":[{"_type":"span","text":"The outline","marks":[]}]},{"style":"h3","_type":"block","_key":"34024674e160","markDefs":[],"children":[{"_type":"span","text":"More narrow details","marks":[]}]},{"style":"h4","_type":"block","_key":"06ca981a1d18","markDefs":[],"children":[{"_type":"span","text":"Even less thing","marks":[]}]},{"style":"h5","_type":"block","_key":"06ca98afnjkg","markDefs":[],"children":[{"_type":"span","text":"Small header","marks":[]}]},{"style":"h6","_type":"block","_key":"cc0afafn","markDefs":[],"children":[{"_type":"span","text":"Lowest thing","marks":[]}]},{"style":"blockquote","_type":"block","_key":"0ee0381658d0","markDefs":[],"children":[{"_type":"span","text":"A block quote of awesomeness","marks":[]}]},{"style":"normal","_type":"block","_key":"44fb584a634c","markDefs":[],"children":[{"_type":"span","text":"Plain old normal block","marks":[]}]},{"_type":"block","_key":"abcdefg","markDefs":[],"children":[{"_type":"span","text":"Default to \"normal\" style","marks":[]}]}],"output":"Sanity
The outline
More narrow details
Even less thing
Small header
Lowest thing
A block quote of awesomeness
Plain old normal block
Default to "normal" style
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/018-marks-all-the-way-down.json:
--------------------------------------------------------------------------------
1 | {"input":{"_type":"block","children":[{"_key":"a1ph4","_type":"span","marks":["mark1","em","mark2"],"text":"Sanity"},{"_key":"b374","_type":"span","marks":["mark2","mark1","em"],"text":" FTW"}],"markDefs":[{"_key":"mark1","_type":"highlight","thickness":1},{"_key":"mark2","_type":"highlight","thickness":3}]},"output":"Sanity FTW
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/019-keyless.json:
--------------------------------------------------------------------------------
1 | {"input":[{"_type":"block","children":[{"_type":"span","marks":[],"text":"sanity"},{"_type":"span","marks":[],"text":" is a full time job"}],"markDefs":[],"style":"normal"},{"_type":"block","children":[{"_type":"span","marks":[],"text":"in a world that "},{"_type":"span","marks":[],"text":"is always changing"}],"markDefs":[],"style":"normal"}],"output":"sanity is a full time job
in a world that is always changing
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/020-empty-array.json:
--------------------------------------------------------------------------------
1 | {"input":[],"output":""}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/021-list-without-level.json:
--------------------------------------------------------------------------------
1 | {
2 | "input": [
3 | {
4 | "_key": "e3ac53b5b339",
5 | "_type": "block",
6 | "children": [
7 | {
8 | "_type": "span",
9 | "marks": [],
10 | "text": "In-person access: Research appointments"
11 | }
12 | ],
13 | "markDefs": [],
14 | "style": "h2"
15 | },
16 | {
17 | "_key": "a25f0be55c47",
18 | "_type": "block",
19 | "children": [
20 | {
21 | "_type": "span",
22 | "marks": [],
23 | "text": "The collection may be examined by arranging a research appointment "
24 | },
25 | {
26 | "_type": "span",
27 | "marks": [
28 | "strong"
29 | ],
30 | "text": "in advance"
31 | },
32 | {
33 | "_type": "span",
34 | "marks": [],
35 | "text": " by contacting the ACT archivist by email or phone. ACT generally does not accept walk-in research patrons, although requests may be made in person at the Archivist’s office (E15-222). ACT recommends arranging appointments at least three weeks in advance in order to ensure availability. ACT reserves the right to cancel research appointments at any time. Appointment scheduling is subject to institute holidays and closings. "
36 | }
37 | ],
38 | "markDefs": [],
39 | "style": "normal"
40 | },
41 | {
42 | "_key": "9490a3085498",
43 | "_type": "block",
44 | "children": [
45 | {
46 | "_type": "span",
47 | "marks": [],
48 | "text": "The collection space is located at:\n20 Ames Street\nBuilding E15-235\nCambridge, Massachusetts 02139"
49 | }
50 | ],
51 | "markDefs": [],
52 | "style": "normal"
53 | },
54 | {
55 | "_key": "4c37f3bc1d71",
56 | "_type": "block",
57 | "children": [
58 | {
59 | "_type": "span",
60 | "marks": [],
61 | "text": "In-person access: Space policies"
62 | }
63 | ],
64 | "markDefs": [],
65 | "style": "h2"
66 | },
67 | {
68 | "_key": "a77cf4905e83",
69 | "_type": "block",
70 | "children": [
71 | {
72 | "_type": "span",
73 | "marks": [],
74 | "text": "The Archivist or an authorized ACT staff member must attend researchers at all times."
75 | }
76 | ],
77 | "listItem": "bullet",
78 | "markDefs": [],
79 | "style": "normal"
80 | },
81 | {
82 | "_key": "9a039c533554",
83 | "_type": "block",
84 | "children": [
85 | {
86 | "_type": "span",
87 | "marks": [],
88 | "text": "No pens, markers, or adhesives (e.g. “Post-it” notes) are permitted in the collection space; pencils will be provided upon request."
89 | }
90 | ],
91 | "listItem": "bullet",
92 | "markDefs": [],
93 | "style": "normal"
94 | },
95 | {
96 | "_key": "beeee9405136",
97 | "_type": "block",
98 | "children": [
99 | {
100 | "_type": "span",
101 | "marks": [],
102 | "text": "Cotton gloves must be worn when handling collection materials; gloves will be provided by the Archivist."
103 | }
104 | ],
105 | "listItem": "bullet",
106 | "markDefs": [],
107 | "style": "normal"
108 | },
109 | {
110 | "_key": "8b78daa65d60",
111 | "_type": "block",
112 | "children": [
113 | {
114 | "_type": "span",
115 | "marks": [],
116 | "text": "No food or beverages are permitted in the collection space."
117 | }
118 | ],
119 | "listItem": "bullet",
120 | "markDefs": [],
121 | "style": "normal"
122 | },
123 | {
124 | "_key": "d0188e00a887",
125 | "_type": "block",
126 | "children": [
127 | {
128 | "_type": "span",
129 | "marks": [],
130 | "text": "Laptop use is permitted in the collection space, as well as digital cameras and cellphones. Unless otherwise authorized, any equipment in the collection space (including but not limited to computers, telephones, scanners, and viewing equipment) is for use by ACT staff members only."
131 | }
132 | ],
133 | "listItem": "bullet",
134 | "markDefs": [],
135 | "style": "normal"
136 | },
137 | {
138 | "_key": "06486dd9e1c6",
139 | "_type": "block",
140 | "children": [
141 | {
142 | "_type": "span",
143 | "marks": [],
144 | "text": "Photocopying machines in the ACT hallway will be accessible by patrons under the supervision of the Archivist."
145 | }
146 | ],
147 | "listItem": "bullet",
148 | "markDefs": [],
149 | "style": "normal"
150 | },
151 | {
152 | "_key": "e6f6f5255fb6",
153 | "_type": "block",
154 | "children": [
155 | {
156 | "_type": "span",
157 | "marks": [],
158 | "text": "Patrons may only browse materials that have been made available for access."
159 | }
160 | ],
161 | "listItem": "bullet",
162 | "markDefs": [],
163 | "style": "normal"
164 | },
165 | {
166 | "_key": "99b3e265fa02",
167 | "_type": "block",
168 | "children": [
169 | {
170 | "_type": "span",
171 | "marks": [],
172 | "text": "Remote access: Reference requests"
173 | }
174 | ],
175 | "markDefs": [],
176 | "style": "h2"
177 | },
178 | {
179 | "_key": "ea13459d9e46",
180 | "_type": "block",
181 | "children": [
182 | {
183 | "_type": "span",
184 | "marks": [],
185 | "text": "For patrons who are unable to arrange for an on-campus visit to the Archives and Special Collections, reference questions may be directed to the Archivist remotely by email or phone. Generally, emails and phone calls will receive a response within 72 hours of receipt. Requests are typically filled in the order they are received."
186 | }
187 | ],
188 | "markDefs": [],
189 | "style": "normal"
190 | },
191 | {
192 | "_key": "100958e35c94",
193 | "_type": "block",
194 | "children": [
195 | {
196 | "_type": "span",
197 | "marks": [
198 | "strong"
199 | ],
200 | "text": "Use of patron information"
201 | }
202 | ],
203 | "markDefs": [],
204 | "style": "h2"
205 | },
206 | {
207 | "_key": "2e0dde67b7df",
208 | "_type": "block",
209 | "children": [
210 | {
211 | "_type": "span",
212 | "marks": [],
213 | "text": "Patrons requesting collection materials in person or remotely may be asked to provide certain information to the Archivist, such as contact information and topic(s) of research. This information is only used to track requests for statistical evaluations of collection use and will not be disclosed to outside organizations for any purpose. ACT will endeavor to protect the privacy of all patrons accessing collections."
214 | }
215 | ],
216 | "markDefs": [],
217 | "style": "normal"
218 | },
219 | {
220 | "_key": "8f39a1ec6366",
221 | "_type": "block",
222 | "children": [
223 | {
224 | "_type": "span",
225 | "marks": [
226 | "strong"
227 | ],
228 | "text": "Fees"
229 | }
230 | ],
231 | "markDefs": [],
232 | "style": "h2"
233 | },
234 | {
235 | "_key": "090062c9e8ce",
236 | "_type": "block",
237 | "children": [
238 | {
239 | "_type": "span",
240 | "marks": [],
241 | "text": "ACT reserves the right to charge an hourly rate for requests that require more than three hours of research on behalf of a patron (remote requests). Collection materials may be scanned and made available upon request, but digitization of certain materials may incur costs. Additionally, requests to publish, exhibit, or otherwise reproduce and display collection materials may incur use fees."
242 | }
243 | ],
244 | "markDefs": [],
245 | "style": "normal"
246 | },
247 | {
248 | "_key": "e2b58e246069",
249 | "_type": "block",
250 | "children": [
251 | {
252 | "_type": "span",
253 | "marks": [
254 | "strong"
255 | ],
256 | "text": "Use of MIT-owned materials by patrons"
257 | }
258 | ],
259 | "markDefs": [],
260 | "style": "h2"
261 | },
262 | {
263 | "_key": "7cedb6800dc6",
264 | "_type": "block",
265 | "children": [
266 | {
267 | "_type": "span",
268 | "marks": [],
269 | "text": "Permission to examine collection materials in person or remotely (by receiving transfers of digitized materials) does not imply or grant permission to publish or exhibit those materials. Permission to publish, exhibit, or otherwise use collection materials is granted on a case by case basis in accordance with MIT policy, restrictions that may have been placed on materials by donors or depositors, and copyright law. To request permission to publish, exhibit, or otherwise use collection materials, contact the Archivist. "
270 | },
271 | {
272 | "_type": "span",
273 | "marks": [
274 | "strong"
275 | ],
276 | "text": "When permission is granted by MIT, patrons must comply with all guidelines provided by ACT for citations, credits, and copyright statements. Exclusive rights to examine or publish material will not be granted."
277 | }
278 | ],
279 | "markDefs": [],
280 | "style": "normal"
281 | }
282 | ],
283 | "output": "In-person access: Research appointments
The collection may be examined by arranging a research appointment in advance by contacting the ACT archivist by email or phone. ACT generally does not accept walk-in research patrons, although requests may be made in person at the Archivist’s office (E15-222). ACT recommends arranging appointments at least three weeks in advance in order to ensure availability. ACT reserves the right to cancel research appointments at any time. Appointment scheduling is subject to institute holidays and closings.
The collection space is located at:
20 Ames Street
Building E15-235
Cambridge, Massachusetts 02139
In-person access: Space policies
- The Archivist or an authorized ACT staff member must attend researchers at all times.
- No pens, markers, or adhesives (e.g. “Post-it” notes) are permitted in the collection space; pencils will be provided upon request.
- Cotton gloves must be worn when handling collection materials; gloves will be provided by the Archivist.
- No food or beverages are permitted in the collection space.
- Laptop use is permitted in the collection space, as well as digital cameras and cellphones. Unless otherwise authorized, any equipment in the collection space (including but not limited to computers, telephones, scanners, and viewing equipment) is for use by ACT staff members only.
- Photocopying machines in the ACT hallway will be accessible by patrons under the supervision of the Archivist.
- Patrons may only browse materials that have been made available for access.
Remote access: Reference requests
For patrons who are unable to arrange for an on-campus visit to the Archives and Special Collections, reference questions may be directed to the Archivist remotely by email or phone. Generally, emails and phone calls will receive a response within 72 hours of receipt. Requests are typically filled in the order they are received.
Use of patron information
Patrons requesting collection materials in person or remotely may be asked to provide certain information to the Archivist, such as contact information and topic(s) of research. This information is only used to track requests for statistical evaluations of collection use and will not be disclosed to outside organizations for any purpose. ACT will endeavor to protect the privacy of all patrons accessing collections.
Fees
ACT reserves the right to charge an hourly rate for requests that require more than three hours of research on behalf of a patron (remote requests). Collection materials may be scanned and made available upon request, but digitization of certain materials may incur costs. Additionally, requests to publish, exhibit, or otherwise reproduce and display collection materials may incur use fees.
Use of MIT-owned materials by patrons
Permission to examine collection materials in person or remotely (by receiving transfers of digitized materials) does not imply or grant permission to publish or exhibit those materials. Permission to publish, exhibit, or otherwise use collection materials is granted on a case by case basis in accordance with MIT policy, restrictions that may have been placed on materials by donors or depositors, and copyright law. To request permission to publish, exhibit, or otherwise use collection materials, contact the Archivist. When permission is granted by MIT, patrons must comply with all guidelines provided by ACT for citations, credits, and copyright statements. Exclusive rights to examine or publish material will not be granted.
"
284 | }
285 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/022-inline-nodes.json:
--------------------------------------------------------------------------------
1 | {"input":[{"_type":"block","_key":"bd73ec5f61a1","style":"normal","markDefs":[],"children":[{"_type":"span","text":"Also, images are pretty common: ","marks":[]},{"_type":"image","_key":"d234a4fa317a","asset":{"_type":"reference","_ref":"image-YiOKD0O6AdjKPaK24WtbOEv0-3456x2304-jpg"}},{"_type":"span","text":" - as you can see, they can also appear inline!","marks":[]}]},{"_type":"block","_key":"foo","markDefs":[],"children":[{"_type":"span","text":"Sibling paragraph","marks":[]}]}],"output":"Also, images are pretty common:
- as you can see, they can also appear inline!
Sibling paragraph
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/023-hard-breaks.json:
--------------------------------------------------------------------------------
1 | {"input":[{"_type":"block","_key":"bd73ec5f61a1","style":"normal","markDefs":[],"children":[{"_type":"span","text":"A paragraph\ncan have hard\n\nbreaks.","marks":[]}]}],"output":"A paragraph
can have hard
breaks.
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/024-inline-images.json:
--------------------------------------------------------------------------------
1 | {"input":[{"_key":"08707ed2945b","_type":"block","style":"normal","children":[{"_key":"08707ed2945b0","text":"Foo! Bar!","_type":"span","marks":["code"]},{"_key":"a862cadb584f","_type":"image","asset":{"_ref":"image-magnificent_beastZ8Z5qZHHxgrTJf6Hhz-162x120-png","_type":"reference"}},{"_key":"08707ed2945b1","text":"Neat","_type":"span","marks":[]}],"markDefs":[]},{"_key":"abc","_type":"block","style":"normal","children":[{"_key":"08707ed2945b0","text":"Foo! Bar! ","_type":"span","marks":["code"]},{"_key":"a862cadb584f","_type":"image","asset":{"_ref":"image-magnificent_beastZ8Z5qZHHxgrTJf6Hhz-162x120-png","_type":"reference"}},{"_key":"08707ed2945b1","text":" Baz!","_type":"span","marks":["code"]}],"markDefs":[]},{"_key":"def","_type":"block","style":"normal","children":[{"_key":"08707ed2945b0","text":"Foo! Bar! ","_type":"span","marks":[]},{"_key":"a862cadb584f","_type":"image","asset":{"_ref":"image-magnificent_beastZ8Z5qZHHxgrTJf6Hhz-162x120-png","_type":"reference"}},{"_key":"08707ed2945b1","text":" Baz!","_type":"span","marks":["code"]}],"markDefs":[]}],"output":"Foo! Bar!
Neat
Foo! Bar!

Baz!
Foo! Bar! 
Baz!
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/025-image-with-hotspot.json:
--------------------------------------------------------------------------------
1 | {"input":[{"style":"normal","_type":"block","_key":"bd73ec5f61a1","markDefs":[],"children":[{"_type":"span","text":"Also, images are pretty common.","marks":[]}]},{"_type":"image","_key":"53811e851487","asset":{"_type":"reference","_ref":"image-c2f0fc30003e6d7c79dcb5338a9b3d297cab4a8a-2000x1333-jpg"},"crop":{"top":0.0960960960960961,"bottom":0.09609609609609615,"left":0.2340000000000001,"right":0.2240000000000001},"hotspot":{"x":0.505,"y":0.49999999999999994,"height":0.8078078078078077,"width":0.5419999999999998}}],"output":"Also, images are pretty common.

"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/026-inline-block-with-text.json:
--------------------------------------------------------------------------------
1 | {
2 | "input": [
3 | {
4 | "_type": "block",
5 | "_key": "foo",
6 | "style": "normal",
7 | "children": [
8 | {
9 | "_type": "span",
10 | "text": "Men, "
11 | },
12 | {
13 | "_type": "button",
14 | "text": "bli med du også"
15 | },
16 | {
17 | "_type": "span",
18 | "text": ", da!"
19 | }
20 | ]
21 | }
22 | ],
23 | "output": "Men, , da!
"
24 | }
25 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/027-styled-list-items.json:
--------------------------------------------------------------------------------
1 | {
2 | "input": [
3 | {
4 | "style": "normal",
5 | "_type": "block",
6 | "_key": "f94596b05b41",
7 | "markDefs": [],
8 | "children": [
9 | {
10 | "_type": "span",
11 | "text": "Let's test some of these lists!",
12 | "marks": []
13 | }
14 | ]
15 | },
16 | {
17 | "listItem": "bullet",
18 | "style": "normal",
19 | "level": 1,
20 | "_type": "block",
21 | "_key": "937effb1cd06",
22 | "markDefs": [],
23 | "children": [
24 | {
25 | "_type": "span",
26 | "text": "Bullet 1",
27 | "marks": []
28 | }
29 | ]
30 | },
31 | {
32 | "listItem": "bullet",
33 | "style": "h1",
34 | "level": 1,
35 | "_type": "block",
36 | "_key": "bd2d22278b88",
37 | "markDefs": [],
38 | "children": [
39 | {
40 | "_type": "span",
41 | "text": "Bullet 2",
42 | "marks": []
43 | }
44 | ]
45 | },
46 | {
47 | "listItem": "bullet",
48 | "style": "normal",
49 | "level": 1,
50 | "_type": "block",
51 | "_key": "a97d32e9f747",
52 | "markDefs": [],
53 | "children": [
54 | {
55 | "_type": "span",
56 | "text": "Bullet 3",
57 | "marks": []
58 | }
59 | ]
60 | }
61 | ],
62 | "output": "Let's test some of these lists!
- Bullet 1
Bullet 2
- Bullet 3
"
63 | }
64 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/050-custom-block-type.json:
--------------------------------------------------------------------------------
1 | {"input":[{"_type":"code","_key":"9a15ea2ed8a2","language":"javascript","code":"const foo = require('foo')\n\nfoo('hi there', (err, thing) => {\n console.log(err)\n})\n"}],"output":"const foo = require('foo')\n\nfoo('hi there', (err, thing) => {\n console.log(err)\n})\n
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/051-override-defaults.json:
--------------------------------------------------------------------------------
1 | {"input":[{"_type":"image","_key":"d234a4fa317a","asset":{"_type":"reference","_ref":"image-YiOKD0O6AdjKPaK24WtbOEv0-3456x2304-jpg"}}],"output":"
"}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/052-custom-marks.json:
--------------------------------------------------------------------------------
1 | {
2 | "input": {
3 | "_type": "block",
4 | "children": [
5 | {
6 | "_key": "a1ph4",
7 | "_type": "span",
8 | "marks": [
9 | "mark1"
10 | ],
11 | "text": "Sanity"
12 | }
13 | ],
14 | "markDefs": [
15 | {
16 | "_key": "mark1",
17 | "_type": "highlight",
18 | "thickness": 5
19 | }
20 | ]
21 | },
22 | "output": "Sanity
"
23 | }
24 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/053-override-default-marks.json:
--------------------------------------------------------------------------------
1 | {"input":{"_type":"block","children":[{"_key":"a1ph4","_type":"span","marks":["mark1"],"text":"Sanity"}],"markDefs":[{"_key":"mark1","_type":"link","href":"https://sanity.io"}]},"output":""}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/060-list-issue.json:
--------------------------------------------------------------------------------
1 | {"input":[{"_key":"68e32a42bc86","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h2"},{"_key":"e5a6349a2145","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"normal"},{"_key":"22659f66b40b","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"87b8d684fb9e","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"a14d35e806c5","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"4bc360f7123a","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"22f50c9e40a6","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"664cca534e5d","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"1e9b2d0b4ef6","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"24ede750fde5","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"89eeaeac72c5","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"993fb23a4fbb","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"09b00b82c010","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h2"},{"_key":"e1ec0b8bccbe","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"normal"},{"_key":"ff11fb1a52ad","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"normal"},{"_key":"034604cee2d9","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"normal"},{"_key":"836431a777a8","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h2"},{"_key":"a2c1052ca675","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"},{"_type":"span","marks":["abaab54abef7"],"text":"Lorem ipsum"},{"_type":"span","marks":[],"text":"Lorem ipsum"},{"_type":"span","marks":["36e7c773d148"],"text":"Lorem ipsum"},{"_type":"span","marks":[],"text":"Lorem ipsum"},{"_type":"span","marks":["4352c44c3077"],"text":"Lorem ipsum"},{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[{"_key":"abaab54abef7","_type":"link","href":"https://example.com"},{"_key":"36e7c773d148","_type":"link","href":"https://example.com"},{"_key":"4352c44c3077","_type":"link","href":"https://example.com"}],"style":"normal"},{"_key":"008e004a87e3","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h3"},{"_key":"383dddd69bef","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"ea36cba89a66","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"57f05ea5c2bb","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"d5df37eee363","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h3"},{"_key":"61231e9bb2f4","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"e1091120de4d","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"be53f0b95e8b","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"e6538941fddf","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"a852b3d1518a","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"d77890703306","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"d061261ee1d2","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h3"},{"_key":"248cc45717e0","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"09f2ab44df66","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"ba7b45509071","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"},{"_type":"span","marks":["em"],"text":"Lorem ipsum"},{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"12c77502a595","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h3"},{"_key":"078039a7af96","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"e2ea9480bfe5","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"fdc3bfe19845","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"3201b3d02e0d","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"5ef716ee0309","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h3"},{"_key":"9a1430f39842","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"5fa8c1cd9d66","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"29240861e0c7","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h3"},{"_key":"471105eb4eb6","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"2a1754271e84","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"c820d890f8c7","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h3"},{"_key":"b58650f53e9e","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"0ca5f3fb129e","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"f68449f61111","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":2,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"5433045c560a","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":2,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"3d85b3b16d79","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":2,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"03421acc9f6d","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":2,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"3a94842ddd74","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"4e3558037479","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":2,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"2cf4b5ddec6f","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":2,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"93051319ac3e","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":2,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"252749bb01d5","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":2,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"d32eb8106d08","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":2,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"dbdbc5839fb6","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":2,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"f673698e2e27","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":2,"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"2638df8609e7","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h2"},{"_key":"8bd25d26c0ab","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"normal"},{"_key":"58fc3993c18c","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"},{"_type":"span","marks":["em"],"text":"Lorem ipsum"}],"markDefs":[],"style":"normal"},{"_key":"7845e645190f","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h2"},{"_key":"26e1555ec20c","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"normal"},{"_key":"e90b29141e56","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"normal"},{"_key":"7f9ac906a4bd","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h2"},{"_key":"9259af58c8be","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"normal"},{"_key":"d3343fe575d4","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"14c57fd646e8","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"c8e8905dfe9e","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"69c4fe9fa4ed","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"ae19d6d44753","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"number","markDefs":[],"style":"normal"},{"_key":"1136f698594f","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"normal"},{"_key":"d94cdd676b75","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h2"},{"_key":"660d22bd8f2a","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"normal"},{"_key":"55c6814da883","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"},{"_type":"span","marks":["96b9a7384bb9"],"text":"Lorem ipsum"},{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[{"_key":"96b9a7384bb9","_type":"link","href":"https://example.com"}],"style":"normal"},{"_key":"2baca0a20bca","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"},{"_type":"span","marks":["99d77e03056c"],"text":"Lorem ipsum"},{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[{"_key":"99d77e03056c","_type":"link","href":"https://example.com"}],"style":"normal"},{"_key":"512d2c9cc40d","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"},{"_type":"span","marks":["a81f3f515e3e"],"text":"Lorem ipsum"},{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[{"_key":"a81f3f515e3e","_type":"link","href":"https://example.com"}],"style":"normal"},{"_key":"5e68d8b50d64","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"},{"_type":"span","marks":["aedfb56c1761"],"text":"Lorem ipsum"},{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[{"_key":"aedfb56c1761","_type":"link","href":"https://example.com"}],"style":"normal"},{"_key":"8d339b91184a","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"},{"_type":"span","marks":["beec3b2a0459"],"text":"Lorem ipsum"},{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[{"_key":"beec3b2a0459","_type":"link","href":"https://example.com"}],"style":"normal"},{"_key":"09d48934ea88","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"},{"_type":"span","marks":["30559cd94434"],"text":"Lorem ipsum"},{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[{"_key":"30559cd94434","_type":"link","href":"https://example.com"}],"style":"normal"},{"_key":"851a44421210","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"},{"_type":"span","marks":["cf109fae377a"],"text":"Lorem ipsum"},{"_type":"span","marks":[],"text":"Lorem ipsum"}],"level":1,"listItem":"bullet","markDefs":[{"_key":"cf109fae377a","_type":"link","href":"https://example.com"}],"style":"normal"},{"_key":"b584b7aee2be","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"h2"},{"_key":"23e9756111da","_type":"block","children":[{"_type":"span","marks":[],"text":"Lorem ipsum"}],"markDefs":[],"style":"normal"}]}
2 |
--------------------------------------------------------------------------------
/tests/fixtures/upstream/061-missing-mark-serializer.json:
--------------------------------------------------------------------------------
1 | {"input":{"_type":"block","children":[{"_key":"cZUQGmh4","_type":"span","marks":["abc"],"text":"A word of "},{"_key":"toaiCqIK","_type":"span","marks":["abc","em"],"text":"warning;"},{"_key":"gaZingA","_type":"span","marks":[],"text":" Sanity is addictive."}],"markDefs":[]},"output":"A word of warning; Sanity is addictive.
"}
2 |
--------------------------------------------------------------------------------
/tests/test_marker_definitions.py:
--------------------------------------------------------------------------------
1 | # pylint: skip-file
2 | from typing import Type
3 |
4 | from portabletext_html import PortableTextRenderer
5 | from portabletext_html.marker_definitions import (
6 | CommentMarkerDefinition,
7 | EmphasisMarkerDefinition,
8 | LinkMarkerDefinition,
9 | StrikeThroughMarkerDefinition,
10 | StrongMarkerDefinition,
11 | UnderlineMarkerDefinition,
12 | )
13 | from portabletext_html.types import Block, Span
14 |
15 | sample_texts = ['test', None, 1, 2.2, '!"#$%&/()']
16 |
17 |
18 | def test_render_emphasis_marker_success():
19 | for text in sample_texts:
20 | node = Span(_type='span', text=text)
21 | block = Block(_type='block', children=[node.__dict__])
22 | assert EmphasisMarkerDefinition.render_text(node, 'em', block) == f'{text}'
23 | assert EmphasisMarkerDefinition.render(node, 'em', block) == f'{text}'
24 |
25 |
26 | def test_render_strong_marker_success():
27 | for text in sample_texts:
28 | node = Span(_type='span', text=text)
29 | block = Block(_type='block', children=[node.__dict__])
30 | assert StrongMarkerDefinition.render_text(node, 'strong', block) == f'{text}'
31 | assert StrongMarkerDefinition.render(node, 'strong', block) == f'{text}'
32 |
33 |
34 | def test_render_underline_marker_success():
35 | for text in sample_texts:
36 | node = Span(_type='span', text=text)
37 | block = Block(_type='block', children=[node.__dict__])
38 | assert UnderlineMarkerDefinition.render_text(node, 'u', block) == f'{text}'
39 | assert (
40 | UnderlineMarkerDefinition.render(node, 'u', block)
41 | == f'{text}'
42 | )
43 |
44 |
45 | def test_render_strikethrough_marker_success():
46 | for text in sample_texts:
47 | node = Span(_type='span', text=text)
48 | block = Block(_type='block', children=[node.__dict__])
49 | assert StrikeThroughMarkerDefinition.render_text(node, 'strike', block) == f'{text}'
50 | assert StrikeThroughMarkerDefinition.render(node, 'strike', block) == f'{text}'
51 |
52 |
53 | def test_render_link_marker_success():
54 | for text in sample_texts:
55 | node = Span(_type='span', marks=['linkId'], text=text)
56 | block = Block(
57 | _type='block', children=[node.__dict__], markDefs=[{'_type': 'link', '_key': 'linkId', 'href': text}]
58 | )
59 | assert LinkMarkerDefinition.render_text(node, 'linkId', block) == f'{text}'
60 | assert LinkMarkerDefinition.render(node, 'linkId', block) == f'{text}'
61 |
62 |
63 | def test_render_comment_marker_success():
64 | for text in sample_texts:
65 | node = Span(_type='span', text=text)
66 | block = Block(_type='block', children=[node.__dict__])
67 | assert CommentMarkerDefinition.render(node, 'comment', block) == f''
68 |
69 |
70 | def test_custom_marker_definition():
71 | from portabletext_html.marker_definitions import MarkerDefinition
72 |
73 | class ConditionalMarkerDefinition(MarkerDefinition):
74 | tag = 'em'
75 |
76 | @classmethod
77 | def render_prefix(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str:
78 | marker_definition = next((md for md in context.markDefs if md['_key'] == marker), None)
79 | condition = marker_definition.get('cloudCondition', '')
80 | if not condition:
81 | style = 'display: none'
82 | return f'<{cls.tag} style=\"{style}\">'
83 | else:
84 | return super().render_prefix(span, marker, context)
85 |
86 | @classmethod
87 | def render_text(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str:
88 | marker_definition = next((md for md in context.markDefs if md['_key'] == marker), None)
89 | condition = marker_definition.get('cloudCondition', '')
90 | return span.text if not condition else ''
91 |
92 | renderer = PortableTextRenderer(
93 | blocks={
94 | '_type': 'block',
95 | 'children': [{'_key': 'a1ph4', '_type': 'span', 'marks': ['some_id'], 'text': 'Sanity'}],
96 | 'markDefs': [{'_key': 'some_id', '_type': 'contractConditional', 'cloudCondition': False}],
97 | },
98 | custom_marker_definitions={'contractConditional': ConditionalMarkerDefinition},
99 | )
100 | result = renderer.render()
101 | assert result == 'Sanity
'
102 |
--------------------------------------------------------------------------------
/tests/test_module_loading.py:
--------------------------------------------------------------------------------
1 | """Smoke tests for the library."""
2 |
3 |
4 | def test_module_should_be_importable():
5 | """Test that we can load the module.
6 |
7 | This catches any compilation issue we might have.
8 | """
9 | from portabletext_html import PortableTextRenderer
10 |
11 | assert PortableTextRenderer
12 |
--------------------------------------------------------------------------------
/tests/test_rendering.py:
--------------------------------------------------------------------------------
1 | import html
2 | import json
3 | from pathlib import Path
4 | from typing import Optional
5 |
6 | import pytest
7 |
8 | from portabletext_html.renderer import MissingSerializerError, UnhandledNodeError, render
9 | from portabletext_html.types import Block
10 |
11 |
12 | def extraInfoSerializer(node: dict, context: Optional[Block], list_item: bool) -> str:
13 | extraInfo = node.get('extraInfo')
14 |
15 | return f'{extraInfo}
'
16 |
17 |
18 | def load_fixture(fixture_name) -> dict:
19 | fixture_file = Path(__file__).parent / 'fixtures' / fixture_name
20 | return json.loads(fixture_file.read_text())
21 |
22 |
23 | def test_simple_span():
24 | simple_span_def = load_fixture('simple_span.json')
25 | output = render(simple_span_def)
26 | assert output == 'Otovo guarantee is good
'
27 |
28 |
29 | def test_multiple_simple_spans_in_single_block():
30 | fixture = load_fixture('multiple_simple_spans.json')
31 | output = render(fixture)
32 | assert output == 'Otovo guarantee is good for all
'
33 |
34 |
35 | def test_simple_xss_escaping():
36 | simple_span_def = load_fixture('simple_xss.json')
37 | output = render(simple_span_def)
38 | danger = html.escape('')
39 | assert output == f'Otovo guarantee is {danger} good
'
40 |
41 |
42 | def test_basic_mark():
43 | fixture = load_fixture('basic_mark.json')
44 | output = render(fixture)
45 | assert output == 'sanity
is the name of the CLI tool.
'
46 |
47 |
48 | def test_multiple_adjecent_marks():
49 | fixture = load_fixture('multiple_adjecent_marks.json')
50 | output = render(fixture)
51 | assert output == 'A word of warning; Sanity is addictive.
'
52 |
53 |
54 | def test_nested_marks():
55 | fixture = load_fixture('nested_marks.json')
56 | output = render(fixture)
57 | assert output == 'A word of warning; Sanity is addictive.
'
58 |
59 |
60 | def test_missing_serializer():
61 | fixture = load_fixture('invalid_type.json')
62 | with pytest.raises(MissingSerializerError):
63 | render(fixture)
64 |
65 |
66 | def test_invalid_node():
67 | fixture = load_fixture('invalid_node.json')
68 | with pytest.raises(UnhandledNodeError):
69 | render(fixture)
70 |
71 |
72 | def test_custom_serializer_node_after_list():
73 | fixture = load_fixture('custom_serializer_node_after_list.json')
74 | output = render(fixture, custom_serializers={'extraInfoBlock': extraInfoSerializer})
75 |
76 | assert output == '- resers
This informations is not supported by Block
'
77 |
--------------------------------------------------------------------------------
/tests/test_upstream_suite.py:
--------------------------------------------------------------------------------
1 | import json
2 | import re
3 | from pathlib import Path
4 | from typing import Optional, Type
5 |
6 | import pytest
7 |
8 | from portabletext_html import render
9 | from portabletext_html.marker_definitions import LinkMarkerDefinition, MarkerDefinition
10 | from portabletext_html.renderer import PortableTextRenderer
11 | from portabletext_html.types import Block, Span
12 |
13 |
14 | def fake_image_serializer(node: dict, context: Optional[Block], list_item: bool):
15 | assert node['_type'] == 'image'
16 | if 'url' in node['asset']:
17 | image_url = node['asset']['url']
18 | else:
19 | project_id = '3do82whm'
20 | dataset = 'production'
21 | asset_ref: str = node['asset']['_ref']
22 | image_path = asset_ref[6:].replace('-jpg', '.jpg').replace('-png', '.png')
23 | image_url = f'https://cdn.sanity.io/images/{project_id}/{dataset}/{image_path}'
24 |
25 | if 'crop' in node and 'hotspot' in node:
26 | crop = node['crop']
27 | hotspot = node['hotspot']
28 | size_match = re.match(r'.*-(\d+)x(\d+)\..*', image_url)
29 | if size_match:
30 | orig_width, orig_height = (int(x) for x in size_match.groups())
31 | rect_x1 = round((orig_width * hotspot['x']) - ((orig_width * hotspot['width']) / 2))
32 | rect_y1 = round((orig_height * hotspot['y']) - ((orig_height * hotspot['height']) / 2))
33 | rect_x2 = round(orig_width - (orig_width * crop['left']) - (orig_width * crop['right']))
34 | rect_y2 = round(orig_height - (orig_height * crop['top']) - (orig_height * crop['bottom']))
35 | # These are passed as "imageOptions" upstream.
36 | # It's up the the implementor of the serializer to fix this.
37 | # We might provide one for images that does something like this, but for now
38 | # let's just make the test suite pass
39 | width = 320
40 | height = 240
41 |
42 | image_url += f'?rect={rect_x1},{rect_y1},{rect_x2},{rect_y2}&w={width}&h={height}'
43 |
44 | image = f'
'
45 | if context:
46 | return image
47 | return f'{image} '
48 |
49 |
50 | def get_fixture(rel_path) -> dict:
51 | """Load and return fixture data as dict."""
52 | return json.loads((Path(__file__).parent / rel_path).read_text())
53 |
54 |
55 | def test_001_empty_block():
56 | fixture_data = get_fixture('fixtures/upstream/001-empty-block.json')
57 | input_blocks = fixture_data['input']
58 | expected_output = fixture_data['output']
59 | output = render(input_blocks)
60 | assert output == expected_output
61 |
62 |
63 | def test_002_single_span():
64 | fixture_data = get_fixture('fixtures/upstream/002-single-span.json')
65 | input_blocks = fixture_data['input']
66 | expected_output = fixture_data['output']
67 | output = render(input_blocks)
68 | assert output == expected_output
69 |
70 |
71 | def test_003_multiple_spa():
72 | fixture_data = get_fixture('fixtures/upstream/003-multiple-spans.json')
73 | input_blocks = fixture_data['input']
74 | expected_output = fixture_data['output']
75 | output = render(input_blocks)
76 | assert output == expected_output
77 |
78 |
79 | def test_004_basic_mark_single_spa():
80 | fixture_data = get_fixture('fixtures/upstream/004-basic-mark-single-span.json')
81 | input_blocks = fixture_data['input']
82 | expected_output = fixture_data['output']
83 | output = render(input_blocks)
84 | assert output == expected_output
85 |
86 |
87 | def test_005_basic_mark_multiple_adjacent_spa():
88 | fixture_data = get_fixture('fixtures/upstream/005-basic-mark-multiple-adjacent-spans.json')
89 | input_blocks = fixture_data['input']
90 | expected_output = fixture_data['output']
91 | output = render(input_blocks)
92 | assert output == expected_output
93 |
94 |
95 | def test_006_basic_mark_nested_mark():
96 | fixture_data = get_fixture('fixtures/upstream/006-basic-mark-nested-marks.json')
97 | input_blocks = fixture_data['input']
98 | expected_output = fixture_data['output']
99 | output = render(input_blocks)
100 | assert output == expected_output
101 |
102 |
103 | def test_007_link_mark_def():
104 | fixture_data = get_fixture('fixtures/upstream/007-link-mark-def.json')
105 | input_blocks = fixture_data['input']
106 | expected_output = fixture_data['output']
107 | output = render(input_blocks)
108 | assert output == expected_output
109 |
110 |
111 | def test_008_plain_header_block():
112 | fixture_data = get_fixture('fixtures/upstream/008-plain-header-block.json')
113 | input_blocks = fixture_data['input']
114 | expected_output = fixture_data['output']
115 | output = render(input_blocks)
116 | assert output == expected_output
117 |
118 |
119 | # Fails because of mark ordering
120 | # expected: app or website
.
121 | # output: app or website.
122 | def test_009_messy_link_text():
123 | fixture_data = get_fixture('fixtures/upstream/009-messy-link-text.json')
124 | input_blocks = fixture_data['input']
125 | expected_output = fixture_data['output']
126 | output = render(input_blocks)
127 | assert output == expected_output
128 |
129 |
130 | def test_010_basic_bullet_list():
131 | fixture_data = get_fixture('fixtures/upstream/010-basic-bullet-list.json')
132 | input_blocks = fixture_data['input']
133 | expected_output = fixture_data['output']
134 | output = render(input_blocks)
135 | assert output == expected_output
136 |
137 |
138 | def test_011_basic_numbered_list():
139 | fixture_data = get_fixture('fixtures/upstream/011-basic-numbered-list.json')
140 | input_blocks = fixture_data['input']
141 | expected_output = fixture_data['output']
142 | output = render(input_blocks)
143 | assert output == expected_output
144 |
145 |
146 | def test_012_image_support():
147 | fixture_data = get_fixture('fixtures/upstream/012-image-support.json')
148 | input_blocks = fixture_data['input']
149 | expected_output = fixture_data['output']
150 | sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer})
151 | output = sbr.render()
152 | assert output == expected_output
153 |
154 |
155 | def test_013_materialized_image_support():
156 | fixture_data = get_fixture('fixtures/upstream/013-materialized-image-support.json')
157 | input_blocks = fixture_data['input']
158 | expected_output = fixture_data['output']
159 | sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer})
160 | output = sbr.render()
161 | assert output == expected_output
162 |
163 |
164 | def test_014_nested_list():
165 | fixture_data = get_fixture('fixtures/upstream/014-nested-lists.json')
166 | input_blocks = fixture_data['input']
167 | expected_output = fixture_data['output']
168 | output = render(input_blocks)
169 | assert output == expected_output
170 |
171 |
172 | def test_015_all_basic_mark():
173 | fixture_data = get_fixture('fixtures/upstream/015-all-basic-marks.json')
174 | input_blocks = fixture_data['input']
175 | expected_output = fixture_data['output']
176 | output = render(input_blocks)
177 | assert output == expected_output
178 |
179 |
180 | def test_016_deep_weird_list():
181 | fixture_data = get_fixture('fixtures/upstream/016-deep-weird-lists.json')
182 | input_blocks = fixture_data['input']
183 | expected_output = fixture_data['output']
184 | output = render(input_blocks)
185 | assert output == expected_output
186 |
187 |
188 | def test_017_all_default_block_style():
189 | fixture_data = get_fixture('fixtures/upstream/017-all-default-block-styles.json')
190 | input_blocks = fixture_data['input']
191 | expected_output = fixture_data['output']
192 | output = render(input_blocks)
193 | assert output == expected_output
194 |
195 |
196 | @pytest.mark.unsupported
197 | def test_018_marks_all_the_way_dow():
198 | fixture_data = get_fixture('fixtures/upstream/018-marks-all-the-way-down.json')
199 | input_blocks = fixture_data['input']
200 | expected_output = fixture_data['output']
201 | output = render(input_blocks)
202 | assert output == expected_output
203 |
204 |
205 | def test_019_keyle():
206 | fixture_data = get_fixture('fixtures/upstream/019-keyless.json')
207 | input_blocks = fixture_data['input']
208 | expected_output = fixture_data['output']
209 | output = render(input_blocks)
210 | assert output == expected_output
211 |
212 |
213 | def test_020_empty_array():
214 | fixture_data = get_fixture('fixtures/upstream/020-empty-array.json')
215 | input_blocks = fixture_data['input']
216 | expected_output = fixture_data['output']
217 | output = render(input_blocks)
218 | assert output == expected_output
219 |
220 |
221 | def test_021_list_without_level():
222 | fixture_data = get_fixture('fixtures/upstream/021-list-without-level.json')
223 | input_blocks = fixture_data['input']
224 | expected_output = fixture_data['output']
225 | output = render(input_blocks)
226 | assert output == expected_output
227 |
228 |
229 | def test_022_inline_node():
230 | fixture_data = get_fixture('fixtures/upstream/022-inline-nodes.json')
231 | input_blocks = fixture_data['input']
232 | expected_output = fixture_data['output']
233 | sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer})
234 | output = sbr.render()
235 | assert output == expected_output
236 |
237 |
238 | def test_023_hard_break():
239 | fixture_data = get_fixture('fixtures/upstream/023-hard-breaks.json')
240 | input_blocks = fixture_data['input']
241 | expected_output = fixture_data['output']
242 | output = render(input_blocks)
243 | assert output == expected_output
244 |
245 |
246 | def test_024_inline_image():
247 | fixture_data = get_fixture('fixtures/upstream/024-inline-images.json')
248 | input_blocks = fixture_data['input']
249 | expected_output = fixture_data['output']
250 | sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer})
251 | output = sbr.render()
252 | assert output == expected_output
253 |
254 |
255 | def test_025_image_with_hotspot():
256 | fixture_data = get_fixture('fixtures/upstream/025-image-with-hotspot.json')
257 | input_blocks = fixture_data['input']
258 | expected_output = fixture_data['output']
259 | sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer})
260 | output = sbr.render()
261 | assert output == expected_output
262 |
263 |
264 | def button_serializer(node: dict, context: Optional[Block], list_item: bool):
265 | return f''
266 |
267 |
268 | def test_026_inline_block_with_text():
269 | fixture_data = get_fixture('fixtures/upstream/026-inline-block-with-text.json')
270 | input_blocks = fixture_data['input']
271 | expected_output = fixture_data['output']
272 | sbr = PortableTextRenderer(input_blocks, custom_serializers={'button': button_serializer})
273 | output = sbr.render()
274 | assert output == expected_output
275 |
276 |
277 | def test_027_styled_list_item():
278 | fixture_data = get_fixture('fixtures/upstream/027-styled-list-items.json')
279 | input_blocks = fixture_data['input']
280 | expected_output = fixture_data['output']
281 | output = render(input_blocks)
282 | assert output == expected_output
283 |
284 |
285 | @pytest.mark.unsupported
286 | def test_050_custom_block_type():
287 | fixture_data = get_fixture('fixtures/upstream/050-custom-block-type.json')
288 | input_blocks = fixture_data['input']
289 | expected_output = fixture_data['output']
290 | output = render(input_blocks)
291 | assert output == expected_output
292 |
293 |
294 | @pytest.mark.unsupported
295 | def test_051_override_default():
296 | fixture_data = get_fixture('fixtures/upstream/051-override-defaults.json')
297 | input_blocks = fixture_data['input']
298 | expected_output = fixture_data['output']
299 | output = render(input_blocks)
300 | assert output == expected_output
301 |
302 |
303 | @pytest.mark.unsupported
304 | def test_052_custom_mark():
305 | fixture_data = get_fixture('fixtures/upstream/052-custom-marks.json')
306 | input_blocks = fixture_data['input']
307 | expected_output = fixture_data['output']
308 |
309 | class CustomMarkerSerializer(MarkerDefinition):
310 | tag = 'span'
311 |
312 | @classmethod
313 | def render_prefix(cls, span: Span, marker: str, context: Block) -> str:
314 | return ''
315 |
316 | output = render(input_blocks, custom_marker_definitions={'mark1': CustomMarkerSerializer})
317 | assert output == expected_output
318 |
319 |
320 | def test_053_override_default_mark():
321 | fixture_data = get_fixture('fixtures/upstream/053-override-default-marks.json')
322 | input_blocks = fixture_data['input']
323 | expected_output = fixture_data['output']
324 |
325 | class CustomLinkMark(LinkMarkerDefinition):
326 | @classmethod
327 | def render_prefix(cls, span, marker, context) -> str:
328 | result = super().render_prefix(span, marker, context)
329 | return result.replace('