├── .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 | 
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 |
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 | | Term |
6 | Definition |
7 | {% if types|length > 0 %}
8 | References |
9 | {% endif %}
10 |
11 |
12 |
13 | {% for term in terms %}
14 |
15 | | {% if defs|length > 0 %}
16 | {% set def = defs[0] %}
17 | {{term}}
19 | {% else %}
20 | {{ term }}
21 | {% endif %}
22 | |
23 | {{term}} |
24 | {{ glossary.definition(section, term) | safe }} |
25 | {% if types|length > 0 %}
26 |
27 |
28 | {% for entry in glossary.get(section, term, 'refs') %}
29 | -
30 | {{ entry.page.title }}
31 |
32 | {% endfor %}
33 |
34 | |
35 | {% endif %}
36 |
37 | {% endfor %}
38 |
39 |
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 |
--------------------------------------------------------------------------------