├── .git-blame-ignore-revs ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── changelog ├── docs ├── changelog.rst ├── conf.py ├── custom_markups.rst ├── index.rst ├── interface.rst ├── overview.rst ├── requirements.txt └── standard_markups.rst ├── markup2html.py ├── markups ├── __init__.py ├── abstract.py ├── asciidoc.py ├── common.py ├── markdown.py ├── py.typed ├── restructuredtext.py └── textile.py ├── pyproject.toml └── tests ├── __init__.py ├── test_asciidoc.py ├── test_markdown.py ├── test_public_api.py ├── test_restructuredtext.py └── test_textile.py /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 1600ac428aa7eb0db31fc0a53a1dcdb7ea827f64 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | matrix: 9 | python: 10 | - '3.10' 11 | - '3.11' 12 | - '3.12' 13 | - '3.13' 14 | - pypy-3.10 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Setup Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python }} 22 | - name: Install pymarkups and all optional dependencies 23 | run: | 24 | pip install --upgrade pip setuptools 25 | pip install .[markdown,restructuredtext,textile,asciidoc,highlighting] 26 | pip install codecov pymdown-extensions 27 | - name: Run tests 28 | run: coverage run -m unittest discover -s tests -v 29 | - name: Run the doctest 30 | run: python -m doctest README.rst -v 31 | - name: Upload reports to Codecov 32 | if: ${{ matrix.python == '3.13' }} 33 | run: codecov 34 | mypy: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: actions/setup-python@v5 39 | with: 40 | python-version: '3.13' 41 | - name: Install mypy and type stubs 42 | run: python -m pip install mypy types-docutils types-PyYAML types-Markdown 43 | - name: Run mypy 44 | run: mypy --ignore-missing-imports --strict . 45 | ruff: 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v4 49 | - uses: actions/setup-python@v5 50 | with: 51 | python-version: '3.13' 52 | - name: Install ruff 53 | run: python -m pip install ruff 54 | - name: Run ruff check 55 | run: ruff check --select F,E,W,I,UP,A,COM --target-version py310 . 56 | - name: Run ruff format 57 | run: ruff format --diff . 58 | pypi-publish: 59 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 60 | needs: 61 | - test 62 | - mypy 63 | - ruff 64 | runs-on: ubuntu-latest 65 | environment: 66 | name: pypi 67 | url: https://pypi.org/p/Markups 68 | permissions: 69 | id-token: write 70 | steps: 71 | - uses: actions/checkout@v4 72 | - uses: actions/setup-python@v5 73 | with: 74 | python-version: '3.13' 75 | - run: pip install build 76 | - run: python -m build 77 | - if: success() 78 | uses: pypa/gh-action-pypi-publish@release/v1 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | MANIFEST 4 | Markups.egg-info 5 | __pycache__ 6 | *.pyc 7 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: "3" 6 | sphinx: 7 | configuration: docs/conf.py 8 | fail_on_warning: true 9 | python: 10 | install: 11 | - method: pip 12 | path: . 13 | - requirements: docs/requirements.txt 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2012-2023 Dmitry Shachnev . 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 3. Neither the name of the author nor the names of its contributors 14 | may be used to endorse or promote products derived from this software 15 | without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 20 | ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE 21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 23 | OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 24 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 25 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 26 | OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF 27 | SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include changelog 4 | include markup2html.py 5 | recursive-include docs *.rst conf.py 6 | recursive-include tests *.py 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://github.com/retext-project/pymarkups/workflows/tests/badge.svg 2 | :target: https://github.com/retext-project/pymarkups/actions 3 | :alt: GitHub Actions status 4 | .. image:: https://codecov.io/gh/retext-project/pymarkups/branch/master/graph/badge.svg 5 | :target: https://codecov.io/gh/retext-project/pymarkups 6 | :alt: Coverage status 7 | .. image:: https://readthedocs.org/projects/pymarkups/badge/?version=latest 8 | :target: https://pymarkups.readthedocs.io/en/latest/ 9 | :alt: ReadTheDocs status 10 | 11 | This module provides a wrapper around various text markup languages. 12 | 13 | Available by default are Markdown_, reStructuredText_, Textile_ and AsciiDoc_, 14 | but you can easily add your own markups. 15 | 16 | Usage example: 17 | 18 | .. code:: python 19 | 20 | >>> import markups 21 | >>> markup = markups.get_markup_for_file_name("myfile.rst") 22 | >>> markup.name 23 | 'reStructuredText' 24 | >>> markup.attributes[markups.common.SYNTAX_DOCUMENTATION] 25 | 'https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html' 26 | >>> text = """ 27 | ... Hello, world! 28 | ... ============= 29 | ... 30 | ... This is an example **reStructuredText** document. 31 | ... """ 32 | >>> result = markup.convert(text) 33 | >>> result.get_document_title() 34 | 'Hello, world!' 35 | >>> print(result.get_document_body()) # doctest: +NORMALIZE_WHITESPACE 36 |
37 |

Hello, world!

38 |

This is an example reStructuredText document.

39 |
40 | 41 | .. _Markdown: https://daringfireball.net/projects/markdown/ 42 | .. _reStructuredText: https://docutils.sourceforge.io/rst.html 43 | .. _Textile: https://en.wikipedia.org/wiki/Textile_(markup_language) 44 | .. _AsciiDoc: https://asciidoc.org 45 | 46 | The release version can be downloaded from PyPI_ or installed using:: 47 | 48 | pip install Markups 49 | 50 | .. _PyPI: https://pypi.org/project/Markups/ 51 | 52 | The source code is hosted on GitHub_. 53 | 54 | .. _GitHub: https://github.com/retext-project/pymarkups 55 | 56 | The documentation is available online_ or can be generated from source by 57 | installing Sphinx_ and running:: 58 | 59 | python3 -m sphinx docs build/sphinx/html 60 | 61 | .. _online: https://pymarkups.readthedocs.io/en/latest/ 62 | .. _Sphinx: https://www.sphinx-doc.org/en/master/ 63 | -------------------------------------------------------------------------------- /changelog: -------------------------------------------------------------------------------- 1 | Version 4.1.1, 2025-04-29 2 | ========================= 3 | 4 | * Adapt the tests to Pygments 2.19 (contributed by Markéta Calábková in #20). 5 | * Add support for Debian ``node-mathjax-full`` package. 6 | * Use :PEP:`639` license expression (needs setuptools 77 or newer). 7 | 8 | Version 4.1.0, 2024-12-08 9 | ========================= 10 | 11 | * Incompatible change: Python 3.9 is no longer supported. 12 | * Made the order of Markdown extensions deterministic 13 | (fixes a problem with ``pymdownx.superfences``). 14 | * Adopted ruff for code quality checks and auto-formatting. 15 | 16 | Version 4.0.0, 2023-01-16 17 | ========================= 18 | 19 | Incompatible changes: 20 | 21 | * Python versions older than 3.9 are no longer supported. 22 | * Python-Markdown versions older than 3.0 are no longer supported. 23 | * Setuptools 61.2 or higher is required to build the project. 24 | * ``setup.py`` has been removed. Use ``pip``, ``build`` or other :PEP:`517` 25 | compatible tool. 26 | 27 | Other changes: 28 | 29 | * Added AsciiDocMarkup (contributed by Dave Kuhlman in #17). 30 | * Made the tests pass with Pygments ≥ 2.11. 31 | * Made the tests pass when PyYAML is not installed (#18). 32 | * Reformatted code in accordance with :PEP:`8` standard. 33 | * Fixed mypy errors and added a :PEP:`561` ``py.typed`` file. 34 | 35 | Version 3.1.3, 2021-11-21 36 | ========================= 37 | 38 | * Fixed logic to load extensions file when PyYAML module is not available 39 | (issue #16, thanks foxB612 for the bug report). 40 | * Made the tests pass with docutils 0.18. 41 | 42 | Version 3.1.2, 2021-09-06 43 | ========================= 44 | 45 | * Incompatible change: Python 3.6 is no longer supported. 46 | * Fixed replacing Markdown extensions in document. 47 | * Fixed crash when using TOC backrefs in reStructuredText (issue #14, 48 | thanks Hrissimir for the patch). 49 | 50 | Version 3.1.1, 2021-03-05 51 | ========================= 52 | 53 | * The reStructuredText markup now includes line numbers information in 54 | ``data-posmap`` attributes. 55 | * The reStructuredText markup now uses only ``minimal.css`` stylesheet 56 | (not ``plain.css`` anymore). 57 | * Added support for the upcoming docutils 0.17 release to the tests. 58 | 59 | Version 3.1.0, 2021-01-31 60 | ========================= 61 | 62 | Incompatible changes: 63 | 64 | * Python versions older than 3.6 are no longer supported. 65 | 66 | Other changes: 67 | 68 | * Instead of ``pkg_resources``, ``importlib.metadata`` is now used. 69 | * For Markdown markup, ``markdown-extensions.yaml`` files are now supported 70 | in addition to ``markdown-extensions.txt`` files. 71 | * Type annotations were added for public API. 72 | * The reStructuredText markup no longer raises exceptions for invalid markup. 73 | * MathJax v3 is now supported in addition to v2. Also, the Arch Linux mathjax 74 | packages are now supported (issue #4). 75 | * Added Pygments CSS support for the ``pymdownx.highlight`` Markdown extension. 76 | 77 | Version 3.0.0, 2018-05-03 78 | ========================= 79 | 80 | Incompatible changes: 81 | 82 | * The deprecated AbstractMarkup API has been removed. 83 | * Python 3.2 is no longer supported. 84 | * The output now uses HTML5 instead of HTML4. 85 | * The custom markups are now registered with entry points. 86 | * The ``get_custom_markups()`` method has been removed. 87 | * New required dependency: python-markdown-math_. 88 | 89 | Other changes: 90 | 91 | * The upcoming Python-Markdown 3.x release is now supported. 92 | 93 | .. _python-markdown-math: https://pypi.org/project/python-markdown-math/ 94 | 95 | Version 2.0.1, 2017-06-24 96 | ========================= 97 | 98 | * The new MathJax CDN is used, the old one will be shut down soon. 99 | * When using MathJax with Markdown, the AMSmath and AMSsymbols extensions are 100 | now enabled. 101 | 102 | Version 2.0.0, 2016-05-09 103 | ========================= 104 | 105 | Incompatible changes: 106 | 107 | * Changed the API of pymarkups to clearly separate the conversion step from 108 | access to the various elements of the result. The old API is deprecated 109 | and will be removed in a future release. Please see the documentation for 110 | details on using the new API. 111 | * The reStructuredText markup now includes document title and subtitle in 112 | the HTML body. 113 | 114 | Other changes: 115 | 116 | * Added a ``markup2html.py`` reference script to show API usage. 117 | * Improved support for specifying Markdown extensions in the document. 118 | 119 | Version 1.0.1, 2015-12-22 120 | ========================= 121 | 122 | * The Textile markup now uses the recommended python-textile API. 123 | * Fixed warnings during installation. 124 | * Python-Markdown Math extension updated to the latest version. 125 | 126 | Version 1.0, 2015-12-13 127 | ======================= 128 | 129 | * Web module removed, as ReText no longer needs it. 130 | * Textile markup updated to work with the latest version of Python-Textile 131 | module. 132 | * The setup script now uses setuptools when it is available. 133 | * Testsuite and documentation improvements. 134 | 135 | Version 0.6.3, 2015-06-16 136 | ========================= 137 | 138 | * No-change re-upload with fixed tarball and changelog. 139 | 140 | Version 0.6.2, 2015-06-09 141 | ========================= 142 | 143 | * Markdown markup: fixed detection of codehilite extension with options. 144 | * Added a warning about deprecation of the markups.web module. 145 | 146 | Version 0.6.1, 2015-04-19 147 | ========================= 148 | 149 | * PyMarkups now uses warnings system instead of printing messages to 150 | stderr. 151 | * Improvements to Markdown markup: 152 | 153 | + Fixed parsing math that contains nested environments (thanks to Gautam 154 | Iyer for the patch). 155 | + Fixed crash on extensions names starting with dot. 156 | 157 | * Miscellaneous fixes. 158 | 159 | Version 0.6, 2015-01-25 160 | ======================= 161 | 162 | Incompatible changes: 163 | 164 | * Custom markups are now normal Python modules. 165 | * Web module no longer supports Python 2.x. 166 | 167 | Other changes: 168 | 169 | * Refactor the code related to Markdown extensions to make it work with 170 | upcoming Python-Markdown releases. 171 | * MathJax extension is now in a separate module. 172 | 173 | Version 0.5.2, 2014-11-05 174 | ========================= 175 | 176 | * Fixed loading of Markdown extensions with options. 177 | 178 | Version 0.5.1, 2014-09-16 179 | ========================= 180 | 181 | * Fixed Markdown markup crash on empty files. 182 | * Include documentation in the tarballs. 183 | * Testsuite improvements. 184 | 185 | Version 0.5, 2014-07-25 186 | ======================= 187 | 188 | * Improvements to Markdown markup: 189 | 190 | + All math delimiters except ``$...$`` are now enabled by default. 191 | + ``remove_extra`` extension now disables formulas support. 192 | + It is now possible to specify required extensions in the first line of 193 | the file. 194 | 195 | * Add Sphinx documentation. 196 | 197 | Version 0.4, 2013-11-30 198 | ======================= 199 | 200 | * Add Textile markup. 201 | * reStructuredText markup now supports file names and settings overrides. 202 | * Web module now raises WebUpdateError when updating fails. 203 | 204 | Version 0.3, 2013-07-25 205 | ======================= 206 | 207 | * MathJax support in Markdown has been improved and no longer relies on 208 | tex2jax extension. 209 | * It is now possible to pass extensions list to MarkdownMarkup constructor. 210 | * Pygments style is now configurable. 211 | * Testsuite improvements. 212 | 213 | Version 0.2.3, 2012-11-02 214 | ========================= 215 | 216 | * Fix support for custom working directory in web module. 217 | * Bug fixes in Markdown module and tests. 218 | 219 | Version 0.2.2, 2012-10-02 220 | ========================= 221 | 222 | * Re-written math support for Markdown. 223 | * Add tests to the tarball. 224 | * Add example template for web module. 225 | * Bug fixes in Markdown and web modules. 226 | 227 | Version 0.2.1, 2012-09-09 228 | ========================= 229 | 230 | * Add caching support, to speed up get_document_body function. 231 | * Add testsuite. 232 | * Fix some bugs in markdown module. 233 | 234 | Version 0.2, 2012-09-04 235 | ======================= 236 | 237 | * Initial release. 238 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | ======================== 2 | Python-Markups changelog 3 | ======================== 4 | 5 | This changelog only lists the most important changes that 6 | happened in Python-Markups. Please see the `Git log`_ for 7 | the full list of changes. 8 | 9 | .. _`Git log`: https://github.com/retext-project/pymarkups/commits/master 10 | 11 | .. include:: ../changelog 12 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | 6 | # If extensions (or modules to document with autodoc) are in another directory, 7 | # add these directories to sys.path here. If the directory is relative to the 8 | # documentation root, use os.path.abspath to make it absolute, like shown here. 9 | sys.path.insert(0, os.path.abspath("..")) 10 | 11 | # -- General configuration ------------------------------------------------ 12 | 13 | # Add any Sphinx extension module names here, as strings. They can be 14 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 15 | # ones. 16 | extensions = [ 17 | "sphinx.ext.autodoc", 18 | ] 19 | 20 | # Add any paths that contain templates here, relative to this directory. 21 | templates_path = ["_templates"] 22 | 23 | # The suffix of source filenames. 24 | source_suffix = ".rst" 25 | 26 | # The master toctree document. 27 | master_doc = "index" 28 | 29 | # General information about the project. 30 | project = "Python-Markups" 31 | copyright = "2025, Dmitry Shachnev" # noqa: A001 32 | 33 | # The version info for the project you're documenting, acts as replacement for 34 | # |version| and |release|, also used in various other places throughout the 35 | # built documents. 36 | from markups import __version__, __version_tuple__ # noqa: E402 37 | 38 | # The short X.Y version. 39 | version = "{}.{}".format(*__version_tuple__) 40 | # The full version, including alpha/beta/rc tags. 41 | release = __version__ 42 | 43 | # The name of the Pygments (syntax highlighting) style to use. 44 | pygments_style = "sphinx" 45 | 46 | # -- Options for HTML output ---------------------------------------------- 47 | 48 | # The theme to use for HTML and HTML Help pages. See the documentation for 49 | # a list of builtin themes. 50 | html_theme = "nature" 51 | -------------------------------------------------------------------------------- /docs/custom_markups.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Custom Markups 3 | ============== 4 | 5 | Registering the markup module 6 | ============================= 7 | 8 | A third-party markup is a Python module that can be installed 9 | the usual way. 10 | 11 | To register your markup class with PyMarkups, make it inherit from 12 | :class:`~markups.abstract.AbstractMarkup`, and add that class to 13 | your module's ``entry_points``, in the “pymarkups” entry point group. 14 | 15 | For example: 16 | 17 | .. code-block:: toml 18 | :caption: pyproject.toml 19 | 20 | [project.entry-points.pymarkups] 21 | mymarkup = "mymodule:MyMarkupClass" 22 | 23 | See the `setuptools documentation`_ on entry points for details. 24 | 25 | To check if the module was found by Python-Markups, one can check 26 | if the module is present in return value of 27 | :func:`~markups.get_all_markups` function. 28 | 29 | .. versionchanged:: 3.0 30 | The custom markups should be registered using the entry points 31 | mechanism, the ``pymarkups.txt`` file is no longer supported. 32 | 33 | .. _`setuptools documentation`: https://setuptools.pypa.io/en/latest/userguide/entry_point.html 34 | 35 | Importing third-party modules 36 | ============================= 37 | 38 | A markup must not directly import any third party Python module it uses 39 | at file level. Instead, it should check the module availability in 40 | :meth:`~markups.abstract.AbstractMarkup.available` static method. 41 | 42 | That method can try to import the needed modules, and return ``True`` in 43 | case of success, and ``False`` in case of failure. 44 | 45 | Implementing methods 46 | ==================== 47 | 48 | Any markup must inherit from :class:`~markups.abstract.AbstractMarkup`. 49 | 50 | Third-party markups must implement :class:`~markups.abstract.AbstractMarkup`'s 51 | :meth:`~markups.abstract.AbstractMarkup.convert` method, which must perform the 52 | time-consuming part of markup conversion and return a newly constructed 53 | instance of (a subclass of) :class:`~markups.abstract.ConvertedMarkup`. 54 | 55 | :class:`~markups.abstract.ConvertedMarkup` encapsulates the title, body, 56 | stylesheet and javascript of a converted document. Of these only the body is 57 | required during construction, the others default to an empty string. If 58 | additional markup-specific state is required to implement 59 | :class:`~markups.abstract.ConvertedMarkup`, a subclass can be defined and an 60 | instance of it returned from :meth:`~markups.abstract.AbstractMarkup.convert` 61 | instead. 62 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | =================================== 2 | Python-Markups module documentation 3 | =================================== 4 | 5 | Introduction to Python-Markups 6 | ============================== 7 | 8 | Python-Markups is a module that provides unified interface for using 9 | various markup languages, such as Markdown, reStructuredText, Textile and 10 | AsciiDoc. It is also possible for clients to create and register their 11 | own markup languages. 12 | 13 | The output language Python-Markups works with is HTML. Stylesheets and 14 | JavaScript sections are supported. 15 | 16 | The abstract interface that any markup implements is 17 | :class:`~markups.abstract.AbstractMarkup`. 18 | 19 | Contents 20 | ======== 21 | 22 | .. toctree:: 23 | 24 | overview 25 | interface 26 | standard_markups 27 | custom_markups 28 | changelog 29 | 30 | Links 31 | ===== 32 | 33 | * Python-Markups source code is hosted on GitHub_. 34 | * You can get the source tarball from PyPI_. 35 | * It is also packaged in Debian_. 36 | 37 | .. _GitHub: https://github.com/retext-project/pymarkups 38 | .. _PyPI: https://pypi.org/project/Markups/ 39 | .. _Debian: https://packages.debian.org/sid/source/pymarkups 40 | -------------------------------------------------------------------------------- /docs/interface.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Markup interface 3 | ================ 4 | 5 | The main class for interacting with markups is :class:`~markups.abstract.AbstractMarkup`. 6 | 7 | However, you shouldn't create direct instances of that class. Instead, use one of the 8 | :doc:`standard markup classes `. 9 | 10 | .. autoclass:: markups.abstract.AbstractMarkup 11 | :members: 12 | 13 | When :class:`~markups.abstract.AbstractMarkup`'s 14 | :meth:`~markups.abstract.AbstractMarkup.convert` method is called it will 15 | return an instance of :class:`~markups.abstract.ConvertedMarkup` or a subclass 16 | thereof that provides access to the conversion results. 17 | 18 | .. autoclass:: markups.abstract.ConvertedMarkup 19 | :members: 20 | -------------------------------------------------------------------------------- /docs/overview.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | API overview 3 | ============ 4 | 5 | For the basic usage of Python-Markups, one should import some markup 6 | class from :mod:`markups`, create an instance of that class, and use 7 | the :meth:`~markups.abstract.AbstractMarkup.convert` method: 8 | 9 | >>> import markups 10 | >>> markup = markups.ReStructuredTextMarkup() 11 | >>> markup.convert('*reStructuredText* test').get_document_body() 12 | '
\n

reStructuredText test

\n
\n' 13 | 14 | For advanced usage (like dynamically choosing the markup class), 15 | one may use one of the functions documented below. 16 | 17 | Getting lists of available markups 18 | ================================== 19 | 20 | .. autofunction:: markups.get_all_markups 21 | .. autofunction:: markups.get_available_markups 22 | 23 | Getting a specific markup 24 | ========================= 25 | 26 | .. autofunction:: markups.get_markup_for_file_name 27 | .. autofunction:: markups.find_markup_class_by_name 28 | 29 | .. _configuration-directory: 30 | 31 | Configuration directory 32 | ======================= 33 | 34 | Some markups can provide configuration files that the user may use 35 | to change the behavior. 36 | 37 | These files are stored in a single configuration directory. 38 | 39 | If :envvar:`XDG_CONFIG_HOME` is defined, then the configuration 40 | directory is it. Otherwise, it is :file:`.config` subdirectory in 41 | the user's home directory. 42 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx>=3.4 2 | -------------------------------------------------------------------------------- /docs/standard_markups.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Built-in markups 3 | ================ 4 | 5 | These markups are available by default: 6 | 7 | Markdown markup 8 | =============== 9 | 10 | Markdown_ markup uses Python-Markdown_ as a backend (version 2.6 or later 11 | is required). 12 | 13 | There are several ways to enable `Python-Markdown extensions`_. 14 | 15 | * List extensions in a file named :file:`markdown-extensions.yaml` or 16 | :file:`markdown-extensions.txt` in the :ref:`configuration directory 17 | `. The extensions will be automatically applied 18 | to all documents. 19 | * If :file:`markdown-extensions.yaml` or :file:`markdown-extensions.txt` 20 | is placed into working directory, all documents in that directory will 21 | get extensions that are listed in that file. 22 | * If first line of a document contains ":samp:`Required extensions: 23 | {ext1 ext2 ...}`", that list will be applied to a document. 24 | * Finally, one can programmatically pass list of extension names to 25 | :class:`markups.MarkdownMarkup` constructor. 26 | 27 | The YAML file should be a list of extensions, possibly with configuration 28 | options, for example: 29 | 30 | .. code-block:: yaml 31 | 32 | - smarty: 33 | substitutions: 34 | left-single-quote: "‚" 35 | right-single-quote: "‘" 36 | smart_dashes: False 37 | - toc: 38 | permalink: True 39 | separator: "_" 40 | toc_depth: 3 41 | - sane_lists 42 | 43 | Or using a JSON-like syntax: 44 | 45 | .. code-block:: yaml 46 | 47 | ["smarty", "sane_lists"] 48 | 49 | YAML support works only when the PyYAML_ module is installed. 50 | 51 | The txt file is a simple list of extensions, separated by newlines. Lines 52 | starting with ``#`` are treated as comments and ignored. It is possible to 53 | specify string options in brackets, for example:: 54 | 55 | toc(title=Contents) 56 | sane_lists 57 | 58 | The same syntax to specify options works in the ``Required extensions`` 59 | line. You can put it into a comment to make it invisible in the output:: 60 | 61 | 62 | 63 | The `Math Markdown extension`_ is enabled by default. This extension 64 | supports a syntax for LaTeX-style math formulas (powered by MathJax_). 65 | The delimiters are: 66 | 67 | ================ =============== 68 | Inline math Standalone math 69 | ================ =============== 70 | ``$...$`` [#f1]_ ``$$...$$`` 71 | ``\(...\)`` ``\[...\]`` 72 | ================ =============== 73 | 74 | .. [#f1] To enable single-dollar-sign delimiter, one should add 75 | ``mdx_math(enable_dollar_delimiter=1)`` to the extensions list. 76 | 77 | The `Python-Markdown Extra`_ set of extensions is enabled by default. 78 | To disable it, one can enable virtual ``remove_extra`` extension 79 | (which also completely disables LaTeX formulas support). 80 | 81 | The default file extension associated with Markdown markup is ``.mkd``, 82 | though many other extensions (including ``.md`` and ``.markdown``) are 83 | supported as well. 84 | 85 | .. _Markdown: https://daringfireball.net/projects/markdown/ 86 | .. _Python-Markdown: https://python-markdown.github.io/ 87 | .. _MathJax: https://www.mathjax.org/ 88 | .. _`Python-Markdown extensions`: https://python-markdown.github.io/extensions/ 89 | .. _PyYAML: https://pypi.org/project/PyYAML/ 90 | .. _`Math Markdown extension`: https://github.com/mitya57/python-markdown-math 91 | .. _`Python-Markdown Extra`: https://python-markdown.github.io/extensions/extra/ 92 | 93 | .. autoclass:: markups.MarkdownMarkup 94 | 95 | reStructuredText markup 96 | ======================== 97 | 98 | This markup provides support for reStructuredText_ language (the language 99 | this documentation is written in). It uses Docutils_ Python module. 100 | 101 | The file extension associated with reStructuredText markup is ``.rst``. 102 | 103 | .. _reStructuredText: https://docutils.sourceforge.io/rst.html 104 | .. _Docutils: https://docutils.sourceforge.io/ 105 | 106 | .. autoclass:: markups.ReStructuredTextMarkup 107 | 108 | Textile markup 109 | ============== 110 | 111 | This markup provides support for Textile_ language. It uses python-textile_ 112 | module. 113 | 114 | The file extension associated with Textile markup is ``.textile``. 115 | 116 | .. _Textile: https://textile-lang.com 117 | .. _python-textile: https://github.com/textile/python-textile 118 | 119 | .. autoclass:: markups.TextileMarkup 120 | 121 | AsciiDoc markup 122 | =============== 123 | 124 | This markup provides support for AsciiDoc_ language. It uses asciidoc-py_ 125 | module. 126 | 127 | The file extension associated with AsciiDoc markup is ``.adoc``. 128 | 129 | .. _AsciiDoc: https://asciidoc.org 130 | .. _asciidoc-py: https://asciidoc-py.github.io 131 | 132 | .. autoclass:: markups.AsciiDocMarkup 133 | -------------------------------------------------------------------------------- /markup2html.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import sys 5 | 6 | import markups 7 | 8 | 9 | def export_file(args: argparse.Namespace) -> None: 10 | markup = markups.get_markup_for_file_name(args.input_file) 11 | with open(args.input_file) as fp: 12 | text = fp.read() 13 | if not markup: 14 | sys.exit("Markup not available.") 15 | converted = markup.convert(text) 16 | 17 | html = converted.get_whole_html( 18 | include_stylesheet=args.include_stylesheet, 19 | fallback_title=args.fallback_title, 20 | webenv=args.web_environment, 21 | ) 22 | 23 | with open(args.output_file, "w") as output: 24 | output.write(html) 25 | 26 | 27 | if __name__ == "__main__": 28 | parser = argparse.ArgumentParser() 29 | parser.add_argument( 30 | "--web-environment", 31 | help="export for web environment", 32 | action="store_true", 33 | ) 34 | parser.add_argument( 35 | "--include-stylesheet", 36 | help="embed the stylesheet into html", 37 | action="store_true", 38 | ) 39 | parser.add_argument( 40 | "--fallback-title", 41 | help="fallback title of the HTML document", 42 | metavar="TITLE", 43 | ) 44 | parser.add_argument("input_file", help="input file") 45 | parser.add_argument("output_file", help="output file") 46 | args = parser.parse_args() 47 | export_file(args) 48 | -------------------------------------------------------------------------------- /markups/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of python-markups module 2 | # License: 3-clause BSD, see LICENSE file 3 | # Copyright: (C) Dmitry Shachnev, 2012-2024 4 | 5 | from importlib.metadata import entry_points 6 | from typing import Literal, overload 7 | 8 | from markups.abstract import AbstractMarkup 9 | from markups.asciidoc import AsciiDocMarkup 10 | from markups.markdown import MarkdownMarkup 11 | from markups.restructuredtext import ReStructuredTextMarkup 12 | from markups.textile import TextileMarkup 13 | 14 | __version_tuple__ = (4, 1, 1) 15 | __version__ = ".".join(map(str, __version_tuple__)) 16 | 17 | __all__ = [ 18 | "AbstractMarkup", 19 | "AsciiDocMarkup", 20 | "MarkdownMarkup", 21 | "ReStructuredTextMarkup", 22 | "TextileMarkup", 23 | "find_markup_class_by_name", 24 | "get_all_markups", 25 | "get_available_markups", 26 | "get_markup_for_file_name", 27 | ] 28 | 29 | builtin_markups = [ 30 | MarkdownMarkup, 31 | ReStructuredTextMarkup, 32 | TextileMarkup, 33 | AsciiDocMarkup, 34 | ] 35 | 36 | # Public API 37 | 38 | 39 | def get_all_markups() -> list[type[AbstractMarkup]]: 40 | """ 41 | :returns: list of all markups (both standard and custom ones) 42 | """ 43 | entrypoints = entry_points(group="pymarkups") 44 | return [entry_point.load() for entry_point in entrypoints] 45 | 46 | 47 | def get_available_markups() -> list[type[AbstractMarkup]]: 48 | """ 49 | :returns: list of all available markups (markups whose 50 | :meth:`~markups.abstract.AbstractMarkup.available` 51 | method returns True) 52 | """ 53 | available_markups = [] 54 | for markup in get_all_markups(): 55 | if markup.available(): 56 | available_markups.append(markup) 57 | return available_markups 58 | 59 | 60 | @overload 61 | def get_markup_for_file_name( 62 | filename: str, 63 | return_class: Literal[False] = False, 64 | ) -> AbstractMarkup | None: ... 65 | 66 | 67 | @overload 68 | def get_markup_for_file_name( 69 | filename: str, 70 | return_class: Literal[True], 71 | ) -> type[AbstractMarkup] | None: ... 72 | 73 | 74 | def get_markup_for_file_name( 75 | filename: str, 76 | return_class: bool = False, 77 | ) -> AbstractMarkup | type[AbstractMarkup] | None: 78 | """ 79 | :param filename: name of the file 80 | :param return_class: if true, this function will return 81 | a class rather than an instance 82 | 83 | :returns: a markup with 84 | :attr:`~markups.abstract.AbstractMarkup.file_extensions` 85 | attribute containing extension of `filename`, if found, 86 | otherwise ``None`` 87 | 88 | >>> import markups 89 | >>> markup = markups.get_markup_for_file_name('foo.mkd') 90 | >>> markup.convert('**Test**').get_document_body() 91 | '

Test

\\n' 92 | >>> markups.get_markup_for_file_name('bar.rst', return_class=True) 93 | 94 | """ 95 | markup_class = None 96 | for markup in get_all_markups(): 97 | for extension in markup.file_extensions: 98 | if filename.endswith(extension): 99 | markup_class = markup 100 | if return_class: 101 | return markup_class 102 | if markup_class and markup_class.available(): 103 | return markup_class(filename=filename) 104 | return None 105 | 106 | 107 | def find_markup_class_by_name(name: str) -> type[AbstractMarkup] | None: 108 | """ 109 | :returns: a markup with 110 | :attr:`~markups.abstract.AbstractMarkup.name` 111 | attribute matching `name`, if found, otherwise ``None`` 112 | 113 | >>> import markups 114 | >>> markups.find_markup_class_by_name('textile') 115 | 116 | """ 117 | for markup in get_all_markups(): 118 | if markup.name.lower() == name.lower(): 119 | return markup 120 | return None 121 | -------------------------------------------------------------------------------- /markups/abstract.py: -------------------------------------------------------------------------------- 1 | # This file is part of python-markups module 2 | # License: 3-clause BSD, see LICENSE file 3 | # Copyright: (C) Dmitry Shachnev, 2012-2024 4 | 5 | from __future__ import annotations 6 | 7 | from typing import Any 8 | 9 | whole_html_template = """ 10 | 11 | 12 | 13 | {custom_headers}{title} 14 | {stylesheet}{javascript} 15 | 16 | {body} 17 | 18 | 19 | """ 20 | 21 | 22 | class AbstractMarkup: 23 | """Abstract class for markup languages. 24 | 25 | :param filename: optional name of the file 26 | """ 27 | 28 | #: name of the markup visible to user 29 | name: str 30 | #: various attributes, like links to website and syntax documentation 31 | attributes: dict[int, Any] 32 | #: indicates which file extensions are associated with the markup 33 | file_extensions: tuple[str, ...] 34 | #: the default file extension 35 | default_extension: str 36 | 37 | def __init__(self, filename: str | None = None): 38 | self.filename = filename 39 | 40 | @staticmethod 41 | def available() -> bool: 42 | """ 43 | :returns: whether the markup is ready for use 44 | 45 | (for example, whether the required third-party 46 | modules are importable) 47 | """ 48 | return True 49 | 50 | def convert(self, text: str) -> ConvertedMarkup: 51 | """ 52 | :returns: a ConvertedMarkup instance (or a subclass thereof) 53 | containing the markup converted to HTML 54 | """ 55 | raise NotImplementedError 56 | 57 | 58 | class ConvertedMarkup: 59 | """This class encapsulates the title, body, stylesheet and javascript 60 | of a converted document. 61 | 62 | Instances of this class are created by :meth:`.AbstractMarkup.convert` 63 | method, usually it should not be instantiated directly. 64 | """ 65 | 66 | def __init__( 67 | self, 68 | body: str, 69 | title: str = "", 70 | stylesheet: str = "", 71 | javascript: str = "", 72 | ): 73 | self.title = title 74 | self.stylesheet = stylesheet 75 | self.javascript = javascript 76 | self.body = body 77 | 78 | def get_document_title(self) -> str: 79 | """ 80 | :returns: the document title 81 | """ 82 | return self.title 83 | 84 | def get_document_body(self) -> str: 85 | """ 86 | :returns: the contents of the ```` HTML tag 87 | """ 88 | return self.body 89 | 90 | def get_stylesheet(self) -> str: 91 | """ 92 | :returns: the contents of ``\n" 129 | if include_stylesheet 130 | else "" 131 | ) 132 | 133 | context = { 134 | "body": self.get_document_body(), 135 | "title": self.get_document_title() or fallback_title, 136 | "javascript": self.get_javascript(webenv), 137 | "stylesheet": stylesheet, 138 | "custom_headers": custom_headers, 139 | } 140 | return whole_html_template.format(**context) 141 | -------------------------------------------------------------------------------- /markups/asciidoc.py: -------------------------------------------------------------------------------- 1 | # This file is part of python-markups module 2 | # License: 3-clause BSD, see LICENSE file 3 | # Copyright: (C) Dave Kuhlman, 2022 4 | 5 | import importlib 6 | import warnings 7 | from io import StringIO 8 | 9 | import markups.common as common 10 | from markups.abstract import AbstractMarkup, ConvertedMarkup 11 | 12 | 13 | class AsciiDocMarkup(AbstractMarkup): 14 | """Markup class for AsciiDoc language. 15 | Inherits :class:`~markups.abstract.AbstractMarkup`. 16 | """ 17 | 18 | name = "asciidoc" 19 | attributes = { 20 | common.LANGUAGE_HOME_PAGE: "https://asciidoc.org", 21 | common.MODULE_HOME_PAGE: "https://asciidoc-py.github.io", 22 | common.SYNTAX_DOCUMENTATION: "https://asciidoc-py.github.io/userguide.html", 23 | } 24 | 25 | file_extensions = (".adoc", ".asciidoc") 26 | default_extension = ".adoc" 27 | 28 | @staticmethod 29 | def available() -> bool: 30 | try: 31 | importlib.import_module("asciidoc") 32 | importlib.import_module("lxml") 33 | except ImportError: 34 | return False 35 | return True 36 | 37 | def convert(self, text: str) -> ConvertedMarkup: 38 | import asciidoc 39 | from lxml import etree 40 | 41 | outfile = StringIO() 42 | infile = StringIO(text) 43 | opts = [ 44 | ("--backend", "html5"), 45 | ("--attribute", r"newline=\n"), 46 | ("--attribute", "footer-style=none"), 47 | ("--out-file", outfile), 48 | ] 49 | try: 50 | asciidoc.execute(None, opts, [infile]) 51 | except SystemExit as ex: 52 | warnings.warn(str(ex.__context__), SyntaxWarning) 53 | pass 54 | result = outfile.getvalue() 55 | parser = etree.HTMLParser() 56 | root_element = etree.fromstring(result, parser) 57 | head_element = root_element.xpath("./head")[0] 58 | title_element = root_element.xpath("./head/title")[0] 59 | style_elements = root_element.xpath("./head/style") 60 | javascript_elements = root_element.xpath("./head/script") 61 | body_element = root_element.xpath("./body")[0] 62 | head = "" 63 | for child in head_element.getchildren(): 64 | head += etree.tostring( 65 | child, 66 | encoding="unicode", 67 | method="html", 68 | ) 69 | body = "" 70 | for child in body_element.getchildren(): 71 | body += etree.tostring( 72 | child, 73 | encoding="unicode", 74 | method="html", 75 | ) 76 | title = title_element.text 77 | stylesheet = "" 78 | for style_element in style_elements: 79 | stylesheet += style_element.text 80 | javascript = "" 81 | for javascript_element in javascript_elements: 82 | javascript += etree.tostring( 83 | javascript_element, 84 | encoding="unicode", 85 | method="html", 86 | ) 87 | return ConvertedMarkup(body, title, stylesheet, javascript) 88 | -------------------------------------------------------------------------------- /markups/common.py: -------------------------------------------------------------------------------- 1 | # This file is part of python-markups module 2 | # License: 3-clause BSD, see LICENSE file 3 | # Copyright: (C) Dmitry Shachnev, 2012-2025 4 | 5 | import os.path 6 | 7 | # Some common constants and functions 8 | (LANGUAGE_HOME_PAGE, MODULE_HOME_PAGE, SYNTAX_DOCUMENTATION) = range(3) 9 | CONFIGURATION_DIR = ( 10 | os.getenv("XDG_CONFIG_HOME") 11 | or os.getenv("APPDATA") 12 | or os.path.expanduser("~/.config") 13 | ) 14 | MATHJAX2_LOCAL_FILES = ( 15 | "/usr/share/javascript/mathjax/MathJax.js", # Debian libjs-mathjax 16 | "/usr/share/mathjax2/MathJax.js", # Arch Linux mathjax2 17 | ) 18 | MATHJAX3_LOCAL_FILES = ( 19 | "/usr/share/nodejs/mathjax-full/es5/tex-chtml.js", # Debian node-mathjax-full 20 | "/usr/share/mathjax/tex-chtml.js", # Arch Linux mathjax 21 | ) 22 | MATHJAX_WEB_URL = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js" 23 | 24 | PYGMENTS_STYLE = "default" 25 | 26 | 27 | def get_pygments_stylesheet(selector: str | None, style: str | None = None) -> str: 28 | if style is None: 29 | style = PYGMENTS_STYLE 30 | if style == "": 31 | return "" 32 | try: 33 | from pygments.formatters import HtmlFormatter 34 | except ImportError: 35 | return "" 36 | else: 37 | defs = HtmlFormatter(style=style).get_style_defs(selector) 38 | assert isinstance(defs, str) 39 | return defs + "\n" 40 | 41 | 42 | def get_mathjax_url_and_version(webenv: bool) -> tuple[str, int]: 43 | if not webenv: 44 | for path in MATHJAX3_LOCAL_FILES: 45 | if os.path.exists(path): 46 | return f"file://{path}", 3 47 | for path in MATHJAX2_LOCAL_FILES: 48 | if os.path.exists(path): 49 | return f"file://{path}", 2 50 | return MATHJAX_WEB_URL, 3 51 | -------------------------------------------------------------------------------- /markups/markdown.py: -------------------------------------------------------------------------------- 1 | # This file is part of python-markups module 2 | # License: 3-clause BSD, see LICENSE file 3 | # Copyright: (C) Dmitry Shachnev, 2012-2024 4 | 5 | from __future__ import annotations 6 | 7 | import importlib 8 | import os 9 | import re 10 | import warnings 11 | from collections.abc import Iterable, Iterator 12 | from typing import Any 13 | 14 | import markups.common as common 15 | from markups.abstract import AbstractMarkup, ConvertedMarkup 16 | 17 | try: 18 | import yaml 19 | 20 | HAVE_YAML = True 21 | except ImportError: 22 | HAVE_YAML = False 23 | 24 | MATHJAX2_CONFIG = """ 35 | """ 36 | 37 | # Taken from: 38 | # https://docs.mathjax.org/en/latest/upgrading/v2.html?highlight=upgrading#changes-in-the-mathjax-api 39 | MATHJAX3_CONFIG = """ 40 | 59 | """ # noqa: E501 60 | 61 | extensions_re = re.compile(r"required.extensions: (.+)", flags=re.IGNORECASE) 62 | extension_name_re = re.compile(r"[a-z0-9_.]+(?:\([^)]+\))?", flags=re.IGNORECASE) 63 | 64 | _canonicalized_ext_names: dict[str, str] = {} 65 | 66 | _name_and_config = tuple[str, dict[str, Any]] 67 | 68 | 69 | class MarkdownMarkup(AbstractMarkup): 70 | """Markup class for Markdown language. 71 | Inherits :class:`~markups.abstract.AbstractMarkup`. 72 | 73 | :param extensions: list of extension names 74 | :type extensions: list 75 | """ 76 | 77 | name = "Markdown" 78 | attributes = { 79 | common.LANGUAGE_HOME_PAGE: "https://daringfireball.net/projects/markdown/", 80 | common.MODULE_HOME_PAGE: "https://github.com/Python-Markdown/markdown", 81 | common.SYNTAX_DOCUMENTATION: "https://daringfireball.net/projects/markdown/syntax", 82 | } 83 | 84 | file_extensions = (".md", ".mkd", ".mkdn", ".mdwn", ".mdown", ".markdown") 85 | default_extension = ".mkd" 86 | 87 | @staticmethod 88 | def available() -> bool: 89 | try: 90 | import markdown 91 | 92 | importlib.import_module("mdx_math") 93 | except ImportError: 94 | return False 95 | return getattr(markdown, "__version_info__", (2,)) >= (3,) 96 | 97 | def _load_extensions_list_from_txt_file( 98 | self, 99 | filename: str, 100 | ) -> Iterator[_name_and_config]: 101 | with open(filename) as extensions_file: 102 | for line in extensions_file: 103 | if not line.startswith("#"): 104 | yield self._split_extension_config(line.rstrip()) 105 | 106 | def _load_extensions_list_from_yaml_file( 107 | self, 108 | filename: str, 109 | ) -> Iterator[_name_and_config]: 110 | with open(filename) as extensions_file: 111 | try: 112 | data = yaml.safe_load(extensions_file) 113 | except yaml.YAMLError as ex: 114 | warnings.warn(f"Failed parsing {filename}: {ex}", SyntaxWarning) 115 | raise OSError from ex 116 | if isinstance(data, list): 117 | for item in data: 118 | if isinstance(item, dict): 119 | yield from item.items() 120 | elif isinstance(item, str): 121 | yield item, {} 122 | 123 | def _get_global_extensions( 124 | self, 125 | filename: str | None, 126 | ) -> Iterator[_name_and_config]: 127 | local_directory = os.path.dirname(filename) if filename else "" 128 | choices = [ 129 | os.path.join(local_directory, "markdown-extensions.yaml"), 130 | os.path.join(local_directory, "markdown-extensions.txt"), 131 | os.path.join(common.CONFIGURATION_DIR, "markdown-extensions.yaml"), 132 | os.path.join(common.CONFIGURATION_DIR, "markdown-extensions.txt"), 133 | ] 134 | for choice in choices: 135 | if choice.endswith(".yaml") and not HAVE_YAML: 136 | continue 137 | try: 138 | if choice.endswith(".txt"): 139 | yield from self._load_extensions_list_from_txt_file(choice) 140 | else: 141 | yield from self._load_extensions_list_from_yaml_file(choice) 142 | except OSError: 143 | continue # Cannot open file, move to the next choice 144 | else: 145 | break # File loaded successfully, skip the remaining choices 146 | 147 | def _get_document_extensions(self, text: str) -> Iterator[_name_and_config]: 148 | lines = text.splitlines() 149 | match = extensions_re.search(lines[0]) if lines else None 150 | if match: 151 | extensions = extension_name_re.findall(match.group(1)) 152 | yield from self._split_extensions_configs(extensions) 153 | 154 | def _canonicalize_extension_name(self, extension_name: str) -> str | None: 155 | prefixes = ("markdown.extensions.", "", "mdx_") 156 | for prefix in prefixes: 157 | try: 158 | module = importlib.import_module(prefix + extension_name) 159 | if not hasattr(module, "makeExtension"): 160 | continue 161 | except (ImportError, ValueError, TypeError): 162 | pass 163 | else: 164 | return prefix + extension_name 165 | return None 166 | 167 | def _split_extension_config(self, extension_name: str) -> _name_and_config: 168 | """Splits the configuration options from the extension name.""" 169 | lb = extension_name.find("(") 170 | if lb == -1: 171 | return extension_name, {} 172 | extension_name, parameters = extension_name[:lb], extension_name[lb + 1 : -1] 173 | pairs = [x.split("=") for x in parameters.split(",")] 174 | return extension_name, {x.strip(): y.strip() for (x, y) in pairs} 175 | 176 | def _split_extensions_configs( 177 | self, 178 | extensions: Iterable[str], 179 | ) -> Iterator[_name_and_config]: 180 | """Splits the configuration options from a list of strings. 181 | 182 | :returns: a generator of (name, config) tuples 183 | """ 184 | for extension in extensions: 185 | yield self._split_extension_config(extension) 186 | 187 | def _apply_extensions( 188 | self, 189 | document_extensions: Iterable[_name_and_config] | None = None, 190 | ) -> None: 191 | extensions = self.global_extensions.copy() 192 | extensions.extend(self._split_extensions_configs(self.requested_extensions)) 193 | if document_extensions is not None: 194 | extensions.extend(document_extensions) 195 | 196 | extension_names = {"markdown.extensions.extra", "mdx_math"} 197 | extension_configs = {} 198 | 199 | for name, config in extensions: 200 | if name == "mathjax": 201 | mathjax_config = {"enable_dollar_delimiter": True} 202 | extension_configs["mdx_math"] = mathjax_config 203 | elif name == "remove_extra": 204 | if "markdown.extensions.extra" in extension_names: 205 | extension_names.remove("markdown.extensions.extra") 206 | if "mdx_math" in extension_names: 207 | extension_names.remove("mdx_math") 208 | else: 209 | if name in _canonicalized_ext_names: 210 | canonical_name = _canonicalized_ext_names[name] 211 | else: 212 | candidate = self._canonicalize_extension_name(name) 213 | if candidate is None: 214 | warnings.warn( 215 | f'Extension "{name}" does not exist.', 216 | ImportWarning, 217 | ) 218 | continue 219 | canonical_name = candidate 220 | _canonicalized_ext_names[name] = canonical_name 221 | extension_names.add(canonical_name) 222 | extension_configs[canonical_name] = config 223 | self.md = self.markdown.Markdown( 224 | extensions=sorted(extension_names), 225 | extension_configs=extension_configs, 226 | output_format="html5", 227 | ) 228 | self.extensions = extension_names 229 | self.extension_configs = extension_configs 230 | 231 | def __init__( 232 | self, 233 | filename: str | None = None, 234 | extensions: list[str] | None = None, 235 | ): 236 | AbstractMarkup.__init__(self, filename) 237 | import markdown 238 | 239 | self.markdown = markdown 240 | self.requested_extensions = extensions or [] 241 | self.global_extensions: list[_name_and_config] = [] 242 | if extensions is None: 243 | self.global_extensions.extend(self._get_global_extensions(filename)) 244 | self._apply_extensions() 245 | 246 | def convert(self, text: str) -> ConvertedMarkdown: 247 | # Determine body 248 | self.md.reset() 249 | self._apply_extensions(self._get_document_extensions(text)) 250 | body = self.md.convert(text) + "\n" 251 | 252 | # Determine title 253 | if hasattr(self.md, "Meta") and "title" in self.md.Meta: 254 | title = str.join(" ", self.md.Meta["title"]) 255 | else: 256 | title = "" 257 | 258 | # Determine stylesheet 259 | css_class = None 260 | 261 | if "markdown.extensions.codehilite" in self.extensions: 262 | config = self.extension_configs.get("markdown.extensions.codehilite", {}) 263 | css_class = config.get("css_class", "codehilite") 264 | stylesheet = common.get_pygments_stylesheet(f".{css_class}") 265 | elif "pymdownx.highlight" in self.extensions: 266 | config = self.extension_configs.get("pymdownx.highlight", {}) 267 | css_class = config.get("css_class", "highlight") 268 | stylesheet = common.get_pygments_stylesheet(f".{css_class}") 269 | else: 270 | stylesheet = "" 271 | 272 | return ConvertedMarkdown(body, title, stylesheet) 273 | 274 | 275 | class ConvertedMarkdown(ConvertedMarkup): 276 | def get_javascript(self, webenv: bool = False) -> str: 277 | if '\n' 123 | return script_tag % (mathjax_url, async_attr) 124 | -------------------------------------------------------------------------------- /markups/textile.py: -------------------------------------------------------------------------------- 1 | # This file is part of python-markups module 2 | # License: 3-clause BSD, see LICENSE file 3 | # Copyright: (C) Dmitry Shachnev, 2013-2024 4 | 5 | import importlib 6 | 7 | import markups.common as common 8 | from markups.abstract import AbstractMarkup, ConvertedMarkup 9 | 10 | 11 | class TextileMarkup(AbstractMarkup): 12 | """Markup class for Textile language. 13 | Inherits :class:`~markups.abstract.AbstractMarkup`. 14 | """ 15 | 16 | name = "Textile" 17 | attributes = { 18 | common.LANGUAGE_HOME_PAGE: "https://textile-lang.com", 19 | common.MODULE_HOME_PAGE: "https://github.com/textile/python-textile", 20 | common.SYNTAX_DOCUMENTATION: "https://movabletype.org/documentation/author/textile-2-syntax.html", 21 | } 22 | 23 | file_extensions = (".textile",) 24 | default_extension = ".textile" 25 | 26 | @staticmethod 27 | def available() -> bool: 28 | try: 29 | importlib.import_module("textile") 30 | except ImportError: 31 | return False 32 | return True 33 | 34 | def __init__(self, filename: str | None = None): 35 | AbstractMarkup.__init__(self, filename) 36 | from textile import textile 37 | 38 | self.textile = textile 39 | 40 | def convert(self, text: str) -> ConvertedMarkup: 41 | return ConvertedMarkup(self.textile(text)) 42 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=77.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "Markups" 7 | description = "A wrapper around various text markups" 8 | readme = "README.rst" 9 | authors = [{name = "Dmitry Shachnev", email = "mitya57@gmail.com"}] 10 | license = "BSD-3-Clause" 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Operating System :: OS Independent", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Topic :: Text Processing :: Markup", 22 | "Topic :: Text Processing :: General", 23 | "Topic :: Software Development :: Libraries :: Python Modules", 24 | ] 25 | requires-python = ">=3.10" 26 | dynamic = ["version"] 27 | 28 | [project.urls] 29 | Homepage = "https://github.com/retext-project/pymarkups" 30 | Documentation = "https://pymarkups.readthedocs.io/en/latest/" 31 | "Issue Tracker" = "https://github.com/retext-project/pymarkups/issues/" 32 | Changelog = "https://pymarkups.readthedocs.io/en/latest/changelog.html" 33 | 34 | [project.optional-dependencies] 35 | markdown = ["Markdown>=3", "PyYAML", "python-markdown-math"] 36 | restructuredtext = ["docutils"] 37 | textile = ["textile"] 38 | highlighting = ["Pygments"] 39 | asciidoc = ["asciidoc", "lxml"] 40 | 41 | [project.entry-points.pymarkups] 42 | markdown = "markups.markdown:MarkdownMarkup" 43 | restructuredtext = "markups.restructuredtext:ReStructuredTextMarkup" 44 | textile = "markups.textile:TextileMarkup" 45 | asciidoc = "markups.asciidoc:AsciiDocMarkup" 46 | 47 | [tool.setuptools] 48 | packages = ["markups"] 49 | include-package-data = false 50 | 51 | [tool.setuptools.package-data] 52 | markups = ["py.typed"] 53 | 54 | [tool.setuptools.dynamic] 55 | version = {attr = "markups.__version__"} 56 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/retext-project/pymarkups/ea173e23407f32b804cfb52b82cf1e548d7b8235/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_asciidoc.py: -------------------------------------------------------------------------------- 1 | # This file is part of python-markups test suite 2 | # License: 3-clause BSD, see LICENSE file 3 | # Copyright: (C) Dave Kuhlman, 2022 4 | 5 | import unittest 6 | 7 | from markups.asciidoc import AsciiDocMarkup 8 | 9 | 10 | @unittest.skipUnless( 11 | AsciiDocMarkup.available(), 12 | "asciidoc.py and/or lxml not available", 13 | ) 14 | class AsciiDocTextTest(unittest.TestCase): 15 | def test_basic(self) -> None: 16 | self.maxDiff = None 17 | markup = AsciiDocMarkup() 18 | converted = markup.convert(BASIC_TEXT) 19 | body = converted.get_document_body() 20 | title = converted.get_document_title() 21 | stylesheet = converted.get_stylesheet() 22 | title_expected = "Hello, world!" 23 | self.assertIn(CONTENT_PART_EXPECTED, body) 24 | self.assertEqual(title_expected, title) 25 | self.assertGreater(len(stylesheet), 100) 26 | 27 | def test_error_handling(self) -> None: 28 | markup = AsciiDocMarkup() 29 | with self.assertWarnsRegex( 30 | SyntaxWarning, 31 | "section title not allowed in list item", 32 | ): 33 | converted = markup.convert(INVALID_SYNTAX) 34 | self.assertIn("Foo", converted.get_document_body()) 35 | 36 | def test_unicode(self) -> None: 37 | markup = AsciiDocMarkup() 38 | converted = markup.convert("Тест") 39 | body = converted.get_document_body() 40 | self.assertIn("Тест", body) 41 | 42 | 43 | # 44 | # ================================================================= 45 | # 46 | # Data to be used for comparison of correct results. 47 | # 48 | 49 | BASIC_TEXT = """\ 50 | = Hello, world! 51 | :toc: 52 | 53 | == Some subtitle 54 | 55 | This is an example *asciidoc* document. 56 | """ 57 | 58 | CONTENT_PART_EXPECTED = """\ 59 |
60 |
61 |

Some subtitle

62 |
63 |

This is an example asciidoc document.

64 |
65 |
66 |
67 | """ # noqa: E501 68 | 69 | INVALID_SYNTAX = """\ 70 | - Foo 71 | + 72 | = Bar 73 | """ 74 | -------------------------------------------------------------------------------- /tests/test_markdown.py: -------------------------------------------------------------------------------- 1 | # This file is part of python-markups test suite 2 | # License: 3-clause BSD, see LICENSE file 3 | # Copyright: (C) Dmitry Shachnev, 2012-2023 4 | 5 | import importlib 6 | import unittest 7 | import warnings 8 | from os.path import join 9 | from tempfile import TemporaryDirectory 10 | 11 | from markups.markdown import MarkdownMarkup, _canonicalized_ext_names 12 | 13 | try: 14 | import pymdownx 15 | except ImportError: 16 | pymdownx = None 17 | 18 | try: 19 | importlib.import_module("yaml") 20 | HAVE_YAML = True 21 | except ImportError: 22 | HAVE_YAML = False 23 | 24 | tables_source = """th1 | th2 25 | --- | --- 26 | t11 | t21 27 | t12 | t22""" 28 | 29 | tables_output = """ 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
th1th2
t11t21
t12t22
47 | """ 48 | 49 | deflists_source = """Apple 50 | : Pomaceous fruit of plants of the genus Malus in 51 | the family Rosaceae. 52 | 53 | Orange 54 | : The fruit of an evergreen tree of the genus Citrus.""" 55 | 56 | deflists_output = """
57 |
Apple
58 |
Pomaceous fruit of plants of the genus Malus in 59 | the family Rosaceae.
60 |
Orange
61 |
The fruit of an evergreen tree of the genus Citrus.
62 |
63 | """ 64 | 65 | mathjax_header = "\n\n" 66 | 67 | mathjax_source = r"""$i_1$ some text \$escaped\$ $i_2$ 68 | 69 | \(\LaTeX\) \\(escaped\) 70 | 71 | $$m_1$$ text $$m_2$$ 72 | 73 | \[m_3\] text \[m_4\] 74 | 75 | \( \sin \alpha \) text \( \sin \beta \) 76 | 77 | \[ \alpha \] text \[ \beta \] 78 | 79 | \$$escaped\$$ \\[escaped\] 80 | """ 81 | 82 | mathjax_output = r"""

83 | some text $escaped$ 84 |

85 |

86 | \(escaped)

87 |

88 | text 89 |

90 |

91 | text 92 |

93 |

94 | text 95 |

96 |

97 | text 98 |

99 |

$$escaped$$ \[escaped]

100 | """ # noqa: E501 101 | 102 | mathjax_multiline_source = r""" 103 | $$ 104 | \TeX 105 | \LaTeX 106 | $$ 107 | """ 108 | 109 | mathjax_multiline_output = r"""

110 | 114 |

115 | """ 116 | 117 | mathjax_multilevel_source = r""" 118 | \begin{equation*} 119 | \begin{pmatrix} 120 | 1 & 0\\ 121 | 0 & 1 122 | \end{pmatrix} 123 | \end{equation*} 124 | """ 125 | 126 | mathjax_multilevel_output = r"""

127 | 133 |

134 | """ 135 | 136 | triple_backticks_in_list_source = """ 137 | 1. List item 1 138 | 139 | ```python 140 | import this 141 | ``` 142 | 143 | 2. List item 2 144 | """ 145 | 146 | 147 | @unittest.skipUnless(MarkdownMarkup.available(), "Markdown not available") 148 | class MarkdownTest(unittest.TestCase): 149 | maxDiff = None 150 | 151 | def setUp(self) -> None: 152 | warnings.simplefilter("ignore", Warning) 153 | 154 | def test_empty_file(self) -> None: 155 | markup = MarkdownMarkup() 156 | self.assertEqual(markup.convert("").get_document_body(), "\n") 157 | 158 | def test_extensions_loading(self) -> None: 159 | markup = MarkdownMarkup() 160 | self.assertIsNone(markup._canonicalize_extension_name("nonexistent")) 161 | self.assertIsNone( 162 | markup._canonicalize_extension_name("nonexistent(someoption)"), 163 | ) 164 | self.assertIsNone(markup._canonicalize_extension_name(".foobar")) 165 | self.assertEqual( 166 | markup._canonicalize_extension_name("meta"), 167 | "markdown.extensions.meta", 168 | ) 169 | name, parameters = markup._split_extension_config("toc(anchorlink=1, foo=bar)") 170 | self.assertEqual(name, "toc") 171 | self.assertEqual(parameters, {"anchorlink": "1", "foo": "bar"}) 172 | 173 | def test_loading_extensions_by_module_name(self) -> None: 174 | markup = MarkdownMarkup(extensions=["markdown.extensions.footnotes"]) 175 | source = ( 176 | "Footnotes[^1] have a label and the content.\n\n" 177 | "[^1]: This is a footnote content." 178 | ) 179 | html = markup.convert(source).get_document_body() 180 | self.assertIn(" None: 184 | markup = MarkdownMarkup( 185 | extensions=["remove_extra", "toc", "markdown.extensions.toc"], 186 | ) 187 | self.assertEqual(len(markup.extensions), 1) 188 | self.assertIn("markdown.extensions.toc", markup.extensions) 189 | 190 | def test_extensions_parameters(self) -> None: 191 | markup = MarkdownMarkup(extensions=["toc(anchorlink=1)"]) 192 | html = markup.convert("## Header").get_document_body() 193 | self.assertEqual( 194 | html, 195 | '\n', 196 | ) 197 | self.assertEqual(_canonicalized_ext_names["toc"], "markdown.extensions.toc") 198 | 199 | def test_document_extensions_parameters(self) -> None: 200 | markup = MarkdownMarkup(extensions=[]) 201 | toc_header = "\n\n" 202 | html = markup.convert(toc_header + "## Header").get_document_body() 203 | self.assertEqual( 204 | html, 205 | toc_header + '\n", 207 | ) 208 | toc_header = ( 209 | "\n\n" 211 | ) 212 | html = markup.convert( 213 | toc_header + "[TOC]\n\n# Header\n[[Link]]", 214 | ).get_document_body() 215 | self.assertEqual( 216 | html, 217 | toc_header + '
' 218 | 'Table of contents\n
\n" 221 | '\n' 222 | '

Link

\n', 223 | ) 224 | 225 | def test_document_extensions_change(self) -> None: 226 | """Extensions from document should be replaced on each run, not added.""" 227 | markup = MarkdownMarkup(extensions=[]) 228 | toc_header = "\n\n" 229 | content = "[TOC]\n\n# Header" 230 | html = markup.convert(toc_header + content).get_document_body() 231 | self.assertNotIn("

[TOC]

", html) 232 | html = markup.convert(content).get_document_body() 233 | self.assertIn("

[TOC]

", html) 234 | html = markup.convert(toc_header + content).get_document_body() 235 | self.assertNotIn("

[TOC]

", html) 236 | 237 | def test_extra(self) -> None: 238 | markup = MarkdownMarkup() 239 | html = markup.convert(tables_source).get_document_body() 240 | self.assertEqual(tables_output, html) 241 | html = markup.convert(deflists_source).get_document_body() 242 | self.assertEqual(deflists_output, html) 243 | 244 | def test_remove_extra(self) -> None: 245 | markup = MarkdownMarkup(extensions=["remove_extra"]) 246 | html = markup.convert(tables_source).get_document_body() 247 | self.assertNotIn("", html) 248 | 249 | def test_remove_extra_document_extension(self) -> None: 250 | markup = MarkdownMarkup(extensions=[]) 251 | html = markup.convert( 252 | "Required-Extensions: remove_extra\n\n" + tables_source, 253 | ).get_document_body() 254 | self.assertNotIn("
", html) 255 | 256 | def test_remove_extra_double(self) -> None: 257 | """Removing extra twice should not cause a crash.""" 258 | markup = MarkdownMarkup(extensions=["remove_extra"]) 259 | markup.convert("Required-Extensions: remove_extra\n") 260 | 261 | def test_remove_extra_removes_mathjax(self) -> None: 262 | markup = MarkdownMarkup(extensions=["remove_extra"]) 263 | html = markup.convert("$$1$$").get_document_body() 264 | self.assertNotIn("math/tex", html) 265 | 266 | def test_meta(self) -> None: 267 | markup = MarkdownMarkup() 268 | text = "Required-Extensions: meta\nTitle: Hello, world!\n\nSome text here." 269 | title = markup.convert(text).get_document_title() 270 | self.assertEqual("Hello, world!", title) 271 | 272 | def test_default_math(self) -> None: 273 | # by default $...$ delimiter should be disabled 274 | markup = MarkdownMarkup(extensions=[]) 275 | self.assertEqual("

$1$

\n", markup.convert("$1$").get_document_body()) 276 | self.assertEqual( 277 | '

\n\n

\n', 278 | markup.convert("$$1$$").get_document_body(), 279 | ) 280 | 281 | def test_mathjax(self) -> None: 282 | markup = MarkdownMarkup(extensions=["mathjax"]) 283 | # Escaping should work 284 | self.assertEqual("", markup.convert("Hello, \\$2+2$!").get_javascript()) 285 | js = markup.convert(mathjax_source).get_javascript() 286 | self.assertIn(" None: 291 | markup = MarkdownMarkup() 292 | text = mathjax_header + mathjax_source 293 | body = markup.convert(text).get_document_body() 294 | self.assertEqual(mathjax_header + mathjax_output, body) 295 | 296 | def test_mathjax_multiline(self) -> None: 297 | markup = MarkdownMarkup(extensions=["mathjax"]) 298 | body = markup.convert(mathjax_multiline_source).get_document_body() 299 | self.assertEqual(mathjax_multiline_output, body) 300 | 301 | def test_mathjax_multilevel(self) -> None: 302 | markup = MarkdownMarkup() 303 | body = markup.convert(mathjax_multilevel_source).get_document_body() 304 | self.assertEqual(mathjax_multilevel_output, body) 305 | 306 | def test_mathjax_asciimath(self) -> None: 307 | markup = MarkdownMarkup(extensions=["mdx_math(use_asciimath=1)"]) 308 | converted = markup.convert(r"\( [[a,b],[c,d]] \)") 309 | body = converted.get_document_body() 310 | self.assertIn('