diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml deleted file mode 100644 index 2cf3ca0..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 -m "not unsupported" --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 397a32d..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 -m "not unsupported" + 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 83% rename from sanity_html/marker_definitions.py rename to portabletext_html/marker_definitions.py index 5947160..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,16 +30,22 @@ 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 @@ -97,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.get('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/sanity_html/renderer.py b/portabletext_html/renderer.py similarity index 65% rename from sanity_html/renderer.py rename to portabletext_html/renderer.py index bfdfd8e..1ca2ce3 100644 --- a/sanity_html/renderer.py +++ b/portabletext_html/renderer.py @@ -1,32 +1,47 @@ from __future__ import annotations import html -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast -from sanity_html.constants import STYLE_MAP -from sanity_html.dataclasses import Block, Span -from sanity_html.marker_definitions import DefaultMarkerDefinition -from sanity_html.utils import get_list_tags, is_block, is_list, is_span +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 Callable, Dict, List, Optional, Type, Union + from typing import Any, Callable, Dict, List, Optional, Type, Union - from sanity_html.marker_definitions import MarkerDefinition + from portabletext_html.marker_definitions import MarkerDefinition -# TODO: Let user pass custom code block definitions/plugins -# to represent custom types (see children definition in portable text spec) +class UnhandledNodeError(Exception): + """Raised when we receive a node that we cannot parse.""" + pass -class SanityBlockRenderer: - """HTML renderer for Sanity block content.""" + +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, - custom_serializers: dict[str, Callable[[dict, Optional[Block], bool], str]] = None, + 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 {} @@ -39,16 +54,19 @@ def __init__( 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, Block(**node)) - result += ''.join([self._render_node(n, Block(**node), list_item=True) for n in tree]) + 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): @@ -58,11 +76,8 @@ def render(self) -> str: result += self._render_node(node) # render non-list nodes immediately if list_nodes: - tree = self._normalize_list_tree(list_nodes, Block(**node)) - result += ''.join( - self._render_node(n, Block(**node), list_item=True) for n in tree - ) - + 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() @@ -79,45 +94,51 @@ def _render_node(self, node: dict, context: Optional[Block] = None, list_item: b :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): - 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) + 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 - assert context # this should be a cast - return self._render_span(span, block=context) # context is span's outer block - elif custom_serializer := self._custom_serializers.get(node.get('_type', '')): - return custom_serializer(node, context, list_item) else: - print('Unexpected code path 👺') # noqa: T001 # TODO: Remove after thorough testing - return '' + 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 = '' - if not list_item: - tag = STYLE_MAP[block.style] + 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) + for child_node in block.children: + text += self._render_node(child_node, context=block) + + if not list_item or tag != 'p': 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: + 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 [] @@ -125,14 +146,28 @@ def _render_span(self, span: Span, block: Block) -> str: 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) - result += html.escape(span.text).replace('\n', '
') + # 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) @@ -147,7 +182,7 @@ def _render_list(self, node: Block, context: Optional[Block]) -> str: result += tail return result - def _normalize_list_tree(self, nodes: list, block: Block) -> list[dict]: + def _normalize_list_tree(self, nodes: list) -> list[dict]: tree = [] current_list = None @@ -186,7 +221,7 @@ def _normalize_list_tree(self, nodes: list, block: Block) -> list[dict]: 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) + current_list['children'].append(node) continue current_list = self._list_from_block(node) tree.append(current_list) @@ -221,7 +256,7 @@ def _list_from_block(self, block: dict) -> dict: } -def render(blocks: List[Dict]) -> str: +def render(blocks: List[Dict], *args: Any, **kwargs: Any) -> str: """Shortcut function inspired by Sanity's own blocksToHtml.h callable.""" - renderer = SanityBlockRenderer(blocks) + renderer = PortableTextRenderer(blocks, *args, **kwargs) return renderer.render() diff --git a/sanity_html/dataclasses.py b/portabletext_html/types.py similarity index 63% rename from sanity_html/dataclasses.py rename to portabletext_html/types.py index 24cf457..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_default_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,8 +37,8 @@ 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) @@ -54,9 +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. """ - marker_definitions = get_default_marker_definitions(self.markDefs) - marker_definitions.update(self.marker_definitions) - self.marker_definitions = marker_definitions + self.marker_definitions = self._add_custom_marker_definitions() self.marker_frequencies = self._compute_marker_frequencies() def _compute_marker_frequencies(self) -> dict[str, int]: @@ -69,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 80% rename from sanity_html/utils.py rename to portabletext_html/utils.py index 1c75293..3977d81 100644 --- a/sanity_html/utils.py +++ b/portabletext_html/utils.py @@ -2,12 +2,12 @@ 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_default_marker_definitions(mark_defs: list[dict]) -> dict[str, Type[MarkerDefinition]]: @@ -20,8 +20,9 @@ def get_default_marker_definitions(mark_defs: list[dict]) -> dict[str, Type[Mark 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} diff --git a/pyproject.toml b/pyproject.toml index 50d3536..7dc039a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,18 @@ [tool.poetry] -name = "python-sanity-html" -version = "0.0.4" +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,11 +54,11 @@ include_trailing_comma = true line_length = 120 [tool.pytest.ini_options] -addopts = ['--cov=sanity_html','--cov-report', 'term-missing'] -markers = ["unsupported"] +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/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 572d003..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,6 +35,7 @@ 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_text(node, 'u', block) == f'{text}' assert ( UnderlineMarkerDefinition.render(node, 'u', block) == f'{text}' @@ -39,6 +46,7 @@ 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_text(node, 'strike', block) == f'{text}' assert StrikeThroughMarkerDefinition.render(node, 'strike', block) == f'{text}' @@ -48,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}' @@ -56,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 de16467..08efedf 100644 --- a/tests/test_upstream_suite.py +++ b/tests/test_upstream_suite.py @@ -1,14 +1,14 @@ import json import re from pathlib import Path -from typing import Optional +from typing import Optional, Type import pytest -from sanity_html import render -from sanity_html.dataclasses import Block -from sanity_html.marker_definitions import LinkMarkerDefinition, MarkerDefinition -from sanity_html.renderer import SanityBlockRenderer +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): @@ -27,7 +27,7 @@ def fake_image_serializer(node: dict, context: Optional[Block], list_item: bool) 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()] + 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'])) @@ -147,7 +147,7 @@ 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'] - sbr = SanityBlockRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) + sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) output = sbr.render() assert output == expected_output @@ -156,7 +156,7 @@ 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'] - sbr = SanityBlockRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) + sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) output = sbr.render() assert output == expected_output @@ -230,7 +230,7 @@ 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'] - sbr = SanityBlockRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) + sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) output = sbr.render() assert output == expected_output @@ -247,7 +247,7 @@ 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'] - sbr = SanityBlockRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) + sbr = PortableTextRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) output = sbr.render() assert output == expected_output @@ -256,16 +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'] - sbr = SanityBlockRenderer(input_blocks, custom_serializers={'image': fake_image_serializer}) + 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 @@ -300,7 +305,15 @@ 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 @@ -315,7 +328,7 @@ def render_prefix(cls, span, marker, context) -> str: result = super().render_prefix(span, marker, context) return result.replace('