diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml deleted file mode 100644 index e641e78..0000000 --- a/.github/workflows/codecov.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: coverage - -on: - pull_request: - push: - branches: - - main - -jobs: - codecov: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: 3.9 - - name: Install poetry - uses: snok/install-poetry@v1.1.2 - with: - virtualenvs-create: true - virtualenvs-in-project: true - - uses: actions/cache@v2 - id: cache-venv - with: - path: .venv - key: ${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}-0 - - run: poetry install --no-interaction --no-root - if: steps.cache-venv.outputs.cache-hit != 'true' - - run: poetry install --no-interaction - - run: poetry run pytest --cov-report=xml - - uses: codecov/codecov-action@v1 - with: - file: ./coverage.xml - fail_ci_if_error: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7bf7c2f..fd2ef12 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/setup-python@v2 with: python-version: 3.9 - - uses: snok/install-poetry@v1.1.2 + - uses: snok/install-poetry@v1.1.6 - name: Publish to test-pypi run: | poetry config repositories.test https://test.pypi.org/legacy/ @@ -28,7 +28,7 @@ jobs: - uses: actions/setup-python@v2 with: python-version: 3.9 - - uses: snok/install-poetry@v1.1.2 + - uses: snok/install-poetry@v1.1.6 - name: Publish to pypi run: | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 67e2635..22b34ce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,49 +13,63 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: "3.10" - uses: actions/cache@v2 + id: cache-venv with: - path: | - ~/.cache/pip - ~/.cache/pre-commit - key: ${{ runner.os }}-pip-2 - restore-keys: | - ${{ runner.os }}-pip- - ${{ runner.os }}- - - run: python -m pip install pre-commit - - run: pre-commit run --all-files + path: .venv + key: venv-1 + - run: | + python -m venv .venv --upgrade-deps + source .venv/bin/activate + pip install pre-commit + if: steps.cache-venv.outputs.cache-hit != 'true' + - uses: actions/cache@v2 + id: pre-commit-cache + with: + path: ~/.cache/pre-commit + key: key-1 + - run: | + source .venv/bin/activate + pre-commit run --all-files + test: - needs: linting runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["3.9"] + python-version: [ "3.7.14", "3.8.14", "3.9.15", "3.10.8", "3.11.0", "3.12.0-alpha.1" ] steps: - - name: Check out repository - uses: actions/checkout@v2 - - name: Set up python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install poetry - uses: snok/install-poetry@v1.1.2 - with: - virtualenvs-create: true - virtualenvs-in-project: true - - name: Load cached venv - uses: actions/cache@v2 + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: "${{ matrix.python-version }}" + - uses: actions/cache@v2 + id: poetry-cache + with: + path: ~/.local + key: key-2 + - uses: snok/install-poetry@v1 + with: + virtualenvs-create: false + - uses: actions/cache@v2 id: cache-venv with: path: .venv - key: ${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}-3 - - name: Install dependencies - run: poetry install --no-interaction --no-root + key: ${{ hashFiles('**/poetry.lock') }}-1 + - run: | + python -m venv .venv + source .venv/bin/activate + pip install -U pip wheel + poetry install --no-interaction --no-root if: steps.cache-venv.outputs.cache-hit != 'true' - - name: Install package - run: poetry install --no-interaction - name: Run tests run: | source .venv/bin/activate - poetry run pytest + pytest -m "not unsupported" --cov-report=xml + coverage report + - uses: codecov/codecov-action@v2 + with: + file: ./coverage.xml + fail_ci_if_error: true + if: matrix.python-version == '3.10' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 83ec612..6493166 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/ambv/black - rev: 20.8b1 + rev: 22.10.0 hooks: - id: black args: [ "--quiet" ] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + rev: v4.3.0 hooks: - id: check-ast - id: check-merge-conflict @@ -18,8 +18,8 @@ repos: - id: trailing-whitespace - id: mixed-line-ending - id: trailing-whitespace - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.9.0 + - repo: https://github.com/pycqa/flake8 + rev: 5.0.4 hooks: - id: flake8 additional_dependencies: [ @@ -34,17 +34,20 @@ repos: 'flake8-printf-formatting', 'flake8-type-checking', ] + args: + - '--allow-star-arg-any' - repo: https://github.com/asottile/pyupgrade - rev: v2.12.0 + rev: v3.2.2 hooks: - id: pyupgrade args: [ "--py36-plus", "--py37-plus",'--keep-runtime-typing' ] - repo: https://github.com/pycqa/isort - rev: 5.8.0 + rev: 5.10.1 hooks: - id: isort - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.812 + rev: v0.991 hooks: - id: mypy - additional_dependencies: [ pytest ] + additional_dependencies: + - types-requests diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index e69de29..0000000 diff --git a/LICENSE.txt b/LICENSE similarity index 100% rename from LICENSE.txt rename to LICENSE diff --git a/README.md b/README.md index bb9e9a7..fd610a8 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,223 @@ - - Package version - - - Code coverage - - - Test status - - - Supported Python versions - - - Checked with mypy - +[![pypi](https://img.shields.io/pypi/v/portabletext-html.svg)](https://pypi.org/project/portabletext-html/) +[![test](https://github.com/otovo/python-portabletext-html/actions/workflows/test.yml/badge.svg)](https://github.com/otovo/python-portabletext-html/actions/workflows/test.yml) +[![code coverage](https://codecov.io/gh/otovo/python-portabletext-html/branch/main/graph/badge.svg)](https://codecov.io/gh/otovo/python-portabletext-html) +[![supported python versions](https://img.shields.io/badge/python-3.7%2B-blue)](https://pypi.org/project/python-portabletext-html/) -# Python Sanity HTML Renderer +# Portable Text HTML Renderer for Python -> Repo is currently a work in progress. Not ready to be used. +This package generates HTML from [Portable Text](https://github.com/portabletext/portabletext). -HTML renderer for [Sanity's](https://www.sanity.io/) [Portable Text](https://github.com/portabletext/portabletext) format. +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. -Written as a python alternative to [Sanity's](https://www.sanity.io/) [block-content-to-html](https://www.npmjs.com/package/%40sanity/block-content-to-html) npm package, -for when you don't have access to a JavaScript runtime. +## Installation + +``` +pip install portabletext-html +``` -### TODO +## Usage -- [ ] Add support for image type +Instantiate the `PortableTextRenderer` class with your content and call the `render` method. -## Installation +The following content +```python +from portabletext_html import PortableTextRenderer + +renderer = PortableTextRenderer({ + "_key": "R5FvMrjo", + "_type": "block", + "children": [ + {"_key": "cZUQGmh4", "_type": "span", "marks": ["strong"], "text": "A word of"}, + {"_key": "toaiCqIK", "_type": "span", "marks": ["strong"], "text": " warning;"}, + {"_key": "gaZingsA", "_type": "span", "marks": [], "text": " Sanity is addictive."} + ], + "markDefs": [], + "style": "normal" +}) +renderer.render() ``` -pip install python-sanity-html + +Generates this HTML +```html +

A word of warning; Sanity is addictive.

``` -## Usage +### Supported types + +The `block` and `span` types are supported out of the box. + +### Custom types + +Beyond the built-in types, you have the freedom to provide +your own serializers to render any custom `_type` the way you +would like to. + +To illustrate, if you passed this data to the renderer class: + +```python +from portabletext_html import PortableTextRenderer + +renderer = PortableTextRenderer({ + "_type": "block", + "_key": "foo", + "style": "normal", + "children": [ + { + "_type": "span", + "text": "Press, " + }, + { + "_type": "button", + "text": "here" + }, + { + "_type": "span", + "text": ", now!" + } + ] +}) +renderer.render() +``` + +The renderer would actually throw an error here, since `button` +does not have a corresponding built-in type serializer by default. -To parse your block content as HTML, simply instantiate the parser like this +To render this text you must provide your own serializer, like this: ```python -from sanity_html import SanityBlockRenderer +from portabletext_html import PortableTextRenderer + + +def button_serializer(node: dict, context: Optional[Block], list_item: bool): + return f'' -renderer = SanityBlockRenderer(block_content) +renderer = PortableTextRenderer( + ..., + custom_serializers={'button': button_serializer} +) output = renderer.render() ``` +With the custom serializer provided, the renderer would now successfully +output the following HTML: + +```html +

Press , now!

+``` + +### Supported mark definitions + +The package provides several built-in marker definitions and styles: + +**decorator marker definitions** + +- `em` +- `strong` +- `code` +- `underline` +- `strike-through` + +**annotation marker definitions** + +- `link` +- `comment` + +### Custom mark definitions + +Like with custom type serializers, additional serializers for +marker definitions and styles can be passed in like this: + +```python +from portabletext_html import PortableTextRenderer + +renderer = PortableTextRenderer( + ..., + custom_marker_definitions={'em': ComicSansEmphasis} +) +renderer.render() +``` + +The primary difference between a type serializer and a mark definition serializer +is that the latter uses a class structure, and has three required methods. + +Here's an example of a custom style, adding an extra font +to the built-in equivalent serializer: + +```python +from portabletext_html.marker_definitions import MarkerDefinition + + +class ComicSansEmphasis(MarkerDefinition): + tag = 'em' + + @classmethod + def render_prefix(cls, span: Span, marker: str, context: Block) -> str: + return f'<{cls.tag} style="font-family: "Comic Sans MS", "Comic Sans", cursive;">' + + @classmethod + def render_suffix(cls, span: Span, marker: str, context: Block) -> str: + return f'' + + @classmethod + def render_text(cls, span: Span, marker: str, context: Block) -> str: + # custom rendering logic can be placed here + return str(span.text) + + @classmethod + def render(cls, span: Span, marker: str, context: Block) -> str: + result = cls.render_prefix(span, marker, context) + result += str(span.text) + result += cls.render_suffix(span, marker, context) + return result +``` + +Since the `render_suffix` and `render` methods here are actually identical to the base class, +they do not need to be specified, and the whole example can be reduced to: + +```python +from portabletext_html.marker_definitions import MarkerDefinition # base +from portabletext_html import PortableTextRenderer + + +class ComicSansEmphasis(MarkerDefinition): + tag = 'em' + + @classmethod + def render_prefix(cls, span: Span, marker: str, context: Block) -> str: + return f'<{cls.tag} style="font-family: "Comic Sans MS", "Comic Sans", cursive;">' + + +renderer = PortableTextRenderer( + ..., + custom_marker_definitions={'em': ComicSansEmphasis} +) +renderer.render() +``` + + +### Supported styles + +Blocks can optionally define a `style` tag. These styles are supported: + +- `h1` +- `h2` +- `h3` +- `h4` +- `h5` +- `h6` +- `blockquote` +- `normal` + +## Missing features + +For anyone interested, we would be happy to see a +default built-in serializer for the `image` type added. +In the meantime, users should be able to serialize image types by passing a custom serializer. + ## Contributing Contributions are always appreciated 👏 -For details, see the [CONTRIBUTING.md](https://github.com/otovo/python-sanity-html/blob/main/CONTRIBUTING.md). +For details, see the [CONTRIBUTING.md](https://github.com/otovo/python-portabletext-html/blob/main/CONTRIBUTING.md). diff --git a/poetry.lock b/poetry.lock index 53f8891..b05bfd9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,6 +1,6 @@ [[package]] name = "atomicwrites" -version = "1.4.0" +version = "1.4.1" description = "Atomic file writes." category = "dev" optional = false @@ -8,25 +8,25 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "20.3.0" +version = "22.1.0" description = "Classes Without Boilerplate" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.5" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] -docs = ["furo", "sphinx", "zope.interface"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] +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"] +docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] +tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] [[package]] name = "colorama" -version = "0.4.4" +version = "0.4.6" description = "Cross-platform colored terminal text." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" [[package]] name = "coverage" @@ -41,17 +41,35 @@ toml = ["toml"] [[package]] name = "flake8" -version = "3.9.0" +version = "3.9.2" description = "the modular source code checker: pep8 pyflakes and co" category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} mccabe = ">=0.6.0,<0.7.0" pycodestyle = ">=2.7.0,<2.8.0" pyflakes = ">=2.3.0,<2.4.0" +[[package]] +name = "importlib-metadata" +version = "5.0.0" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +perf = ["ipython"] +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)"] + [[package]] name = "iniconfig" version = "1.1.1" @@ -70,33 +88,37 @@ python-versions = "*" [[package]] name = "packaging" -version = "20.9" +version = "21.3" description = "Core utilities for Python packages" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] -pyparsing = ">=2.0.2" +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "pluggy" -version = "0.13.1" +version = "1.0.0" description = "plugin and hook calling mechanisms for python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] [[package]] name = "py" -version = "1.10.0" +version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pycodestyle" @@ -116,15 +138,18 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pyparsing" -version = "2.4.7" -description = "Python parsing module" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" category = "dev" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "6.2.3" +version = "6.2.5" description = "pytest: simple powerful testing with Python" category = "dev" optional = false @@ -134,9 +159,10 @@ python-versions = ">=3.6" atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<1.0.0a1" +pluggy = ">=0.12,<2.0" py = ">=1.8.2" toml = "*" @@ -145,7 +171,7 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm [[package]] name = "pytest-cov" -version = "2.11.1" +version = "2.12.1" description = "Pytest plugin for measuring coverage." category = "dev" optional = false @@ -154,9 +180,10 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.dependencies] coverage = ">=5.2.1" pytest = ">=4.6" +toml = "*" [package.extras] -testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] [[package]] name = "toml" @@ -166,23 +193,42 @@ category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "typing-extensions" +version = "4.4.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "zipp" +version = "3.10.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +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)"] + [metadata] lock-version = "1.1" -python-versions = "^3.9" -content-hash = "031b6c95c5355805b7c59e9aef1a159db3e8339edd40c50292ae2ae81e7bf191" +python-versions = '^3.7' +content-hash = "c641d950bccb6ffac52cf3fcd3571b51f5e31d4864c03e763fe2748919bf855b" [metadata.files] atomicwrites = [ - {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, - {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, + {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, ] attrs = [ - {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, - {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, + {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, + {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, ] colorama = [ - {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, - {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] coverage = [ {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, @@ -239,8 +285,12 @@ coverage = [ {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] flake8 = [ - {file = "flake8-3.9.0-py2.py3-none-any.whl", hash = "sha256:12d05ab02614b6aee8df7c36b97d1a3b2372761222b19b58621355e82acddcff"}, - {file = "flake8-3.9.0.tar.gz", hash = "sha256:78873e372b12b093da7b5e5ed302e8ad9e988b38b063b61ad937f26ca58fc5f0"}, + {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, + {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, +] +importlib-metadata = [ + {file = "importlib_metadata-5.0.0-py3-none-any.whl", hash = "sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43"}, + {file = "importlib_metadata-5.0.0.tar.gz", hash = "sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -251,16 +301,16 @@ mccabe = [ {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] packaging = [ - {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, - {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] pluggy = [ - {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, - {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] py = [ - {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, - {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] pycodestyle = [ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, @@ -271,18 +321,26 @@ pyflakes = [ {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pyparsing = [ - {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, - {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, ] pytest = [ - {file = "pytest-6.2.3-py3-none-any.whl", hash = "sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"}, - {file = "pytest-6.2.3.tar.gz", hash = "sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634"}, + {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, + {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, ] pytest-cov = [ - {file = "pytest-cov-2.11.1.tar.gz", hash = "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7"}, - {file = "pytest_cov-2.11.1-py2.py3-none-any.whl", hash = "sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da"}, + {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, + {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, ] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] +typing-extensions = [ + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, +] +zipp = [ + {file = "zipp-3.10.0-py3-none-any.whl", hash = "sha256:4fcb6f278987a6605757302a6e40e896257570d11c51628968ccb2a47e80c6c1"}, + {file = "zipp-3.10.0.tar.gz", hash = "sha256:7a7262fd930bd3e36c50b9a64897aec3fafff3dfdeec9623ae22b40e93f99bb8"}, +] diff --git a/portabletext_html/__init__.py b/portabletext_html/__init__.py new file mode 100644 index 0000000..bd2f0d5 --- /dev/null +++ b/portabletext_html/__init__.py @@ -0,0 +1,3 @@ +from portabletext_html.renderer import PortableTextRenderer, render + +__all__ = ['PortableTextRenderer', 'render'] diff --git a/sanity_html/constants.py b/portabletext_html/constants.py similarity index 88% rename from sanity_html/constants.py rename to portabletext_html/constants.py index bc73e2d..f5ead44 100644 --- a/sanity_html/constants.py +++ b/portabletext_html/constants.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from sanity_html.marker_definitions import ( +from portabletext_html.marker_definitions import ( CodeMarkerDefinition, CommentMarkerDefinition, EmphasisMarkerDefinition, @@ -15,7 +15,7 @@ if TYPE_CHECKING: from typing import Dict, Type - from sanity_html.marker_definitions import MarkerDefinition + from portabletext_html.marker_definitions import MarkerDefinition STYLE_MAP = { 'h1': 'h1', diff --git a/portabletext_html/logger.py b/portabletext_html/logger.py new file mode 100644 index 0000000..18122b3 --- /dev/null +++ b/portabletext_html/logger.py @@ -0,0 +1,13 @@ +""" +Logging setup. + +The rest of the code gets the logger through this module rather than +`logging.getLogger` to make sure that it is configured. +""" +import logging + +logger = logging.getLogger('portabletext_html') + +if not logger.handlers: # pragma: no cover + logger.setLevel(logging.WARNING) + logger.addHandler(logging.NullHandler()) diff --git a/sanity_html/marker_definitions.py b/portabletext_html/marker_definitions.py similarity index 79% rename from sanity_html/marker_definitions.py rename to portabletext_html/marker_definitions.py index fa7a83f..396f4e1 100644 --- a/sanity_html/marker_definitions.py +++ b/portabletext_html/marker_definitions.py @@ -2,10 +2,12 @@ from typing import TYPE_CHECKING +from portabletext_html.logger import logger + if TYPE_CHECKING: from typing import Type - from sanity_html.dataclasses import Block, Span + from portabletext_html.types import Block, Span class MarkerDefinition: @@ -19,6 +21,7 @@ def render_prefix(cls: Type[MarkerDefinition], span: Span, marker: str, context: Usually this this the opening of the HTML tag. """ + logger.debug('Rendering %s prefix', cls.tag) return f'<{cls.tag}>' @classmethod @@ -27,20 +30,32 @@ def render_suffix(cls: Type[MarkerDefinition], span: Span, marker: str, context: Usually this this the closing of the HTML tag. """ + logger.debug('Rendering %s suffix', cls.tag) return f'' @classmethod def render(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str: """Render the marked span directly with prefix and suffix.""" result = cls.render_prefix(span, marker, context) - result += str(span.text) + result += cls.render_text(span, marker, context) result += cls.render_suffix(span, marker, context) return result + @classmethod + def render_text(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str: + """Render the content part for a marked span.""" + return str(span.text) + # Decorators +class DefaultMarkerDefinition(MarkerDefinition): + """Marker used for unknown definitions.""" + + tag = 'span' + + class EmphasisMarkerDefinition(MarkerDefinition): """Marker definition for rendering.""" @@ -91,10 +106,10 @@ def render_prefix(cls: Type[MarkerDefinition], span: Span, marker: str, context: The href attribute is fetched from the provided block context using the provided marker key. """ - marker_defintion = next((md for md in context.markDefs if md['_key'] == marker), None) - if not marker_defintion: + marker_definition = next((md for md in context.markDefs if md['_key'] == marker), None) + if not marker_definition: raise ValueError(f'Marker definition for key: {marker} not found in parent block context') - href = marker_defintion['href'] + href = marker_definition.get('href', '') return f'' diff --git a/sanity_html/py.typed b/portabletext_html/py.typed similarity index 100% rename from sanity_html/py.typed rename to portabletext_html/py.typed diff --git a/portabletext_html/renderer.py b/portabletext_html/renderer.py new file mode 100644 index 0000000..1ca2ce3 --- /dev/null +++ b/portabletext_html/renderer.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import html +from typing import TYPE_CHECKING, cast + +from portabletext_html.constants import STYLE_MAP +from portabletext_html.logger import logger +from portabletext_html.marker_definitions import DefaultMarkerDefinition +from portabletext_html.types import Block, Span +from portabletext_html.utils import get_list_tags, is_block, is_list, is_span + +if TYPE_CHECKING: + from typing import Any, Callable, Dict, List, Optional, Type, Union + + from portabletext_html.marker_definitions import MarkerDefinition + + +class UnhandledNodeError(Exception): + """Raised when we receive a node that we cannot parse.""" + + pass + + +class MissingSerializerError(UnhandledNodeError): + """ + Raised when an unrecognized node _type value is found. + + This usually means that you need to pass a custom serializer + to handle the custom type. + """ + + pass + + +class PortableTextRenderer: + """HTML renderer for Sanity's portable text format.""" + + def __init__( + self, + blocks: Union[list[dict], dict], + custom_marker_definitions: dict[str, Type[MarkerDefinition]] | None = None, + custom_serializers: dict[str, Callable[[dict, Optional[Block], bool], str]] | None = None, + ) -> None: + logger.debug('Initializing block renderer') + self._wrapper_element: Optional[str] = None + self._custom_marker_definitions = custom_marker_definitions or {} + self._custom_serializers = custom_serializers or {} + + if isinstance(blocks, dict): + self._blocks = [blocks] + elif isinstance(blocks, list): + self._blocks = blocks + self._wrapper_element = 'div' if len(blocks) > 1 else '' + + def render(self) -> str: + """Render HTML from self._blocks.""" + logger.debug('Rendering HTML') + + if not self._blocks: + return '' + + result = '' + list_nodes: List[Dict] = [] + + for node in self._blocks: + + if list_nodes and not is_list(node): + tree = self._normalize_list_tree(list_nodes) + result += ''.join([self._render_node(n, list_item=True) for n in tree]) + list_nodes = [] # reset list_nodes + + if is_list(node): + list_nodes.append(node) + continue # handle all elements ^ when the list ends + + result += self._render_node(node) # render non-list nodes immediately + + if list_nodes: + tree = self._normalize_list_tree(list_nodes) + result += ''.join(self._render_node(n, Block(**node), list_item=True) for n in tree) + + result = result.strip() + + if self._wrapper_element: + return f'<{self._wrapper_element}>{result}' + return result + + def _render_node(self, node: dict, context: Optional[Block] = None, list_item: bool = False) -> str: + """ + Call the correct render method depending on the node type. + + :param node: Block content node - can be block, span, or list (block). + :param context: Optional context. Spans are passed with a Block instance as context for mark lookups. + :param list_item: Whether we are handling a list upstream (impacts block handling). + """ + if is_list(node): + logger.debug('Rendering node as list') + block = Block(**node, marker_definitions=self._custom_marker_definitions) + return self._render_list(block, context) + + elif is_block(node): + logger.debug('Rendering node as block') + block = Block(**node, marker_definitions=self._custom_marker_definitions) + return self._render_block(block, list_item=list_item) + + elif is_span(node): + logger.debug('Rendering node as span') + span = Span(**node) + context = cast('Block', context) # context should always be a Block here + return self._render_span(span, block=context) + + elif self._custom_serializers.get(node.get('_type', '')): + return self._custom_serializers.get(node.get('_type', ''))(node, context, list_item) # type: ignore + + else: + if '_type' in node: + raise MissingSerializerError( + f'Found unhandled node type: {node["_type"]}. ' 'Most likely this requires a custom serializer.' + ) + else: + raise UnhandledNodeError(f'Received node that we cannot handle: {node}') + + def _render_block(self, block: Block, list_item: bool = False) -> str: + text, tag = '', STYLE_MAP[block.style] + + if not list_item or tag != 'p': + text += f'<{tag}>' + + for child_node in block.children: + text += self._render_node(child_node, context=block) + + if not list_item or tag != 'p': + text += f'' + + return text + + def _render_span(self, span: Span, block: Block) -> str: + logger.debug('Rendering span') + result: str = '' + prev_node, next_node = block.get_node_siblings(span) + + prev_marks = prev_node.get('marks', []) if prev_node else [] + next_marks = next_node.get('marks', []) if next_node else [] + + sorted_marks = sorted(span.marks, key=lambda x: -block.marker_frequencies[x]) + for mark in sorted_marks: + if mark in prev_marks: + continue + + marker_callable = block.marker_definitions.get(mark, DefaultMarkerDefinition)() + result += marker_callable.render_prefix(span, mark, block) + + # to avoid rendering the text multiple times, + # only the first custom mark will be used + custom_mark_text_rendered = False + if sorted_marks: + for mark in sorted_marks: + if custom_mark_text_rendered or mark in prev_marks: + continue + marker_callable = block.marker_definitions.get(mark, DefaultMarkerDefinition)() + result += marker_callable.render_text(span, mark, block) + custom_mark_text_rendered = True + + if not custom_mark_text_rendered: + result += html.escape(span.text).replace('\n', '
') + + for mark in reversed(sorted_marks): + if mark in next_marks: + continue + + marker_callable = block.marker_definitions.get(mark, DefaultMarkerDefinition)() + result += marker_callable.render_suffix(span, mark, block) + + return result + + def _render_list(self, node: Block, context: Optional[Block]) -> str: + assert node.listItem + head, tail = get_list_tags(node.listItem) + result = head + for child in node.children: + result += f'
  • {self._render_block(Block(**child), True)}
  • ' + result += tail + return result + + def _normalize_list_tree(self, nodes: list) -> list[dict]: + tree = [] + + current_list = None + for node in nodes: + if not is_block(node): + tree.append(node) + current_list = None + continue + + if current_list is None: + current_list = self._list_from_block(node) + tree.append(current_list) + continue + + if node.get('level') == current_list['level'] and node.get('listItem') == current_list['listItem']: + current_list['children'].append(node) + continue + + if node.get('level') > current_list['level']: + new_list = self._list_from_block(node) + current_list['children'][-1]['children'].append(new_list) + current_list = new_list + continue + + if node.get('level') < current_list['level']: + parent = self._find_list(tree[-1], level=node.get('level'), list_item=node.get('listItem')) + if parent: + current_list = parent + current_list['children'].append(node) + continue + current_list = self._list_from_block(node) + tree.append(current_list) + continue + + if node.get('listItem') != current_list['listItem']: + match = self._find_list(tree[-1], level=node.get('level')) + if match and match['listItem'] == node.get('listItem'): + current_list = match + current_list['children'].append(node) + continue + current_list = self._list_from_block(node) + tree.append(current_list) + continue + # TODO: Warn + tree.append(node) + + return tree + + def _find_list(self, root_node: dict, level: int, list_item: Optional[str] = None) -> Optional[dict]: + filter_on_type = isinstance(list_item, str) + if ( + root_node.get('_type') == 'list' + and root_node.get('level') == level + and (filter_on_type and root_node.get('listItem') == list_item) + ): + return root_node + + children = root_node.get('children') + if children: + return self._find_list(children[-1], level, list_item) + + return None + + def _list_from_block(self, block: dict) -> dict: + return { + '_type': 'list', + '_key': f'${block["_key"]}-parent', + 'level': block.get('level'), + 'listItem': block['listItem'], + 'children': [block], + } + + +def render(blocks: List[Dict], *args: Any, **kwargs: Any) -> str: + """Shortcut function inspired by Sanity's own blocksToHtml.h callable.""" + renderer = PortableTextRenderer(blocks, *args, **kwargs) + return renderer.render() diff --git a/sanity_html/dataclasses.py b/portabletext_html/types.py similarity index 59% rename from sanity_html/dataclasses.py rename to portabletext_html/types.py index 0ebe8f5..898d61e 100644 --- a/sanity_html/dataclasses.py +++ b/portabletext_html/types.py @@ -1,15 +1,14 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING -from sanity_html.utils import get_marker_definitions +from portabletext_html.utils import get_default_marker_definitions if TYPE_CHECKING: from typing import Literal, Optional, Tuple, Type, Union - from sanity_html.marker_definitions import MarkerDefinition - from sanity_html.types import SanityIdType + from portabletext_html.marker_definitions import MarkerDefinition @dataclass(frozen=True) @@ -22,7 +21,7 @@ class Span: _type: Literal['span'] text: str - _key: SanityIdType = None + _key: Optional[str] = None marks: list[str] = field(default_factory=list) # keys that correspond with block.mark_definitions style: Literal['normal'] = 'normal' @@ -38,13 +37,13 @@ class Block: _type: Literal['block'] - _key: SanityIdType = None - style: Literal['h1', 'h2', 'h3', 'h4', 'normal'] = 'normal' + _key: Optional[str] = None + style: Literal['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'normal'] = 'normal' level: Optional[int] = None listItem: Optional[Literal['bullet', 'number', 'square']] = None children: list[dict] = field(default_factory=list) markDefs: list[dict] = field(default_factory=list) - marker_definitions: dict[str, Type[MarkerDefinition]] = field(init=False) + marker_definitions: dict[str, Type[MarkerDefinition]] = field(default_factory=dict) marker_frequencies: dict[str, int] = field(init=False) def __post_init__(self) -> None: @@ -54,7 +53,7 @@ def __post_init__(self) -> None: To make handling of span `marks` simpler, we define marker_definitions as a dict, from which we can directly look up both annotation marks or decorator marks. """ - self.marker_definitions = get_marker_definitions(self.markDefs) + self.marker_definitions = self._add_custom_marker_definitions() self.marker_frequencies = self._compute_marker_frequencies() def _compute_marker_frequencies(self) -> dict[str, int]: @@ -67,26 +66,38 @@ def _compute_marker_frequencies(self) -> dict[str, int]: counts[mark] = 0 return counts + def _add_custom_marker_definitions(self) -> dict[str, Type[MarkerDefinition]]: + marker_definitions = get_default_marker_definitions(self.markDefs) + marker_definitions.update(self.marker_definitions) + for definition in self.markDefs: + if definition['_type'] in self.marker_definitions: + marker = self.marker_definitions[definition['_type']] + marker_definitions[definition['_key']] = marker + # del marker_definitions[definition['_type']] + return marker_definitions + def get_node_siblings(self, node: Union[dict, Span]) -> Tuple[Optional[dict], Optional[dict]]: """Return the sibling nodes (prev, next) to the given node.""" if not self.children: - return (None, None) + return None, None try: if type(node) == dict: - node = cast(dict, node) node_idx = self.children.index(node) elif type(node) == Span: - node = cast(Span, node) - node_idx = self.children.index(next((c for c in self.children if c.get('_key') == node._key), {})) + for index, item in enumerate(self.children): + if 'text' in item and node.text == item['text']: + # Is it possible to handle several identical texts? + node_idx = index + break + else: + raise ValueError(f'Expected dict or Span but received {type(node)}') except ValueError: - return (None, None) + return None, None - prev_node = None next_node = None - if node_idx >= 1: - prev_node = self.children[node_idx - 1] - if node_idx < len(self.children) - 2: + prev_node = self.children[node_idx - 1] if node_idx != 0 else None + if node_idx != len(self.children) - 1: next_node = self.children[node_idx + 1] - return (prev_node, next_node) + return prev_node, next_node diff --git a/sanity_html/utils.py b/portabletext_html/utils.py similarity index 72% rename from sanity_html/utils.py rename to portabletext_html/utils.py index d3c6d5e..3977d81 100644 --- a/sanity_html/utils.py +++ b/portabletext_html/utils.py @@ -2,15 +2,15 @@ from typing import TYPE_CHECKING -from sanity_html.constants import ANNOTATION_MARKER_DEFINITIONS, DECORATOR_MARKER_DEFINITIONS +from portabletext_html.constants import ANNOTATION_MARKER_DEFINITIONS, DECORATOR_MARKER_DEFINITIONS if TYPE_CHECKING: from typing import Type - from sanity_html.marker_definitions import MarkerDefinition + from portabletext_html.marker_definitions import MarkerDefinition -def get_marker_definitions(mark_defs: list[dict]) -> dict[str, Type[MarkerDefinition]]: +def get_default_marker_definitions(mark_defs: list[dict]) -> dict[str, Type[MarkerDefinition]]: """ Convert JSON definitions to a map of marker definition renderers. @@ -20,8 +20,9 @@ def get_marker_definitions(mark_defs: list[dict]) -> dict[str, Type[MarkerDefini marker_definitions = {} for definition in mark_defs: - marker = ANNOTATION_MARKER_DEFINITIONS[definition['_type']] - marker_definitions[definition['_key']] = marker + if definition['_type'] in ANNOTATION_MARKER_DEFINITIONS: + marker = ANNOTATION_MARKER_DEFINITIONS[definition['_type']] + marker_definitions[definition['_key']] = marker return {**marker_definitions, **DECORATOR_MARKER_DEFINITIONS} @@ -38,7 +39,7 @@ def is_span(node: dict) -> bool: def is_block(node: dict) -> bool: """Check whether a node is a block node.""" - return node.get('_type') == 'block' and 'listItem' not in node + return node.get('_type') == 'block' def get_list_tags(list_item: str) -> tuple[str, str]: diff --git a/pyproject.toml b/pyproject.toml index cd4b3c1..7dc039a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,18 @@ [tool.poetry] -name = "python-sanity-html" -version = "0.0.3" +name = 'portabletext-html' +version = '1.1.3' description = "HTML renderer for Sanity's Portable Text format" -homepage = "https://github.com/otovo/python-sanity-html" -repository = "https://github.com/otovo/python-sanity-html" -authors = ["Kristian Klette "] -maintainers = ["Sondre Lillebø Gundersen "] -license = "Apache2" -readme = "README.md" -keywords = ["Sanity", "Portable text", "HTML", "Parsing"] +homepage = 'https://github.com/otovo/python-sanity-html' +repository = 'https://github.com/otovo/python-sanity-html' +authors = ['Kristian Klette '] +maintainers = ['Sondre Lillebø Gundersen '] +license = 'Apache2' +readme = 'README.md' +keywords = ['sanity', 'portable', 'text', 'html', 'parsing'] include = ['CHANGELOG.md'] -packages = [{ include = 'sanity_html' }] +packages = [{ include = 'portabletext_html' }] classifiers = [ - 'Development Status :: 3 - Alpha', + 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'Environment :: Web Environment', 'Operating System :: OS Independent', @@ -21,26 +21,26 @@ classifiers = [ 'Topic :: Text Processing :: Markup', 'Topic :: Text Processing :: Markup :: HTML', 'Programming Language :: Python', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', 'Typing :: Typed', ] -[tool.poetry.urls] -"Changelog" = "https://github.com/otovo/python-sanity-html/blob/main/CHANGELOG.md" - [tool.poetry.dependencies] -python = "^3.9" +python = '^3.7' [tool.poetry.dev-dependencies] -pytest = "^6.2.3" -flake8 = "^3.9.0" -pytest-cov = "^2.11.1" -coverage = "^5.5" +pytest = '^6.2.3' +flake8 = '^3.9.0' +pytest-cov = '^2.11.1' +coverage = '^5.5' [build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ['poetry-core>=1.0.0'] +build-backend = 'poetry.core.masonry.api' [tool.black] line-length = 120 @@ -54,10 +54,11 @@ include_trailing_comma = true line_length = 120 [tool.pytest.ini_options] -addopts = ['--cov=sanity_html','--cov-report', 'term-missing'] +addopts = ['--cov=portabletext_html','--cov-report', 'term-missing'] +markers = ['unsupported'] [tool.coverage.run] -source = ['sanity_html/*'] +source = ['portabletext_html/*'] omit = [] branch = true diff --git a/sanity_html/__init__.py b/sanity_html/__init__.py deleted file mode 100644 index 879ae73..0000000 --- a/sanity_html/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Python Sanity HTML Renderer.""" - -from sanity_html.renderer import SanityBlockRenderer, render - -__all__ = ['SanityBlockRenderer', 'render'] diff --git a/sanity_html/renderer.py b/sanity_html/renderer.py deleted file mode 100644 index d1b1a27..0000000 --- a/sanity_html/renderer.py +++ /dev/null @@ -1,162 +0,0 @@ -from __future__ import annotations - -import html -from typing import TYPE_CHECKING - -from sanity_html.constants import STYLE_MAP -from sanity_html.dataclasses import Block, Span -from sanity_html.utils import get_list_tags, is_block, is_list, is_span - -if TYPE_CHECKING: - from typing import Dict, List, Optional, Union - - -# TODO: Let user pass custom code block definitions/plugins -# to represent custom types (see children definition in portable text spec) - - -class SanityBlockRenderer: - """HTML renderer for Sanity block content.""" - - def __init__(self, blocks: Union[list[dict], dict]) -> None: - self._wrapper_element: Optional[str] = None - - if isinstance(blocks, dict): - self._blocks = [blocks] - elif isinstance(blocks, list): - self._blocks = blocks - self._wrapper_element = 'div' - - def render(self) -> str: - """Render HTML from self._blocks.""" - if not self._blocks: - return '' - - result = '' - list_nodes: List[Dict] = [] - for node in self._blocks: - - if list_nodes and not is_list(node): - result += self._render_list(list_nodes) - list_nodes = [] # reset list_nodes - - if is_list(node): - list_nodes.append(node) - continue # handle all elements ^ when the list ends - - result += self._render_node(node) # render non-list nodes immediately - - if list_nodes: - result += self._render_list(list_nodes) - - result = result.strip() - - if self._wrapper_element: - return f'<{self._wrapper_element}>{result}' - return result - - def _render_node(self, node: dict, context: Optional[Block] = None, list_item: bool = False) -> str: - """ - Call the correct render method depending on the node type. - - :param node: Block content node - can be block, span, or list (block). - :param context: Optional context. Spans are passed with a Block instance as context for mark lookups. - :param list_item: Whether we are handling a list upstream (impacts block handling). - """ - if is_block(node): - block = Block(**node) - return self._render_block(block, list_item=list_item) - - elif is_span(node): - if isinstance(node, str): - # TODO: Remove if we there's no coverage for this after we've fixed tests - # not convinced this code path is possible - put it in because the sanity lib checks for it - span = Span(**{'text': node}) - else: - span = Span(**node) - - assert context # this should be a cast - return self._render_span(span, block=context) # context is span's outer block - - else: - print('Unexpected code path 👺') # noqa: T001 # TODO: Remove after thorough testing - return '' - - def _render_block(self, block: Block, list_item: bool = False) -> str: - text = '' - if not list_item: - tag = STYLE_MAP[block.style] - text += f'<{tag}>' - - for child_node in block.children: - text += self._render_node(child_node, context=block) - text += f'' - else: - for child_node in block.children: - text += self._render_node(child_node, context=block) - return text - - def _render_span(self, span: Span, block: Block) -> str: - result: str = '' - prev_node, next_node = block.get_node_siblings(span) - prev_marks = prev_node.get('marks', []) if prev_node else [] - next_marks = next_node.get('marks', []) if next_node else [] - - sorted_marks = sorted(span.marks, key=lambda x: -block.marker_frequencies[x]) - for mark in sorted_marks: - if mark in prev_marks: - continue - marker_callable = block.marker_definitions[mark]() - result += marker_callable.render_prefix(span, mark, block) - - result += html.escape(span.text) - - for mark in reversed(sorted_marks): - if mark in next_marks: - continue - - marker_callable = block.marker_definitions[mark]() - result += marker_callable.render_suffix(span, mark, block) - - return result - - def _render_list(self, nodes: list) -> str: - result, tag_dict = '', {} - for index, node in enumerate(nodes): - - current_level = node['level'] # 1 - prev_level = nodes[index - 1]['level'] if index > 0 else 0 # default triggers first condition below - - list_item = node.pop('listItem') # popping this attribute lets us call render_node for non-list handling - node_inner_html = '
  • ' + ''.join(list(self._render_node(node, list_item=True))) + '
  • ' - - if current_level > prev_level: - list_tags = get_list_tags(list_item) - result += list_tags[0] - result += node_inner_html - tag_dict[current_level] = list_tags[1] - continue - - elif current_level == prev_level: - result += node_inner_html - continue - - elif current_level < prev_level: - result += node_inner_html - result += tag_dict.pop(prev_level) - continue - - else: - print('Unexpected code path 🕵🏻‍') # noqa: T001 # TODO: Remove or alter when done testing - - # there should be one or more tag in the dict for us to close off - for value in tag_dict.values(): - result += value - - return result - - -def render(blocks: List[Dict]) -> str: - """Shortcut function inspired by Sanity's own blocksToHtml.h callable.""" - renderer = SanityBlockRenderer(blocks) - return renderer.render() diff --git a/sanity_html/types.py b/sanity_html/types.py deleted file mode 100644 index 35e3c1f..0000000 --- a/sanity_html/types.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Optional - - SanityIdType = Optional[str] # represents a [:13] uuid hex diff --git a/setup.cfg b/setup.cfg index 54a9a23..44e5250 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,6 +10,10 @@ ignore= ANN101, # W503 line break before binary operator W503, + # ANN002 and ANN003 missing type annotation for *args and **kwargs + ANN002, ANN003 + # SM106 - Handle error cases first + SIM106 select = E, F, N, W @@ -43,3 +47,21 @@ exclude = max-complexity = 15 max-line-length = 120 + +[mypy] +show_error_codes = True +warn_unused_ignores = True +strict_optional = True +incremental = True +ignore_missing_imports = True +warn_redundant_casts = True +warn_unused_configs = True +disallow_untyped_defs = True +disallow_untyped_calls = True +local_partial_types = True +show_traceback = True +exclude = + .venv/ + +[mypy-tests.*] +ignore_errors = True diff --git a/tests/fixtures/custom_serializer_node_after_list.json b/tests/fixtures/custom_serializer_node_after_list.json new file mode 100644 index 0000000..39386cf --- /dev/null +++ b/tests/fixtures/custom_serializer_node_after_list.json @@ -0,0 +1,20 @@ +[ + { + "_key": "e5b6e416e6e9", + "_type": "block", + "children": [ + { "_key": "3bbbff0f158b", "_type": "span", "marks": [], "text": "resers" } + ], + "level": 1, + "listItem": "bullet", + "markDefs": [], + "style": "normal" + }, + { + "_key": "73405dda68e0", + "_type": "extraInfoBlock", + "extraInfo": "This informations is not supported by Block", + "markDefs": [], + "style": "normal" + } +] diff --git a/tests/fixtures/invalid_node.json b/tests/fixtures/invalid_node.json new file mode 100644 index 0000000..74f09bf --- /dev/null +++ b/tests/fixtures/invalid_node.json @@ -0,0 +1,13 @@ +{ + "_key": "73405dda68e7", + "children": [ + { + "_key": "25a09c61d80a", + "_type": "span", + "marks": [], + "text": "Otovo guarantee is good" + } + ], + "markDefs": [], + "style": "normal" +} diff --git a/tests/fixtures/invalid_type.json b/tests/fixtures/invalid_type.json new file mode 100644 index 0000000..745ac66 --- /dev/null +++ b/tests/fixtures/invalid_type.json @@ -0,0 +1,14 @@ +{ + "_key": "73405dda68e7", + "_type": "invalid_type", + "children": [ + { + "_key": "25a09c61d80a", + "_type": "span", + "marks": [], + "text": "Otovo guarantee is good" + } + ], + "markDefs": [], + "style": "normal" +} diff --git a/tests/fixtures/upstream/005-basic-mark-multiple-adjacent-spans.json b/tests/fixtures/upstream/005-basic-mark-multiple-adjacent-spans.json index 091ecab..d1c2ef8 100644 --- a/tests/fixtures/upstream/005-basic-mark-multiple-adjacent-spans.json +++ b/tests/fixtures/upstream/005-basic-mark-multiple-adjacent-spans.json @@ -1 +1,33 @@ -{"input":{"_key":"R5FvMrjo","_type":"block","children":[{"_key":"cZUQGmh4","_type":"span","marks":["strong"],"text":"A word of"},{"_key":"toaiCqIK","_type":"span","marks":["strong"],"text":" warning;"},{"_key":"gaZingA","_type":"span","marks":[],"text":" Sanity is addictive."}],"markDefs":[],"style":"normal"},"output":"

    A word of warning; Sanity is addictive.

    "} +{ + "input": { + "_key": "R5FvMrjo", + "_type": "block", + "children": [ + { + "_key": "cZUQGmh4", + "_type": "span", + "marks": [ + "strong" + ], + "text": "A word of" + }, + { + "_key": "toaiCqIK", + "_type": "span", + "marks": [ + "strong" + ], + "text": " warning;" + }, + { + "_key": "gaZingA", + "_type": "span", + "marks": [], + "text": " Sanity is addictive." + } + ], + "markDefs": [], + "style": "normal" + }, + "output": "

    A word of warning; Sanity is addictive.

    " +} diff --git a/tests/fixtures/upstream/012-image-support.json b/tests/fixtures/upstream/012-image-support.json index 6197ecd..5d2d958 100644 --- a/tests/fixtures/upstream/012-image-support.json +++ b/tests/fixtures/upstream/012-image-support.json @@ -1 +1,26 @@ -{"input":[{"style":"normal","_type":"block","_key":"bd73ec5f61a1","markDefs":[],"children":[{"_type":"span","text":"Also, images are pretty common.","marks":[]}]},{"_type":"image","_key":"d234a4fa317a","asset":{"_type":"reference","_ref":"image-YiOKD0O6AdjKPaK24WtbOEv0-3456x2304-jpg"}}],"output":"

    Also, images are pretty common.

    "} +{ + "input": [ + { + "style": "normal", + "_type": "block", + "_key": "bd73ec5f61a1", + "markDefs": [], + "children": [ + { + "_type": "span", + "text": "Also, images are pretty common.", + "marks": [] + } + ] + }, + { + "_type": "image", + "_key": "d234a4fa317a", + "asset": { + "_type": "reference", + "_ref": "image-YiOKD0O6AdjKPaK24WtbOEv0-3456x2304-jpg" + } + } + ], + "output": "

    Also, images are pretty common.

    " +} diff --git a/tests/fixtures/upstream/021-list-without-level.json b/tests/fixtures/upstream/021-list-without-level.json index ee1146d..5ff95af 100644 --- a/tests/fixtures/upstream/021-list-without-level.json +++ b/tests/fixtures/upstream/021-list-without-level.json @@ -1 +1,284 @@ -{"input":[{"_key":"e3ac53b5b339","_type":"block","children":[{"_type":"span","marks":[],"text":"In-person access: Research appointments"}],"markDefs":[],"style":"h2"},{"_key":"a25f0be55c47","_type":"block","children":[{"_type":"span","marks":[],"text":"The collection may be examined by arranging a research appointment "},{"_type":"span","marks":["strong"],"text":"in advance"},{"_type":"span","marks":[],"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. "}],"markDefs":[],"style":"normal"},{"_key":"9490a3085498","_type":"block","children":[{"_type":"span","marks":[],"text":"The collection space is located at:\n20 Ames Street\nBuilding E15-235\nCambridge, Massachusetts 02139"}],"markDefs":[],"style":"normal"},{"_key":"4c37f3bc1d71","_type":"block","children":[{"_type":"span","marks":[],"text":"In-person access: Space policies"}],"markDefs":[],"style":"h2"},{"_key":"a77cf4905e83","_type":"block","children":[{"_type":"span","marks":[],"text":"The Archivist or an authorized ACT staff member must attend researchers at all times."}],"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"9a039c533554","_type":"block","children":[{"_type":"span","marks":[],"text":"No pens, markers, or adhesives (e.g. “Post-it” notes) are permitted in the collection space; pencils will be provided upon request."}],"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"beeee9405136","_type":"block","children":[{"_type":"span","marks":[],"text":"Cotton gloves must be worn when handling collection materials; gloves will be provided by the Archivist."}],"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"8b78daa65d60","_type":"block","children":[{"_type":"span","marks":[],"text":"No food or beverages are permitted in the collection space."}],"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"d0188e00a887","_type":"block","children":[{"_type":"span","marks":[],"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."}],"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"06486dd9e1c6","_type":"block","children":[{"_type":"span","marks":[],"text":"Photocopying machines in the ACT hallway will be accessible by patrons under the supervision of the Archivist."}],"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"e6f6f5255fb6","_type":"block","children":[{"_type":"span","marks":[],"text":"Patrons may only browse materials that have been made available for access."}],"listItem":"bullet","markDefs":[],"style":"normal"},{"_key":"99b3e265fa02","_type":"block","children":[{"_type":"span","marks":[],"text":"Remote access: Reference requests"}],"markDefs":[],"style":"h2"},{"_key":"ea13459d9e46","_type":"block","children":[{"_type":"span","marks":[],"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."}],"markDefs":[],"style":"normal"},{"_key":"100958e35c94","_type":"block","children":[{"_type":"span","marks":["strong"],"text":"Use of patron information"}],"markDefs":[],"style":"h2"},{"_key":"2e0dde67b7df","_type":"block","children":[{"_type":"span","marks":[],"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."}],"markDefs":[],"style":"normal"},{"_key":"8f39a1ec6366","_type":"block","children":[{"_type":"span","marks":["strong"],"text":"Fees"}],"markDefs":[],"style":"h2"},{"_key":"090062c9e8ce","_type":"block","children":[{"_type":"span","marks":[],"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."}],"markDefs":[],"style":"normal"},{"_key":"e2b58e246069","_type":"block","children":[{"_type":"span","marks":["strong"],"text":"Use of MIT-owned materials by patrons"}],"markDefs":[],"style":"h2"},{"_key":"7cedb6800dc6","_type":"block","children":[{"_type":"span","marks":[],"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. "},{"_type":"span","marks":["strong"],"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."}],"markDefs":[],"style":"normal"}],"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.

    "} +{ + "input": [ + { + "_key": "e3ac53b5b339", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "text": "In-person access: Research appointments" + } + ], + "markDefs": [], + "style": "h2" + }, + { + "_key": "a25f0be55c47", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "text": "The collection may be examined by arranging a research appointment " + }, + { + "_type": "span", + "marks": [ + "strong" + ], + "text": "in advance" + }, + { + "_type": "span", + "marks": [], + "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. " + } + ], + "markDefs": [], + "style": "normal" + }, + { + "_key": "9490a3085498", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "text": "The collection space is located at:\n20 Ames Street\nBuilding E15-235\nCambridge, Massachusetts 02139" + } + ], + "markDefs": [], + "style": "normal" + }, + { + "_key": "4c37f3bc1d71", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "text": "In-person access: Space policies" + } + ], + "markDefs": [], + "style": "h2" + }, + { + "_key": "a77cf4905e83", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "text": "The Archivist or an authorized ACT staff member must attend researchers at all times." + } + ], + "listItem": "bullet", + "markDefs": [], + "style": "normal" + }, + { + "_key": "9a039c533554", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "text": "No pens, markers, or adhesives (e.g. “Post-it” notes) are permitted in the collection space; pencils will be provided upon request." + } + ], + "listItem": "bullet", + "markDefs": [], + "style": "normal" + }, + { + "_key": "beeee9405136", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "text": "Cotton gloves must be worn when handling collection materials; gloves will be provided by the Archivist." + } + ], + "listItem": "bullet", + "markDefs": [], + "style": "normal" + }, + { + "_key": "8b78daa65d60", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "text": "No food or beverages are permitted in the collection space." + } + ], + "listItem": "bullet", + "markDefs": [], + "style": "normal" + }, + { + "_key": "d0188e00a887", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "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." + } + ], + "listItem": "bullet", + "markDefs": [], + "style": "normal" + }, + { + "_key": "06486dd9e1c6", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "text": "Photocopying machines in the ACT hallway will be accessible by patrons under the supervision of the Archivist." + } + ], + "listItem": "bullet", + "markDefs": [], + "style": "normal" + }, + { + "_key": "e6f6f5255fb6", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "text": "Patrons may only browse materials that have been made available for access." + } + ], + "listItem": "bullet", + "markDefs": [], + "style": "normal" + }, + { + "_key": "99b3e265fa02", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "text": "Remote access: Reference requests" + } + ], + "markDefs": [], + "style": "h2" + }, + { + "_key": "ea13459d9e46", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "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." + } + ], + "markDefs": [], + "style": "normal" + }, + { + "_key": "100958e35c94", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [ + "strong" + ], + "text": "Use of patron information" + } + ], + "markDefs": [], + "style": "h2" + }, + { + "_key": "2e0dde67b7df", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "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." + } + ], + "markDefs": [], + "style": "normal" + }, + { + "_key": "8f39a1ec6366", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [ + "strong" + ], + "text": "Fees" + } + ], + "markDefs": [], + "style": "h2" + }, + { + "_key": "090062c9e8ce", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "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." + } + ], + "markDefs": [], + "style": "normal" + }, + { + "_key": "e2b58e246069", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [ + "strong" + ], + "text": "Use of MIT-owned materials by patrons" + } + ], + "markDefs": [], + "style": "h2" + }, + { + "_key": "7cedb6800dc6", + "_type": "block", + "children": [ + { + "_type": "span", + "marks": [], + "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. " + }, + { + "_type": "span", + "marks": [ + "strong" + ], + "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." + } + ], + "markDefs": [], + "style": "normal" + } + ], + "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.

    " +} diff --git a/tests/fixtures/upstream/026-inline-block-with-text.json b/tests/fixtures/upstream/026-inline-block-with-text.json index 7de2d7d..164f1c1 100644 --- a/tests/fixtures/upstream/026-inline-block-with-text.json +++ b/tests/fixtures/upstream/026-inline-block-with-text.json @@ -1 +1,24 @@ -{"input":[{"_type":"block","_key":"foo","style":"normal","children":[{"_type":"span","text":"Men, "},{"_type":"button","text":"bli med du ogsĂĄ"},{"_type":"span","text":", da!"}]}],"output":"

    Men, , da!

    "} +{ + "input": [ + { + "_type": "block", + "_key": "foo", + "style": "normal", + "children": [ + { + "_type": "span", + "text": "Men, " + }, + { + "_type": "button", + "text": "bli med du ogsĂĄ" + }, + { + "_type": "span", + "text": ", da!" + } + ] + } + ], + "output": "

    Men, , da!

    " +} diff --git a/tests/fixtures/upstream/027-styled-list-items.json b/tests/fixtures/upstream/027-styled-list-items.json index 2ffdfc6..7b8d93b 100644 --- a/tests/fixtures/upstream/027-styled-list-items.json +++ b/tests/fixtures/upstream/027-styled-list-items.json @@ -1 +1,63 @@ -{"input":[{"style":"normal","_type":"block","_key":"f94596b05b41","markDefs":[],"children":[{"_type":"span","text":"Let's test some of these lists!","marks":[]}]},{"listItem":"bullet","style":"normal","level":1,"_type":"block","_key":"937effb1cd06","markDefs":[],"children":[{"_type":"span","text":"Bullet 1","marks":[]}]},{"listItem":"bullet","style":"h1","level":1,"_type":"block","_key":"bd2d22278b88","markDefs":[],"children":[{"_type":"span","text":"Bullet 2","marks":[]}]},{"listItem":"bullet","style":"normal","level":1,"_type":"block","_key":"a97d32e9f747","markDefs":[],"children":[{"_type":"span","text":"Bullet 3","marks":[]}]}],"output":"

    Let's test some of these lists!

    • Bullet 1
    • Bullet 2

    • Bullet 3
    "} +{ + "input": [ + { + "style": "normal", + "_type": "block", + "_key": "f94596b05b41", + "markDefs": [], + "children": [ + { + "_type": "span", + "text": "Let's test some of these lists!", + "marks": [] + } + ] + }, + { + "listItem": "bullet", + "style": "normal", + "level": 1, + "_type": "block", + "_key": "937effb1cd06", + "markDefs": [], + "children": [ + { + "_type": "span", + "text": "Bullet 1", + "marks": [] + } + ] + }, + { + "listItem": "bullet", + "style": "h1", + "level": 1, + "_type": "block", + "_key": "bd2d22278b88", + "markDefs": [], + "children": [ + { + "_type": "span", + "text": "Bullet 2", + "marks": [] + } + ] + }, + { + "listItem": "bullet", + "style": "normal", + "level": 1, + "_type": "block", + "_key": "a97d32e9f747", + "markDefs": [], + "children": [ + { + "_type": "span", + "text": "Bullet 3", + "marks": [] + } + ] + } + ], + "output": "

    Let's test some of these lists!

    • Bullet 1
    • Bullet 2

    • Bullet 3
    " +} diff --git a/tests/fixtures/upstream/052-custom-marks.json b/tests/fixtures/upstream/052-custom-marks.json index e263ad4..a6de602 100644 --- a/tests/fixtures/upstream/052-custom-marks.json +++ b/tests/fixtures/upstream/052-custom-marks.json @@ -1 +1,23 @@ -{"input":{"_type":"block","children":[{"_key":"a1ph4","_type":"span","marks":["mark1"],"text":"Sanity"}],"markDefs":[{"_key":"mark1","_type":"highlight","thickness":5}]},"output":"

    Sanity

    "} +{ + "input": { + "_type": "block", + "children": [ + { + "_key": "a1ph4", + "_type": "span", + "marks": [ + "mark1" + ], + "text": "Sanity" + } + ], + "markDefs": [ + { + "_key": "mark1", + "_type": "highlight", + "thickness": 5 + } + ] + }, + "output": "

    Sanity

    " +} diff --git a/tests/test_marker_definitions.py b/tests/test_marker_definitions.py index 5112b9a..2677218 100644 --- a/tests/test_marker_definitions.py +++ b/tests/test_marker_definitions.py @@ -1,5 +1,8 @@ -from sanity_html.dataclasses import Block, Span -from sanity_html.marker_definitions import ( +# pylint: skip-file +from typing import Type + +from portabletext_html import PortableTextRenderer +from portabletext_html.marker_definitions import ( CommentMarkerDefinition, EmphasisMarkerDefinition, LinkMarkerDefinition, @@ -7,6 +10,7 @@ StrongMarkerDefinition, UnderlineMarkerDefinition, ) +from portabletext_html.types import Block, Span sample_texts = ['test', None, 1, 2.2, '!"#$%&/()'] @@ -15,6 +19,7 @@ def test_render_emphasis_marker_success(): for text in sample_texts: node = Span(_type='span', text=text) block = Block(_type='block', children=[node.__dict__]) + assert EmphasisMarkerDefinition.render_text(node, 'em', block) == f'{text}' assert EmphasisMarkerDefinition.render(node, 'em', block) == f'{text}' @@ -22,6 +27,7 @@ def test_render_strong_marker_success(): for text in sample_texts: node = Span(_type='span', text=text) block = Block(_type='block', children=[node.__dict__]) + assert StrongMarkerDefinition.render_text(node, 'strong', block) == f'{text}' assert StrongMarkerDefinition.render(node, 'strong', block) == f'{text}' @@ -29,14 +35,19 @@ def test_render_underline_marker_success(): for text in sample_texts: node = Span(_type='span', text=text) block = Block(_type='block', children=[node.__dict__]) - assert UnderlineMarkerDefinition.render(node, 'u', block) == f'{text}' + assert UnderlineMarkerDefinition.render_text(node, 'u', block) == f'{text}' + assert ( + UnderlineMarkerDefinition.render(node, 'u', block) + == f'{text}' + ) def test_render_strikethrough_marker_success(): for text in sample_texts: node = Span(_type='span', text=text) block = Block(_type='block', children=[node.__dict__]) - assert StrikeThroughMarkerDefinition.render(node, 'strike', block) == f'{text}' + assert StrikeThroughMarkerDefinition.render_text(node, 'strike', block) == f'{text}' + assert StrikeThroughMarkerDefinition.render(node, 'strike', block) == f'{text}' def test_render_link_marker_success(): @@ -45,6 +56,7 @@ def test_render_link_marker_success(): block = Block( _type='block', children=[node.__dict__], markDefs=[{'_type': 'link', '_key': 'linkId', 'href': text}] ) + assert LinkMarkerDefinition.render_text(node, 'linkId', block) == f'{text}' assert LinkMarkerDefinition.render(node, 'linkId', block) == f'
    {text}' @@ -53,3 +65,37 @@ def test_render_comment_marker_success(): node = Span(_type='span', text=text) block = Block(_type='block', children=[node.__dict__]) assert CommentMarkerDefinition.render(node, 'comment', block) == f'' + + +def test_custom_marker_definition(): + from portabletext_html.marker_definitions import MarkerDefinition + + class ConditionalMarkerDefinition(MarkerDefinition): + tag = 'em' + + @classmethod + def render_prefix(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str: + marker_definition = next((md for md in context.markDefs if md['_key'] == marker), None) + condition = marker_definition.get('cloudCondition', '') + if not condition: + style = 'display: none' + return f'<{cls.tag} style=\"{style}\">' + else: + return super().render_prefix(span, marker, context) + + @classmethod + def render_text(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str: + marker_definition = next((md for md in context.markDefs if md['_key'] == marker), None) + condition = marker_definition.get('cloudCondition', '') + return span.text if not condition else '' + + renderer = PortableTextRenderer( + blocks={ + '_type': 'block', + 'children': [{'_key': 'a1ph4', '_type': 'span', 'marks': ['some_id'], 'text': 'Sanity'}], + 'markDefs': [{'_key': 'some_id', '_type': 'contractConditional', 'cloudCondition': False}], + }, + custom_marker_definitions={'contractConditional': ConditionalMarkerDefinition}, + ) + result = renderer.render() + assert result == '

    Sanity

    ' diff --git a/tests/test_module_loading.py b/tests/test_module_loading.py index d1a6523..03fe074 100644 --- a/tests/test_module_loading.py +++ b/tests/test_module_loading.py @@ -6,6 +6,6 @@ def test_module_should_be_importable(): This catches any compilation issue we might have. """ - from sanity_html import SanityBlockRenderer + from portabletext_html import PortableTextRenderer - assert SanityBlockRenderer + assert PortableTextRenderer diff --git a/tests/test_rendering.py b/tests/test_rendering.py index a9eaf8d..1c82309 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -1,8 +1,18 @@ import html import json from pathlib import Path +from typing import Optional -from sanity_html.renderer import render +import pytest + +from portabletext_html.renderer import MissingSerializerError, UnhandledNodeError, render +from portabletext_html.types import Block + + +def extraInfoSerializer(node: dict, context: Optional[Block], list_item: bool) -> str: + extraInfo = node.get('extraInfo') + + return f'

    {extraInfo}

    ' def load_fixture(fixture_name) -> dict: @@ -45,3 +55,22 @@ def test_nested_marks(): fixture = load_fixture('nested_marks.json') output = render(fixture) assert output == '

    A word of warning; Sanity is addictive.

    ' + + +def test_missing_serializer(): + fixture = load_fixture('invalid_type.json') + with pytest.raises(MissingSerializerError): + render(fixture) + + +def test_invalid_node(): + fixture = load_fixture('invalid_node.json') + with pytest.raises(UnhandledNodeError): + render(fixture) + + +def test_custom_serializer_node_after_list(): + fixture = load_fixture('custom_serializer_node_after_list.json') + output = render(fixture, custom_serializers={'extraInfoBlock': extraInfoSerializer}) + + assert output == '

    This informations is not supported by Block

    ' diff --git a/tests/test_upstream_suite.py b/tests/test_upstream_suite.py index 82ddf6b..08efedf 100644 --- a/tests/test_upstream_suite.py +++ b/tests/test_upstream_suite.py @@ -1,9 +1,50 @@ import json +import re from pathlib import Path +from typing import Optional, Type import pytest -from sanity_html import render +from portabletext_html import render +from portabletext_html.marker_definitions import LinkMarkerDefinition, MarkerDefinition +from portabletext_html.renderer import PortableTextRenderer +from portabletext_html.types import Block, Span + + +def fake_image_serializer(node: dict, context: Optional[Block], list_item: bool): + assert node['_type'] == 'image' + if 'url' in node['asset']: + image_url = node['asset']['url'] + else: + project_id = '3do82whm' + dataset = 'production' + asset_ref: str = node['asset']['_ref'] + image_path = asset_ref[6:].replace('-jpg', '.jpg').replace('-png', '.png') + image_url = f'https://cdn.sanity.io/images/{project_id}/{dataset}/{image_path}' + + if 'crop' in node and 'hotspot' in node: + crop = node['crop'] + hotspot = node['hotspot'] + size_match = re.match(r'.*-(\d+)x(\d+)\..*', image_url) + if size_match: + orig_width, orig_height = (int(x) for x in size_match.groups()) + rect_x1 = round((orig_width * hotspot['x']) - ((orig_width * hotspot['width']) / 2)) + rect_y1 = round((orig_height * hotspot['y']) - ((orig_height * hotspot['height']) / 2)) + rect_x2 = round(orig_width - (orig_width * crop['left']) - (orig_width * crop['right'])) + rect_y2 = round(orig_height - (orig_height * crop['top']) - (orig_height * crop['bottom'])) + # These are passed as "imageOptions" upstream. + # It's up the the implementor of the serializer to fix this. + # We might provide one for images that does something like this, but for now + # let's just make the test suite pass + width = 320 + height = 240 + + image_url += f'?rect={rect_x1},{rect_y1},{rect_x2},{rect_y2}&w={width}&h={height}' + + image = f'' + if context: + return image + return f'
    {image}
    ' def get_fixture(rel_path) -> dict: @@ -106,7 +147,8 @@ def test_012_image_support(): fixture_data = get_fixture('fixtures/upstream/012-image-support.json') input_blocks = fixture_data['input'] expected_output = fixture_data['output'] - output = render(input_blocks) + sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) + output = sbr.render() assert output == expected_output @@ -114,7 +156,8 @@ def test_013_materialized_image_support(): fixture_data = get_fixture('fixtures/upstream/013-materialized-image-support.json') input_blocks = fixture_data['input'] expected_output = fixture_data['output'] - output = render(input_blocks) + sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) + output = sbr.render() assert output == expected_output @@ -150,7 +193,7 @@ def test_017_all_default_block_style(): assert output == expected_output -@pytest.mark.skip('Requires custom definitions') +@pytest.mark.unsupported def test_018_marks_all_the_way_dow(): fixture_data = get_fixture('fixtures/upstream/018-marks-all-the-way-down.json') input_blocks = fixture_data['input'] @@ -187,7 +230,8 @@ def test_022_inline_node(): fixture_data = get_fixture('fixtures/upstream/022-inline-nodes.json') input_blocks = fixture_data['input'] expected_output = fixture_data['output'] - output = render(input_blocks) + sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) + output = sbr.render() assert output == expected_output @@ -203,7 +247,8 @@ def test_024_inline_image(): fixture_data = get_fixture('fixtures/upstream/024-inline-images.json') input_blocks = fixture_data['input'] expected_output = fixture_data['output'] - output = render(input_blocks) + sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) + output = sbr.render() assert output == expected_output @@ -211,15 +256,21 @@ def test_025_image_with_hotspot(): fixture_data = get_fixture('fixtures/upstream/025-image-with-hotspot.json') input_blocks = fixture_data['input'] expected_output = fixture_data['output'] - output = render(input_blocks) + sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) + output = sbr.render() assert output == expected_output +def button_serializer(node: dict, context: Optional[Block], list_item: bool): + return f'' + + def test_026_inline_block_with_text(): fixture_data = get_fixture('fixtures/upstream/026-inline-block-with-text.json') input_blocks = fixture_data['input'] expected_output = fixture_data['output'] - output = render(input_blocks) + sbr = PortableTextRenderer(input_blocks, custom_serializers={'button': button_serializer}) + output = sbr.render() assert output == expected_output @@ -231,6 +282,7 @@ def test_027_styled_list_item(): assert output == expected_output +@pytest.mark.unsupported def test_050_custom_block_type(): fixture_data = get_fixture('fixtures/upstream/050-custom-block-type.json') input_blocks = fixture_data['input'] @@ -239,6 +291,7 @@ def test_050_custom_block_type(): assert output == expected_output +@pytest.mark.unsupported def test_051_override_default(): fixture_data = get_fixture('fixtures/upstream/051-override-defaults.json') input_blocks = fixture_data['input'] @@ -247,11 +300,20 @@ def test_051_override_default(): assert output == expected_output +@pytest.mark.unsupported def test_052_custom_mark(): fixture_data = get_fixture('fixtures/upstream/052-custom-marks.json') input_blocks = fixture_data['input'] expected_output = fixture_data['output'] - output = render(input_blocks) + + class CustomMarkerSerializer(MarkerDefinition): + tag = 'span' + + @classmethod + def render_prefix(cls, span: Span, marker: str, context: Block) -> str: + return '' + + output = render(input_blocks, custom_marker_definitions={'mark1': CustomMarkerSerializer}) assert output == expected_output @@ -259,7 +321,15 @@ def test_053_override_default_mark(): fixture_data = get_fixture('fixtures/upstream/053-override-default-marks.json') input_blocks = fixture_data['input'] expected_output = fixture_data['output'] - output = render(input_blocks) + + class CustomLinkMark(LinkMarkerDefinition): + @classmethod + def render_prefix(cls, span, marker, context) -> str: + result = super().render_prefix(span, marker, context) + return result.replace('