├── .gitignore ├── .idea ├── .gitignore ├── codeStyles │ └── codeStyleConfig.xml └── inspectionProfiles │ └── Project_Default.xml ├── LICENSE ├── README.md ├── contributors.md ├── examples └── talkpython │ └── README.md ├── extensibility.md ├── markdown_subtemplate ├── __init__.py ├── caching │ ├── __init__.py │ ├── cache_entry.py │ ├── memory_cache.py │ └── subtemplate_cache.py ├── engine.py ├── exceptions.py ├── infrastructure │ ├── __init__.py │ ├── markdown_transformer.py │ └── page.py ├── logging │ ├── __init__.py │ ├── log_level.py │ ├── null_logger.py │ ├── stdout_logger.py │ └── subtemplate_logger.py └── storage │ ├── __init__.py │ ├── file_storage.py │ └── subtemplate_storage.py ├── readme_resources ├── mitlicense.svg ├── workflow_image_layout.png └── workflow_image_layout.pptx ├── requirements-dev.txt ├── requirements.txt ├── setup.py ├── tests ├── _all_tests.py ├── engine_tests.py ├── logging_tests.py ├── page_tests.py └── templates │ ├── _shared │ ├── basic_import.md │ ├── nested_import.md │ ├── replacements.md │ └── second_import.md │ └── home │ ├── basic_markdown.md │ ├── empty_markdown.md │ ├── import1.md │ ├── import_missing.md │ ├── import_nested.md │ ├── markdown_with_html.md │ ├── replacements_case_error.md │ ├── replacements_import.md │ ├── two_imports.md │ └── variables.md └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .idea/$CACHE_FILE$ 131 | .idea/markdown-subtemplate.iml 132 | .idea/misc.xml 133 | .idea/modules.xml 134 | .idea/vcs.xml 135 | .idea/dictionaries/mkennedy.xml 136 | .idea/inspectionProfiles/profiles_settings.xml 137 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /workspace.xml -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Michael Kennedy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # markdown-subtemplate 2 | [![](https://img.shields.io/badge/python-3.6+-blue.svg)](https://www.python.org/downloads/) 3 | [![](https://img.shields.io/pypi/l/markdown-subtemplate.svg)](https://github.com/mikeckennedy/markdown-subtemplate/blob/master/LICENSE) 4 | [![](https://img.shields.io/pypi/dm/markdown-subtemplate.svg)](https://pypi.org/project/markdown-subtemplate/) 5 | 6 | A template engine to render Markdown with external template imports and basic variable replacements. 7 | 8 | ## Motivation 9 | 10 | We often make a choice between data-driven server apps (typical Flask app), CMSes that let us edit content on the web such as WordPress, and even flat file systems like Pelican. 11 | 12 | These are presented as an either-or. You either get a full database driven app or you get a CMS, but not both. This project is meant to help add CMS like features to your data-driven web apps and even author them as static markdown files. 13 | 14 | Here's how it works: 15 | 16 | 1. You write standard markdown content. 17 | 2. Markdown content can be shared and imported into your top-level markdown. 18 | 3. Fragments of HTML can be used when css classes and other specializations are needed, but generally HTML is avoided. 19 | 4. A dictionary of variables and their values to replace in the merged markdown is processed. 20 | 5. Markdown content is converted to HTML and embedded in your larger site layout (e.g. within a Jinja2 template). 21 | 6. Markdown transforms are cached to achieve very high performance regardless of the complexity of the content. 22 | 23 | ## Standard workflow 24 | 25 | Write markdown content, merge it with other markdown files, deliver it as HTML as part of your larger site. 26 | 27 | ![](https://raw.githubusercontent.com/mikeckennedy/markdown-subtemplate/master/readme_resources/workflow_image_layout.png) 28 | 29 | ## Usage 30 | 31 | To use the library, simply install it. 32 | 33 | ```bash 34 | pip3 install markdown-subtemplate 35 | ``` 36 | 37 | Next, write a markdown template, `page.md`: 38 | 39 | ```markdown 40 | ## This is a sub-title 41 | 42 | * Here's an entry 43 | * And another 44 | ``` 45 | 46 | Register the template engine in your web app startup: 47 | 48 | ```python 49 | from markdown_subtemplate import engine 50 | # Set the template folder so that when you ask for page.md 51 | # the system knows where to look. 52 | 53 | engine.set_template_folder(full_path_to_template_folder) 54 | ``` 55 | 56 | Then generate the HTML content via: 57 | 58 | ```python 59 | data = {'variable1': 'Value 1', 'variable2': 'Value 2'} 60 | contents = engine.get_page('page.md', data) 61 | ``` 62 | 63 | Finally, pass the HTML fragment to be rendered in the larger page context: 64 | 65 | ```python 66 | # A Pyramid view method: 67 | 68 | @view_config(route_name='landing', renderer='landing.pt') 69 | def landing(request): 70 | data = {'variable1': 'Value 1', 'variable2': 'Value 2'} 71 | contents = engine.get_page('page.md', data) 72 | 73 | return { 74 | 'name': 'Project name', 75 | 'contents': contents 76 | } 77 | ``` 78 | 79 | And the larger website template grabs the content and renders it, `landing.pt`: 80 | 81 | ```html 82 | ... 83 |
84 | ${structure:contents} 85 |
86 | ... 87 | ``` 88 | 89 | ## Beware the danger! 90 | 91 | This library is meant for INTERNAL usage only. It's to help you add CMS features to your app. It is **not** for taking user input and making a forum or something like that. 92 | 93 | To allow for the greatest control, you can embed small fragments of HTML in the markdown (e.g. to add a CSS class or other actions). This means the markdown is processed in **UNSAFE** mode. It would allow for script injection attacks if opened to the public. 94 | 95 | ## Extensibility 96 | 97 | `markdown-subtemplate` has three axis of extensibility: 98 | 99 | * **Storage** - Load markdown content from disk, db, or elsewhere. 100 | * **Caching** - Cache generated markdown and HTML in memory, DB, or you pick! 101 | * **Logging** - If you are using a logging framework, plug in logging messages from the library. 102 | 103 | See the [extensibility doc](https://github.com/mikeckennedy/markdown-subtemplate/blob/master/extensibility.md) for details and examples. 104 | 105 | 106 | ## Nested markdown 107 | 108 | One of the reason's this project exists, rather than just passing markdown text to a markdown library is allowing nesting / importing of markdown files. 109 | 110 | If you have page fragments that need to appear more than once, create a dedicated markdown import file that can be managed and versioned in one place. Here's how: 111 | 112 | ### Created an imported file in TEMPLATES/_shared 113 | 114 | All imported markdown files are located in subpaths of `TEMPLATES/_shared` where `TEMPLATES` is the path you set during startup. 115 | 116 | ``` 117 | TEMPLATES 118 | |- _shared 119 | |- contact.md 120 | |- footer.md 121 | |-pages 122 | | - page.md 123 | | - about.md 124 | ``` 125 | 126 | Write the imported / shared markdown, `contact.md`: 127 | 128 | ```markdown 129 | Contact us via email [us@us.com](mailto:us.com) or on 130 | Twitter via [@us](https://twitter.com/us) 131 | ``` 132 | 133 | Then in your page, e.g. `page.md` you can add an import statement: 134 | 135 | ```markdown 136 | # Our amazing page 137 | 138 | Here is some info **about the page**. It's standard markdown. 139 | 140 | Want to contact us? Here are some options: 141 | [IMPORT CONTACT] 142 | 143 | And a footer: 144 | [IMPORT FOOTER] 145 | ``` 146 | 147 | The resulting markdown is just replacing the `IMPORT` statements with the contents of those files, then passing the whole thing through a markdown to HTML processor. 148 | 149 | ## Variables 150 | 151 | `markdown_subtemplate` has some limited support for variable replacements. Given this markdown page: 152 | 153 | ```markdown 154 | # Example: $TITLE$ 155 | 156 | Welcome to the $PROJECT$ project. Here are some details 157 | ... 158 | ``` 159 | 160 | You can populate the variable values with: 161 | 162 | ```python 163 | data = {'title': 'Markdown Transformers', 'project': 'sub templates'} 164 | contents = engine.get_page('page.md', data) 165 | ``` 166 | 167 | Note that the variable names must be all-caps in the template. Missing variable statements in markdown that appear in the data dictionary are ignored. 168 | 169 | ## Variables within nested templates 170 | 171 | One problem you might run into is you have some reusable section, like this: 172 | 173 | ``` 174 | // shared_promo.md 175 | 176 | We are running a special! Click here to see the featured item: 177 | 178 | [Our latest project, 50% off](/markdown-editor?utm_source={SOMETHING_FROM_THE_PAGE}) 179 | ``` 180 | 181 | It's generally reusable, but you want that `SOMETHING_FROM_THE_PAGE` to vary depending on which page they are on. 182 | You could do this in Python code but that would be out of band and actually a little hard to do. So you can 183 | now define hierarchical variables in markdown. 184 | 185 | Define nested variables as follows with `name=VARNAME` and `value=var-value`: 186 | 187 | ``` 188 | [VARIABLE VARNAME="var-value"] 189 | ``` 190 | 191 | In the following page, we choose a `source` and reuse this shared template: 192 | 193 | ``` 194 | // top-level.md 195 | 196 | # Welcome to the page 197 | 198 | Page content here... 199 | 200 | [VARIABLE SOMETHING_FROM_THE_PAGE="editor-youtube-landing-page"] 201 | [IMPORT shared_promo] 202 | ``` 203 | 204 | The result would be effective this markdown, then turned into HTML: 205 | 206 | ```markdown 207 | # Welcome to the page 208 | 209 | Page content here... 210 | 211 | We are running a special! Click here to see the featured item: 212 | 213 | [Our latest project, 50% off](/markdown-editor?utm_source=editor-youtube-landing-page) 214 | ``` 215 | 216 | Just like regular variables, they are enclosed in `{` and `}` and are upper case where they are used. 217 | 218 | 219 | 220 | ## Requirements 221 | 222 | This library requires **Python 3.6 or higher**. Because, *f-yes*! (f-strings). 223 | 224 | ## Licence 225 | 226 | `markdown-subtemplate` is distributed under the MIT license. 227 | 228 | ## Authors 229 | 230 | `markdown_subtemplate` was written by [Michael Kennedy](https://github.com/mikeckennedy). 231 | -------------------------------------------------------------------------------- /contributors.md: -------------------------------------------------------------------------------- 1 | # markdown-subtemplate Contributors 2 | 3 | ## Authors 4 | 5 | [Michael Kennedy](https://github.com/mikeckennedy) 6 | 7 | ## Contributors 8 | 9 | [RhinoCodes](https://github.com/RhinoCodes) 10 | 11 | -------------------------------------------------------------------------------- /examples/talkpython/README.md: -------------------------------------------------------------------------------- 1 | # Talk Python Training Example 2 | 3 | This project was extracted from an internal CMS built to power [Talk Python Training](https://training.talkpython.fm/)'s landing pages, for example, [this one](https://training.talkpython.fm/courses/explore_100days_web/100-days-of-web-in-python). 4 | 5 | We have lots of sections on our landing pages that are repeated. Hence the ability to import them into the landing page is very helpful. If you visit the page: 6 | 7 | [https://training.talkpython.fm/courses/explore_100days_web/100-days-of-web-in-python](https://training.talkpython.fm/courses/explore_100days_web/100-days-of-web-in-python) 8 | 9 | The sections from **What's this course about and how is it different?** to **The time to act is now** are generated with content similar to below. 10 | 11 | ## 100-web.md (top level page) 12 | 13 | ```markdown 14 | ## What's this course about and how is it different? 15 | 16 | 100 days of code isn’t just about the time commitment. 17 | **The true power is in utilising that time effectively 18 | with a tailored set of projects**. That’s why we have 24 19 | practical projects, each paired with 20-60 minute video 20 | lessons at the beginning of the project. 21 | 22 | Just a small sampling of the projects you’ll work on include: 23 | 24 | * Create your very own Static Site 25 | * ... 26 | * Web Scraping with BeautifulSoup4 and newspaper3k 27 | * And 17 more projects! 28 | 29 | View the full [course outline](#course_outline). 30 | ... 31 | 32 | ## Who is this course for? 33 | 34 | This course is for **anyone who knows the basics of Python and 35 | wants to push themselves into the world of Python Web Development 36 | for 100 days with hands-on projects**. 37 | ... 38 | 39 | [IMPORT TRANSCRIPTS] 40 | ## Who we are and why should you take our course? 41 | 42 | **Meet Michael Kennedy:** 43 | 44 | [IMPORT MICHAEL] 45 | 46 | **Meet Bob Belderbos:** 47 | 48 | [IMPORT BOB_B] 49 | 50 | **Meet Julian Sequeira:** 51 | 52 | [IMPORT JULIAN_S] 53 | 54 | [IMPORT OFFICE_HOURS] 55 | 56 | [IMPORT PYTHON_3_STATEMENT] 57 | 58 | ## The time to act is now 59 | 60 | The **#100DaysOfCode** challenge is an epic adventure. 61 | Don't got it alone. Take our course and we'll 62 | be your guide with both lessons and projects. 63 | ``` 64 | 65 | 66 | ## transcripts.md (imported section) 67 | 68 | ```markdown 69 | ## Follow along with subtitles and transcripts 70 | 71 | Each course comes with subtitles and full transcripts. 72 | The transcripts are available as a separate searchable page for 73 | each lecture. They also are available in course-wide search results 74 | to help you find just the right lecture. 75 | 76 |

77 | Each course has subtitles available in the video player. 81 |

82 | ``` 83 | 84 | ## Explore course Chameleon template 85 | 86 | The template contents are show in the larger context of the site via: 87 | 88 | ```html 89 | ... explore_course.pt:[]() main contents, nav, etc. ... 90 |
91 | ${structure:contents} 92 |
93 | ``` 94 | 95 | These templates are rendered with `markdown_subtemplate` using this code: 96 | 97 | ```python 98 | # contents returned as part of the model/dictionary from Pyramid. 99 | contents = markdown_subtemplate.engine.get_page('100-web.md', {}) 100 | ``` 101 | 102 | ## Other details 103 | 104 | This site is using two custom extensibility points. First, we are using a wrapper around the logging to integrate with [logbook](https://logbook.readthedocs.io/en/stable/). Secondly, we have a custom cache provider that is backed by MongoDB rather than in-memory. This allows us to reset the cache and have effects across our scaled out web worker processes. 105 | 106 | -------------------------------------------------------------------------------- /extensibility.md: -------------------------------------------------------------------------------- 1 | # Extensibility 2 | 3 | `markdown-subtemplate` has three axis of extensibility: 4 | 5 | * **Storage** - Load markdown content from disk, db, or elsewhere. 6 | * **Caching** - Cache generated markdown and HTML in memory, DB, or you pick! 7 | * **Logging** - If you are using a logging framework, plug in logging messages from the library. 8 | 9 | ## Storage 10 | 11 | Out of the box, `markdown-subtemplate` will load markdown files from a structure directory: 12 | 13 | ``` 14 | TEMPLATE_FOLDER 15 | | 16 | |- shared 17 | | - contact.md 18 | | - social.md 19 | |- home # arbitrary organizing directories 20 | | - index.md # Use template_path: home/index.md 21 | | - about.md 22 | ``` 23 | 24 | This is implemented by the `markdown_subtemplate.storage.file_storage.FileStore` class. It must be configured as follows: 25 | 26 | ```python 27 | from markdown_subtemplate.storage.file_storage import FileStore 28 | folder = FULL_PATH_TO-TEMPLATE_FOLDER 29 | FileStore.set_template_folder(folder) 30 | ``` 31 | 32 | If you want to change the storage engine, just create a base class of `markdown_subtemplate.storage.SubtemplateStorage`. It's an abstract class so just implement the abstract methods. 33 | 34 | Here is an example from SQLAlchemy. Define a model to read/write data: 35 | 36 | ```python 37 | # SQLAlchemy entity class: 38 | class MarkdownPage(SqlAlchemyBase): 39 | __tablename__ = 'markdown_pages' 40 | 41 | id = sa.Column(sa.String, primary_key=True) 42 | name = sa.Column(sa.String, index=True) 43 | created_date = sa.Column(sa.DateTime, default=datetime.datetime.now) 44 | is_shared = sa.Column(sa.Boolean, index=True, default=False) 45 | text = sqlalchemy.Column(sa.String) 46 | ``` 47 | 48 | Then implement the storage engine class: 49 | 50 | ```python 51 | class MarkdownSubTemplateDBStorage(storage.SubtemplateStorage): 52 | def get_markdown_text(self, template_path: str) -> Optional[str]: 53 | if not template_path: 54 | return None 55 | 56 | template_path = template_path.strip().lower() 57 | session = DbSession.create() # Method to generate a SQLAlchemy session. 58 | 59 | mk: MarkdownPage = session.query(MarkdownPage) \ 60 | .filter(MarkdownPage.id == template_path) \ 61 | .first() 62 | 63 | session.close() 64 | 65 | if not mk: 66 | return None 67 | 68 | return mk.text 69 | 70 | def get_shared_markdown(self, import_name: str) -> Optional[str]: 71 | if not import_name: 72 | return None 73 | 74 | import_name = import_name.strip().lower() 75 | session = DbSession.create() 76 | 77 | mk: MarkdownPage = session.query(MarkdownPage) \ 78 | .filter(MarkdownPage.name == import_name, MarkdownPage.is_shared == True) \ 79 | .first() 80 | 81 | session.close() 82 | 83 | if not mk: 84 | return None 85 | 86 | return mk.text 87 | 88 | def is_initialized(self) -> bool: 89 | # Check whether connection string, etc set in SQLAlchemy 90 | return True 91 | 92 | def clear_settings(self): 93 | pass 94 | ``` 95 | 96 | Of course, you'll need a way to enter these into the DB but that's technically outside of the content of this discussion. 97 | 98 | Finally, you'll need to set this storage engine as the implementation at process startup: 99 | 100 | ```python 101 | from markdown_subtemplate import storage 102 | 103 | store = MarkdownSubTemplateDBStorage() 104 | storage.set_storage(store) 105 | ``` 106 | 107 | ## Caching 108 | 109 | By default, `markdown-subtemplate` will cache generated markdown and HTML in memory. This often is fine. If you do nothing, this will happen automatically and your page generation will be much faster if you reuse content or request it more than once. 110 | 111 | But web environments typically have many processes serving their content. For example, at [Talk Python Training](https://training.talkpython.fm/) we currently have 8-10 uWSGI worker processes running in parallel. 112 | 113 | In this situation, caching all the content in memory has a few drawbacks. 114 | * All content is cached in memory 10x what it would normally cost. 115 | * Content that has to be generated, which can be much slower, is done 10x as often. 116 | * Restarting the server for a new version of code requires everything to be regenerated 10x again making startup slow. 117 | * Clearing the cache, if wanted, is effectively impossible (how to you cleanly signal all 10 processes exactly once?) 118 | 119 | In these situations, storing the cache content in a database or Redis would be better. At Talk Python, we use MongoDB as the backing cache store. 120 | 121 | Below are two examples. They follow the pattern: 122 | 123 | 1. Create an entity to store in the DB for cache data 124 | 2. Create a base class of `markdown_subtemplate.caching.SubtemplateCache` 125 | 3. Override the abstract methods 126 | 4. Register your caching engine with `markdown-subtemplate` at startup. 127 | 128 | ### SQLAlchemy Cachine Engine: 129 | 130 | **First, create the entity to store in the DB**. 131 | 132 | ```python 133 | class MarkdownCache(SqlAlchemyBase): 134 | __tablename__ = 'markdown_cache' 135 | 136 | id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) 137 | key = sa.Column(sa.String, index=True) 138 | type = sa.Column(sa.String, index=True) 139 | name = sa.Column(sa.String, index=True) 140 | contents = sa.Column(sa.String) 141 | 142 | created_date = sa.Column(sa.DateTime, default=datetime.datetime.now, index=True) 143 | ``` 144 | 145 | **Second, implement the caching engine**: 146 | 147 | ```python 148 | from markdown_subtemplate import caching 149 | class MarkdownSubTemplateDBCache(caching.SubtemplateCache): 150 | def get_html(self, key: str) -> caching.CacheEntry: 151 | session = DbSession.create() 152 | 153 | cache_entry = session.query(MarkdownCache).filter( 154 | MarkdownCache.key == key, MarkdownCache.type == 'html' 155 | ).first() 156 | 157 | session.close() 158 | 159 | return cache_entry 160 | 161 | def add_html(self, key: str, name: str, html_contents: str) -> caching.CacheEntry: 162 | session = DbSession.create() 163 | 164 | item = self.get_html(key) 165 | if not item: 166 | item = MarkdownCache() 167 | session.add(item) 168 | 169 | item.type = 'html' 170 | item.key = key 171 | item.name = name 172 | item.contents = html_contents 173 | 174 | if html_contents: 175 | session.commit() 176 | 177 | session.close() 178 | 179 | # Not technical a base class, but duck-type equivalent. 180 | # noinspection PyTypeChecker 181 | return item 182 | 183 | def get_markdown(self, key: str) -> caching.CacheEntry: 184 | session = DbSession.create() 185 | 186 | cache_entry = session.query(MarkdownCache).filter( 187 | MarkdownCache.key == key, MarkdownCache.type == 'markdown' 188 | ).first() 189 | 190 | session.close() 191 | 192 | return cache_entry 193 | 194 | def add_markdown(self, key: str, name: str, markdown_contents: str) -> caching.CacheEntry: 195 | session = DbSession.create() 196 | 197 | item = self.get_markdown(key) 198 | if not item: 199 | item = MarkdownCache() 200 | session.add(item) 201 | 202 | item.type = 'markdown' 203 | item.key = key 204 | item.name = name 205 | item.contents = markdown_contents 206 | 207 | if markdown_contents: 208 | session.commit() 209 | session.close() 210 | 211 | # Not technical a base class, but duck-type equivalent. 212 | # noinspection PyTypeChecker 213 | return item 214 | 215 | def clear(self): 216 | session = DbSession.create() 217 | 218 | for entry in session.query(MarkdownCache): 219 | session.delete(entry) 220 | 221 | session.commit() 222 | 223 | def count(self) -> int: 224 | session = DbSession.create() 225 | count = session.query(MarkdownCache).count() 226 | session.close() 227 | 228 | return count 229 | ``` 230 | 231 | One minor oddity is the return value is `caching.CacheEntry` whereas that's not the real return value. But the SQLAlchemy entity does implement every field that is present in `CacheEntry`, so duck typing and all that. 232 | 233 | **Finally, register the new caching engine at process startup**. 234 | 235 | ```python 236 | from markdown_subtemplate import caching 237 | 238 | cache = MarkdownSubTemplateDBCache() 239 | caching.set_cache(cache) 240 | ``` 241 | 242 | ### MongoDB Cachine Engine: 243 | 244 | Using MongoDB as a backing store for the cache is basically the same as SQLAlchemy in principle. We'll be using MongoEngine. 245 | 246 | **First, create the entity to store in the DB**. 247 | 248 | ```python 249 | import mongoengine as me 250 | 251 | class CmsCache(me.Document): 252 | key: str = me.StringField(required=True) 253 | type: str = me.StringField(required=True) 254 | name: str = me.StringField() 255 | contents: str = me.StringField(required=True) 256 | created_date: datetime.datetime = me.DateTimeField(default=datetime.now) 257 | 258 | meta = { 259 | 'db_alias': 'core', 260 | 'collection': 'cms_cache', 261 | 'indexes': [ 262 | {'fields': ['key', 'type']}, 263 | 'key', 264 | 'type', 265 | 'name', 266 | 'created_date', 267 | ], 268 | 'ordering': ['-created_date'] 269 | } 270 | ``` 271 | 272 | **Second, implement the caching engine**: 273 | 274 | ```python 275 | class MarkdownSubTemplateMongoDBCache(SubtemplateCache): 276 | def get_html(self, key: str) -> CacheEntry: 277 | return CmsCache.objects(key=key, type='html').first() 278 | 279 | def add_html(self, key: str, name: str, html_contents: str) -> CacheEntry: 280 | item = self.get_html(key) 281 | if item: 282 | return item 283 | 284 | item = CmsCache() 285 | item.type = 'html' 286 | item.key = key 287 | item.name = name 288 | item.contents = html_contents 289 | item.save() 290 | 291 | # Not technical a base class, but duck-type equivalent. 292 | # noinspection PyTypeChecker 293 | return item 294 | 295 | def get_markdown(self, key: str) -> CacheEntry: 296 | return CmsCache.objects(key=key, type='markdown').first() 297 | 298 | def add_markdown(self, key: str, name: str, markdown_contents: str) -> CacheEntry: 299 | item = self.get_markdown(key) 300 | if item: 301 | return item 302 | 303 | item = CmsCache() 304 | item.type = 'markdown' 305 | item.key = key 306 | item.name = name 307 | item.contents = markdown_contents 308 | item.save() 309 | 310 | # Not technical a base class, but duck-type equivalent. 311 | # noinspection PyTypeChecker 312 | return item 313 | 314 | def clear(self): 315 | CmsCache.objects().delete() 316 | 317 | def count(self) -> int: 318 | return CmsCache.objects().count() 319 | ``` 320 | 321 | One minor oddity is the return value is `caching.CacheEntry` whereas that's not the real return value. But the MongoEngine entity does implement every field that is present in `CacheEntry`, so duck typing and all that. 322 | 323 | **Finally, register the new caching engine at process startup**. 324 | 325 | ```python 326 | from markdown_subtemplate import caching 327 | 328 | cache = MarkdownSubTemplateMongoDBCache() 329 | caching.set_cache(cache) 330 | ``` 331 | 332 | 333 | ## Logging 334 | 335 | By default, `markdown-subtemplate` will log to standard out using `print()` and log level `INFO` from the builtin `StdOutLogger` class. 336 | 337 | You can change the log level by using the `markdown_subtemplate.logging.LogLevel` class: 338 | 339 | ```python 340 | # Change logging level from default LogLevel.info to LogLevel.error 341 | 342 | log = logging.get_log() 343 | log.log_level = LogLevel.error 344 | ``` 345 | 346 | You can disable logging by setting its level to `LogLevel.off`. 347 | 348 | If you use a logging framework, you likely want to direct log messages through that framework. So you can, like the above two subsystems, implement a class based on an abstract base class. 349 | 350 | Let's log through a preconfigured [Logbook](https://logbook.readthedocs.io/en/stable/) setup (which goes to stdout in dev and rotating files in prod). 351 | 352 | **First, create a base class**: 353 | 354 | ```python 355 | import logbook 356 | import markdown_subtemplate 357 | from markdown_subtemplate.logging import LogLevel 358 | 359 | class MarkdownLogger(markdown_subtemplate.logging.SubtemplateLogger): 360 | def __init__(self, log_level: int): 361 | super().__init__(log_level) 362 | self.logbook_logger = logbook.Logger("Markdown Templates") 363 | 364 | def verbose(self, text: str): 365 | if not self.should_log(LogLevel.verbose, text): 366 | return 367 | 368 | self.logbook_logger.trace(text) 369 | 370 | def trace(self, text: str): 371 | if not self.should_log(LogLevel.trace, text): 372 | return 373 | 374 | self.logbook_logger.trace(text) 375 | 376 | def info(self, text: str): 377 | if not self.should_log(LogLevel.info, text): 378 | return 379 | 380 | self.logbook_logger.info(text) 381 | 382 | def error(self, text: str): 383 | if not self.should_log(LogLevel.error, text): 384 | return 385 | 386 | self.logbook_logger.error(text) 387 | ``` 388 | 389 | That's pretty straightforward. But do be sure to check at each level whether you should log as we do with: 390 | 391 | ```python 392 | if not self.should_log(LEVEL, text): 393 | return 394 | ``` 395 | 396 | Finally, register this logging engine at process startup: 397 | 398 | ```python 399 | From markdown_subtemplate import logging 400 | 401 | log = MarkdownLogger(LogLevel.info) # Set the level you want 402 | logging.set_log(log) 403 | ``` 404 | -------------------------------------------------------------------------------- /markdown_subtemplate/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | markdown_subtemplate - A template engine to render 3 | Markdown with external template imports and variable replacements. 4 | """ 5 | 6 | __version__ = '0.2.22' 7 | __author__ = 'Michael Kennedy ' 8 | __all__ = [] 9 | 10 | from . import caching 11 | from . import engine 12 | from . import exceptions 13 | from . import logging 14 | from . import storage 15 | from .infrastructure import markdown_transformer 16 | -------------------------------------------------------------------------------- /markdown_subtemplate/caching/__init__.py: -------------------------------------------------------------------------------- 1 | from .cache_entry import CacheEntry 2 | from .memory_cache import MemoryCache 3 | from .subtemplate_cache import SubtemplateCache 4 | from ..exceptions import ArgumentExpectedException 5 | 6 | __cache: SubtemplateCache = MemoryCache() 7 | 8 | 9 | def set_cache(cache_instance: SubtemplateCache): 10 | global __cache 11 | if not cache_instance or not isinstance(cache_instance, SubtemplateCache): 12 | raise ArgumentExpectedException('cache_instance') 13 | 14 | __cache = cache_instance 15 | 16 | 17 | def get_cache() -> SubtemplateCache: 18 | return __cache 19 | -------------------------------------------------------------------------------- /markdown_subtemplate/caching/cache_entry.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | CacheEntry = namedtuple("CacheEntry", "key, name, created, contents") 4 | -------------------------------------------------------------------------------- /markdown_subtemplate/caching/memory_cache.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from .cache_entry import CacheEntry 4 | from .subtemplate_cache import SubtemplateCache 5 | 6 | 7 | class MemoryCache(SubtemplateCache): 8 | markdown_cache = {} 9 | html_cache = {} 10 | 11 | def get_html(self, key: str) -> CacheEntry: 12 | return self.html_cache.get(key) 13 | 14 | def add_html(self, key: str, name: str, html_contents: str) -> CacheEntry: 15 | entry = CacheEntry(key=key, name=name, created=datetime.now(), contents=html_contents) 16 | self.html_cache[key] = entry 17 | 18 | return entry 19 | 20 | def get_markdown(self, key: str) -> CacheEntry: 21 | return self.markdown_cache.get(key) 22 | 23 | def add_markdown(self, key: str, name: str, markdown_contents: str) -> CacheEntry: 24 | entry = CacheEntry(key=key, name=name, created=datetime.now(), contents=markdown_contents) 25 | self.markdown_cache[key] = entry 26 | 27 | return entry 28 | 29 | def clear(self): 30 | self.markdown_cache.clear() 31 | self.html_cache.clear() 32 | 33 | def count(self) -> int: 34 | return len(self.markdown_cache) + len(self.html_cache) 35 | -------------------------------------------------------------------------------- /markdown_subtemplate/caching/subtemplate_cache.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Optional 3 | 4 | from .cache_entry import CacheEntry 5 | 6 | 7 | class SubtemplateCache(abc.ABC): 8 | @abc.abstractmethod 9 | def get_html(self, key: str) -> CacheEntry: 10 | pass 11 | 12 | @abc.abstractmethod 13 | def add_html(self, key: str, name: str, html_contents: str) -> CacheEntry: 14 | pass 15 | 16 | @abc.abstractmethod 17 | def get_markdown(self, key: str) -> CacheEntry: 18 | pass 19 | 20 | @abc.abstractmethod 21 | def add_markdown(self, key: str, name: str, markdown_contents: str) -> CacheEntry: 22 | pass 23 | 24 | @abc.abstractmethod 25 | def clear(self): 26 | pass 27 | 28 | @abc.abstractmethod 29 | def count(self) -> int: 30 | pass 31 | 32 | 33 | -------------------------------------------------------------------------------- /markdown_subtemplate/engine.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from . import caching as __caching, storage 4 | from . import logging as __logging 5 | from .infrastructure import page as __page 6 | 7 | 8 | def get_page(template_path: str, data: Dict[str, Any] = {}) -> str: 9 | from markdown_subtemplate.exceptions import InvalidOperationException 10 | log = __logging.get_log() 11 | 12 | if not storage.is_initialized(): 13 | msg = "Storage engine is not initialized." 14 | log.error("engine.get_page: " + msg) 15 | raise InvalidOperationException(msg) 16 | 17 | log.verbose(f"engine.get_page: Getting page content for {template_path}") 18 | return __page.get_page(template_path, data) 19 | 20 | 21 | def clear_cache(): 22 | log = __logging.get_log() 23 | cache = __caching.get_cache() 24 | 25 | item_count = cache.count() 26 | cache.clear() 27 | 28 | log.info(f"engine.clear_cache: Cache cleared, reclaimed {item_count:,} items.") 29 | 30 | -------------------------------------------------------------------------------- /markdown_subtemplate/exceptions.py: -------------------------------------------------------------------------------- 1 | class MarkdownTemplateException(Exception): 2 | pass 3 | 4 | 5 | class ArgumentExpectedException(MarkdownTemplateException): 6 | pass 7 | 8 | 9 | class InvalidOperationException(MarkdownTemplateException): 10 | pass 11 | 12 | 13 | class PathException(MarkdownTemplateException): 14 | pass 15 | 16 | 17 | class TemplateNotFoundException(PathException): 18 | def __init__(self, template_path): 19 | super().__init__(f'Template not found: {template_path}.') 20 | 21 | 22 | -------------------------------------------------------------------------------- /markdown_subtemplate/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeckennedy/markdown-subtemplate/138f46c433b641ad18882da6d3f232f9736d9a32/markdown_subtemplate/infrastructure/__init__.py -------------------------------------------------------------------------------- /markdown_subtemplate/infrastructure/markdown_transformer.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | import markdown2 4 | from .. import caching as __caching 5 | 6 | # Note: Do NOT enable link-patterns, it causes a crash. 7 | __enabled_markdown_extras = [ 8 | "cuddled-lists", 9 | "code-friendly", 10 | "fenced-code-blocks", 11 | "tables" 12 | ] 13 | 14 | 15 | def transform(text, safe_mode=True): 16 | if not text: 17 | return text 18 | 19 | hash_val = get_hash(text) 20 | 21 | cache = __caching.get_cache() 22 | entry = cache.get_html(hash_val) 23 | if entry: 24 | return entry.contents 25 | 26 | html = markdown2.markdown(text, extras=__enabled_markdown_extras, safe_mode=safe_mode) 27 | cache.add_html(hash_val, f"markdown_transformer:{hash_val}", html) 28 | 29 | return html 30 | 31 | 32 | def get_hash(text): 33 | md5 = hashlib.md5() 34 | data = text.encode('utf-8') 35 | md5.update(data) 36 | 37 | return md5.hexdigest() 38 | -------------------------------------------------------------------------------- /markdown_subtemplate/infrastructure/page.py: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | from typing import Dict, Optional, Any, List 4 | 5 | from markdown_subtemplate import caching as __caching 6 | from markdown_subtemplate.infrastructure import markdown_transformer 7 | from markdown_subtemplate.exceptions import ArgumentExpectedException, TemplateNotFoundException 8 | from markdown_subtemplate import logging as __logging 9 | import markdown_subtemplate.storage as __storage 10 | from markdown_subtemplate.logging import SubtemplateLogger 11 | from markdown_subtemplate.storage import SubtemplateStorage 12 | 13 | 14 | # noinspection DuplicatedCode 15 | def get_page(template_path: str, data: Dict[str, Any]) -> str: 16 | if not template_path or not template_path.strip(): 17 | raise ArgumentExpectedException('template_path') 18 | 19 | template_path = template_path.strip().lower() 20 | 21 | cache = __caching.get_cache() 22 | log = __logging.get_log() 23 | 24 | key = f'html: {template_path}' 25 | entry = cache.get_html(key) 26 | if entry: 27 | log.trace(f"CACHE HIT: Reusing {template_path} from HTML cache.") 28 | contents = entry.contents 29 | 30 | # Is there data that needs to be folded in? Process it. 31 | if data: 32 | contents = process_variables(contents, data) 33 | 34 | # Return the cached data, no need to transform for variables. 35 | return contents 36 | 37 | t0 = datetime.datetime.now() 38 | 39 | # Get the markdown with imports and substitutions 40 | markdown = get_markdown(template_path) 41 | inline_variables = {} 42 | markdown = get_inline_variables(markdown, inline_variables, log) 43 | # Convert markdown to HTML 44 | html = get_html(markdown) 45 | 46 | # Cache inline variables, but not the passed in data as that varies per request (query string, etc). 47 | html = process_variables(html, inline_variables) 48 | cache.add_html(key, key, html) 49 | 50 | # Replace the passed variables each time. 51 | html = process_variables(html, data) 52 | 53 | dt = datetime.datetime.now() - t0 54 | 55 | msg = f"Created contents for {template_path}:{data} in {int(dt.total_seconds() * 1000):,} ms." 56 | log.info(f"GENERATING HTML: {msg}") 57 | 58 | return html 59 | 60 | 61 | def get_html(markdown_text: str, unsafe_data=False) -> str: 62 | html = markdown_transformer.transform(markdown_text, unsafe_data) 63 | return html 64 | 65 | 66 | # noinspection DuplicatedCode 67 | def get_markdown(template_path: str, data: Dict[str, Any] = None) -> str: 68 | if data is None: 69 | data = {} 70 | 71 | cache = __caching.get_cache() 72 | log = __logging.get_log() 73 | 74 | key = f'markdown: {template_path}' 75 | entry = cache.get_markdown(key) 76 | if entry: 77 | log.trace(f"CACHE HIT: Reusing {template_path} from MARKDOWN cache.") 78 | if not data: 79 | return entry.contents 80 | else: 81 | return process_variables(entry.contents, data) 82 | 83 | t0 = datetime.datetime.now() 84 | 85 | text = load_markdown_contents(template_path) 86 | cache.add_markdown(key, key, text) 87 | if data: 88 | text = process_variables(text, data) 89 | 90 | dt = datetime.datetime.now() - t0 91 | 92 | msg = f"Created contents for {template_path} in {int(dt.total_seconds() * 1000):,} ms." 93 | log.trace(f"GENERATING MARKDOWN: {msg}") 94 | 95 | return text 96 | 97 | 98 | def load_markdown_contents(template_path: str) -> Optional[str]: 99 | if not template_path: 100 | return '' 101 | 102 | log = __logging.get_log() 103 | log.verbose(f"Loading markdown template: {template_path}") 104 | 105 | page_md = get_page_markdown(template_path) 106 | if not page_md: 107 | return '' 108 | 109 | lines = page_md.split('\n') 110 | lines = process_imports(lines) 111 | 112 | final_markdown = "\n".join(lines).strip() 113 | 114 | return final_markdown 115 | 116 | 117 | def get_page_markdown(template_path: str) -> Optional[str]: 118 | if not template_path or not template_path.strip(): 119 | raise TemplateNotFoundException("No template file specified: template_path=''.") 120 | 121 | store: SubtemplateStorage = __storage.get_storage() 122 | return store.get_markdown_text(template_path) 123 | 124 | 125 | def get_shared_markdown(import_name: str) -> Optional[str]: 126 | if not import_name or not import_name.strip(): 127 | raise ArgumentExpectedException('import_name') 128 | 129 | store: SubtemplateStorage = __storage.get_storage() 130 | return store.get_shared_markdown(import_name) 131 | 132 | 133 | def process_imports(lines: List[str]) -> List[str]: 134 | log = __logging.get_log() 135 | line_data = list(lines) 136 | 137 | for idx, line in enumerate(line_data): 138 | if not line.strip().startswith('[IMPORT '): 139 | continue 140 | 141 | import_statement = line.strip() 142 | import_name = import_statement \ 143 | .replace('[IMPORT ', '') \ 144 | .replace(']', '') \ 145 | .strip() 146 | 147 | log.verbose(f"Loading import: {import_name}...") 148 | 149 | markdown = get_shared_markdown(import_name) 150 | if markdown is not None: 151 | markdown_lines = markdown.split('\n') 152 | else: 153 | markdown_lines = ['', f'ERROR: IMPORT {import_name} not found', ''] 154 | 155 | line_data = line_data[:idx] + markdown_lines + line_data[idx + 1:] 156 | 157 | return process_imports(line_data) 158 | 159 | return line_data 160 | 161 | 162 | def process_variables(raw_text: str, data: Dict[str, Any]) -> str: 163 | if not raw_text: 164 | return raw_text 165 | 166 | log = __logging.get_log() 167 | 168 | keys = list(data.keys()) 169 | key_placeholders = { 170 | key: f"${key.strip().upper()}$" 171 | for key in keys 172 | if key and isinstance(key, str) 173 | } 174 | 175 | transformed_text = raw_text 176 | for key in keys: 177 | if key_placeholders[key] not in transformed_text: 178 | continue 179 | 180 | log.verbose(f"Replacing {key_placeholders[key]}...") 181 | transformed_text = transformed_text.replace(key_placeholders[key], str(data[key])) 182 | 183 | return transformed_text 184 | 185 | 186 | def get_inline_variables(markdown: str, new_vars: Dict[str, str], log: Optional[SubtemplateLogger]) -> str: 187 | pattern = '[VARIABLE ' 188 | 189 | if pattern not in markdown and pattern.lower() not in markdown: 190 | return markdown 191 | 192 | lines: List[str] = markdown.split('\n') 193 | final_lines = [] 194 | 195 | for l in lines: 196 | 197 | if not( l and l.strip().upper().startswith(pattern)): 198 | final_lines.append(l) 199 | continue 200 | 201 | text = l.strip() 202 | text = text[len(pattern):].strip("]") 203 | parts = text.split('=') 204 | if len(parts) != 2: 205 | if log: 206 | log.error(f"Invalid variable definition in markdown: {l}.") 207 | continue 208 | 209 | name = parts[0].strip().upper() 210 | value = parts[1].strip() 211 | has_quotes = ( 212 | (value.startswith('"') or value.startswith("'")) and 213 | (value.endswith('"') or value.endswith("'")) 214 | ) 215 | 216 | if not has_quotes: 217 | if log: 218 | log.error(f"Invalid variable definition in markdown, missing quotes surrounding value: {l}.") 219 | continue 220 | 221 | value = value.strip('\'"').strip() 222 | 223 | new_vars[name]=value 224 | 225 | if new_vars: 226 | return "\n".join(final_lines) 227 | else: 228 | return markdown 229 | -------------------------------------------------------------------------------- /markdown_subtemplate/logging/__init__.py: -------------------------------------------------------------------------------- 1 | from .log_level import LogLevel 2 | from .stdout_logger import StdOutLogger 3 | from .null_logger import NullLogger 4 | from .subtemplate_logger import SubtemplateLogger 5 | 6 | from ..exceptions import MarkdownTemplateException 7 | 8 | __log: SubtemplateLogger = StdOutLogger(LogLevel.info) 9 | 10 | 11 | def get_log() -> SubtemplateLogger: 12 | return __log 13 | 14 | 15 | def set_log(log: SubtemplateLogger): 16 | global __log 17 | 18 | if not log or not isinstance(log, SubtemplateLogger): 19 | raise MarkdownTemplateException('log') 20 | 21 | __log = log 22 | -------------------------------------------------------------------------------- /markdown_subtemplate/logging/log_level.py: -------------------------------------------------------------------------------- 1 | class LogLevel: 2 | verbose = 0 3 | trace = 1 4 | info = 2 5 | error = 3 6 | off = 100 7 | 8 | names = { 9 | 0: 'verbose', 10 | 1: 'trace', 11 | 2: 'info', 12 | 3: 'error', 13 | 100: 'off', 14 | } 15 | -------------------------------------------------------------------------------- /markdown_subtemplate/logging/null_logger.py: -------------------------------------------------------------------------------- 1 | from .subtemplate_logger import SubtemplateLogger 2 | from .log_level import LogLevel 3 | 4 | 5 | class NullLogger(SubtemplateLogger): 6 | def __init__(self): 7 | super().__init__(LogLevel.error) 8 | 9 | def verbose(self, text: str): 10 | pass 11 | 12 | def trace(self, text: str): 13 | pass 14 | 15 | def info(self, text: str): 16 | pass 17 | 18 | def error(self, text: str): 19 | pass 20 | -------------------------------------------------------------------------------- /markdown_subtemplate/logging/stdout_logger.py: -------------------------------------------------------------------------------- 1 | from .log_level import LogLevel 2 | from .subtemplate_logger import SubtemplateLogger 3 | 4 | 5 | class StdOutLogger(SubtemplateLogger): 6 | prefix = 'Markdown Subtemplates' 7 | 8 | def verbose(self, text: str): 9 | if not self.should_log(LogLevel.verbose, text): 10 | return 11 | 12 | self._publish(text, LogLevel.verbose) 13 | 14 | def trace(self, text: str): 15 | if not self.should_log(LogLevel.trace, text): 16 | return 17 | 18 | self._publish(text, LogLevel.trace) 19 | 20 | def info(self, text: str): 21 | if not self.should_log(LogLevel.info, text): 22 | return 23 | 24 | self._publish(text, LogLevel.info) 25 | 26 | def error(self, text: str): 27 | if not self.should_log(LogLevel.error, text): 28 | return 29 | 30 | self._publish(text, LogLevel.error) 31 | 32 | def _publish(self, text: str, level: int): 33 | print(f"[{self.prefix}: {LogLevel.names[level].capitalize()}] {text}") 34 | -------------------------------------------------------------------------------- /markdown_subtemplate/logging/subtemplate_logger.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | 4 | class SubtemplateLogger(abc.ABC): 5 | 6 | def __init__(self, log_level: int): 7 | self.log_level = log_level 8 | 9 | @abc.abstractmethod 10 | def verbose(self, text: str): 11 | pass 12 | 13 | @abc.abstractmethod 14 | def trace(self, text: str): 15 | pass 16 | 17 | @abc.abstractmethod 18 | def info(self, text: str): 19 | pass 20 | 21 | @abc.abstractmethod 22 | def error(self, text: str): 23 | pass 24 | 25 | def should_log(self, level: int, text: str) -> bool: 26 | return self.log_level <= level and (text and text.strip()) 27 | -------------------------------------------------------------------------------- /markdown_subtemplate/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from markdown_subtemplate.exceptions import ArgumentExpectedException 2 | from markdown_subtemplate.storage.subtemplate_storage import SubtemplateStorage 3 | from markdown_subtemplate.storage import file_storage 4 | 5 | __storage: SubtemplateStorage = None 6 | 7 | 8 | def set_storage(storage_instance: SubtemplateStorage): 9 | global __storage 10 | if not storage_instance or not isinstance(storage_instance, SubtemplateStorage): 11 | raise ArgumentExpectedException('storage_instance') 12 | 13 | __storage = storage_instance 14 | 15 | 16 | def get_storage() -> SubtemplateStorage: 17 | global __storage 18 | if not __storage: 19 | from markdown_subtemplate.storage.file_storage import FileStore 20 | __storage = FileStore() 21 | return __storage 22 | 23 | 24 | def is_initialized() -> bool: 25 | return get_storage().is_initialized() 26 | -------------------------------------------------------------------------------- /markdown_subtemplate/storage/file_storage.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional, List 3 | 4 | from markdown_subtemplate.exceptions import TemplateNotFoundException, ArgumentExpectedException, \ 5 | InvalidOperationException 6 | from . import SubtemplateStorage 7 | 8 | 9 | class FileStore(SubtemplateStorage): 10 | __template_folder: Optional[str] = None 11 | 12 | def get_markdown_text(self, template_path) -> str: 13 | if not template_path or not template_path.strip(): 14 | raise TemplateNotFoundException("No template file specified: template_path=''.") 15 | 16 | file_name = os.path.basename(template_path) 17 | file_parts = os.path.dirname(template_path).split(os.path.sep) 18 | folder = FileStore.get_folder(file_parts) 19 | full_file = os.path.join(folder, file_name).lower() 20 | 21 | if not os.path.exists(full_file): 22 | raise TemplateNotFoundException(full_file) 23 | 24 | with open(full_file, 'r', encoding='utf-8') as fin: 25 | return fin.read() 26 | 27 | def get_shared_markdown(self, import_name): 28 | folder = FileStore.get_folder(['_shared']) 29 | file = os.path.join(folder, import_name.strip().lower() + '.md') 30 | 31 | if not os.path.exists(file): 32 | raise TemplateNotFoundException(file) 33 | 34 | with open(file, 'r', encoding='utf-8') as fin: 35 | return fin.read() 36 | 37 | def is_initialized(self) -> bool: 38 | return bool(FileStore.__template_folder) 39 | 40 | @staticmethod 41 | def get_folder(path_parts: List[str]) -> str: 42 | if not path_parts: 43 | raise ArgumentExpectedException('path_parts') 44 | 45 | if not FileStore.__template_folder: 46 | raise InvalidOperationException("You must set the template folder before calling this method.") 47 | 48 | parts = [ 49 | p.strip().strip('/').strip('\\').lower() 50 | for p in path_parts 51 | ] 52 | parent_folder = os.path.abspath(FileStore.__template_folder) 53 | folder = os.path.join(parent_folder, *parts) 54 | 55 | return folder 56 | 57 | @staticmethod 58 | def set_template_folder(full_path: str): 59 | from ..exceptions import PathException 60 | import markdown_subtemplate.logging as logging 61 | log = logging.get_log() 62 | 63 | test_path = os.path.abspath(full_path) 64 | if test_path != full_path: 65 | msg = f"{full_path} is not an absolute path." 66 | log.error("engine.set_template_folder: " + msg) 67 | raise PathException(msg) 68 | 69 | if not os.path.exists(full_path): 70 | msg = f"{full_path} does not exist." 71 | log.error("engine.set_template_folder: " + msg) 72 | raise PathException(msg) 73 | 74 | if not os.path.isdir(full_path): 75 | msg = f"{full_path} is not a directory." 76 | log.error("engine.set_template_folder: " + msg) 77 | raise PathException(msg) 78 | 79 | log.info(f"Template folder set: {full_path}") 80 | 81 | FileStore.__template_folder = full_path 82 | 83 | def clear_settings(self): 84 | FileStore.__template_folder = None 85 | -------------------------------------------------------------------------------- /markdown_subtemplate/storage/subtemplate_storage.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | 4 | class SubtemplateStorage(abc.ABC): 5 | @abc.abstractmethod 6 | def get_markdown_text(self, template_path) -> str: 7 | pass 8 | 9 | @abc.abstractmethod 10 | def get_shared_markdown(self, import_name) -> str: 11 | pass 12 | 13 | @abc.abstractmethod 14 | def is_initialized(self) -> bool: 15 | pass 16 | 17 | @abc.abstractmethod 18 | def clear_settings(self): 19 | pass 20 | -------------------------------------------------------------------------------- /readme_resources/mitlicense.svg: -------------------------------------------------------------------------------- 1 | licenselicenseMITMIT -------------------------------------------------------------------------------- /readme_resources/workflow_image_layout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeckennedy/markdown-subtemplate/138f46c433b641ad18882da6d3f232f9736d9a32/readme_resources/workflow_image_layout.png -------------------------------------------------------------------------------- /readme_resources/workflow_image_layout.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeckennedy/markdown-subtemplate/138f46c433b641ad18882da6d3f232f9736d9a32/readme_resources/workflow_image_layout.pptx -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | pytest 4 | twine 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | markdown2 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import re 4 | 5 | from setuptools import find_packages 6 | from setuptools import setup 7 | 8 | 9 | def read(filename): 10 | filename = os.path.join(os.path.dirname(__file__), filename) 11 | text_type = type(u"") 12 | with io.open(filename, mode="r", encoding='utf-8') as fd: 13 | return re.sub(text_type(r':[a-z]+:`~?(.*?)`'), text_type(r'``\1``'), fd.read()) 14 | 15 | 16 | def read_version(): 17 | filename = os.path.join(os.path.dirname(__file__), 'markdown_subtemplate', '__init__.py') 18 | with open(filename, mode="r", encoding='utf-8') as fin: 19 | for line in fin: 20 | if line and line.strip() and line.startswith('__version__'): 21 | return line.split('=')[1].strip().strip("'") 22 | 23 | return "0.0.0.0" 24 | 25 | 26 | requirements_txt = os.path.join( 27 | os.path.dirname(__file__), 28 | 'requirements.txt' 29 | ) 30 | 31 | with open(requirements_txt, 'r', encoding='utf-8') as fin: 32 | requires = [ 33 | line.strip() 34 | for line in fin 35 | if line and line.strip() and not line.strip().startswith('#') 36 | ] 37 | 38 | 39 | setup( 40 | name="markdown_subtemplate", 41 | version=read_version(), 42 | url="https://github.com/mikeckennedy/markdown-subtemplate", 43 | license='MIT', 44 | 45 | author="Michael Kennedy", 46 | author_email="michael@talkpython.fm", 47 | 48 | description="A template engine to render Markdown with external template imports and variable replacements.", 49 | long_description=read("README.md"), 50 | long_description_content_type='text/markdown', 51 | 52 | packages=find_packages(exclude=('tests',)), 53 | 54 | install_requires=requires, 55 | 56 | classifiers=[ 57 | 'Development Status :: 3 - Alpha', 58 | 'License :: OSI Approved :: MIT License', 59 | 'Programming Language :: Python', 60 | 'Programming Language :: Python :: 3', 61 | 'Programming Language :: Python :: 3.6', 62 | 'Programming Language :: Python :: 3.7', 63 | 'Programming Language :: Python :: 3.8', 64 | ], 65 | ) 66 | -------------------------------------------------------------------------------- /tests/_all_tests.py: -------------------------------------------------------------------------------- 1 | # noinspection PyUnresolvedReferences 2 | from engine_tests import * 3 | # noinspection PyUnresolvedReferences 4 | from page_tests import * 5 | # noinspection PyUnresolvedReferences 6 | from logging_tests import * 7 | -------------------------------------------------------------------------------- /tests/engine_tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from markdown_subtemplate import engine, caching, storage 6 | from markdown_subtemplate import exceptions 7 | 8 | template_folder = os.path.join(os.path.dirname(__file__), 'templates') 9 | 10 | 11 | def test_init_folder_required(): 12 | storage.get_storage().clear_settings() 13 | 14 | with pytest.raises(exceptions.InvalidOperationException): 15 | engine.get_page('abc', {}) 16 | 17 | 18 | def test_init_folder_missing(): 19 | with pytest.raises(exceptions.PathException): 20 | storage.file_storage.FileStore.set_template_folder('bad/cats/') 21 | 22 | 23 | def test_init_folder_success(): 24 | storage.file_storage.FileStore.set_template_folder(template_folder) 25 | 26 | 27 | def test_clear_cache(): 28 | caching.get_cache().clear() 29 | -------------------------------------------------------------------------------- /tests/logging_tests.py: -------------------------------------------------------------------------------- 1 | from markdown_subtemplate import logging 2 | from markdown_subtemplate.logging import LogLevel 3 | 4 | 5 | def test_default_log_level(): 6 | log = logging.get_log() 7 | assert log.log_level == LogLevel.info 8 | 9 | 10 | def test_can_change_log_level(): 11 | log = logging.get_log() 12 | 13 | level = log.log_level 14 | try: 15 | log.log_level = LogLevel.error 16 | assert log.log_level == LogLevel.error 17 | finally: 18 | log.log_level = level 19 | 20 | 21 | def test_should_log_yes(): 22 | log = logging.get_log() 23 | assert log.should_log(LogLevel.info, "MSG") 24 | assert log.should_log(LogLevel.error, "MSG") 25 | 26 | 27 | def test_should_log_no(): 28 | log = logging.get_log() 29 | assert not log.should_log(LogLevel.verbose, "MSG") 30 | assert not log.should_log(LogLevel.trace, "MSG") 31 | 32 | 33 | def test_logging_off(): 34 | log = logging.get_log() 35 | level = log.log_level 36 | try: 37 | log.log_level = LogLevel.off 38 | assert not log.should_log(LogLevel.error, 'MSG') 39 | assert not log.should_log(LogLevel.verbose, 'MSG') 40 | finally: 41 | log.log_level = level 42 | -------------------------------------------------------------------------------- /tests/page_tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from markdown_subtemplate import engine 6 | from markdown_subtemplate import exceptions 7 | from markdown_subtemplate.infrastructure import page 8 | from markdown_subtemplate.storage.file_storage import FileStore 9 | 10 | FileStore.set_template_folder( 11 | os.path.join(os.path.dirname(__file__), 'templates')) 12 | 13 | 14 | def test_missing_template_by_file(): 15 | with pytest.raises(exceptions.TemplateNotFoundException): 16 | engine.get_page(os.path.join('home', 'hiding.md'), {}) 17 | 18 | 19 | def test_missing_template_by_folder(): 20 | with pytest.raises(exceptions.TemplateNotFoundException): 21 | engine.get_page(os.path.join('hiding', 'index.md'), {}) 22 | 23 | 24 | def test_empty_template(): 25 | html = engine.get_page(os.path.join('home', 'empty_markdown.md'), {}) 26 | assert html == '' 27 | 28 | 29 | def test_basic_markdown_template(): 30 | template = os.path.join('home', 'basic_markdown.md') 31 | md = page.get_markdown(template, {'a': 1, 'b': 2}) 32 | 33 | text = ''' 34 | # This is the basic title 35 | 36 | We have a paragraph with [a link](https://talkpython.fm). 37 | 38 | * Bullet 1 39 | * Bullet 2 40 | * Bullet 3 41 | 42 | '''.strip() 43 | assert text == md.strip() 44 | 45 | 46 | def test_basic_markdown_html(): 47 | template = os.path.join('home', 'basic_markdown.md') 48 | html = engine.get_page(template, {'a': 1, 'b': 2}) 49 | 50 | text = ''' 51 |

This is the basic title

52 | 53 |

We have a paragraph with a link.

54 | 55 | 60 | '''.strip() 61 | assert text == html.strip() 62 | 63 | 64 | def test_import_markdown(): 65 | template = os.path.join('home', 'import1.md') 66 | md = page.get_markdown(template, {'a': 1, 'b': 2}) 67 | 68 | text = ''' 69 | # This page imports one thing. 70 | 71 | We have a paragraph with [a link](https://talkpython.fm). 72 | 73 | ## This is a basic import. 74 | 75 | You'll see imported things. 76 | 77 | 78 | And more inline **content**. 79 | '''.strip() 80 | assert text == md.strip() 81 | 82 | 83 | def test_nested_import_markdown(): 84 | template = os.path.join('home', 'import_nested.md') 85 | md = page.get_markdown(template, {'a': 1, 'b': 2}) 86 | 87 | text = ''' 88 | # This page imports nested things. 89 | 90 | We have a paragraph with [a link](https://talkpython.fm). 91 | 92 | ### This page imports stuff and is imported. 93 | 94 | ## This is a basic import. 95 | 96 | You'll see imported things. 97 | 98 | 99 | And more nested import content. 100 | 101 | And more inline **content**. 102 | '''.strip() 103 | assert text == md.strip() 104 | 105 | 106 | def test_variable_replacement_markdown(): 107 | template = os.path.join('home', 'replacements_import.md') 108 | md = page.get_markdown(template, {'Title': 'Best Title Ever!', 'link': 'https://training.talkpython.fm'}) 109 | 110 | text = ''' 111 | # This page imports things with data. 112 | 113 | We have a paragraph with [a link](https://training.talkpython.fm). 114 | 115 | ### This page had a title set: Best Title Ever! 116 | 117 | And more content with the word TITLE. 118 | 119 | 120 | And more inline **content**. 121 | '''.strip() 122 | assert text == md.strip() 123 | 124 | 125 | def test_two_imports_markdown(): 126 | template = os.path.join('home', 'two_imports.md') 127 | md = page.get_markdown(template, {}) 128 | 129 | text = ''' 130 | # This page imports nested things. 131 | 132 | We have a paragraph with [a link](https://talkpython.fm). 133 | 134 | ## This is a basic import. 135 | 136 | You'll see imported things. 137 | 138 | 139 | ## This is the second import. 140 | 141 | You'll see imported things. 142 | 143 | 144 | And more inline **content**. 145 | '''.strip() 146 | assert text == md.strip() 147 | 148 | 149 | def test_variable_definition_html(): 150 | template = os.path.join('home', 'variables.md') 151 | html = page.get_page(template, {}) 152 | 153 | text = ''' 154 |

This page defines a variable.

155 | 156 |

We have a paragraph with a link.

157 | 158 |

This page had a title set: Variables rule!

159 | 160 |

And more content with the word TITLE.

161 | '''.strip() 162 | 163 | assert text == html.strip() 164 | 165 | 166 | def test_variable_definition_html_cached(): 167 | template = os.path.join('home', 'variables.md') 168 | 169 | # for some reason, the cached version doesn't include the local variables 170 | # let's call it twice and see if we can catch it and guard against it. 171 | page.get_page(template, {}) 172 | html = page.get_page(template, {}) 173 | 174 | text = ''' 175 |

This page defines a variable.

176 | 177 |

We have a paragraph with a link.

178 | 179 |

This page had a title set: Variables rule!

180 | 181 |

And more content with the word TITLE.

182 | '''.strip() 183 | 184 | print("HTML: ") 185 | print(html) 186 | print("TEST: ") 187 | print(text) 188 | print(flush=True) 189 | 190 | assert text == html.strip() 191 | 192 | 193 | def test_no_lowercase_replacements_markdown(): 194 | template = os.path.join('home', 'replacements_case_error.md') 195 | md = page.get_markdown(template, {'title': 'the title', 'link': 'The link'}) 196 | 197 | text = ''' 198 | # This page imports things with data. 199 | 200 | We have a paragraph with [a link]($Link$). 201 | 202 | And this was a title: $title$ 203 | 204 | And more inline **content**. 205 | '''.strip() 206 | assert text == md.strip() 207 | 208 | 209 | def test_html_with_replacement(): 210 | template = os.path.join('home', 'replacements_import.md') 211 | html = engine.get_page(template, {'Title': 'Best Title Ever!', 'link': 'https://training.talkpython.fm'}) 212 | 213 | text = ''' 214 |

This page imports things with data.

215 | 216 |

We have a paragraph with a link.

217 | 218 |

This page had a title set: Best Title Ever!

219 | 220 |

And more content with the word TITLE.

221 | 222 |

And more inline content.

223 | '''.strip() 224 | assert text == html.strip() 225 | 226 | 227 | def test_html_with_embedded_html(): 228 | template = os.path.join('home', 'markdown_with_html.md') 229 | html = engine.get_page(template, {}) 230 | 231 | text = ''' 232 |

This is the basic title

233 | 234 |

We have a paragraph with a link.

235 | 236 | 241 | 242 |

We also have an image with some details:

243 | 244 |

247 | 248 |

End of the message.

249 | '''.strip() 250 | assert text == html.strip() 251 | 252 | 253 | def test_missing_import_markdown(): 254 | template = os.path.join('home', 'import_missing.md') 255 | with pytest.raises(exceptions.TemplateNotFoundException): 256 | page.get_markdown(template, {'a': 1, 'b': 2}) 257 | 258 | 259 | def test_pull_inline_variables(): 260 | md = """ 261 | # Title 262 | 263 | [variable var1="abc"] 264 | [variable var2="xyz"] 265 | 266 | Ending 267 | """ 268 | vars = {} 269 | page.get_inline_variables(md, vars, None) 270 | 271 | assert {"VAR1": "abc", "VAR2": "xyz"} == vars 272 | 273 | 274 | def test_no_inline_variables(): 275 | md = """ 276 | # Title 277 | 278 | - a 279 | - b 280 | - c 281 | 282 | Ending 283 | """ 284 | vars = {} 285 | page.get_inline_variables(md, vars, None) 286 | 287 | assert {} == vars 288 | -------------------------------------------------------------------------------- /tests/templates/_shared/basic_import.md: -------------------------------------------------------------------------------- 1 | ## This is a basic import. 2 | 3 | You'll see imported things. 4 | -------------------------------------------------------------------------------- /tests/templates/_shared/nested_import.md: -------------------------------------------------------------------------------- 1 | ### This page imports stuff and is imported. 2 | 3 | [IMPORT BASIC_IMPORT] 4 | 5 | And more nested import content. -------------------------------------------------------------------------------- /tests/templates/_shared/replacements.md: -------------------------------------------------------------------------------- 1 | ### This page had a title set: $TITLE$ 2 | 3 | And more content with the word TITLE. 4 | -------------------------------------------------------------------------------- /tests/templates/_shared/second_import.md: -------------------------------------------------------------------------------- 1 | ## This is the second import. 2 | 3 | You'll see imported things. 4 | -------------------------------------------------------------------------------- /tests/templates/home/basic_markdown.md: -------------------------------------------------------------------------------- 1 | # This is the basic title 2 | 3 | We have a paragraph with [a link](https://talkpython.fm). 4 | 5 | * Bullet 1 6 | * Bullet 2 7 | * Bullet 3 8 | 9 | -------------------------------------------------------------------------------- /tests/templates/home/empty_markdown.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeckennedy/markdown-subtemplate/138f46c433b641ad18882da6d3f232f9736d9a32/tests/templates/home/empty_markdown.md -------------------------------------------------------------------------------- /tests/templates/home/import1.md: -------------------------------------------------------------------------------- 1 | # This page imports one thing. 2 | 3 | We have a paragraph with [a link](https://talkpython.fm). 4 | 5 | [IMPORT BASIC_IMPORT] 6 | 7 | And more inline **content**. 8 | -------------------------------------------------------------------------------- /tests/templates/home/import_missing.md: -------------------------------------------------------------------------------- 1 | # This page imports one thing. 2 | 3 | We have a paragraph with [a link](https://talkpython.fm). 4 | 5 | [IMPORT VERY_BAD_IMPORT] 6 | 7 | And more inline **content**. 8 | -------------------------------------------------------------------------------- /tests/templates/home/import_nested.md: -------------------------------------------------------------------------------- 1 | # This page imports nested things. 2 | 3 | We have a paragraph with [a link](https://talkpython.fm). 4 | 5 | [IMPORT NESTED_IMPORT] 6 | 7 | And more inline **content**. 8 | -------------------------------------------------------------------------------- /tests/templates/home/markdown_with_html.md: -------------------------------------------------------------------------------- 1 | # This is the basic title 2 | 3 | We have a paragraph with [a link](https://talkpython.fm). 4 | 5 | * Bullet 1 6 | * Bullet 2 7 | * Bullet 3 8 | 9 | 10 | We also have an image with some details: 11 | 12 | 15 | 16 | End of the message. 17 | -------------------------------------------------------------------------------- /tests/templates/home/replacements_case_error.md: -------------------------------------------------------------------------------- 1 | # This page imports things with data. 2 | 3 | We have a paragraph with [a link]($Link$). 4 | 5 | And this was a title: $title$ 6 | 7 | And more inline **content**. 8 | -------------------------------------------------------------------------------- /tests/templates/home/replacements_import.md: -------------------------------------------------------------------------------- 1 | # This page imports things with data. 2 | 3 | We have a paragraph with [a link]($LINK$). 4 | 5 | [IMPORT REPLACEMENTS] 6 | 7 | And more inline **content**. 8 | -------------------------------------------------------------------------------- /tests/templates/home/two_imports.md: -------------------------------------------------------------------------------- 1 | # This page imports nested things. 2 | 3 | We have a paragraph with [a link](https://talkpython.fm). 4 | 5 | [IMPORT BASIC_IMPORT] 6 | 7 | [IMPORT SECOND_IMPORT] 8 | 9 | And more inline **content**. 10 | -------------------------------------------------------------------------------- /tests/templates/home/variables.md: -------------------------------------------------------------------------------- 1 | # This page defines a variable. 2 | 3 | We have a paragraph with [a link](https://talkpython.fm). 4 | 5 | [VARIABLE title="Variables rule!"] 6 | 7 | [IMPORT REPLACEMENTS] 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py34,py35,py36,py37 3 | 4 | [testenv] 5 | commands = py.test markdown_subtemplate 6 | deps = pytest 7 | --------------------------------------------------------------------------------