├── .flake8 ├── .github └── workflows │ ├── ci.yml │ ├── release.yml │ └── static.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── configuration.md ├── css │ └── extra.css ├── definitions.md ├── getting_started.md ├── index.md ├── process.drawio ├── releases.md ├── static │ └── tooltip-full.png └── usage │ ├── api.md │ ├── customization.md │ ├── default.md │ ├── definition.md │ ├── index.md │ ├── linking.md │ ├── pagerefs.md │ ├── sections.md │ └── summary.md ├── etc └── requirements.txt ├── mkdocs.yml ├── pyproject.toml ├── setup.cfg ├── src └── mkdocs_ezglossary_plugin │ ├── __init__.py │ ├── glossary.py │ ├── plugin.py │ ├── plurals.py │ ├── template.py │ └── templates │ ├── definition.html │ ├── link.html │ ├── refs-list.html │ ├── refs-short.html │ ├── summary-detailed.html │ ├── summary-table.html │ └── summary.html ├── tests ├── conftest.py ├── custom │ └── summary.html ├── mock.py ├── test_definitions.py ├── test_glossary.py ├── test_helpers.py ├── test_link.py ├── test_page_ref.py ├── test_summary.py └── test_tooltip_preserve_text.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = 4 | .test/, 5 | build/, 6 | dist/ 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: run CI checks 2 | 3 | on: 4 | # Runs on pushes targeting the default branch 5 | push: 6 | branches: ["main", "branches/*"] 7 | pull_request: 8 | branches: ["main", "branches/*"] 9 | 10 | jobs: 11 | ci: 12 | name: run CI checks 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Set up Python 3.10 19 | uses: actions/setup-python@v3 20 | with: 21 | python-version: "3.10" 22 | 23 | - name: install build environment 24 | run: python3 -m pip install --upgrade tox flake8 25 | 26 | - name: Lint with flake8 27 | run: | 28 | # stop the build if there are Python syntax errors or undefined names 29 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 30 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 31 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 32 | 33 | - name: run pytests 34 | run: tox 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: publish release to pypi.org 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | pypi-publish: 10 | name: Upload release to PyPI 11 | runs-on: ubuntu-latest 12 | environment: 13 | name: pypi 14 | url: https://pypi.org/p/mkdocs-glossary-plugin 15 | permissions: 16 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Set up Python 3.10 21 | uses: actions/setup-python@v3 22 | with: 23 | python-version: "3.10" 24 | 25 | - name: install build environment 26 | run: python3 -m pip install --upgrade build tox flake8 27 | 28 | - name: Lint with flake8 29 | run: | 30 | # stop the build if there are Python syntax errors or undefined names 31 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 32 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 33 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 34 | 35 | - name: run pytests 36 | run: tox 37 | 38 | - name: build mkdocs-ezglossary-plugin 39 | run: python3 -m build . 40 | 41 | - name: Publish package distributions to PyPI 42 | uses: pypa/gh-action-pypi-publish@release/v1 43 | -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main", "branches/*"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v5 36 | - name: Build pages 37 | run: | 38 | pip3 install -r etc/requirements.txt 39 | pip3 install . 40 | mkdocs build 41 | - name: Upload artifact 42 | uses: actions/upload-pages-artifact@v3 43 | with: 44 | # Upload entire repository 45 | path: 'site/' 46 | - name: Deploy to GitHub Pages 47 | id: deployment 48 | uses: actions/deploy-pages@v4 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | *.swp 3 | *.swo 4 | __pycache__ 5 | *.egg-info/ 6 | *.DS_Store 7 | *.md.html 8 | .tox 9 | .idea/ 10 | .venv*/ 11 | mycommand.md.markdown 12 | site/ 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Claudio Klingler 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 | # mkdocs-ezglossary-plugin 2 | 3 | > A powerful glossary plugin for MkDocs 4 | 5 | ## Features 6 | 7 | - Multiple glossary sections 8 | - Case-sensitive and case-insensitive term matching 9 | - Rich text formatting in definitions 10 | - Reference tracking and linking 11 | - Customizable summaries 12 | - Unicode support 13 | - PDF export support 14 | - Custom templates via Jinja2 15 | - Plural forms support (English, Spanish, French, German) 16 | 17 | ## Documentation 18 | 19 | 📚 Read the [full documentation](https://realtimeprojects.github.io/mkdocs-ezglossary) 20 | 21 | ## Prerequisites 22 | 23 | This plugin requires one of: 24 | - [Material for MkDocs definition lists](https://squidfunk.github.io/mkdocs-material/reference/lists/) (recommended) 25 | - Any plugin that generates [HTML description lists](https://www.w3schools.com/HTML/html_lists.asp) 26 | 27 | ## Installation 28 | 29 | ```bash 30 | pip install mkdocs-ezglossary-plugin 31 | ``` 32 | 33 | ## Quick Start 34 | 35 | ### 1. Enable the Plugin 36 | 37 | Add ezglossary to your mkdocs.yml: 38 | 39 | ```yaml 40 | plugins: 41 | - search 42 | - ezglossary 43 | ``` 44 | 45 | ### 2. Define Terms 46 | 47 | Add glossary terms anywhere in your documentation: 48 | 49 | ```markdown 50 | fruit:apple 51 | : A very delicious and healthy fruit growing on trees. 52 | ``` 53 | 54 | ### 3. Link to Terms 55 | 56 | Reference terms in your documentation: 57 | 58 | ```markdown 59 | An a day keeps the doctor away. 60 | ``` 61 | 62 | ### 4. Create a Summary 63 | 64 | Generate a summary of all terms: 65 | 66 | ```markdown 67 | # Glossary 68 | 69 | 70 | ``` 71 | 72 | ## Configuration 73 | 74 | Basic configuration options: 75 | 76 | ```yaml 77 | plugins: 78 | - ezglossary: 79 | # Case-insensitive term matching 80 | ignore_case: true 81 | 82 | # Show references in definitions 83 | inline_refs: short # none, short, list 84 | 85 | # Enable plural forms (English) 86 | plurals: en # en, es, it, fr, de 87 | 88 | # Custom templates directory 89 | templates: docs/templates 90 | ``` 91 | 92 | See the [configuration documentation](https://realtimeprojects.github.io/mkdocs-ezglossary/configuration) for all options. 93 | 94 | ## Contributing 95 | 96 | Contributions are welcome! Please feel free to submit a Pull Request. 97 | 98 | ## License 99 | 100 | `mkdocs-ezglossary-plugin` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. 101 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/css/extra.css: -------------------------------------------------------------------------------- 1 | dl { margin-left:2em; border-left: 1px dottet; } 2 | dt { font-weight: bold; line-height: 1.2em; } 3 | dd { line-height: 1em; } 4 | h6.code { font-size: 1em; font-weight: bold } 5 | -------------------------------------------------------------------------------- /docs/definitions.md: -------------------------------------------------------------------------------- 1 | # Definitions 2 | 3 | term:term 4 | : A term is a definition of a word in your glossary. 5 | 6 | The same term can be defined in multiple 7 | 8 | term:section 9 | : ezglossary supports adding terms to different sections. This 10 | allows defining multiple glossaries and printing individual 11 | for each section 12 | 13 | term:summary 14 | : The summary is a list of all defined terms in a section. 15 | 16 | It contains links to their and . 17 | 18 | term:definition 19 | : The definition of a term in a definition lists. 20 | 21 | The ezglossary plugin hooks in the html generation process, 22 | and looks for definition lists. In case it identifies 23 | a 24 | 25 | term:reference 26 | : The reference is a place in your documentation where you 27 | refer to a term by placing a [link](usage/linking.md). 28 | 29 | ## Summary 30 | 31 | 32 | -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 |
4 | 5 | - ## 1. Prerequisites 6 | 7 | This plugin requires the 8 | [material definition lists](https://squidfunk.github.io/mkdocs-material/reference/lists/) 9 | to be active or any other plugin which generates 10 | [html description lists](https://www.w3schools.com/HTML/html_lists.asp). 11 | 12 | - ## 2. Installation 13 | 14 | Install the ezglossary plugin whereever you installed mkdocs using 15 | the python package manager: 16 | 17 | pip3 install mkdocs-ezglossary-plugin 18 | 19 | - ## 3. Activation 20 | 21 | Add the following lines to your `mkdocs.yml` plugins-section to activate 22 | the plugin: 23 | 24 | ``` yaml 25 | plugins: 26 | - search 27 | - ezglossary 28 | ``` 29 | 30 | - ## 4. Further reading 31 | 32 | Check the [usage](usage/index.md) documentation on how to use 33 | your the plugin. 34 |
35 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # mkdocs-ezglossary-plugin 2 | > glossary support for mkdocs 3 | 4 |
5 | 6 | - ## Features 7 | - Defining multiple glossaries via [sections](usage/sections.md) 8 | - [Linking](usage/linking.md) to glossary entries in text 9 | - Printing a [summary](usage/summary.md) of your glossary with definitions and 10 | references anywhere in your documentation. 11 | - [Page references](usage/pagerefs.md) 12 | - Unicode support 13 | - PDF support 14 | - [Customizable output](usage/customization.md) 15 | - Singular / Plural support 16 | 17 | - ## Usage 18 | - Read the [getting started](getting_started.md) instructions 19 | for installation and basic configuration of ezglossary. 20 | - Read the [usage](usage/index.md) documentation 21 | to see how to use ezglossary in your documentation. 22 | - Read the [sections](usage/sections.md) documentation to see 23 | how to configure sections. 24 | 25 |
26 | -------------------------------------------------------------------------------- /docs/process.drawio: -------------------------------------------------------------------------------- 1 | 7Vtdk5s2FP01fswOHwbjx9S7aaZNJjvjtM0+7cggQF2BGCGv7fz6SiA+hb3UNcZJeTK6ugJJ93DukYRn5ira/0pBEn4mHsQzQ/P2M/N+Zhi6rtn8R1gOucVaGLkhoMiTTpVhjb5DadSkdYs8mDYcGSGYoaRpdEkcQ5c1bIBSsmu6+QQ3n5qAACqGtQuwav0LeSwsxmUvq4qPEAWhfLRjLPKKCBTOciRpCDyyq5nMh5m5ooSw/CraryAWk1fMS97uw5HasmMUxqxPg9XTE/52eI73rvbHn7/fg98eo/CdLnubskMxYujxCZBFQllIAhID/FBZf6FkG3tQ3FbnpcrnEyGJNP4NGTvIaIItI9wUsgjLWp/E7AOIEBagWJEIubwLaxCn/OfzWjrI1vpcllcEE5p10dQ009TfF36yrxovp4ySlzJQhrAkwEVx8DXr2Ttxr3zAYpRHJ1KaUrKlLjwxe3MJSEADyE74WWW4+XsCSQQZPfB2FGLA0GuzH0ACNij9qpjyCxnWfxFi2clXgLfySSR+Tih83mwRfxna4ecwTcRlCPeAR5VPWAIp4l2BtLI+FiYxxbsQMbjm8yya7TgNtIKN9rB4sXUFPQNDoebpGBvTtjtB4iOMa56eBR1vfgI8r5AyuD8NHzXcRQNbEsKhVd7V+KUgjbBOLdpACNF1BQVXIAE+XfTwTUTrziqKT/W6+70MZV461EuPNUxmxj1i+b1sw5Llp1pddS9RKG71Q4LvIsxl9WQuXevGcm/qkk0fCeJdLN+BefsdsFrYzgcgW7XgXXbjPyDemdLe8OCxx0x7Vmfa42rtOQL0hSuxeEp91059Zp/UZ1819S0nIjifCOyeRLAYkwjso0TA14wMygQz8cAVecDWbo0HFp0gISnLkDIh5NoIcZxbQ8goiaK2RjIXjVVStS7qXiX9kJC5SFJyeialUTdlHIVvBM/cRcf3Y2LC4Ns8sgHuS5DB7suWYRQXfOJxzftFzDrLAHKnWZchmZPIsF0HbvxeyADQ8d3BZGcr3RgqlzgdVKJrg6lObQr/9cI/d24t/OZRSTqtTc9KHwIfyAX4PUYBn5t7JqougR0+kgZ2zA4dUp5VNdCzHAw96oKGRzziFg/6KEYMERGmn25VOwyzXERy6H0PgvRRRYeuHgVRGKBUcAjvfAeINJ+SaCZPSQ2NdwOoyMIYJWmP5ATSJD+hzain462NkOd1QPHymcmzN7bVi3N83zfcwTKTsWixy9JSVzldR0Gl8fIYUfdNA0zSFMh7d+Uk98Clhgep2UOg5JH9tHlLsaQyntaYOLkVBWMUuJA4sTskzOKqEsZQFey0q3abCmbe5BhHU7FT7sfVwWMMBx5V//4vFMzpffmjjHKUlS6oYMpvq95SMMJrPAVT3PhsBXOMnCYRcz7BtJbXzrxjq/a6IiYH6SRibkrEmFpTxOjaYnQVoy6IxAYMt1RwsTETEeb8Ygfiiof3JeMVIiiHk56gni4nPoGCk4haezLLTUR09j6feXtE1P0VynSueNsaWTfmukpNXdAx5suhoKNu81GYYBHot+hJ7NRwGkvV2o0YNHDZFojnSyprOwnKqomoiaKGW8jrPCnejb7nY6hfPwDPy0S9D/nQXChmnviVxC6S4ASV4bKZrUBl/HSmHltXnJRuoyijJC2zhAR72aos45yj8ntCyPlfUbX0zrAI4cXqv0r5d9jVP77Mh38A -------------------------------------------------------------------------------- /docs/releases.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## v2.1.0 4 | | 2025-09-25 5 | 6 | - Fix #29 HTML encoding issue, 7 | - Fix #26, support nested glossary links 8 | - Improve documentation 9 | - Add translation rules for spanish, french, german 10 | 11 | ## v2.0.0 12 | | 2025-02-10 13 | 14 | - Fix #24: Show all references in glossary (summary) when `ignore_case=true`. 15 | - Fix #25: Fix #25: make template file path relative to mkdocs.yml 16 | 17 | !!! Note 18 | 19 | This version required major refactorying of the `Glossary` class. 20 | The interface to it changed. You may need to update your custom templates 21 | in order to work. 22 | 23 | ## v1.7.1 24 | | 2024-10-11 25 | 26 | - Fix #22: Plural configuration did not work. 27 | 28 | ## v1.7.0 29 | | 2024-10-10 30 | 31 | - Glossary: Add section and term as attributes of Entry 32 | - Links: Add name attribute containing "{section:term}" 33 | - Fix html entity handling. 34 | - Basic support of [handling plurals](usage/linking.md#handling-plurals). See 35 | also [#18](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/18) 36 | - Fix #21: Support [ignoring case for terms](usage/linking.md#case-sensitivity) 37 | - Fix #20: Usage of `tooltip:short` 38 | - Fix #19: Usage of `inline_refs:list` 39 | 40 | ## v1.6.10 41 | | 2024-05-08 42 | 43 | - Fix handling of non-existing canonica_url. 44 | 45 | ## v1.6.9 46 | | 2024-05-08 47 | 48 | - Fixed: [#16](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/16): 49 | Relative links not working on ReadTheDocs. 50 | 51 | ## v1.6.8 52 | | ~2024-04-24~ 53 | 54 | - Fixed: [#15](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/15): 55 | Links to undefined glossary entries are displayed. 56 | 57 | ## v1.6.7 58 | | ~2024-04-23~ 59 | 60 | - Fixed: [#14](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/14): 61 | Add `&` to [list of unsupported characters for terms and section](usage/definition.md#limitations) 62 | to handle html-entity-encoded mailto links. 63 | 64 | ## v1.6.6 65 | | ~2024-04-23~ 66 | 67 | - Fixed: [#14](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/14): 68 | Add `@` to [list of unsupported characters for terms and section](usage/definition.md#limitations). 69 | 70 | ## v1.6.5 71 | | ~2024-04-23~ 72 | 73 | - Fixed: [#13](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/13): 74 | Support [html entities](usage/definition.md#limitations) in sections and terms. 75 | 76 | ## v1.6.4 77 | | ~2024-04-23~ 78 | 79 | - Fixed: [#12](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/12): 80 | Support [unicode characters](usage/linking.md#using-markdown-links) for terms. 81 | 82 | ## v1.6.3 83 | | ~2024-04-22~ 84 | 85 | - Fixed: [#11](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/11): 86 | Glossary entries with hyphens lead to errors. 87 | 88 | ## v1.6.2 89 | | ~2024-04-22~ 90 | 91 | - Removed debug warnings in logging 92 | 93 | ## v1.6.1 94 | | ~2024-04-16~ 95 | 96 | - Fixed: [#10](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/10): 97 | Links in PDF exports are not working 98 | - Removed debug warnings in logging 99 | 100 | ## v1.6.0 101 | | ~2024-04-15~ 102 | 103 | - Fixed: [#7](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/7): 104 | Support [markdown links](usage/linking.md) for linking of unicode characters and support for emojis. 105 | 106 | ## v1.5.9 107 | | ~2024-04-15~ 108 | 109 | - Fixed: [#8](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/8): 110 | typo on documentation for configuration. 111 | - Fixed: [#9](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/9): 112 | Handling linking with individual text in tables 113 | - Fixed: [#9](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/9): 114 | Handling summaries with options in tables 115 | - Fixed `no_defs`/`no_refs`/`do_defs`/`do_refs` handling in default summary theme 116 | 117 | ## v1.5.8 118 | | ~2024-03-08~ 119 | 120 | - Fixed typo in README.md 121 | 122 | ## v1.5.7 123 | | ~2023-11-27~ 124 | 125 | - Fixed: [#3](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/3) 126 | (Support formatting of definitions) 127 | 128 | ## v1.5.6 129 | | ~2023-11-22~ 130 | 131 | - Fixed: [#4](https://github.com/realtimeprojects/mkdocs-ezglossary/issues/4) 132 | (Links to default section not working) 133 | 134 | ## v1.5.5 135 | | ~2023-09-29~ 136 | 137 | - Fix doc errors in README.md 138 | 139 | ## v1.5.4 140 | | ~2023-09-06~ 141 | 142 | - Fix anchor lookup for page references 143 | 144 | ## v1.5.3 145 | | ~2023-09-06~ 146 | 147 | - Fix error in summary-detailed template. 148 | 149 | ## v1.5.2 150 | | ~2023-09-06~ 151 | 152 | - Fix project description and keywords 153 | 154 | ## v1.5.1 155 | | ~2023-09-05~ 156 | 157 | - Directly link definition to term in summary 158 | - summary theme "detailed": Safe output for definition 159 | - summary theme "table": Safe output for definition 160 | 161 | - Add "[table](usage/summary.md#themes)" summary theme 162 | ## v1.5.0 163 | | ~2023-09-05~ 164 | 165 | - Add "[table](usage/summary.md#themes)" summary theme 166 | 167 | ## v1.4.0 168 | | ~2023-09-04~ 169 | 170 | - Support [page references](usage/pagerefs.md) 171 | - Support [themes for summary](usage/summary.md#themes) 172 | - Add [Glossary.definition()][mkdocs_ezglossary_plugin.glossary.Glossary.definition] 173 | 174 | ## v1.3.1 175 | | ~2023-09-03~ 176 | 177 | - Fix reference counter for short list 178 | 179 | ## v1.3.0 180 | | ~2023-09-03~ 181 | 182 | - Support [default sections](usage/default.md) 183 | 184 | ## v1.2.2 185 | | ~2023-09-02~ 186 | 187 | - Fix project long description 188 | 189 | ## v1.2.1 190 | | ~2023-09-02~ 191 | 192 | - Optimize reference list output for definition. 193 | 194 | ## v1.2.0 195 | | ~2023-09-02~ 196 | 197 | - Support [customizable output](usage/customization.md) 198 | 199 | ~ 200 | ## v1.1.2 201 | | ~2023-09-01~ 202 | 203 | - fix html syntax for summary 204 | - add section id to summary data list 205 | 206 | ## v1.1.1 207 | | ~2023-09-01~ 208 | 209 | - reduce log noise 210 | - fix html output for inline refs 211 | - rename value "off" to "none" 212 | 213 | ## v1.1.0 214 | | ~2023-08-31~ 215 | 216 | - support whitespaces and dashes in terms. 217 | - add configuration to 218 | disable listing definitions in summary. 219 | - Support overriding and 220 | using `[no|do]_[refs|defs]` 221 | as summary modifier. See 222 | [documentation](https://realtimeprojects.github.io/mkdocs-ezglossary/usage/summary#overriding-the-output-behaviour). 223 | - Support per-section definition of and 224 | 225 | - Support inline references in term definitions 226 | 227 | 228 | ## v1.0.4 229 | | ~2023-08-31~ 230 | 231 | - Fix: individual link text only allowed single words. 232 | 233 | ## v1.0.3 234 | | ~2023-08-31~ 235 | 236 | - Documentation added 237 | 238 | ## v1.0.2 239 | | ~2023-08-31~ 240 | 241 | - Fix README.md 242 | 243 | ## v1.0.1 244 | | ~2023-08-31~ 245 | 246 | - Remove noise outupt 247 | 248 | ## v1.0.0 249 | | ~2023-08-31~ 250 | 251 | - Initial release 252 | -------------------------------------------------------------------------------- /docs/static/tooltip-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realtimeprojects/mkdocs-ezglossary/d3a4d3820acc89ce0a94554a10124c085b422b3d/docs/static/tooltip-full.png -------------------------------------------------------------------------------- /docs/usage/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | # Glossary class reference 4 | 5 | ::: mkdocs_ezglossary_plugin.glossary.Glossary 6 | 7 | # Entry class reference 8 | 9 | ::: mkdocs_ezglossary_plugin.glossary.Entry 10 | -------------------------------------------------------------------------------- /docs/usage/customization.md: -------------------------------------------------------------------------------- 1 | # Customization 2 | 3 | ezglossary supports customizing the generated HTML via the Jinja2 4 | template engine. 5 | 6 | To customize the generated HTML, add the `templates` configuration option 7 | pointing to a directory containing the Jinja2 templates for each output: 8 | 9 | ```yaml 10 | plugins: 11 | ezglossary: 12 | templates: docs/templates 13 | ``` 14 | 15 | The content of the templates directory should look like this: 16 | 17 | ```text 18 | docs/ 19 | templates/ 20 | links.html # template for the reference links 21 | definition.html # template for term definitions 22 | refs-short.html # template for references in 'short' mode 23 | refs-long.html # template for references in 'long' mode 24 | summary.html # template for the summary 25 | ``` 26 | 27 | ## Reference Links 28 | 29 | The `links.html` template allows customization of the 30 | [reference links](linking.md) (``). 31 | 32 | The default template looks like this: 33 | 34 | ```jinja 35 | {{ text }} 39 | ``` 40 | 41 | See [variables](#variables) for a detailed description of the available variables. 42 | 43 | ## Reference List in Term Definition 44 | 45 | The `refs-short.html` and `refs-long.html` templates render the 46 | reference links in the [term definition](definition.md). 47 | 48 | See the `inline_refs` configuration option to learn how to 49 | enable the reference list in term definitions. 50 | 51 | The default template for the short mode looks like this: 52 | 53 | ```jinja 54 |
55 | {% set counter = 0 %} 56 | {% for entry in entries %} 57 | {% set counter = counter + 1 %} 58 | 59 | 61 | [{{ counter }}] 62 | 63 | 64 | {% endfor %} 65 |
66 | ``` 67 | 68 | ## Term Definition 69 | 70 | The `definition.html` template is used for rendering term definitions: 71 | 72 | ```jinja 73 |
74 | {{ term }} 75 |
76 |
77 | {{ definition|safe }} 78 |
79 | {{ reflink }} 80 |
81 | ``` 82 | 83 | !!! Note 84 | Use the "safe" filter to avoid HTML escaping for the 85 | definition content. 86 | 87 | ## Summary 88 | 89 | The `summary.html` template renders the [summary](summary.md) output: 90 | 91 | ```jinja 92 |
93 | {% for term in terms %} 94 |
{{term}}
95 |
96 |
    97 | {% for type in types %} 98 | {% for entry in glossary.get(section, term, type) %} 99 |
  • 100 | {{ entry.page.title }} 101 | [{{ type[:-1] }}] 102 |
  • 103 | {% endfor %} 104 | {% endfor %} 105 |
106 |
107 | {% endfor %} 108 |
109 | ``` 110 | 111 | !!! Note 112 | Summaries support [custom themes](summary.md#themes) 113 | 114 | ## Variables 115 | 116 | target 117 | : The anchor ID for this link entry, used to support direct linking from the 118 | [summary](summary.md) or [definition](definition.md). 119 | 120 | entries 121 | : A list of [Entry][mkdocs_ezglossary_plugin.glossary.Entry] objects 122 | representing each reference to this term. 123 | 124 | entry 125 | : An instance of [Entry][mkdocs_ezglossary_plugin.glossary.Entry] 126 | describing the definition of this term. 127 | 128 | root 129 | : A prefix containing the relative path to the root directory. 130 | 131 | glossary 132 | : The [Glossary][mkdocs_ezglossary_plugin.glossary.Glossary] 133 | instance holding the complete glossary. 134 | 135 | terms 136 | : The list of terms in this glossary section. 137 | 138 | text 139 | : The original link text. 140 | 141 | section 142 | : The current section for this summary. 143 | 144 | types 145 | : A list of types to display in this summary. 146 | Contains one or more of: `refs`, `defs`. 147 | 148 | reflink 149 | : An internal reference link used for post-processing 150 | and injection of reference links. 151 | 152 | ## Configuration 153 | 154 | configuration:template 155 | : Directory path containing Jinja2 templates for customizing the output. 156 | If set, templates are loaded from this directory when the files exist. 157 | The path is relative to the location of your mkdocs.yml file. 158 | 159 | -------------------------------------------------------------------------------- /docs/usage/default.md: -------------------------------------------------------------------------------- 1 | # Default section 2 | 3 | With the default configuration, only references with the syntax `section:term` are recognized 4 | and processed by ezglossary. In order to activate parsing of all term definitions, 5 | activate the 'default' section in the configuration: 6 | 7 | ``` yaml 8 | plugins: 9 | ezglossary: 10 | use_default: true 11 | ``` 12 | 13 | ## Definition 14 | 15 | When activated, any term definition even without section identifier is recognized 16 | and added to the 'default' section: 17 | 18 | ``` markdown 19 | term 20 | : definition of term 21 | ``` 22 | 23 | !!! Example 24 | 25 | term1 26 | : definition of term1 27 | 28 | term2 29 | : definition of term1 30 | 31 | ## Linking 32 | 33 | Links to terms in the default section can be specified with the syntax: 34 | 35 | 36 | 37 | 38 | !!! Example 39 | 40 | - See the or 41 | 42 | ## Summary 43 | 44 | In order to print the summary for the default section, use: 45 | 46 | ``` markdown 47 | 48 | ``` 49 | 50 | !!! Example 51 | 52 | 53 | -------------------------------------------------------------------------------- /docs/usage/definition.md: -------------------------------------------------------------------------------- 1 | # Defining Glossary Terms 2 | 3 | ## Basic Usage 4 | 5 | ezglossary uses [Material definition lists](https://squidfunk.github.io/mkdocs-material/reference/lists/) 6 | to define glossary terms. Simply add a definition list with section specifiers anywhere 7 | in your documentation: 8 | 9 | !!! Note 10 | Alternatively, you can directly use [html description lists](https://www.w3schools.com/HTML/html_lists.asp) 11 | in your page as well. 12 | 13 | ```markdown 14 | term:glossary 15 | : A list of specialized words with their definitions 16 | ``` 17 | 18 | The format is `section:term`, where `section` specifies the glossary section 19 | this term belongs to. 20 | 21 | !!! Note 22 | You can also use [HTML description lists](https://www.w3.org/TR/html401/struct/lists.html#h-10.3) 23 | directly in your page. 24 | 25 | !!! Example 26 | 27 | Define terms in the section `demo`: 28 | 29 | ``` markdown 30 | *demo:my_term1* 31 | : Definition of my_term 1 32 | 33 | demo:my_term2 34 | : Definition of my_term 2 35 | 36 | *`demo:my_term2`* 37 | : Definition of my_term 2 38 | ``` 39 | 40 | *demo:my_term1* 41 | : Definition of my_term 1 42 | 43 | ## Text Formatting 44 | 45 | You can use Markdown formatting in your definitions: 46 | 47 | ```markdown 48 | demo:formatted_term 49 | : A term with **bold**, *italic*, and `code` formatting. 50 | 51 | You can even use multiple paragraphs and other Markdown elements: 52 | - Lists 53 | - Tables 54 | - Code blocks 55 | ``` 56 | 57 | ## Reference Links 58 | 59 | By setting `inline_refs` in your configuration, you can enable reference links 60 | directly in term definitions: 61 | 62 | ```yaml 63 | plugins: 64 | ezglossary: 65 | inline_refs: short # Options: none, short, list 66 | ``` 67 | 68 | Or configure it per section: 69 | 70 | ```yaml 71 | plugins: 72 | ezglossary: 73 | section_config: 74 | - name: demo 75 | inline_refs: short 76 | ``` 77 | 78 | ## Special Characters 79 | 80 | The following characters require special handling in section names and terms. 81 | Replace them with their HTML entities: 82 | 83 | | Character | HTML Entity | Usage | 84 | |-----------|------------|-------| 85 | | `#` (start) | `#` | Section/term starting with # | 86 | | `&` (start) | `&` | Section/term starting with & | 87 | | `/` | `/` | Path separator | 88 | | `\|` | `¦` | Vertical bar | 89 | | `"` | `"` | Double quote | 90 | | `<` | `&#lt;` | Less than | 91 | | `>` | `&#gt;` | Greater than | 92 | | `:` | `:` | Colon | 93 | | `@` | `@` | At sign | 94 | 95 | !!! Example 96 | 97 | ```markdown 98 | demo:special#term 99 | : A term using the # character 100 | 101 | Reference: 102 | ``` 103 | 104 | ## Configuration 105 | 106 | configuration:ignore_case 107 | : When enabled, terms are matched case-insensitively. Default: `false` 108 | 109 | ```yaml 110 | plugins: 111 | ezglossary: 112 | ignore_case: true 113 | ``` 114 | 115 | configuration:inline_refs 116 | : Controls how references appear in term definitions. Options: 117 | - `none` (default): No inline references 118 | - `short`: Numbered references like `[1]` 119 | - `list`: Full reference list with page names 120 | 121 | ```yaml 122 | plugins: 123 | ezglossary: 124 | inline_refs: short 125 | ``` 126 | 127 | ## Further Reading 128 | 129 | - [Linking to Terms](linking.md): Learn how to reference defined terms 130 | - [Sections](sections.md): Organize terms in different sections 131 | - [Summary](summary.md): Generate glossary summaries 132 | -------------------------------------------------------------------------------- /docs/usage/index.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 |
4 | 5 | - ## Basic Steps 6 | 7 | 1. [Define glossary terms](definition.md) 8 | 2. [Create references to terms](linking.md) 9 | 3. [Generate a summary](summary.md) 10 | 11 | - ## Advanced Features 12 | 13 | - [Page references](pagerefs.md) 14 | - [Glossary sections](sections.md) 15 | - [Default section](default.md) 16 | - [Custom templates](customization.md) 17 | - [API reference](api.md) 18 | 19 |
20 | -------------------------------------------------------------------------------- /docs/usage/linking.md: -------------------------------------------------------------------------------- 1 | # Linking to Glossary Terms 2 | 3 | ## Basic Usage 4 | 5 | To link to a glossary term, use the following syntax: 6 | 7 | ```markdown 8 | See the for more details 9 | ``` 10 | 11 | This creates a link to the term's definition in your documentation. 12 | 13 | !!! Example 14 | 15 | ```markdown 16 | See the for definition of term 1 17 | See the for definition of term 2 18 | ``` 19 | 20 | Output: 21 | - See the for definition of term 1 22 | - See the for definition of term 2 23 | 24 | ## Case Sensitivity 25 | > Available since version 1.7.0 26 | 27 | By default, term definitions and references are case-sensitive. You must use 28 | the exact spelling in your links as used in the term definition. 29 | 30 | To enable case-insensitive matching, set `ignore_case` to `true`: 31 | 32 | ```yaml 33 | plugins: 34 | ezglossary: 35 | ignore_case: true 36 | ``` 37 | 38 | !!! Example 39 | With `ignore_case: true`, all these links point to the same term: 40 | ```markdown 41 | - 42 | - 43 | - 44 | ``` 45 | 46 | ## Custom Link Text 47 | 48 | By default, the term itself is used as the link text. You can override this 49 | using the `|` modifier: 50 | 51 | ```markdown 52 | See our documentation 53 | ``` 54 | 55 | !!! Example 56 | ```markdown 57 | Learn about 58 | ``` 59 | 60 | Output: 61 | - Learn about 62 | 63 | !!! Note 64 | When using custom link text in tables, escape the `|` character: 65 | ```markdown 66 | | Description | Reference | 67 | |------------|-----------| 68 | | First term | | 69 | ``` 70 | 71 | ## Handling Plurals 72 | > Available since version 1.7.0 73 | 74 | The plugin can automatically match plural forms to their singular definitions. 75 | This is useful when you want to use the plural form in your text while linking 76 | to the singular definition. 77 | 78 | !!! Example 79 | Definition: 80 | ```markdown 81 | GPU 82 | : A Graphics Processing Unit is... 83 | ``` 84 | 85 | Usage: 86 | ```markdown 87 | Many support parallel processing 88 | ``` 89 | 90 | ### Supported Languages 91 | 92 | Plural handling is available for: 93 | - English (en) 94 | - Spanish (es) 95 | - French (fr) 96 | - German (de) 97 | 98 | Enable plural handling in your configuration: 99 | 100 | ```yaml 101 | plugins: 102 | ezglossary: 103 | plurals: en # Use 'en', 'es', 'fr', or 'de' 104 | ``` 105 | 106 | ### Plural Lookup Methods 107 | 108 | #### Using the inflect Library 109 | 110 | The [inflect library](https://github.com/jaraco/inflect) provides robust plural-to-singular 111 | conversion for English terms: 112 | 113 | ```yaml 114 | plugins: 115 | ezglossary: 116 | plurals: inflect 117 | ``` 118 | 119 | #### Using Built-in Rules 120 | 121 | The plugin includes basic plural rules for supported languages: 122 | 123 | ```yaml 124 | plugins: 125 | ezglossary: 126 | plurals: en # or 'es', 'fr', 'de' 127 | ``` 128 | 129 | ## Using Markdown Links 130 | > Available since version 1.6.0 131 | 132 | When is enabled, the plugin also processes standard Markdown links: 133 | 134 | ```yaml 135 | plugins: 136 | ezglossary: 137 | markdown_links: true 138 | ``` 139 | 140 | This is especially useful for: 141 | - Terms containing Unicode characters 142 | - Terms with emojis 143 | - Integration with other Markdown tools 144 | 145 | !!! Example 146 | ```markdown 147 | - See [](configuration:tooltip) for details 148 | - See [tooltips](configuration:tooltip) for details 149 | ``` 150 | 151 | Output: 152 | - See [](configuration:tooltip) for details 153 | - See [tooltips](configuration:tooltip) for details 154 | 155 | ## Tooltips 156 | 157 | Control whether hovering over links shows definition previews using the option: 158 | 159 | ```yaml 160 | plugins: 161 | ezglossary: 162 | tooltip: short # Options: none, short, full 163 | ``` 164 | 165 | Options: 166 | - `none`: No tooltips (default) 167 | - `short`: Show first line of definition 168 | - `full`: Show complete definition 169 | 170 | !!! Example 171 | With `tooltip: full`: 172 | 173 | ![Tooltip Example](../static/tooltip-full.png) 174 | 175 | ## Configuration 176 | 177 | configuration:tooltip 178 | : Configure [tooltips](#tooltips) for reference links. Default: `none` 179 | 180 | configuration:plurals 181 | : Configure plural handling. Options: `none` (default), `en`, `es`, `fr`, `de`, `inflect` 182 | 183 | configuration:ignore_case 184 | : Enable case-insensitive term matching. Default: `false` 185 | 186 | configuration:markdown_links 187 | : Enable processing of standard Markdown links. Default: `false` 188 | -------------------------------------------------------------------------------- /docs/usage/pagerefs.md: -------------------------------------------------------------------------------- 1 | --- 2 | terms: 3 | - demo: page_reference 4 | --- 5 | 6 | # Page References 7 | 8 | ## Overview 9 | 10 | If your page itself is a term definition, you can define the term in the page's metadata: 11 | 12 | ```markdown 13 | --- 14 | terms: 15 | -
: 16 | --- 17 | ``` 18 | 19 | When a term definition is placed in the page's metadata, the page is added to the glossary with the specified section and an empty description. This allows you to link to the page using the `` syntax: 20 | 21 | ```markdown 22 | - See for details 23 | ``` 24 | 25 | !!! Example 26 | 27 | - See for details 28 | 29 | ## Anchors 30 | 31 | You can refer to a title (or any other anchor in the page) in the definition: 32 | 33 | ```markdown 34 | --- 35 | terms: 36 | -
: "#anchor" 37 | --- 38 | ``` 39 | 40 | ## Multiple Terms 41 | 42 | You can define multiple terms for sections as well: 43 | 44 | ```markdown 45 | --- 46 | terms: 47 | -
: 48 | - 49 | - : 50 | --- 51 | ``` 52 | 53 | !!! Example 54 | 55 | ```markdown 56 | --- 57 | terms: 58 | - demo: 59 | - term1 60 | - term2: help 61 | - configuration: 62 | - term3 63 | --- 64 | ``` 65 | 66 | ## Default Section 67 | 68 | You can add page references to the default section: 69 | 70 | ```markdown 71 | --- 72 | terms: 73 | - 74 | - "#" 75 | --- 76 | ``` 77 | 78 | !!! Example 79 | 80 | ```markdown 81 | --- 82 | terms: 83 | - term1 84 | - "term2#help" 85 | --- 86 | ``` 87 | 88 | ## Definitions 89 | 90 | The page reference uses the following rules in the given order to determine the definition of the term: 91 | 92 | 1. If an [anchor definition](#anchor-definitions) is defined for the term, the anchor definition is used. 93 | 2. If a page subtitle is defined in the page metadata, the subtitle is used. 94 | 3. The page title is used. 95 | 96 | ## Anchor Definitions 97 | 98 | Anchor definitions allow you to specify the definition for a term for page references: 99 | 100 | ```markdown 101 | --- 102 | terms: 103 | ... 104 | anchors: 105 | - : 106 | --- 107 | ``` 108 | 109 | !!! Example 110 | 111 | ```markdown 112 | --- 113 | anchors: 114 | - help: Definition of help 115 | --- 116 | ``` 117 | -------------------------------------------------------------------------------- /docs/usage/sections.md: -------------------------------------------------------------------------------- 1 | # Sections 2 | 3 | ## Overview 4 | 5 | The key feature of ezglossary is that you can define multiple 6 | . Like described in the [definition](definition.md) documentation, 7 | every is assigned to a section. 8 | 9 | This allows [printing individual summaries](summary.md) for each . 10 | 11 | ## Predefining sections 12 | 13 | By default, you don't need to predefine the sections you use in your documentation. 14 | However, in order to avoid spelling errors, you can enable the mode 15 | 16 | If strict mode is activated, you also need to configure . 17 | 18 | ## Configuration 19 | 20 | configuration:strict 21 | : If set to `true`, ezglossary prints warnings if you - define a - or - refer to - 22 | a term for a undefined section: 23 | 24 | ``` markdown 25 | plugins: 26 | - search 27 | - ezglossary: 28 | strict: true 29 | ``` 30 | 31 | configuration:sections 32 | : Defines a list of sections that are recognized by ezglossary. 33 | 34 | If mode is enabled, only 35 | and for a defined sections are processed by ezglossary: 36 | 37 | ``` markdown 38 | plugins: 39 | - search 40 | - ezglossary: 41 | strict: true 42 | sections: 43 | - terms 44 | - configuration 45 | - demo 46 | ``` 47 | 48 | -------------------------------------------------------------------------------- /docs/usage/summary.md: -------------------------------------------------------------------------------- 1 | # Glossary Summary 2 | 3 | ## Basic Usage 4 | 5 | To create a summary of all definitions and references for a glossary section, 6 | add a summary tag anywhere in your documentation: 7 | 8 | ```markdown 9 | # Terms and Definitions 10 | 11 | 12 | ``` 13 | 14 | !!! Example 15 | 16 | Let's generate a summary for the section `demo`: 17 | 18 | ```markdown 19 | # Demo Terms 20 | 21 | 22 | ``` 23 | 24 | This will produce the following summary, containing links to all definitions 25 | and references of terms in your documentation: 26 | 27 | !!! Quote "Output" 28 | 29 | 30 | 31 | ## Customizing Output 32 | 33 | You can override the default behavior for `list_definitions` and 34 | `list_references` by adding modifiers to the glossary reference: 35 | 36 | ```markdown 37 | 38 | ``` 39 | 40 | !!! Note 41 | 42 | When using modifiers in tables, you need to escape the `|` character: 43 | 44 | ```markdown 45 | | Column 1 | Column 2 | 46 | |---------------------------|----------| 47 | | | ... | 48 | ``` 49 | 50 | ### Available Modifiers 51 | 52 | #### `no_refs` 53 | > Exclude reference links from the summary. 54 | 55 | ```markdown 56 | 57 | ``` 58 | 59 | !!! Quote "Output" 60 | 61 | 62 | 63 | #### `no_defs` 64 | > Exclude definitions from the summary. 65 | 66 | ```markdown 67 | 68 | ``` 69 | 70 | !!! Quote "Output" 71 | 72 | 73 | 74 | #### `do_defs` and `do_refs` 75 | > Explicitly include definitions and/or references. 76 | 77 | ```markdown 78 | 79 | ``` 80 | 81 | !!! Quote "Output" 82 | 83 | 84 | 85 | ## Configuration 86 | 87 | configuration:list_definitions 88 | : Controls whether definitions appear in the summary. Default is `true`. 89 | 90 | === "Global Configuration" 91 | 92 | ```yaml 93 | plugins: 94 | - search 95 | - ezglossary: 96 | - list_definitions: false 97 | ``` 98 | 99 | === "Section-specific Configuration" 100 | 101 | ```yaml 102 | plugins: 103 | - search 104 | - ezglossary: 105 | sections: 106 | demo 107 | section_config: 108 | - name: demo 109 | list_references = true 110 | 111 | - list_definitions: false 112 | ``` 113 | 114 | configuration:list_references 115 | : Controls whether references appear in the summary. Default is `true`. 116 | 117 | === "Global Configuration" 118 | 119 | ```yaml 120 | plugins: 121 | - ezglossary: 122 | - list_references: false 123 | ``` 124 | 125 | === "Section-specific Configuration" 126 | 127 | ```yaml 128 | plugins: 129 | - search 130 | - ezglossary: 131 | sections: 132 | demo 133 | section_config: 134 | - name: demo 135 | list_references = true 136 | 137 | - list_references: false 138 | ``` 139 | 140 | ## Themes 141 | 142 | You can customize the summary appearance by passing a `theme` option: 143 | 144 | ```markdown 145 | 146 | ``` 147 | 148 | The theme option loads a custom Jinja template named `summary-.html`. 149 | 150 | !!! Example 151 | 152 | This theme shows the term definitions in the glossary: 153 | 154 | !!! Quote "Output" 155 | 156 | 157 | 158 | ### Available Themes 159 | 160 | detailed 161 | : Displays the term definition in the summary. 162 | 163 | table 164 | : Presents the summary in a tabular format. 165 | 166 | ## Further Reading 167 | 168 | - See [sections](sections.md) documentation for section configuration details. 169 | - Check [customization](customization.md) for creating custom summary templates. 170 | -------------------------------------------------------------------------------- /etc/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs 2 | mkdocs-material 3 | pymdown-extensions 4 | markdown-callouts 5 | mkdocs-callouts 6 | mdx_gh_links 7 | mkdocs-click 8 | mkdocs-ezglossary-plugin 9 | mkdocstrings[python] 10 | pymdown-extensions 11 | 12 | tox 13 | 14 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: ezglossary 2 | theme: 3 | name: material 4 | features: 5 | navigation.path 6 | navigation.indexes 7 | navigation.tabs 8 | 9 | extra_css: 10 | - css/extra.css 11 | 12 | nav: 13 | - home: index.md 14 | - getting started: getting_started.md 15 | - usage: 16 | - usage/index.md 17 | - usage/definition.md 18 | - usage/pagerefs.md 19 | - usage/linking.md 20 | - usage/summary.md 21 | - usage/sections.md 22 | - usage/default.md 23 | - usage/customization.md 24 | - usage/api.md 25 | - configuration: configuration.md 26 | - definitions: definitions.md 27 | - release notes: releases.md 28 | 29 | markdown_extensions: 30 | - toc: 31 | permalink: true 32 | separator: "-" 33 | - attr_list 34 | - def_list 35 | - tables 36 | - pymdownx.tilde 37 | - pymdownx.highlight: 38 | anchor_linenums: true 39 | line_spans: __span 40 | pygments_lang_class: true 41 | - pymdownx.snippets 42 | - pymdownx.superfences 43 | - pymdownx.tabbed: 44 | alternate_style: true 45 | - admonition 46 | - pymdownx.details 47 | - footnotes 48 | - md_in_html 49 | - mkdocs-click 50 | 51 | plugins: 52 | - search 53 | - callouts 54 | - ezglossary: 55 | strict: true 56 | list_definitions: true 57 | list_references: true 58 | markdown_links: true 59 | inline_refs: none 60 | ignore_case: true 61 | tooltip: full 62 | use_default: true 63 | plurals: en 64 | sections: 65 | - setting 66 | - term 67 | - demo 68 | - configuration 69 | section_config: 70 | - name: demo 71 | inline_refs: short 72 | - name: term 73 | inline_refs: short 74 | - mkdocstrings: 75 | handlers: 76 | python: 77 | options: 78 | heading_level: 4 79 | show_source: false 80 | show_root_heading: true 81 | show_symbol_type_toc: true 82 | show_category_heading: true 83 | summary: true 84 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 61.0.0", 4 | "wheel", 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = mkdocs-ezglossary-plugin 3 | version = 2.1.0 4 | description = manage multiple glossaries in mkdocs 5 | keywords = mkdocs, glossary, plugin, references, links 6 | long_description = file: README.md 7 | long_description_content_type = text/markdown 8 | 9 | project_urls = 10 | Homepage = https://github.com/realtimeprojects/mkdocs-ezglossary 11 | Bug Tracker = https://github.com/realtimeprojects/mkdocs-ezglossary/issues 12 | Documentation = https://realtimeprojects.github.io/mkdocs-ezglossary 13 | 14 | [options.packages.find] 15 | where=src 16 | 17 | [options] 18 | package_dir= 19 | =src 20 | packages = mkdocs_ezglossary_plugin 21 | include_package_data = True 22 | install_requires = 23 | mkdocs >= 1.1.0 24 | jinja2 25 | inflect 26 | 27 | [options.package_data] 28 | mkdocs_ezglossary_plugin= 29 | templates/*.html 30 | 31 | [wheel] 32 | universal=1 33 | 34 | [options.entry_points] 35 | mkdocs.plugins = 36 | ezglossary = mkdocs_ezglossary_plugin.plugin:GlossaryPlugin 37 | -------------------------------------------------------------------------------- /src/mkdocs_ezglossary_plugin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/realtimeprojects/mkdocs-ezglossary/d3a4d3820acc89ce0a94554a10124c085b422b3d/src/mkdocs_ezglossary_plugin/__init__.py -------------------------------------------------------------------------------- /src/mkdocs_ezglossary_plugin/glossary.py: -------------------------------------------------------------------------------- 1 | import re 2 | import hashlib 3 | import logging 4 | import inflect 5 | from typing import Tuple 6 | 7 | from .plurals import plurals 8 | 9 | engine = inflect.engine() 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | def get_id(section: str, term: str, linktype: str, index: int = 0) -> str: 15 | """Generate a unique ID for a glossary entry. 16 | 17 | Args: 18 | section: The glossary section. 19 | term: The term. 20 | linktype: Type of link ('refs' or 'defs'). 21 | index: Index of the entry (only used for 'refs'). 22 | 23 | Returns: 24 | str: A unique hash ID, or None if no matching definition exists. 25 | """ 26 | # For case-insensitive matching, convert to lowercase before hashing 27 | section = section.lower() 28 | term = term.lower() 29 | 30 | # Base key is always section:term 31 | key = f"{section}:{term}" 32 | base_id = hashlib.md5(key.encode()).hexdigest() 33 | 34 | # For definitions, just return the base ID 35 | if linktype == 'defs': 36 | return base_id 37 | 38 | # For references, include the index to make each reference unique 39 | if linktype == 'refs': 40 | ref_key = f"{key}:ref:{index}" 41 | return hashlib.md5(ref_key.encode()).hexdigest() 42 | 43 | raise ValueError(f"Invalid linktype: {linktype}") 44 | 45 | 46 | class Entry: 47 | """An entry in the glossary.""" 48 | 49 | def __init__(self, target, page, definition, section, term, ref_id=None): 50 | self.target = target 51 | """The anchor to directly point to this specific link.""" 52 | 53 | self.page = page 54 | """The URL of the page to which this entry points.""" 55 | 56 | self.definition = definition 57 | """The definition of the term.""" 58 | 59 | self.section = section 60 | """The section of the term of this entry. (Since v1.7.0a1)""" 61 | 62 | self.term = term 63 | """The term of this entry. (Since v1.7.0a1)""" 64 | 65 | self.ref_id = ref_id 66 | """The ID of the reference link to this entry.""" 67 | 68 | def __repr__(self): 69 | """Return a string representation of the entry showing all fields.""" 70 | return (f"Entry(section='{self.section}', term='{self.term}', " 71 | f"target='{self.target}', page='{self.page}', " 72 | f"ref_id='{self.ref_id}', definition='{self.definition}')") 73 | 74 | 75 | class Glossary: 76 | """The complete glossary for all sections.""" 77 | 78 | def __init__(self, ignore_case, plurals): 79 | self.clear() 80 | self.ignore_case = ignore_case 81 | self.plurals = plurals 82 | 83 | def clear(self): 84 | """Clear the glossary.""" 85 | # Store sections as: 86 | # { section: { 'defs': {term: {id: Entry}}, 'refs': {term: {id: Entry}} } } 87 | self._glossary = {} 88 | 89 | def add(self, section: str, term: str, linktype: str, page: str, definition: str = None, anchor: str = None): 90 | """Add a new entry to the glossary. 91 | 92 | Args: 93 | section: Section name to add the entry to. 94 | term: The term (can be plural or singular). 95 | linktype: Type of entry ('refs' or 'defs'). 96 | page: Page object where the entry is located. 97 | definition: Optional definition text. 98 | anchor: Optional anchor ID. 99 | 100 | Returns: 101 | str: The ID of the added entry. 102 | """ 103 | term = term.strip() 104 | log.debug(f"glossary.add({section}, {term}, {linktype}, '{definition}', {anchor})") 105 | 106 | # Initialize section if needed 107 | if section not in self._glossary: 108 | log.debug(f" adding section: '{section}'") 109 | self._glossary[section] = {'refs': {}, 'defs': {}} 110 | 111 | # Get the appropriate dictionary for this linktype 112 | links_dict = self._glossary[section][linktype] 113 | 114 | # Initialize term entry if needed 115 | if term not in links_dict: 116 | links_dict[term] = {} 117 | 118 | # Generate IDs 119 | _id = get_id(section, term, linktype, len(links_dict[term])) 120 | log.debug(f" _id: {_id} FOR {section}:{term}:{linktype}:{len(links_dict[term])}") 121 | 122 | anchor = _id if anchor is None else anchor 123 | entry = Entry(anchor, page, definition, section, term, _id) 124 | links_dict[term][_id] = entry 125 | log.debug(f"added entry: {entry}") 126 | 127 | return _id 128 | 129 | def get(self, section: str, term: str, linktype: str) -> list[Entry]: 130 | """Get all entries for a term, including singular/plural variants. 131 | 132 | Args: 133 | section: Section to search in. 134 | term: Term to find (can be plural or singular). 135 | linktype: Type of entries to find ('refs' or 'defs'). 136 | 137 | Returns: 138 | list[Entry]: List of matching Entry objects. 139 | """ 140 | log.debug(f"glossary.get({section}, {term}, {linktype})") 141 | if section not in self._glossary: 142 | log.debug(f" section not in glossary: {section}") 143 | log.debug(f" glossary keys: {self._glossary.keys()}") 144 | return [] 145 | 146 | results = [] 147 | links_dict = self._glossary[section][linktype] 148 | found_terms = set() # Track which terms we've already processed 149 | 150 | # Helper to add entries while handling case sensitivity 151 | def add_entries(search_term): 152 | if search_term in links_dict and search_term not in found_terms: 153 | found_terms.add(search_term) 154 | results.extend(links_dict[search_term].values()) 155 | 156 | # Handle case sensitivity 157 | if self.ignore_case: 158 | term_upper = term.upper() 159 | for stored_term in links_dict: 160 | if stored_term.upper() == term_upper: 161 | add_entries(stored_term) 162 | else: 163 | # Add direct matches only 164 | add_entries(term) 165 | 166 | # Handle plurals 167 | if self.plurals != 'none': 168 | variants = self._get_term_variants(term) 169 | for variant in variants: 170 | if variant != term: # Skip original term as it's already been checked 171 | if self.ignore_case: 172 | variant_upper = variant.upper() 173 | for stored_term in links_dict: 174 | if stored_term.upper() == variant_upper: 175 | add_entries(stored_term) 176 | else: 177 | add_entries(variant) 178 | 179 | return results 180 | 181 | def _get_term_variants(self, term: str) -> set[str]: 182 | """Get all singular/plural variants of a term. 183 | 184 | Args: 185 | term: Term to find variants for. 186 | 187 | Returns: 188 | set[str]: Set of variant terms (including original). 189 | """ 190 | variants = {term} 191 | 192 | if self.plurals == 'inflect': 193 | try: 194 | from inflect import engine 195 | p = engine() 196 | # Try getting singular if term is plural 197 | singular = p.singular_noun(term) 198 | if singular: 199 | variants.add(singular) 200 | else: 201 | # Term might be singular, try getting plural 202 | variants.add(p.plural_noun(term)) 203 | except ImportError: 204 | log.warning("inflect library not available") 205 | return variants 206 | 207 | elif self.plurals in plurals: 208 | term_to_check = term.upper() if self.ignore_case else term 209 | rules = plurals[self.plurals] 210 | 211 | # Try each plural rule 212 | for ending, replacements in rules.items(): 213 | for replacement in replacements: 214 | if self.ignore_case: 215 | ending = ending.upper() 216 | replacement = replacement.upper() 217 | 218 | # Always try both directions for each rule 219 | # Try converting plural to singular 220 | if re.search(ending, term_to_check): 221 | singular = re.sub(ending, replacement, term_to_check) 222 | variants.add(singular) 223 | 224 | # Try converting singular to plural 225 | if replacement: # Only if replacement is not empty 226 | plural = re.sub(f"{replacement}$", ending.rstrip('$'), term_to_check) 227 | variants.add(plural) 228 | else: # For empty replacement, just add 's' 229 | plural = term_to_check + ending.rstrip('$') 230 | variants.add(plural) 231 | 232 | return variants 233 | 234 | def has(self, section: str) -> bool: 235 | """Check if the glossary has a section named **section**. 236 | 237 | Args: 238 | section: The name of the section to check. 239 | 240 | Returns: 241 | bool: True if a section with the given name exists. 242 | """ 243 | return section in self._glossary 244 | 245 | def terms(self, section: str) -> list[str]: 246 | """Get all terms in a section. 247 | 248 | Args: 249 | section: The name of the section. 250 | 251 | Returns: 252 | list[str]: List of terms in the section. 253 | """ 254 | if section not in self._glossary: 255 | return [] 256 | # Combine unique terms from both refs and defs 257 | terms = set() 258 | terms.update(self._glossary[section]['defs'].keys()) 259 | 260 | def update_terms(new_terms): 261 | for term in new_terms: 262 | if term not in terms: 263 | definition = self.get_best_definition(section, term) 264 | if definition and definition.term not in terms: 265 | terms.add(definition.term) 266 | update_terms(self._glossary[section]['refs'].keys()) 267 | 268 | return sorted(terms) 269 | 270 | def definition(self, section: str, term: str) -> str: 271 | """Get the definition for a term. 272 | 273 | Args: 274 | section: The name of the section. 275 | term: The term to get the definition for. 276 | 277 | Returns: 278 | str: The definition of the term. 279 | """ 280 | defs = self.get(section, term, 'defs') 281 | if not defs: 282 | return "" 283 | return defs[0].definition 284 | 285 | def get_refs(self, section, term): 286 | """Get all reference entries for a term. 287 | 288 | Args: 289 | section: The name of the section. 290 | term: The term to get references for. 291 | 292 | Returns: 293 | list[Entry]: List of reference entries. 294 | """ 295 | return self.get(section, term, 'refs') 296 | 297 | def get_defs(self, section, term): 298 | """Get all definition entries for a term. 299 | 300 | Args: 301 | section: The name of the section. 302 | term: The term to get definitions for. 303 | 304 | Returns: 305 | list[Entry]: List of definition entries. 306 | """ 307 | return self.get(section, term, 'defs') 308 | 309 | def ref_by_id(self, id: str) -> Entry: 310 | """Get a reference entry by its ID. 311 | 312 | Args: 313 | id: The ID of the reference to find. 314 | 315 | Returns: 316 | Entry: The matching reference entry. 317 | 318 | Raises: 319 | KeyError: If no reference with the given ID exists. 320 | """ 321 | # Search through all sections and terms 322 | for section in self._glossary.values(): 323 | for term_entries in section['refs'].values(): 324 | for entry_id, entry in term_entries.items(): 325 | if entry_id == id: 326 | return entry 327 | 328 | raise KeyError(f"No reference found with ID: {id}") 329 | 330 | def get_best_definition(self, section: str, term: str) -> Entry: 331 | """Get the best matching definition for a term. 332 | 333 | Args: 334 | section: The glossary section. 335 | term: The base term (usually singular). 336 | 337 | Returns: 338 | Entry: The best matching definition entry, or None if no match found. 339 | """ 340 | defs = self.get(section, term, 'defs') 341 | log.debug(f"get_best_definition({section}:{term}): defs: {defs}") 342 | 343 | # Try exact match with displayed text first 344 | if len(defs) > 1: 345 | log.warning(f"multiple definitions found for <{section}:{term}>, looking for exact match") 346 | for d in defs: 347 | log.warning(f"\t{d}") 348 | if d.term.lower() == term.lower(): 349 | log.warning(f"\tfound exact match: {d}") 350 | return d 351 | log.warning(f"\tno exact match found, using first definition") 352 | 353 | # Fall back to first definition 354 | if defs: 355 | return defs[0] 356 | 357 | return None 358 | -------------------------------------------------------------------------------- /src/mkdocs_ezglossary_plugin/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import os 4 | from html import parser 5 | 6 | from mkdocs.plugins import BasePlugin, event_priority 7 | from mkdocs import config 8 | from mkdocs.config import config_options as co 9 | 10 | from .glossary import Glossary, get_id 11 | 12 | from . import template 13 | 14 | log = logging.getLogger("mkdocs.plugins.ezglossary") 15 | 16 | 17 | class __re: 18 | def __init__(self): 19 | self.ws = r"[\n ]*" 20 | self.section = r"([^:<>\"\|/\@&#][^:<>\"\|/\@]*)" 21 | self.term = self.section 22 | self.text = r"([^>]+)" 23 | self.dt = rf"
(<.*>)?{self.section}:{self.term}(<.*>)?<\/dt>" 24 | self.dt_default = rf"
(<.*>)?{self.term}(<.*>)?<\/dt>" 25 | self.dd = r"
\n?((.|\n)+?)<\/dd>" 26 | self.options = r"([\\\|\=\w\+]+)" 27 | 28 | 29 | _re = __re() 30 | 31 | 32 | class GlossaryConfig(config.base.Config): 33 | tooltip = config.config_options.Choice(('none', 'short', 'full'), default="none") 34 | inline_refs = config.config_options.Choice(('none', 'short', 'list'), default="none") 35 | plurals = config.config_options.Choice(('none', 'en', 'inflect'), default="none") 36 | sections = co.ListOfItems(config.config_options.Type(str), default=[]) 37 | section_config = co.ListOfItems(config.config_options.Type(dict), default=[]) 38 | strict = config.config_options.Type(bool, default=False) 39 | ignore_case = config.config_options.Type(bool, default=False) 40 | markdown_links = config.config_options.Type(bool, default=False) 41 | list_references = config.config_options.Type(bool, default=True) 42 | list_definitions = config.config_options.Type(bool, default=True) 43 | use_default = config.config_options.Type(bool, default=False) 44 | templates = config.config_options.Type(str, default="") 45 | 46 | 47 | class GlossaryPlugin(BasePlugin[GlossaryConfig]): 48 | def __init__(self): 49 | self._uuid = "6251a85a-47d0-11ee-be56-0242ac120002" 50 | self._reflink = "886d7696-137e-4a59-a39d-6f7d311d5bd1" 51 | 52 | def on_pre_build(self, config, **kwargs): 53 | self._glossary = Glossary(self.config.ignore_case, self.config.plurals) 54 | if self.config.strict and "_" not in self.config.sections: 55 | self.config.sections.append("_") 56 | if self.config.strict and len(self.config.sections) == 0: 57 | log.error("ezglossary: no sections defined, but 'strict' is true, plugin disabled") 58 | self._glossary.clear() 59 | 60 | def on_page_markdown(self, content, page, config, files): 61 | attributes = page.meta 62 | 63 | def _get_definition(anchor): 64 | anchors = attributes.get('anchors') 65 | if anchors: 66 | for anchor_def in anchors: 67 | if anchor in anchor_def: 68 | return anchor_def[anchor] 69 | if 'subtitle' in attributes: 70 | return attributes['subtitle'] 71 | return page.title 72 | 73 | def _add2section(section, term, anchor=None): 74 | if not anchor: 75 | if "#" in term: 76 | (term, anchor) = term.split("#") 77 | else: 78 | (term, anchor) = (term, "") 79 | definition = _get_definition(anchor) 80 | log.debug(f"add2section: {section}:{term}:{anchor} -> '{definition}'") 81 | self._glossary.add(section, 82 | term, 83 | 'defs', 84 | page, 85 | definition, 86 | anchor) 87 | 88 | ez = attributes.get('terms') 89 | log.debug(ez) 90 | if not ez: 91 | return content 92 | 93 | for entry in ez: 94 | if isinstance(entry, str): 95 | _add2section("_", entry) 96 | continue 97 | 98 | for (section, data) in entry.items(): 99 | if isinstance(data, str): 100 | _add2section(section, data) 101 | continue 102 | 103 | for term in data: 104 | if isinstance(term, str): 105 | _add2section(section, term) 106 | if isinstance(term, dict): 107 | for term, anchor in term.items(): 108 | _add2section(section, term, anchor) 109 | return content 110 | 111 | @event_priority(5000) 112 | def on_page_content(self, content, page, config, files): 113 | content = self._find_definitions(content, page) 114 | content = self._register_glossary_links(content, page) 115 | return content 116 | 117 | def on_post_page(self, output, page, config): 118 | _dir = os.path.dirname(page.url) 119 | levels = len(_dir.split("/")) 120 | if page.canonical_url and page.canonical_url.replace(config.site_url or "", "").lstrip("/").count("/") < 1: 121 | root = "./" * levels 122 | else: 123 | root = "../" * levels 124 | output = self._replace_glossary_links(output, page, root) 125 | output = self._replace_inline_refs(output, page, root) 126 | output = self._print_glossary(output, root) 127 | return output 128 | 129 | def _print_glossary(self, html, root): 130 | def _replace(mo): 131 | types = [] 132 | section = mo.group(1) 133 | options = mo.group(2) or "" 134 | 135 | lr = "do_refs" in options 136 | if "no_refs" not in options and "do_refs" not in options: 137 | lr = self._get_config(section, 'list_references') 138 | if lr: 139 | types.append('refs') 140 | 141 | ld = "do_defs" in options 142 | if "no_defs" not in options and "do_defs" not in options: 143 | ld = self._get_config(section, 'list_definitions') 144 | if ld: 145 | types.append('defs') 146 | 147 | if len(types) == 0: 148 | log.warning("list_definitons and list_references disabled, summary will be empty") 149 | 150 | if not self._glossary.has(section=section): 151 | log.warning(f"no section '{section}' found in glossary") 152 | 153 | terms = self._glossary.terms(section) 154 | theme = "" 155 | for option in options.replace("\\|", "|").split("|"): 156 | if "theme" in option: 157 | theme = "-" + option.split("=")[1] 158 | return template.render(f"summary{theme}.html", 159 | self.config, 160 | glossary=self._glossary, 161 | types=types, 162 | section=section, 163 | terms=terms, 164 | root=root) 165 | return html 166 | 167 | regex = rf"" 168 | return re.sub(regex, _replace, html) 169 | 170 | def _register_glossary_links(self, output, page): 171 | 172 | def _add_link(section, term, text): 173 | section = "_" if (section == "default" or section is None) else section 174 | term = term if term else "__None__" 175 | text = text if text else "__None__" 176 | log.debug(f"glossary: found link: {section}/{term}/{text}") 177 | id =self._glossary.add(section, term, 'refs', page) 178 | return f"{self._uuid}:{id}:<{text}>" 179 | 180 | def _replace(mo): 181 | return _add_link(mo.group(1), mo.group(2), mo.group(4)) 182 | 183 | def _replace_default(mo): 184 | return _add_link(None, mo.group(1), mo.group(3)) 185 | 186 | def _replace_href(mo): 187 | return _add_link(mo.group(2), mo.group(3), mo.group(4)) 188 | 189 | regex = rf"<{_re.section}\:{_re.term}(\\?\|({_re.text}))?>" 190 | output = re.sub(regex, _replace, output) 191 | regex = rf"<{_re.term}:(\\?\|({_re.text}))?>" 192 | output = re.sub(regex, _replace_default, output) 193 | 194 | if self.config.markdown_links: 195 | regex = rf'({_re.text})?' 196 | output = re.sub(regex, _replace_href, output) 197 | 198 | return output 199 | 200 | def _replace_inline_refs(self, output, page, root): 201 | def _replace(mo): 202 | section = mo.group(1) 203 | term = mo.group(2) 204 | log.debug(f"inline_refs: looking up: '{section}/{term}'") 205 | 206 | mode = self._get_config(section, 'inline_refs') 207 | 208 | entries = self._glossary.get(section, term, 'refs') 209 | return template.render(f"refs-{mode}.html", 210 | self.config, 211 | entries=entries, 212 | root=root) 213 | 214 | regex = fr"{self._reflink}:{_re.section}:{_re.term}" 215 | return re.sub(regex, _replace, output) 216 | 217 | def _replace_glossary_links(self, output, page, root): 218 | """ Search for links to glossary entries detected and marked 219 | in the first stage and replace them with an actual html 220 | link pointing to that glossary definition. 221 | """ 222 | def _replace(mo): 223 | id = mo.group(1) 224 | # The text is HTML entity encoded, so we need to decode it 225 | text = mo.group(2) 226 | 227 | # Decode HTML entities in the text 228 | import html 229 | text = html.unescape(text) 230 | 231 | td = self._glossary.ref_by_id(id) 232 | text = td.term if text == "__None__" else text 233 | entry = self._glossary.get_best_definition(td.section, td.term) 234 | 235 | if entry is None: 236 | log.warning(f"page '{page.url}' refers to undefined glossary entry {td.section}:{td.term}") 237 | term = "" if td.term == "__None__" else td.term 238 | text = "" if text == "__None__" else text 239 | sec = f"{td.section}:" if td.section != "_" else "" 240 | return f'{text}' 241 | 242 | # Preserve visible text from nested glossary links/anchors before html2text 243 | entry.definition = _preserve_visible_text_for_tooltip(entry.definition) 244 | entry.definition = _html2text(entry.definition) 245 | 246 | return template.render("link.html", 247 | root=root, 248 | config=self.config, 249 | entry=entry, 250 | text=text, 251 | target=id) 252 | 253 | # Try both regex patterns to handle both HTML entity encoded and raw formats 254 | # Check for HTML entity encoded format first 255 | regex_entities = fr"{self._uuid}:([a-f0-9]{{32}}):<([^&]*)>" 256 | regex_original = fr"{self._uuid}:([a-f0-9]{{32}}):<([^>]*)>" 257 | 258 | # Test which pattern has matches 259 | if re.search(regex_entities, output): 260 | regex = regex_entities 261 | else: 262 | regex = regex_original 263 | 264 | result = re.sub(regex, _replace, output) 265 | 266 | return result 267 | 268 | def _find_definitions(self, content, page): 269 | log.debug(f"_find_definitions({page})") 270 | 271 | def _add_entry(section, term, definition, fmt_pre, fmt_post): 272 | log.debug(f"glossary: found definition: {section}:{term}:{definition} {fmt_pre}:{fmt_post}") 273 | 274 | if self.config.tooltip == "none": 275 | _tooltip = "" 276 | if self.config.tooltip == "short": 277 | _tooltip = definition.split("\n")[0] 278 | if self.config.tooltip == "full": 279 | _tooltip = definition 280 | 281 | if section not in self.config.sections and self.config.strict: 282 | log.warning(f"ignoring undefined section '{section}' in glossary") 283 | return None 284 | 285 | _id = self._glossary.add(section, term, 'defs', page, _tooltip) 286 | 287 | inline_refs = self._get_config(section, 'inline_refs') 288 | reflink = f"\n{self._reflink}:{section}:{term}" if inline_refs != "none" else "" 289 | return template.render("definition.html", 290 | self.config, 291 | target=_id, 292 | term=term, 293 | definition=definition, 294 | reflink=reflink, 295 | fmt_pre=fmt_pre if fmt_pre else "", 296 | fmt_post=fmt_post if fmt_post else "") 297 | 298 | def _replace(mo): 299 | fmt_pre = mo.group(1) 300 | section = mo.group(2) 301 | term = mo.group(3) 302 | fmt_post = mo.group(4) 303 | definition = mo.group(5) 304 | rendered = _add_entry(section, term, definition, fmt_pre, fmt_post) 305 | return rendered if rendered else mo.group() 306 | 307 | def _replace_default(mo): 308 | section = "_" 309 | fmt_pre = mo.group(1) 310 | term = mo.group(2) 311 | fmt_post = mo.group(3) 312 | definition = mo.group(4) 313 | rendered = _add_entry(section, term, definition, fmt_pre, fmt_post) 314 | return rendered if rendered else mo.group() 315 | 316 | if self.config.use_default: 317 | regex_dt = re.compile(rf"{_re.dt_default}{_re.ws}{_re.dd}", re.MULTILINE) 318 | content = re.sub(regex_dt, _replace_default, content) 319 | 320 | regex_dt = re.compile(rf"{_re.dt}{_re.ws}{_re.dd}", re.MULTILINE) 321 | ret = re.sub(regex_dt, _replace, content) 322 | return ret 323 | 324 | def _get_section_config(self, section): 325 | for entry in self.config.section_config: 326 | if entry['name'] == section: 327 | return entry 328 | return None 329 | 330 | def _get_config(self, section, entry): 331 | cfg = self._get_section_config(section) 332 | if not cfg or entry not in cfg: 333 | ret = self.config[entry] 334 | else: 335 | ret = cfg[entry] 336 | log.debug(f"_get_config({section}, {entry}): {ret}") 337 | return ret 338 | 339 | def on_config(self, config: co.Config) -> dict: 340 | """Process the configuration.""" 341 | # Initialize glossary with current config 342 | self._glossary = Glossary( 343 | ignore_case=self.config.ignore_case, 344 | plurals=self.config.plurals 345 | ) 346 | 347 | # Make templates path relative to the config file directory 348 | if self.config.templates: 349 | config_dir = os.path.dirname(config['config_file_path']) 350 | self.config.templates = os.path.normpath( 351 | os.path.join(config_dir, self.config.templates) 352 | ) 353 | 354 | return config 355 | 356 | 357 | def _preserve_visible_text_for_tooltip(s: str) -> str: 358 | """ 359 | Normalize nested glossary/HTML constructs to *visible text* only, so that 360 | _html2text() receives the intended words for tooltip rendering. 361 | 362 | Args: 363 | s (str): The input string containing glossary/HTML constructs. 364 | 365 | Returns: 366 | # Keep anchor text, drop the tag. 367 | # Note: This regex does not handle nested anchor tags, but nested elements are invalid in HTML and not expected in glossary definitions. 368 | s = re.sub(r']*>(.*?)', r'\1', s, flags=re.IGNORECASE | re.DOTALL) 369 | - -> TERM 370 | - <name:TERM> -> TERM 371 | - -> name 372 | - TEXT -> TEXT 373 | """ 374 | # Keep anchor text, drop the tag 375 | s = re.sub(r']*>(.*?)', r'\1', s, flags=re.IGNORECASE | re.DOTALL) 376 | # Glossary markers to plain text (escaped/raw/name-only) 377 | s = re.sub(r'<([A-Za-z0-9_-]+):([^><|]+)>', r'\2', s) 378 | s = re.sub(r'<([A-Za-z0-9_-]+):([^><|]+)>', r'\2', s) 379 | s = re.sub(r'<([A-Za-z0-9_-]+):>', r'\1', s) 380 | return s 381 | 382 | 383 | def _html2text(content): 384 | class HTMLFilter(parser.HTMLParser): 385 | def __init__(self): 386 | super().__init__() 387 | self.text = "" 388 | 389 | def handle_data(self, data): 390 | self.text += data 391 | 392 | f = HTMLFilter() 393 | log.debug(f"adding {content}") 394 | f.feed(content) 395 | return f.text.strip() -------------------------------------------------------------------------------- /src/mkdocs_ezglossary_plugin/plurals.py: -------------------------------------------------------------------------------- 1 | plurals = { 2 | 'en': { 3 | # Regular plurals 4 | 's$': [''], # cats -> cat 5 | 'es$': ['', 'is'], # boxes -> box, analysis -> analyses 6 | 'ies$': ['y'], # cities -> city 7 | 'ves$': ['f', 'fe'], # wolves -> wolf, knives -> knife 8 | 'ses$': ['s'], # buses -> bus 9 | 'zes$': ['z'], # quizzes -> quiz 10 | 11 | # Latin/Greek plurals 12 | 'i$': ['us'], # fungi -> fungus 13 | 'ae$': ['a'], # larvae -> larva 14 | 'a$': ['on', 'um'], # phenomena -> phenomenon, data -> datum 15 | 16 | # Irregular plurals 17 | 'children$': ['child'], 18 | 'geese$': ['goose'], 19 | 'men$': ['man'], # also handles: women -> woman 20 | 'teeth$': ['tooth'], 21 | 'feet$': ['foot'], 22 | 'mice$': ['mouse'], 23 | 'people$': ['person'], 24 | 'oxen$': ['ox'], 25 | 'indices$': ['index'], 26 | 'matrices$': ['matrix'], 27 | 'vertices$': ['vertex'], 28 | 'appendices$': ['appendix'], 29 | }, 30 | 31 | 'es': { 32 | # Regular plurals 33 | 'es$': [''], # árboles -> árbol, coches -> coche 34 | 's$': [''], # casas -> casa 35 | 36 | # Special cases with accent marks 37 | 'enes$': ['en'], # exámenes -> examen 38 | 'ones$': ['ón'], # situaciones -> situación 39 | 'eses$': ['és'], # ingleses -> inglés 40 | 41 | # Irregular plurals 42 | 'ces$': ['z'], # peces -> pez 43 | 44 | # Special plurals ending in -es 45 | 'ís$': ['í'], # rubís -> rubí 46 | 'ús$': ['ú'], # bambús -> bambú 47 | }, 48 | 49 | 'de': { 50 | # Regular plurals 51 | 'en$': [''], # Katzen -> Katze 52 | 'n$': [''], # Frauen -> Frau 53 | 'e$': [''], # Hunde -> Hund 54 | 'er$': [''], # Kinder -> Kind 55 | 56 | # Umlaut plurals (need to be handled in code) 57 | 'äuser$': ['aus'], # Häuser -> Haus 58 | 'äume$': ['aum'], # Bäume -> Baum 59 | 'änder$': ['and'], # Länder -> Land 60 | 'äfte$': ['aft'], # Kräfte -> Kraft 61 | 'ücher$': ['uch'], # Bücher -> Buch 62 | 'öfe$': ['of'], # Höfe -> Hof 63 | 64 | # Special cases 65 | 'ien$': ['ium'], # Laboratorien -> Laboratorium 66 | 'a$': ['um'], # Musea -> Museum 67 | 'i$': ['us'], # Alumni -> Alumnus 68 | }, 69 | 70 | 'fr': { 71 | # Regular plurals 72 | 's$': [''], # maisons -> maison 73 | 'x$': [''], # chevaux -> cheval 74 | 'aux$': ['al'], # journaux -> journal 75 | 'eaux$': ['eau'], # tableaux -> tableau 76 | 77 | # Special endings 78 | 'eux$': ['eu'], # jeux -> jeu 79 | 'oux$': ['ou'], # bijoux -> bijou 80 | 81 | # Irregular plurals that end in -s 82 | 'ils$': ['il'], # fils -> fil 83 | 'ails$': ['ail'], # travails -> travail 84 | 85 | # Academic terms (from Latin) 86 | 'a$': ['um'], # média -> médium 87 | 'i$': ['us'], # alumni -> alumnus 88 | 'ae$': ['a'], # algae -> alga 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/mkdocs_ezglossary_plugin/template.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from jinja2 import Environment, PackageLoader, select_autoescape, FileSystemLoader 4 | 5 | env = Environment( 6 | loader=PackageLoader("mkdocs_ezglossary_plugin"), 7 | autoescape=select_autoescape() 8 | ) 9 | 10 | 11 | def render(file, config, **kwargs): 12 | tpl = load(file, config) 13 | return tpl.render(**kwargs) 14 | 15 | 16 | def load(file, config): 17 | if not config.templates: 18 | return env.get_template(file) 19 | if not os.path.exists(os.path.join(config.templates, file)): 20 | return env.get_template(file) 21 | 22 | templateLoader = FileSystemLoader(searchpath=config.templates) 23 | templateEnv = Environment(loader=templateLoader) 24 | return templateEnv.get_template(file) 25 | -------------------------------------------------------------------------------- /src/mkdocs_ezglossary_plugin/templates/definition.html: -------------------------------------------------------------------------------- 1 |
2 | {{ fmt_pre|safe }}{{ term|safe }} {{ fmt_post|safe }} 3 |
4 |
{{ definition|safe }}{{ reflink }}
5 | 6 | -------------------------------------------------------------------------------- /src/mkdocs_ezglossary_plugin/templates/link.html: -------------------------------------------------------------------------------- 1 | {{ text|safe }} 6 | -------------------------------------------------------------------------------- /src/mkdocs_ezglossary_plugin/templates/refs-list.html: -------------------------------------------------------------------------------- 1 |

2 |

3 | {% set counter = 0 %} 4 | 15 |
16 |

17 | -------------------------------------------------------------------------------- /src/mkdocs_ezglossary_plugin/templates/refs-short.html: -------------------------------------------------------------------------------- 1 |

2 |

3 | {% for entry in entries %} 4 | 5 | 7 | [{{ loop.index }}] 8 | 9 | 10 | {% endfor %} 11 |
12 |

13 | -------------------------------------------------------------------------------- /src/mkdocs_ezglossary_plugin/templates/summary-detailed.html: -------------------------------------------------------------------------------- 1 |
2 | {% for term in terms %} 3 | {% set defs = glossary.get(section, term, 'defs') %} 4 |
{% if defs|length > 0 %} 5 | {% set def = defs[0] %} 6 | {{term}} 8 | {% else %} 9 | {{ term }} 10 | {% endif %} 11 |
12 |
13 |

14 | {{ glossary.definition(section, term) | safe }} 15 |

16 |
    17 | {% for entry in glossary.get(section, term, 'refs') %} 18 |
  • 19 | {{ entry.page.title }} 20 |
  • 21 | {% endfor %} 22 |
23 |
24 | {% endfor %} 25 |
26 | -------------------------------------------------------------------------------- /src/mkdocs_ezglossary_plugin/templates/summary-table.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | {% if types|length > 0 %} 8 | 9 | {% endif %} 10 | 11 | 12 | 13 | {% for term in terms %} 14 | 15 | 23 | 24 | 25 | {% if types|length > 0 %} 26 | 35 | {% endif %} 36 | 37 | {% endfor %} 38 | 39 |
TermDefinitionReferences
{% if defs|length > 0 %} 16 | {% set def = defs[0] %} 17 | {{term}} 19 | {% else %} 20 | {{ term }} 21 | {% endif %} 22 | {{term}}{{ glossary.definition(section, term) | safe }} 27 |
    28 | {% for entry in glossary.get(section, term, 'refs') %} 29 |
  • 30 | {{ entry.page.title }} 31 |
  • 32 | {% endfor %} 33 |
34 |
40 |
41 | -------------------------------------------------------------------------------- /src/mkdocs_ezglossary_plugin/templates/summary.html: -------------------------------------------------------------------------------- 1 |
2 | {% for term in terms %} 3 | {% set defs = glossary.get(section, term, 'defs') %} 4 |
{% if defs|length > 0 %} 5 | {% set def = defs[0] %} 6 | {{term}} 8 | {% else %} 9 | {{ term }} 10 | {% endif %} 11 |
12 |
13 |
    14 | {% for type in types %} 15 | {% for entry in glossary.get(section, term, type) %} 16 |
  • 17 | {{ entry.page.title }} 18 |
  • 19 | {% endfor %} 20 | {% endfor %} 21 |
22 |
23 | {% endfor %} 24 |
25 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import yaml 3 | 4 | import mock 5 | 6 | 7 | @pytest.fixture 8 | def config(): 9 | _default = """ 10 | strict: false 11 | list_definitions: true 12 | list_references: true 13 | inline_refs: "none" 14 | tooltip: "none" 15 | ignore_case: false 16 | plurals: "none" 17 | sections: [] 18 | section_config: [] 19 | templates: 20 | use_default: false 21 | """ 22 | config = yaml.safe_load(_default) 23 | return mock.Config(**config) 24 | 25 | 26 | @pytest.fixture 27 | def simple(): 28 | _simple = dict( 29 | file="simple.md", 30 | title="Hello", 31 | html=""" 32 | 33 |
34 |
35 |
test:first
36 |
first term
37 |
test:second
38 |
second term 39 | this text is formatted 40 |
41 |
test:third
42 |
43 | third term 44 | 45 | detailed description of third term 46 |
47 |
test:hyphens-abc def
48 |
hyphen abc def
49 |
test:hyphens-abc xyz
50 |
hyphen abc xyz
51 |
52 |
53 |
default
54 |
default term
55 |
default2
56 |
default2 term
57 |
default3
58 |
default3 term
59 |
🚧🚧
60 |
default 🚧🚧🚧
61 |
62 |
63 |
64 |
65 |
demo:first
66 |
demo 1
67 |
demo:second
68 |
demo 2
69 |
demo:third
70 |
demo 3
71 |
demo:formatted
72 |
formatted dd
73 |
demo:🚧
74 |
demo 🚧🚧🚧
75 |
76 |
77 |
plurals:GPU
78 |
GPU definition
79 |
plurals:potato
80 |
potato definition
81 |
plurals:potatoes
82 |
potatoes definition
83 |
plurals:goose
84 |
goose definition
85 |
plurals:goose
86 |
goose definition
87 |
plurals:city
88 |
city definition
89 |
plurals:child
90 |
children definition
91 |
plurals:grandchild
92 |
grandchild definition
93 |
94 |
95 | 96 |

See for details.

97 |

See for details.

98 |

See for details.

99 |

See for details.

100 | 101 | """ 102 | ) 103 | return mock.Page.fromdict(_simple) 104 | 105 | 106 | @pytest.fixture 107 | def summary(): 108 | _simple = dict( 109 | file="summary.md", 110 | title="Summary", 111 | html=""" 112 | 113 |
114 | 115 |
116 | 117 |
118 | 119 |
120 | 121 |
122 | 123 |
124 | 125 | 126 |

See for details

127 |

See for details.

128 |

See for details

129 |

See for details.

130 |

See for details.

131 |

mythird

132 |

133 |

See for details.

134 |

See refers to 🚧 for details.

135 |

See for details.

136 |

137 |

See for details

138 |

See for details

139 |

See for details

140 |

See for details

141 |

See for details

142 |

See for details

143 | 144 | """ 145 | ) 146 | return mock.Page.fromdict(_simple) 147 | 148 | 149 | @pytest.fixture 150 | def tablesummary(): 151 | return mock.Page( 152 | file="tablesummary.md", 153 | title="Summary", 154 | ctype="html", 155 | content=""" 156 | 157 |
158 | 159 |
160 | 161 | """) 162 | -------------------------------------------------------------------------------- /tests/custom/summary.html: -------------------------------------------------------------------------------- 1 |
2 | {% for term in terms %} 3 |
{{term}}
4 |
5 |
    6 | {% for type in types %} 7 | {% for entry in glossary.get(section, term, type) %} 8 |
  • 9 | {{ entry.page.title }} 10 | [{{ type[:-1] }}] 11 |
  • 12 | {% endfor %} 13 | {% endfor %} 14 |
15 |
16 | {% endfor %} 17 |
18 | -------------------------------------------------------------------------------- /tests/mock.py: -------------------------------------------------------------------------------- 1 | from lxml import etree 2 | import logging 3 | 4 | from mkdocs_ezglossary_plugin.plugin import GlossaryPlugin 5 | 6 | log = logging.getLogger() 7 | 8 | 9 | class Config(dict): 10 | def __getattr__(self, name): 11 | return self.get(name) 12 | 13 | 14 | class Page: 15 | def __init__(self, title: str, file: str, content: str, ctype="html", meta={}): 16 | self.file = file 17 | self.title = title 18 | self.content = content 19 | self.ctype = ctype 20 | self.meta = meta 21 | 22 | @property 23 | def url(self): 24 | return self.file 25 | 26 | @property 27 | def canonical_url(self): 28 | return f"/{self.file.rsplit('.', maxsplit=1)[0]}/" 29 | 30 | @staticmethod 31 | def fromdict(data: dict): 32 | return Page(data['title'], data['file'], data['html']) 33 | 34 | def __repr__(self): 35 | return f"Page({self.title}, {self.file})" 36 | 37 | 38 | def render_single(page, config): 39 | return render([page], config)[page.url] 40 | 41 | 42 | def render(pages, config): 43 | parser = etree.XMLParser(recover=True) 44 | files = [] 45 | results = {} 46 | plugin = GlossaryPlugin() 47 | plugin.config = config 48 | plugin.on_pre_build(config) 49 | for page in pages: 50 | if page.ctype == "markdown": 51 | results[page.url] = plugin.on_page_markdown(page.content, page, config, files) 52 | for page in pages: 53 | if page.ctype == "html": 54 | results[page.url] = plugin.on_page_content(page.content, page, config, files) 55 | for page in pages: 56 | if page.ctype == "html": 57 | results[page.url] = plugin.on_post_page(results[page.url], page, config) 58 | for page in pages: 59 | fp = open(page.url + "." + page.ctype, "w", encoding="utf-8") 60 | fp.write(results[page.url]) 61 | fp.close() 62 | # log.debug(f"--- >>> {page.url}") 63 | # log.debug(results[page.url]) 64 | # log.debug(f"--- <<< {page.url}") 65 | if page.ctype == "html": 66 | results[page.url] = etree.fromstring(results[page.url], parser=parser) 67 | return results 68 | -------------------------------------------------------------------------------- /tests/test_definitions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import mock 3 | 4 | from yaxp import xpath 5 | 6 | from mkdocs_ezglossary_plugin.glossary import get_id 7 | 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | def test_default_config(simple, config): 13 | html = mock.render_single(simple, config) 14 | log.debug(html) 15 | dl = xpath.dl 16 | dl = dl.has(xpath.dt.has(xpath.a(id=get_id("test", "first", "defs", 0), text="first"))) 17 | dl = dl.has(xpath.dd(text="first term")) 18 | dl = dl.has(xpath.dt.has(xpath.a(id=get_id("test", "second", "defs", 0), text="second"))) 19 | dl = dl.has(xpath.dd(text="*second term")) 20 | assert len(html.xpath(str(dl))) 21 | 22 | dl = xpath.dl 23 | dl = dl.has(xpath.dt.has(xpath.a(id=get_id("demo", "first", "defs", 0), text="first"))) 24 | dl = dl.has(xpath.dd(text="demo 1")) 25 | dl = dl.has(xpath.dt.has(xpath.a(id=get_id("demo", "second", "defs", 0), text="second"))) 26 | dl = dl.has(xpath.dd(text="demo 2")) 27 | assert len(html.xpath(str(dl))) 28 | 29 | dl = xpath.dl 30 | dl = dl.has(xpath.dd(text="*demo 2").has(xpath.a())) 31 | assert len(html.xpath(str(dl))) == 0 32 | 33 | 34 | def test_inline_refs_short(simple, config): 35 | config['inline_refs'] = "short" 36 | html = mock.render_single(simple, config) 37 | 38 | dl = xpath.dl 39 | dl = dl.has(xpath.dt.has(xpath.a(id=get_id("test", "first", "defs", 0), text="first"))) 40 | dl = dl.has(xpath.dd(text="*first term").has(xpath.a())) 41 | assert len(html.xpath(str(dl))) == 0 42 | 43 | dl = xpath.dl() 44 | dl = dl.has(xpath.dt.has(xpath.a(id=get_id("test", "third", "defs", 0), text="third"))) 45 | dl = dl.has(xpath.dd(text="*third term").has(xpath.a(title="Hello", 46 | href="../simple.md#" + get_id("test", "third", "refs", 0), 47 | text="*[1]"))) 48 | assert len(html.xpath(str(dl))) == 1 49 | 50 | 51 | def test_inline_refs_plurals(simple, config): 52 | config['inline_refs'] = "short" 53 | config['plurals'] = "en" 54 | html = mock.render_single(simple, config) 55 | 56 | dl = xpath.dl() 57 | dl = dl.has(xpath.dt.has(xpath.a(id=get_id("plurals", "grandchild", "defs", 0), text="grandchild"))) 58 | dl = dl.has(xpath.dd(text="*grandchild").has(xpath.a(title="Hello", 59 | href="../simple.md#" + get_id("plurals", 60 | "grandchildren", "refs", 0), 61 | text="*[1]"))) 62 | assert len(html.xpath(str(dl))) == 1 63 | 64 | 65 | def test_inline_refs_list(simple, config): 66 | config['inline_refs'] = "list" 67 | html = mock.render_single(simple, config) 68 | 69 | dl = xpath.dl() 70 | dl = dl.has(xpath.dt.has(xpath.a(id=get_id("test", "third", "defs", 0), text="third"))) 71 | dl = dl.has(xpath.dd(text="*third term").has(xpath.a(title="Hello", 72 | href="../simple.md#" + get_id("test", "third", "refs", 0), 73 | text="*Hello"))) 74 | assert len(html.xpath(str(dl))) == 1 75 | 76 | 77 | def test_default_section(simple, config): 78 | config['use_default'] = True 79 | html = mock.render_single(simple, config) 80 | log.debug(html) 81 | dl = xpath.dl 82 | dl = dl.has(xpath.dt.has(xpath.a(id=get_id("_", "default", "defs", 0), text="default"))) 83 | dl = dl.has(xpath.dd(text="default term")) 84 | assert len(html.xpath(str(dl))) 85 | 86 | dl = xpath.dl 87 | dl = dl.has(xpath.dt.has(xpath.a(id=get_id("demo", "first", "defs", 0), text="first"))) 88 | dl = dl.has(xpath.dd(text="demo 1")) 89 | dl = dl.has(xpath.dt.has(xpath.a(id=get_id("demo", "second", "defs", 0), text="second"))) 90 | dl = dl.has(xpath.dd(text="demo 2")) 91 | assert len(html.xpath(str(dl))) 92 | 93 | dl = xpath.dl 94 | dl = dl.has(xpath.dd(text="*demo 2").has(xpath.a())) 95 | assert len(html.xpath(str(dl))) == 0 96 | 97 | 98 | def test_formatted_dt(simple, config): 99 | html = mock.render_single(simple, config) 100 | log.debug(html) 101 | dl = xpath.dl 102 | dl = dl.has(xpath.dt.bold.em.code.has(xpath.a(id=get_id("demo", "formatted", "defs", 0), text="formatted"))) 103 | dl = dl.has(xpath.dd(text="formatted dd")) 104 | assert len(html.xpath(str(dl))) 105 | -------------------------------------------------------------------------------- /tests/test_glossary.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mkdocs_ezglossary_plugin.glossary import Glossary, Entry 3 | from mock import Page 4 | 5 | @pytest.fixture 6 | def mock_page(): 7 | return Page( 8 | title="Test Page", 9 | file="test.md", 10 | content="Test content", 11 | ctype="markdown" 12 | ) 13 | 14 | @pytest.fixture 15 | def basic_glossary(): 16 | return Glossary(ignore_case=False, plurals='none') 17 | 18 | @pytest.fixture 19 | def case_insensitive_glossary(): 20 | return Glossary(ignore_case=True, plurals='none') 21 | 22 | @pytest.fixture 23 | def en_plural_glossary(): 24 | return Glossary(ignore_case=False, plurals='en') 25 | 26 | @pytest.fixture 27 | def inflect_plural_glossary(): 28 | return Glossary(ignore_case=False, plurals='inflect') 29 | 30 | class TestGlossaryBasic: 31 | def test_empty_glossary(self, basic_glossary): 32 | assert basic_glossary.has('test') == False 33 | assert basic_glossary.terms('test') == [] 34 | assert basic_glossary.get('test', 'term', 'refs') == [] 35 | assert basic_glossary.definition('test', 'term') == "" 36 | 37 | def test_add_definition(self, basic_glossary, mock_page): 38 | id1 = basic_glossary.add('test', 'term1', 'defs', mock_page, 'Definition 1') 39 | assert basic_glossary.has('test') 40 | assert basic_glossary.terms('test') == ['term1'] 41 | assert basic_glossary.definition('test', 'term1') == 'Definition 1' 42 | 43 | def test_add_reference(self, basic_glossary, mock_page): 44 | id1 = basic_glossary.add('test', 'term1', 'refs', mock_page) 45 | refs = basic_glossary.get('test', 'term1', 'refs') 46 | assert len(refs) == 1 47 | assert refs[0].page == mock_page 48 | 49 | def test_multiple_entries(self, basic_glossary, mock_page): 50 | # Add multiple definitions and references 51 | basic_glossary.add('test', 'term1', 'defs', mock_page, 'Definition 1') 52 | basic_glossary.add('test', 'term2', 'defs', mock_page, 'Definition 2') 53 | basic_glossary.add('test', 'term1', 'refs', mock_page) 54 | basic_glossary.add('test', 'term2', 'refs', mock_page) 55 | 56 | assert set(basic_glossary.terms('test')) == {'term1', 'term2'} 57 | assert len(basic_glossary.get('test', 'term1', 'refs')) == 1 58 | assert len(basic_glossary.get('test', 'term2', 'refs')) == 1 59 | 60 | class TestGlossaryCaseSensitivity: 61 | def test_case_sensitive_match(self, basic_glossary, mock_page): 62 | basic_glossary.add('test', 'Term', 'defs', mock_page, 'Definition') 63 | assert basic_glossary.get('test', 'term', 'defs') == [] 64 | assert basic_glossary.get('test', 'Term', 'defs') != [] 65 | 66 | def test_case_insensitive_match(self, case_insensitive_glossary, mock_page): 67 | case_insensitive_glossary.add('test', 'Term', 'defs', mock_page, 'Definition') 68 | assert len(case_insensitive_glossary.get('test', 'term', 'defs')) == 1 69 | assert len(case_insensitive_glossary.get('test', 'TERM', 'defs')) == 1 70 | assert len(case_insensitive_glossary.get('test', 'Term', 'defs')) == 1 71 | 72 | class TestGlossaryPlurals: 73 | def test_en_plural_rules(self, en_plural_glossary, mock_page): 74 | # Test cases as tuples of (singular, plural) 75 | test_cases = [ 76 | ('cat', 'cats'), # Basic 's' plural 77 | ('box', 'boxes'), # 'es' plural 78 | ('city', 'cities'), # 'y' to 'ies' 79 | ('knife', 'knives'), # 'fe' to 'ves' 80 | ('child', 'children'), # Irregular plural 81 | ('person', 'people'), # Irregular plural 82 | ('mouse', 'mice'), # Irregular plural 83 | ('tooth', 'teeth'), # Irregular plural 84 | ] 85 | 86 | for singular, plural in test_cases: 87 | # Test singular->plural 88 | en_plural_glossary.add('test', singular, 'defs', mock_page, f'Definition of {singular}') 89 | plural_results = en_plural_glossary.get('test', plural, 'defs') 90 | assert len(plural_results) == 1, f"Failed to find plural '{plural}' for singular '{singular}'" 91 | 92 | # Clear and test plural->singular 93 | en_plural_glossary.clear() 94 | en_plural_glossary.add('test', plural, 'defs', mock_page, f'Definition of {plural}') 95 | singular_results = en_plural_glossary.get('test', singular, 'defs') 96 | assert len(singular_results) == 1, f"Failed to find singular '{singular}' for plural '{plural}'" 97 | en_plural_glossary.clear() 98 | 99 | @pytest.mark.inflect 100 | def test_inflect_plurals(self, inflect_plural_glossary, mock_page): 101 | # Test cases as tuples of (singular, plural) 102 | test_cases = [ 103 | ('dog', 'dogs'), # Regular plural 104 | ('church', 'churches'), # 'es' plural 105 | ('baby', 'babies'), # 'y' to 'ies' 106 | ('wife', 'wives'), # 'fe' to 'ves' 107 | ('criterion', 'criteria'), # Latin plural 108 | # ('analysis', 'analyses'), # Greek plural 109 | ('mouse', 'mice'), # Irregular plural 110 | ('person', 'people'), # Irregular plural 111 | ] 112 | 113 | for singular, plural in test_cases: 114 | # Test singular->plural 115 | inflect_plural_glossary.add('test', singular, 'defs', mock_page, f'Definition of {singular}') 116 | plural_results = inflect_plural_glossary.get('test', plural, 'defs') 117 | assert len(plural_results) == 1, f"Failed to find plural '{plural}' for singular '{singular}'" 118 | 119 | # Clear and test plural->singular 120 | inflect_plural_glossary.clear() 121 | inflect_plural_glossary.add('test', plural, 'defs', mock_page, f'Definition of {plural}') 122 | singular_results = inflect_plural_glossary.get('test', singular, 'defs') 123 | assert len(singular_results) == 1, f"Failed to find singular '{singular}' for plural '{plural}'" 124 | inflect_plural_glossary.clear() 125 | 126 | def test_no_plurals(self, basic_glossary, mock_page): 127 | # Test that plural matching is disabled when plurals='none' 128 | basic_glossary.add('test', 'cat', 'defs', mock_page, 'A feline animal') 129 | assert len(basic_glossary.get('test', 'cats', 'defs')) == 0 130 | 131 | basic_glossary.add('test', 'boxes', 'defs', mock_page, 'Multiple containers') 132 | assert len(basic_glossary.get('test', 'box', 'defs')) == 0 133 | 134 | class TestGlossaryMultipleSections: 135 | def test_multiple_sections(self, basic_glossary, mock_page): 136 | # Add terms to different sections 137 | basic_glossary.add('animals', 'cat', 'defs', mock_page, 'A feline') 138 | basic_glossary.add('colors', 'red', 'defs', mock_page, 'A color') 139 | 140 | assert basic_glossary.has('animals') 141 | assert basic_glossary.has('colors') 142 | assert 'cat' in basic_glossary.terms('animals') 143 | assert 'red' in basic_glossary.terms('colors') 144 | assert 'cat' not in basic_glossary.terms('colors') 145 | 146 | def test_same_term_different_sections(self, basic_glossary, mock_page): 147 | # Add same term to different sections 148 | basic_glossary.add('section1', 'term', 'defs', mock_page, 'Definition 1') 149 | basic_glossary.add('section2', 'term', 'defs', mock_page, 'Definition 2') 150 | 151 | assert basic_glossary.definition('section1', 'term') == 'Definition 1' 152 | assert basic_glossary.definition('section2', 'term') == 'Definition 2' 153 | 154 | class TestGlossaryEdgeCases: 155 | def test_clear_glossary(self, basic_glossary, mock_page): 156 | basic_glossary.add('test', 'term', 'defs', mock_page, 'Definition') 157 | basic_glossary.clear() 158 | assert not basic_glossary.has('test') 159 | assert basic_glossary.terms('test') == [] 160 | 161 | def test_nonexistent_section(self, basic_glossary): 162 | assert basic_glossary.terms('nonexistent') == [] 163 | assert basic_glossary.get('nonexistent', 'term', 'refs') == [] 164 | assert basic_glossary.definition('nonexistent', 'term') == '' 165 | 166 | def test_whitespace_handling(self, basic_glossary, mock_page): 167 | basic_glossary.add('test', ' term ', 'defs', mock_page, 'Definition') 168 | assert 'term' in basic_glossary.terms('test') 169 | assert basic_glossary.definition('test', 'term') == 'Definition' -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from yaxp import xpath as xp 4 | 5 | from mkdocs_ezglossary_plugin.glossary import get_id, Glossary 6 | 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | def has_link(page, 11 | section: str, 12 | term: str, 13 | title: str, 14 | href: str, 15 | text: str, 16 | destination: str = None, 17 | index: int = 0, 18 | is_page_ref: bool = False) -> bool: 19 | """Check if a page contains a glossary link with the specified attributes. 20 | 21 | Args: 22 | page: The page HTML to check 23 | section: The glossary section 24 | term: The term to look for (source term) 25 | title: Expected title attribute 26 | href: Expected base href (e.g., "../simple.md") 27 | text: Expected link text 28 | destination: The term being linked to (defaults to term if not specified) 29 | index: Index of the reference (default: 0) 30 | 31 | Returns: 32 | bool: True if the link exists with all specified attributes 33 | """ 34 | # Use destination term if provided, otherwise use source term 35 | dest_term = destination if destination is not None else term 36 | 37 | # Get IDs for both the reference and the definition 38 | ref_id = get_id(section, term, "refs", index) 39 | log.debug(f"ref_id: {ref_id} for {section}:{term}, refs, {index}") 40 | def_id = "" if is_page_ref else get_id(section, dest_term, "defs", 0) 41 | get_id(section, dest_term, "defs", 0) 42 | log.debug(f"def_id: {def_id} for {section}:{term} -> {dest_term}, defs, 0") 43 | 44 | full_href = f"{href}#{def_id}" 45 | 46 | # Build link attributes 47 | attrs = { 48 | "_class": "mkdocs-ezglossary-link", 49 | "id": ref_id, 50 | "href": full_href, 51 | "text": text 52 | } 53 | if title: 54 | attrs["title"] = "*" + title 55 | 56 | link = xp.a(**attrs) 57 | xpath_str = str(link) 58 | 59 | log.warning(f"Looking for link with xpath: {xpath_str}") 60 | log.warning(f"Expected ref_id: {ref_id}") 61 | log.warning(f"Expected href: {full_href}") 62 | 63 | # Get all links and log them 64 | all_links = page.xpath("//a[@class='mkdocs-ezglossary-link']") 65 | log.warning(f"Found {len(all_links)} glossary links in document:") 66 | for l in all_links: 67 | log.warning(f"Link: id='{l.get('id')}' href='{l.get('href')}' text='{l.text}' title='{l.get('title')}'") 68 | 69 | result = len(page.xpath(xpath_str)) == 1 70 | log.warning(f"Match result: {result}") 71 | 72 | return result 73 | 74 | def has_definition(page, section: str, term: str, definition: str, index: int = 0) -> bool: 75 | """Check if a page contains a term definition with the specified attributes. 76 | 77 | Args: 78 | page: The page HTML to check 79 | section: The glossary section 80 | term: The term to look for 81 | definition: Expected definition text 82 | index: Index of the definition (default: 0) 83 | 84 | Returns: 85 | bool: True if the definition exists with all specified attributes 86 | """ 87 | dl = xp.dl 88 | dl = dl.has(xp.dt.has(xp.a(id=get_id(section, term, "defs", index), text=term))) 89 | dl = dl.has(xp.dd(text=definition)) 90 | return len(page.xpath(str(dl))) == 1 91 | 92 | def has_summary_entry(page, 93 | section: str, 94 | term: str, 95 | href: str = None, 96 | ref_text: str = None): 97 | """Check if a reference entry exists in the glossary summary.""" 98 | # Create xpath for the dl element with class and id 99 | dl = xp.dl(_class="mkdocs-ezglossary-summary", _id=section) 100 | 101 | # Find dt containing the term (case sensitive) 102 | dt = xp.dt(_="*" + term) 103 | dl = dl.has(dt) 104 | 105 | if href: 106 | # Calculate the full href with the target id 107 | full_href = f"{href}#{get_id(section, term.lower(), 'defs', 0)}" 108 | 109 | # Find dd containing a link with the exact href and text 110 | dd = xp.dd.ul.li.a(href=full_href, text=ref_text) 111 | dl = dl.has(dd) 112 | else: 113 | full_href = None 114 | 115 | xpath_str = str(dl) 116 | log.warning(f"Looking for summary entry with xpath: {xpath_str}") 117 | log.warning(f"Expected section: {section}") 118 | log.warning(f"Expected term: {term}") 119 | log.warning(f"Expected href: {full_href}") 120 | log.warning(f"Expected text: {ref_text}") 121 | 122 | # Get all summary entries and log them 123 | all_entries = page.xpath("//dl[@class='mkdocs-ezglossary-summary']") 124 | log.warning(f"Found {len(all_entries)} summary sections in document:") 125 | for entry in all_entries: 126 | section_id = entry.get('id') 127 | terms = entry.xpath(".//dt") 128 | refs = entry.xpath(".//dd//a") 129 | log.warning(f"Section '{section_id}':") 130 | for t in terms: 131 | log.warning(f" Term: '{t.text.strip()}'") 132 | for r in refs: 133 | log.warning(f" Reference: href='{r.get('href')}' text='{r.text.strip()}'") 134 | 135 | result = len(page.xpath(xpath_str)) == 1 136 | log.warning(f"Match result: {result}") 137 | 138 | return result 139 | 140 | def has_summary_reference(page, section: str, term: str, ref_href: str, ref_text: str, index: int = 0) -> bool: 141 | """Check if a summary entry contains a reference with the specified attributes. 142 | 143 | Args: 144 | page: The page HTML to check 145 | section: The glossary section 146 | term: The term to look for 147 | ref_href: Expected reference href 148 | ref_text: Expected reference text 149 | index: Index of the reference (default: 0) 150 | 151 | Returns: 152 | bool: True if the summary reference exists with all specified attributes 153 | """ 154 | dl = xp.dl(_class="mkdocs-ezglossary-summary", _id=section) 155 | dl = dl.has(xp.dt.has(xp.a(text=term))) 156 | dl = dl.has(xp.dd.ul.li.a(href=ref_href, text=ref_text)) 157 | return len(page.xpath(str(dl))) == 1 -------------------------------------------------------------------------------- /tests/test_link.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from yaxp import xpath 4 | 5 | from mkdocs_ezglossary_plugin.glossary import get_id 6 | 7 | import mock 8 | from test_helpers import has_link 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | def _hash(s): 14 | return str(hash(s)) 15 | 16 | 17 | def test_link_no_title(simple, config): 18 | html = mock.render_single(simple, config) 19 | 20 | assert has_link( 21 | page=html, 22 | section="test", 23 | term="third", 24 | title="", 25 | href="../simple.md", 26 | text="third" 27 | ) 28 | 29 | 30 | def test_link_case_sensitive(simple, config): 31 | html = mock.render_single(simple, config) 32 | 33 | # Should not find link when case doesn't match and ignore_case is False 34 | assert not has_link( 35 | page=html, 36 | section="test", 37 | term="First", # Capital F 38 | title="", 39 | href="../simple.md", 40 | text="First" 41 | ) 42 | 43 | 44 | def test_link_ignore_case(simple, config): 45 | config['ignore_case'] = True 46 | html = mock.render_single(simple, config) 47 | 48 | # Should find link when ignore_case is True, regardless of case 49 | assert has_link( 50 | page=html, 51 | section="test", 52 | term="first", 53 | title="", 54 | href="../simple.md", 55 | text="First" 56 | ) 57 | 58 | 59 | def test_link_with_title(simple, config): 60 | config['tooltip'] = 'full' # Enable tooltips 61 | html = mock.render_single(simple, config) 62 | 63 | assert has_link( 64 | page=html, 65 | section="test", 66 | term="third", 67 | title="third term", 68 | href="../simple.md", 69 | text="third" 70 | ) 71 | 72 | 73 | def test_link_with_spaces(simple, summary, config): 74 | summary = mock.render([simple, summary], config)['summary.md'] 75 | 76 | assert has_link( 77 | page=summary, 78 | section="test", 79 | term="hyphens-abc xyz", 80 | title="", 81 | href="../simple.md", 82 | text="hyphens-abc xyz" 83 | ) 84 | 85 | 86 | def test_unicode_links(simple, summary, config): 87 | summary = mock.render([simple, summary], config)['summary.md'] 88 | 89 | assert has_link( 90 | page=summary, 91 | section="demo", 92 | term="🚧", 93 | title="", 94 | href="../simple.md", 95 | text="🚧" 96 | ) 97 | 98 | 99 | def test_plural_links(simple, summary, config): 100 | config['plurals'] = 'en' 101 | summary = mock.render([simple, summary], config)['summary.md'] 102 | 103 | # Test various plural forms 104 | # Format: (section, plural, singular, index) 105 | test_cases = [ 106 | ("plurals", "GPUs", "GPU", 0), 107 | ("plurals", "geese", "goose", 0), 108 | ("plurals", "children", "child", 0), 109 | ("plurals", "cities", "city", 0), 110 | # 2 definitions exist: potato and potatoes, in this case, the plural is used 111 | ("plurals", "potatoes", "potatoes", 0), 112 | ("plurals", "grandchildren", "grandchild", 1), 113 | ] 114 | 115 | for section, plural, singular, index in test_cases: 116 | assert has_link( 117 | page=summary, 118 | section=section, 119 | term=plural, 120 | title="", 121 | href="../simple.md", 122 | text=plural, 123 | destination=singular, 124 | index=index 125 | ), f"Failed to find link for plural '{plural}' (singular: '{singular}')" 126 | 127 | 128 | def test_link_short_title(simple, config): 129 | config['tooltip'] = "short" 130 | html = mock.render_single(simple, config) 131 | log.debug(html) 132 | 133 | dl = xpath.body.p.a(id=get_id("test", "third", "refs", 0), 134 | title="third term", 135 | href="../simple.md#" + get_id("test", "third", "defs", 0), 136 | text="third") 137 | assert len(html.xpath(str(dl))) == 1 138 | 139 | 140 | def test_link_full_title(simple, config): 141 | config['tooltip'] = "full" 142 | html = mock.render_single(simple, config) 143 | log.debug(html) 144 | 145 | dl = xpath.body.p.a(_class="mkdocs-ezglossary-link", 146 | id=get_id("test", "third", "refs", 0), 147 | title="*detailed description of third term", 148 | href="../simple.md#" + get_id("test", "third", "defs", 0), 149 | text="third") 150 | assert len(html.xpath(str(dl))) == 1 151 | 152 | 153 | def test_link_replace_html(simple, config): 154 | config['tooltip'] = "full" 155 | html = mock.render_single(simple, config) 156 | log.debug(html) 157 | 158 | dl = xpath.body.p.a(id=get_id("test", "second", "refs", 0), 159 | title="*this text is formatted", 160 | href="../simple.md#" + get_id("test", "second", "defs", 0), 161 | text="mysecond") 162 | assert len(html.xpath(str(dl))) == 1 163 | 164 | 165 | def test_link_second_ref(simple, summary, config): 166 | config['tooltip'] = "full" 167 | summary = mock.render([simple, summary], config)['summary.md'] 168 | log.debug(summary) 169 | 170 | assert has_link( 171 | page=summary, 172 | section="test", 173 | term="third", 174 | title="third term", 175 | href="../simple.md", 176 | text="third", 177 | destination="third", 178 | index=1 179 | ) 180 | assert has_link( 181 | page=summary, 182 | section="test", 183 | term="third", 184 | title="third term", 185 | href="../simple.md", 186 | text="mythird", 187 | destination="third", 188 | index=2 189 | ) 190 | 191 | 192 | def test_link_default_ref_dis(simple, summary, config): 193 | """ Ensure definitions for the default sections are 194 | ignored when the configuration `use_default` is set 195 | to `False`. 196 | """ 197 | summary = mock.render([simple, summary], config)['summary.md'] 198 | log.debug(summary) 199 | 200 | assert not has_link( 201 | page=summary, 202 | section="_", 203 | term="default", 204 | title="", 205 | href="../simple.md", 206 | text="default", 207 | destination="default" 208 | ) 209 | 210 | 211 | def test_link_default_ref_enabled(simple, summary, config): 212 | """ Ensure definitions for the default sections are 213 | replaced when the configuration `use_default` is set 214 | to `True`. 215 | """ 216 | config['use_default'] = True 217 | summary = mock.render([simple, summary], config)['summary.md'] 218 | log.debug(summary) 219 | 220 | assert has_link( 221 | page=summary, 222 | section="_", 223 | term="default", 224 | title="", 225 | href="../simple.md", 226 | text="default", 227 | destination="default" 228 | ) 229 | assert has_link( 230 | page=summary, 231 | section="_", 232 | term="default2", 233 | title="", 234 | href="../simple.md", 235 | text="mydef2", 236 | destination="default2" 237 | ) 238 | assert has_link( 239 | page=summary, 240 | section="_", 241 | term="default3", 242 | title="", 243 | href="../simple.md", 244 | text="mydef3", 245 | destination="default3" 246 | ) 247 | 248 | 249 | def test_markdown_links_disabled(simple, summary, config): 250 | """ Ensure that markdown links are ignored when `markdown_links` is set to false. 251 | """ 252 | summary = mock.render([simple, summary], config)['summary.md'] 253 | log.debug(summary) 254 | 255 | dl = xpath.body.p.a(href="test:third", 256 | text="mythird") 257 | assert len(summary.xpath(str(dl))) == 1 258 | 259 | 260 | def test_markdown_links_enabled(simple, summary, config): 261 | """ Ensure markdown links are resolved when `markdown_links` is set to true. 262 | """ 263 | config['markdown_links'] = True 264 | summary = mock.render([simple, summary], config)['summary.md'] 265 | log.debug(summary) 266 | 267 | assert has_link( 268 | page=summary, 269 | section="test", 270 | term="third", 271 | title="", 272 | href="../simple.md", 273 | text="third", 274 | destination="third", 275 | index=1 276 | ) 277 | assert has_link( 278 | page=summary, 279 | section="test", 280 | term="third", 281 | title="", 282 | href="../simple.md", 283 | text="mythird", 284 | destination="third", 285 | index=2 286 | ) 287 | 288 | 289 | def test_unicode(simple, summary, config): 290 | """ Unicode links are processed. 291 | """ 292 | config['markdown_links'] = True 293 | config['use_default'] = True 294 | config['tooltip'] = "short" 295 | summary = mock.render([simple, summary], config)['summary.md'] 296 | log.debug(summary) 297 | 298 | assert has_link( 299 | page=summary, 300 | section="demo", 301 | term="🚧", 302 | title="demo 🚧🚧🚧", 303 | href="../simple.md", 304 | text="🚧", 305 | destination="🚧" 306 | ) 307 | assert has_link( 308 | page=summary, 309 | section="_", 310 | term="🚧🚧", 311 | title="default 🚧🚧🚧", 312 | href="../simple.md", 313 | text="refers to 🚧", 314 | destination="🚧🚧" 315 | ) 316 | 317 | 318 | def test_plurals_inflect(simple, summary, config): 319 | """ Plurals are mapped to singulars 320 | """ 321 | config['tooltip'] = "full" 322 | config['plurals'] = 'inflect' 323 | summary = mock.render([simple, summary], config)['summary.md'] 324 | log.debug(summary) 325 | 326 | assert has_link( 327 | page=summary, 328 | section='plurals', 329 | term='children', 330 | title='children definition', 331 | href='../simple.md', 332 | text='children', 333 | destination='child' 334 | ) 335 | assert has_link( 336 | page=summary, 337 | section='plurals', 338 | term='geese', 339 | title='goose definition', 340 | href='../simple.md', 341 | text='geese', 342 | destination='goose' 343 | ) 344 | 345 | 346 | def test_plurals_en(simple, summary, config): 347 | """ Plurals are mapped to singulars 348 | """ 349 | config['tooltip'] = "full" 350 | config['plurals'] = 'en' 351 | summary = mock.render([simple, summary], config)['summary.md'] 352 | log.debug(summary) 353 | 354 | assert has_link( 355 | page=summary, 356 | section='plurals', 357 | term='children', 358 | title='children definition', 359 | href='../simple.md', 360 | text='children', 361 | destination='child' 362 | ) 363 | assert has_link( 364 | page=summary, 365 | section='plurals', 366 | term='geese', 367 | title='goose definition', 368 | href='../simple.md', 369 | text='geese', 370 | destination='goose' 371 | ) 372 | assert has_link( 373 | page=summary, 374 | section='plurals', 375 | term='grandchildren', 376 | title='grandchild definition', 377 | href='../simple.md', 378 | text='grandchildren', 379 | destination='grandchild', 380 | index=1 381 | ) 382 | 383 | 384 | def test_plural_priority(simple, summary, config): 385 | """ If the plural term is defined, this should be used in priority 386 | """ 387 | config['tooltip'] = "full" 388 | config['plurals'] = 'en' 389 | summary = mock.render([simple, summary], config)['summary.md'] 390 | log.debug(summary) 391 | 392 | assert has_link( 393 | page=summary, 394 | section='plurals', 395 | term='potatoes', 396 | title='potatoes definition', 397 | href='../simple.md', 398 | text='potatoes', 399 | destination='potatoes' 400 | ) 401 | 402 | 403 | def test_plurals_disabled(simple, summary, config): 404 | """ Plurals are not mapped to singulars, if not enabled 405 | """ 406 | config['tooltip'] = "full" 407 | summary = mock.render([simple, summary], config)['summary.md'] 408 | log.debug(summary) 409 | 410 | assert not has_link( 411 | page=summary, 412 | section='plurals', 413 | term='children', 414 | title='children definition', 415 | href='simple.md', 416 | text='children', 417 | destination='child' 418 | ) 419 | 420 | def test_hyphens(simple, summary, config): 421 | """ Hyphens in terms are supported 422 | """ 423 | config['markdown_links'] = True 424 | summary = mock.render([simple, summary], config)['summary.md'] 425 | log.debug(summary) 426 | 427 | dl = xpath.body.p.a(id=get_id("test", "hyphens-abc def", "refs", 0), 428 | href="../simple.md#" + get_id("test", "hyphens-abc def", "defs", 0), 429 | text="hyphens-abc def") 430 | assert len(summary.xpath(str(dl))) == 1 431 | dl = xpath.body.p.a(id=get_id("test", "hyphens-abc xyz", "refs", 0), 432 | href="../simple.md#" + get_id("test", "hyphens-abc xyz", "defs", 0), 433 | text="hyphens-abc xyz") 434 | assert len(summary.xpath(str(dl))) == 1 435 | -------------------------------------------------------------------------------- /tests/test_page_ref.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | from yaxp import xpath as xp 3 | 4 | from mkdocs_ezglossary_plugin.glossary import get_id 5 | 6 | import mock 7 | from test_helpers import has_link 8 | 9 | 10 | mycommand = mock.Page( 11 | title="My Command", 12 | file="mycommand.md", 13 | ctype="markdown", 14 | content=""" 15 | # Help page 16 | 17 | ## Overview 18 | 19 | - See 20 | 21 | ## Help 22 | 23 | ## Details 24 | """, 25 | meta=yaml.safe_load(""" 26 | subtitle: page subtitle 27 | terms: 28 | - cmd4 29 | - "cmd5#world2" 30 | - page: cmd2 31 | - page: "cmd3#world" 32 | - page: 33 | - help1 34 | - term2: hello 35 | - term3: details 36 | - cmd: 37 | - help2 38 | - help3: help 39 | anchors: 40 | - world: Description of world 41 | """)) 42 | 43 | commands = mock.Page( 44 | title="Commands", 45 | file="commands.md", 46 | content=""" 47 | 48 | 49 |

See 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | """) 60 | 61 | 62 | def test_page_ref_simple(config): 63 | pages = mock.render([mycommand, commands], config) 64 | 65 | dl = xp.dl(_class="#mkdocs-ezglossary-summary", _id="page") 66 | dl = dl.has(xp.dt.a(href="../mycommand.md#", 67 | text="help1")) 68 | assert len(pages['commands.md'].xpath(str(dl))) == 1 69 | 70 | dd = xp.dd.ul.li.a(href="../commands.md#" + get_id("cmd", "help2", "refs", 0), 71 | text="Commands") 72 | dl = xp.dl(_class="mkdocs-ezglossary-summary", _id="cmd") 73 | dl = dl.has(dd) 74 | dl = dl.has(xp.dt.a(text="help2")) 75 | assert len(pages['commands.md'].xpath(str(dl))) == 1 76 | 77 | 78 | def test_page_ref_anchor(config): 79 | pages = mock.render([mycommand, commands], config) 80 | 81 | a = xp.a(href="../mycommand.md#hello", 82 | text="term2") 83 | dl = xp.dl(_class="#mkdocs-ezglossary-summary", _id="page") 84 | dl = dl.has(xp.dt.has(a)) 85 | assert len(pages['commands.md'].xpath(str(dl))) == 1 86 | 87 | 88 | def test_page_ref_direct(config): 89 | pages = mock.render([mycommand, commands], config) 90 | 91 | dd = xp.dd 92 | a = xp.a(href="../mycommand.md#", 93 | text="cmd2") 94 | dd = dd.has(xp.p(text="*page subtitle")) 95 | dl = xp.dl(_class="#mkdocs-ezglossary-summary", _id="page") 96 | dl = dl.has(xp.dt.has(a)) 97 | dl = dl.has(dd) 98 | assert len(pages['commands.md'].xpath(str(dl))) == 1 99 | 100 | 101 | def test_page_ref_direct_anchor(config): 102 | pages = mock.render([mycommand, commands], config) 103 | 104 | a = xp.a(href="../mycommand.md#world", 105 | text="cmd3") 106 | dd = xp.dd() 107 | dd = dd.has(xp.p(text="*Description of world")) 108 | dl = xp.dl(_class="#mkdocs-ezglossary-summary", _id="page") 109 | dl = dl.has(xp.dt.has(a)) 110 | dl = dl.has(dd) 111 | assert len(pages['commands.md'].xpath(str(dl))) == 1 112 | 113 | 114 | def test_page_ref_default_section(config): 115 | pages = mock.render([mycommand, commands], config) 116 | 117 | a = xp.a(href="../mycommand.md#", 118 | text="cmd4") 119 | dl = xp.dl(_class="mkdocs-ezglossary-summary", _id="_") 120 | dl = dl.has(xp.dt.has(a)) 121 | assert len(pages['commands.md'].xpath(str(dl))) == 1 122 | 123 | 124 | def test_page_ref_default_section_anchor(config): 125 | pages = mock.render([mycommand, commands], config) 126 | 127 | a = xp.a(href="../mycommand.md#world2", 128 | text="cmd5") 129 | dl = xp.dl(_class="mkdocs-ezglossary-summary", _id="_") 130 | dl = dl.has(xp.dt.has(a)) 131 | assert len(pages['commands.md'].xpath(str(dl))) == 1 132 | 133 | 134 | def test_page_ref_link(config): 135 | pages = mock.render([mycommand, commands], config) 136 | 137 | assert has_link( 138 | page=pages['commands.md'], 139 | section="cmd", 140 | term="help2", 141 | title="page subtitle", 142 | href="../mycommand.md", 143 | text="help2", 144 | is_page_ref=True 145 | ) 146 | -------------------------------------------------------------------------------- /tests/test_summary.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from yaxp import xpath as xp 4 | 5 | from mkdocs_ezglossary_plugin.glossary import get_id 6 | 7 | import mock 8 | 9 | from test_helpers import has_summary_entry 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | def test_summary(simple, summary, config): 15 | config['inline_refs'] = "short" 16 | pages = mock.render([simple, summary], config) 17 | summary = pages["summary.md"] 18 | log.debug(summary) 19 | 20 | # first in test glossary 21 | assert has_summary_entry( 22 | page=summary, 23 | section="test", 24 | term="first", 25 | href="../simple.md", 26 | ref_text="Hello" 27 | ) 28 | 29 | # Check reference to third term 30 | assert has_summary_entry( 31 | page=summary, 32 | section="test", 33 | term="third", 34 | href="../simple.md", 35 | ref_text="Hello" 36 | ) 37 | 38 | assert has_summary_entry( 39 | page=summary, 40 | section="demo", 41 | term="first", 42 | href="../simple.md", 43 | ref_text="Hello" 44 | ) 45 | 46 | assert has_summary_entry( 47 | page=summary, 48 | section="demo", 49 | term="third", 50 | href="../simple.md", 51 | ref_text="Hello" 52 | ) 53 | 54 | def test_summary_ignore_case(simple, summary, config): 55 | """ make sure that the summary for a term contains references to terms written in different case. """ 56 | config['ignore_case'] = True 57 | pages = mock.render([simple, summary], config) 58 | summary = pages["summary.md"] 59 | 60 | assert has_summary_entry( 61 | page=summary, 62 | section="test", 63 | term="first" 64 | ) 65 | 66 | assert not has_summary_entry( 67 | page=summary, 68 | section="test", 69 | term="First" 70 | ) 71 | 72 | 73 | def test_summary_noref(simple, summary, config): 74 | config['list_references'] = False 75 | pages = mock.render([simple, summary], config) 76 | summary = pages["summary.md"] 77 | 78 | # Check definition link exists 79 | assert has_summary_entry( 80 | page=summary, 81 | section="test", 82 | term="first", 83 | href="../simple.md", 84 | ref_text="Hello" 85 | ) 86 | 87 | # Check reference link does not exist 88 | assert not has_summary_entry( 89 | page=summary, 90 | section="test", 91 | term="third", 92 | href="../simple.md", 93 | ref_text="first" 94 | ) 95 | 96 | 97 | def test_custom_summary(simple, summary, config): 98 | config['templates'] = "tests/custom" 99 | pages = mock.render([simple, summary], config) 100 | summary = pages["summary.md"] 101 | 102 | dd = xp.dd.ul.li.a(href="../simple.md#" + get_id("test", "third", "refs", 0), 103 | text="Hello") 104 | dl = xp.dl(_class="custom-summary", _id="test") 105 | dl = dl.has(xp.dt(text="third")) 106 | dl = dl.has(dd) 107 | assert len(summary.xpath(str(dl))) == 1 108 | 109 | 110 | def test_default_summary(simple, summary, config): 111 | """check the usage of the default section ('_')""" 112 | 113 | config['use_default'] = True 114 | pages = mock.render([simple, summary], config) 115 | summary = pages["summary.md"] 116 | log.debug(summary) 117 | 118 | assert has_summary_entry( 119 | page=summary, 120 | section="_", 121 | term="default", 122 | href="../simple.md", 123 | ref_text="Hello" 124 | ) 125 | 126 | assert has_summary_entry( 127 | page=summary, 128 | section="_", 129 | term="🚧🚧", 130 | href="../simple.md", 131 | ref_text="Hello" 132 | ) 133 | 134 | 135 | def test_summary_table(simple, tablesummary, config): 136 | pages = mock.render([simple, tablesummary], config) 137 | summary = pages["tablesummary.md"] 138 | log.debug(summary) 139 | 140 | table = xp.div(_class="#mkdocs-ezglossary-summary").table(_id="demo") 141 | tr = xp.tr 142 | table = table.has(xp.thead.has(xp.th(text="Term")) 143 | .has(xp.th(text="Definition")) 144 | .has(xp.th(text="References"))) 145 | tr = tr.has(xp.td(text="*first")) 146 | table.has(tr) 147 | assert len(summary.xpath(str(table))) == 1 148 | -------------------------------------------------------------------------------- /tests/test_tooltip_preserve_text.py: -------------------------------------------------------------------------------- 1 | # tests/test_tooltip_preserve_text.py 2 | """ 3 | Purpose 4 | ------- 5 | Validate that `_preserve_visible_text_for_tooltip()` converts nested glossary 6 | markers and anchors into *visible text* so tooltips render the intended words. 7 | 8 | What it covers 9 | -------------- 10 | 1) Anchor handling: 11 | TEXT -> TEXT 12 | 13 | 2) Glossary markers: 14 | -> TERM 15 | <section:TERM> -> TERM (HTML-escaped) 16 | -> section (keep tag name) 17 | 18 | 3) Idempotency: 19 | Applying the function twice yields the same string. 20 | 21 | 4) No-op for plain text: 22 | Inputs without anchors/markers are unchanged. 23 | 24 | Why this matters 25 | ---------------- 26 | Before the fix, visible words like the referenced term label could disappear 27 | from the tooltip (e.g., "term-a used ..." became "used ..."). These tests 28 | guard against regressions. 29 | 30 | How to run 31 | ---------- 32 | pytest -q 33 | 34 | No MkDocs/Material rendering is required; the tests exercise the helper only. 35 | """ 36 | 37 | import pytest 38 | from mkdocs_ezglossary_plugin.plugin import _preserve_visible_text_for_tooltip as T 39 | 40 | @pytest.mark.parametrize("inp, exp", [ 41 | # 1) Anchor: keep inner text 42 | ('See GRZ docs', 'See GRZ docs'), 43 | 44 | # 2) Raw glossary tag: keep TERM 45 | ('A term', 'A grz term'), 46 | 47 | # 3) Escaped glossary tag: keep TERM 48 | ('A <general:grz> term', 'A grz term'), 49 | 50 | # 4) Name-only glossary tag: keep the tag name 51 | ('Intro section', 'Intro general section'), 52 | 53 | # Realistic repro from the PR description 54 | (' used in combination with other features.', 55 | 'term-a used in combination with other features.'), 56 | ]) 57 | def test_preserve_visible_text_parametrized(inp, exp): 58 | assert T(inp) == exp 59 | 60 | 61 | def test_idempotent(): 62 | """Running the transformer multiple times should not change the result.""" 63 | s = " and docs" 64 | once = T(s) 65 | twice = T(once) 66 | assert once == twice 67 | 68 | 69 | def test_plain_text_is_unchanged(): 70 | """Inputs without anchors/glossary markers should pass through untouched.""" 71 | s = "plain text only" 72 | assert T(s) == s -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | default 4 | 5 | [pytest] 6 | testpaths = tests 7 | 8 | [testenv:default] 9 | setenv= 10 | deps = 11 | yaxp>=0.2.0 12 | mkdocs-material 13 | lxml 14 | pyyaml 15 | pytest>=7 16 | pytest-sugar 17 | inflect>=7.0.0 18 | commands = pytest {posargs} 19 | --------------------------------------------------------------------------------