zendoc 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ # Copyright (c) 2026 Mark Buckwell and contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ name: CI
5
+ on:
6
+ push:
7
+ branches: [main]
8
+ pull_request:
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v5
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - run: pip install -e ".[dev]"
21
+ - run: ruff check .
22
+ - run: mypy src
23
+ - run: pytest --cov=zendoc
24
+
25
+ docs:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v5
29
+ - uses: actions/setup-python@v5
30
+ with:
31
+ python-version: "3.x"
32
+ - run: pip install -e ".[docs]"
33
+ - run: zensical build --clean --strict
@@ -0,0 +1,32 @@
1
+ # Copyright (c) 2026 Mark Buckwell and contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ name: Documentation
5
+ on:
6
+ push:
7
+ branches:
8
+ - main
9
+ workflow_dispatch:
10
+ permissions:
11
+ contents: read
12
+ pages: write
13
+ id-token: write
14
+ jobs:
15
+ deploy:
16
+ environment:
17
+ name: github-pages
18
+ url: ${{ steps.deployment.outputs.page_url }}
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/configure-pages@v5
22
+ - uses: actions/checkout@v5
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.x"
26
+ - run: pip install -e ".[docs]"
27
+ - run: zensical build --clean --strict
28
+ - uses: actions/upload-pages-artifact@v4
29
+ with:
30
+ path: site
31
+ - uses: actions/deploy-pages@v4
32
+ id: deployment
@@ -0,0 +1,33 @@
1
+ # Copyright (c) 2026 Mark Buckwell and contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ name: Publish to PyPI
5
+ on:
6
+ release:
7
+ types: [published]
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v5
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.x"
16
+ - run: pip install build
17
+ - run: python -m build
18
+ - uses: actions/upload-artifact@v4
19
+ with:
20
+ name: dist
21
+ path: dist/
22
+ publish:
23
+ needs: build
24
+ runs-on: ubuntu-latest
25
+ environment: pypi
26
+ permissions:
27
+ id-token: write # required for PyPI trusted publishing
28
+ steps:
29
+ - uses: actions/download-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ htmlcov/
14
+ .DS_Store
15
+ site/
zendoc-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Buckwell and contributors
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.
zendoc-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: zendoc
3
+ Version: 0.1.0
4
+ Summary: A family of Python-Markdown extensions for section cross-references and bibliography/citation handling
5
+ Project-URL: Homepage, https://github.com/buckwem/zendoc-extension
6
+ Project-URL: Issues, https://github.com/buckwem/zendoc-extension/issues
7
+ Author: Mark Buckwell
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: markdown>=3.4
17
+ Provides-Extra: dev
18
+ Requires-Dist: mypy; extra == 'dev'
19
+ Requires-Dist: pytest; extra == 'dev'
20
+ Requires-Dist: pytest-cov; extra == 'dev'
21
+ Requires-Dist: ruff; extra == 'dev'
22
+ Requires-Dist: types-markdown; extra == 'dev'
23
+ Provides-Extra: docs
24
+ Requires-Dist: zensical; extra == 'docs'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # zendoc
28
+
29
+ A family of [Python-Markdown](https://python-markdown.github.io/) extensions
30
+ for section cross-references and bibliography/citation handling, in the
31
+ spirit of [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/):
32
+ each extension is independent and enabled separately. Factored out of
33
+ [zendoc-template](https://github.com/buckwem/zendoc-template) so it can be
34
+ installed and reused independently of that template.
35
+
36
+ > **Status:** early - `zendoc.headings` and `zendoc.refs` are implemented;
37
+ > citation handling isn't yet. See
38
+ > [zendoc-template#25](https://github.com/buckwem/zendoc-template/issues/25)
39
+ > for the tracking issue and scope.
40
+
41
+ **[Full documentation](https://buckwem.github.io/zendoc-extension/)**
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install zendoc
47
+ ```
48
+
49
+ ## Extensions
50
+
51
+ | Extension | Description |
52
+ |---|---|
53
+ | [`zendoc.headings`](https://buckwem.github.io/zendoc-extension/extensions/headings/) | Gives every heading an id and a hierarchical section number ("1", "1.1", "1.2", "2", ...). |
54
+ | [`zendoc.refs`](https://buckwem.github.io/zendoc-extension/extensions/refs/) | `\ref{id}` section cross-references, resolving to the target's current number - similar in spirit to LaTeX's `\ref`. |
55
+
56
+ ```python
57
+ import markdown
58
+
59
+ html = markdown.markdown(text, extensions=["zendoc.headings", "zendoc.refs"])
60
+ ```
61
+
62
+ ```md
63
+ # Introduction {: #intro }
64
+
65
+ See \ref{intro} for background.
66
+ ```
67
+
68
+ `\ref{intro}` resolves to a link reading `1` - the heading's current
69
+ section number - and stays correct if sections are reordered, since
70
+ numbering is recomputed on every conversion. See the
71
+ [docs](https://buckwem.github.io/zendoc-extension/) for options, multi-page
72
+ registry sharing, and full syntax details.
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ python -m venv .venv
78
+ source .venv/bin/activate
79
+ pip install -e ".[dev]"
80
+ pytest
81
+ ```
82
+
83
+ To build the documentation locally:
84
+
85
+ ```bash
86
+ pip install -e ".[docs]"
87
+ zensical serve
88
+ ```
89
+
90
+ ## License
91
+
92
+ MIT - see [LICENSE](LICENSE).
zendoc-0.1.0/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # zendoc
2
+
3
+ A family of [Python-Markdown](https://python-markdown.github.io/) extensions
4
+ for section cross-references and bibliography/citation handling, in the
5
+ spirit of [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/):
6
+ each extension is independent and enabled separately. Factored out of
7
+ [zendoc-template](https://github.com/buckwem/zendoc-template) so it can be
8
+ installed and reused independently of that template.
9
+
10
+ > **Status:** early - `zendoc.headings` and `zendoc.refs` are implemented;
11
+ > citation handling isn't yet. See
12
+ > [zendoc-template#25](https://github.com/buckwem/zendoc-template/issues/25)
13
+ > for the tracking issue and scope.
14
+
15
+ **[Full documentation](https://buckwem.github.io/zendoc-extension/)**
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install zendoc
21
+ ```
22
+
23
+ ## Extensions
24
+
25
+ | Extension | Description |
26
+ |---|---|
27
+ | [`zendoc.headings`](https://buckwem.github.io/zendoc-extension/extensions/headings/) | Gives every heading an id and a hierarchical section number ("1", "1.1", "1.2", "2", ...). |
28
+ | [`zendoc.refs`](https://buckwem.github.io/zendoc-extension/extensions/refs/) | `\ref{id}` section cross-references, resolving to the target's current number - similar in spirit to LaTeX's `\ref`. |
29
+
30
+ ```python
31
+ import markdown
32
+
33
+ html = markdown.markdown(text, extensions=["zendoc.headings", "zendoc.refs"])
34
+ ```
35
+
36
+ ```md
37
+ # Introduction {: #intro }
38
+
39
+ See \ref{intro} for background.
40
+ ```
41
+
42
+ `\ref{intro}` resolves to a link reading `1` - the heading's current
43
+ section number - and stays correct if sections are reordered, since
44
+ numbering is recomputed on every conversion. See the
45
+ [docs](https://buckwem.github.io/zendoc-extension/) for options, multi-page
46
+ registry sharing, and full syntax details.
47
+
48
+ ## Development
49
+
50
+ ```bash
51
+ python -m venv .venv
52
+ source .venv/bin/activate
53
+ pip install -e ".[dev]"
54
+ pytest
55
+ ```
56
+
57
+ To build the documentation locally:
58
+
59
+ ```bash
60
+ pip install -e ".[docs]"
61
+ zensical serve
62
+ ```
63
+
64
+ ## License
65
+
66
+ MIT - see [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ # Release Notes
2
+
3
+ ## 0.1.0 (2026-07-14)
4
+
5
+ Initial release.
6
+
7
+ - `zendoc.headings`: heading ids and hierarchical section numbering,
8
+ backed by a shared `IdRegistry`.
9
+ - `zendoc.refs`: `\ref{id}` section cross-references, resolving to the
10
+ target's current section number, including forward references within a
11
+ document and across a shared registry.
12
+ - Documentation site built with Zensical, published at
13
+ [buckwem.github.io/zendoc-extension](https://buckwem.github.io/zendoc-extension/).
@@ -0,0 +1,5 @@
1
+ # License
2
+
3
+ ```text
4
+ --8<-- "LICENSE"
5
+ ```
@@ -0,0 +1,104 @@
1
+ # Headings
2
+
3
+ ## Overview
4
+
5
+ `zendoc.headings` gives every heading in a document an `id` and a
6
+ hierarchical section number, and records both - along with the heading's
7
+ text and level - in a shared registry keyed by a document "source" name.
8
+
9
+ It exists to be a foundation other zendoc extensions build on:
10
+ [zendoc.refs](refs.md) resolves `\ref{id}` by looking an id up in exactly
11
+ this registry. You can also enable `zendoc.headings` on its own if you just
12
+ want ids/numbers on your headings without cross-references.
13
+
14
+ Numbering is per-document and hierarchical: an `h1` is a top-level counter
15
+ ("1", "2", ...), an `h2` nests under the nearest preceding `h1` ("1.1",
16
+ "1.2", ...), and so on down through `h6`. Numbers are recomputed from
17
+ scratch on every conversion, so reordering headings always produces correct
18
+ numbers on the next build - there's no stored/stale numbering state.
19
+
20
+ ## Example
21
+
22
+ ```md
23
+ # Introduction
24
+
25
+ ## Background
26
+
27
+ ## Scope
28
+
29
+ # Method
30
+ ```
31
+
32
+ produces (numbers shown for illustration - `zendoc.headings` doesn't render
33
+ numbers into the heading text itself, only into the registry; see
34
+ [zendoc.refs](refs.md) for rendering a number inline):
35
+
36
+ | Heading | id | number |
37
+ |---|---|---|
38
+ | Introduction | `introduction` | `1` |
39
+ | Background | `background` | `1.1` |
40
+ | Scope | `scope` | `1.2` |
41
+ | Method | `method` | `2` |
42
+
43
+ ## Ids
44
+
45
+ An id comes from one of, in order of precedence:
46
+
47
+ 1. An explicit id set via
48
+ [`attr_list`](https://python-markdown.github.io/extensions/attr_list/),
49
+ e.g. `# Introduction {: #custom-id }`.
50
+ 2. Python-Markdown's own [`toc`](https://python-markdown.github.io/extensions/toc/)
51
+ extension, which `zendoc.headings` enables automatically (with its
52
+ defaults) if you haven't already enabled it yourself - so if you *have*
53
+ configured `toc` (e.g. with `permalink: true`), that configuration is
54
+ left untouched and reused.
55
+ 3. A minimal built-in slugify fallback, used only if `toc` is somehow not
56
+ registered at all (this should not normally happen, since
57
+ `zendoc.headings` enables it).
58
+
59
+ ## Unnumbered headings
60
+
61
+ A heading with an `unnumbered` class - e.g. a cover page or title slide -
62
+ still gets an id, but is skipped when computing section numbers, so it
63
+ doesn't consume a counter position:
64
+
65
+ ```md
66
+ # Cover Page {: .unnumbered }
67
+
68
+ # Introduction
69
+ ```
70
+
71
+ `Introduction` above is still numbered `1`, as if `Cover Page` weren't
72
+ there at all.
73
+
74
+ ## Options
75
+
76
+ | Option | Type | Default | Description |
77
+ |---|---|---|---|
78
+ | `source` | `str` | `""` | Identifier for the current document (e.g. its file path). Used to scope this document's entries in the registry, and to safely clear/replace them on a rebuild of the same document. |
79
+ | `registry` | `IdRegistry \| None` | a new `IdRegistry()` | Share one registry across multiple documents/conversions - see below. Passed as a constructor keyword, not a string-based config value (Python-Markdown's config system can't carry arbitrary Python objects safely). |
80
+
81
+ ## Sharing a registry across a multi-page build
82
+
83
+ To resolve cross-page references, every page in a build needs to write into
84
+ - and read from - the *same* `IdRegistry` instance. Construct one and pass
85
+ it to every page's extension instance, along with that page's own `source`:
86
+
87
+ ```python
88
+ import markdown
89
+ from zendoc.headings import HeadingsExtension
90
+ from zendoc.util import IdRegistry
91
+
92
+ registry = IdRegistry()
93
+
94
+ for path, text in pages:
95
+ html = markdown.markdown(
96
+ text,
97
+ extensions=[HeadingsExtension(registry=registry, source=path)],
98
+ )
99
+ ```
100
+
101
+ A duplicate id registered from two *different* sources raises
102
+ `zendoc.util.DuplicateIdError` - re-converting the *same* source (e.g. a
103
+ live-reload dev server) is safe and expected; its previous entries are
104
+ cleared first.
@@ -0,0 +1,125 @@
1
+ # Refs
2
+
3
+ ## Overview
4
+
5
+ `zendoc.refs` adds a `\ref{id}` cross-reference syntax - similar in spirit
6
+ to LaTeX's `\ref` - that resolves to the *current* section number of the
7
+ heading with that id:
8
+
9
+ ```md
10
+ # Introduction {: #intro }
11
+
12
+ See \ref{intro} for background.
13
+ ```
14
+
15
+ renders `\ref{intro}` as a link to `#intro` reading `1`. Because the number
16
+ is looked up fresh on every conversion (see [zendoc.headings](headings.md)),
17
+ it stays correct even if sections are added, removed, or reordered - you
18
+ never have to manually renumber a cross-reference.
19
+
20
+ `zendoc.refs` depends on the id/number registry that
21
+ [zendoc.headings](headings.md) builds. If you enable `zendoc.refs` on its
22
+ own, it transparently enables `zendoc.headings` for you with matching
23
+ defaults, so a single document works with no extra configuration. If you
24
+ list both explicitly, list `zendoc.headings` first - see
25
+ [Multi-page builds](#multi-page-builds) below.
26
+
27
+ ## Syntax
28
+
29
+ ```
30
+ \ref{<id>}
31
+ ```
32
+
33
+ `<id>` is the target heading's id - either one you set explicitly via
34
+ [`attr_list`](https://python-markdown.github.io/extensions/attr_list/)
35
+ (`# Introduction {: #intro }`), or the one
36
+ [`toc`](https://python-markdown.github.io/extensions/toc/) derived
37
+ automatically from the heading text (see [zendoc.headings](headings.md#ids)
38
+ for the exact precedence).
39
+
40
+ `\ref{...}` is recognised the same way Python-Markdown's own inline syntax
41
+ is - meaning it's protected inside inline code spans and fenced code
42
+ blocks, so it's safe to show as a literal example:
43
+
44
+ ````md
45
+ Type `\ref{intro}` to reference a section.
46
+
47
+ ```
48
+ \ref{intro}
49
+ ```
50
+ ````
51
+
52
+ Neither of the two shown above is resolved; both render the literal text.
53
+
54
+ ## Forward references
55
+
56
+ A reference to a heading defined *later* in the same document resolves
57
+ correctly:
58
+
59
+ ```md
60
+ See \ref{background} below.
61
+
62
+ ## Background {: #background }
63
+ ```
64
+
65
+ ## Unresolved references
66
+
67
+ `\ref{id}` renders the `unresolved` marker (`??` by default) instead of a
68
+ number when:
69
+
70
+ - `id` doesn't exist in the registry at all - e.g. a typo, or a reference
71
+ to a heading in a page that hasn't been converted yet in a multi-page
72
+ build (the same way an undefined LaTeX `\ref` shows `??` until a later
73
+ compilation pass).
74
+ - `id` exists but belongs to a heading marked `unnumbered` (see
75
+ [zendoc.headings](headings.md#unnumbered-headings)) - it's still a valid
76
+ link target in this case, just without a number to show.
77
+
78
+ ```md
79
+ # Cover Page {: .unnumbered }
80
+
81
+ See \ref{cover-page}.
82
+ ```
83
+
84
+ renders `\ref{cover-page}` as `??`, linked to `#cover-page`.
85
+
86
+ ## Options
87
+
88
+ | Option | Type | Default | Description |
89
+ |---|---|---|---|
90
+ | `unresolved` | `str` | `"??"` | Text rendered when `id` doesn't resolve to a numbered heading. |
91
+ | `registry` | `IdRegistry \| None` | discovered from a sibling `zendoc.headings`, or a new one | Share one registry across multiple documents - see below. Passed as a constructor keyword, not a string-based config value. |
92
+
93
+ ## Multi-page builds
94
+
95
+ To resolve cross-page references, give `zendoc.headings` and `zendoc.refs`
96
+ the *same* `IdRegistry` on every page, converting pages in the order
97
+ cross-references should become resolvable in (a reference to a page not
98
+ yet converted resolves to `unresolved`, as above):
99
+
100
+ ```python
101
+ import markdown
102
+ from zendoc.headings import HeadingsExtension
103
+ from zendoc.refs import RefsExtension
104
+ from zendoc.util import IdRegistry
105
+
106
+ registry = IdRegistry()
107
+
108
+ for path, text in pages:
109
+ html = markdown.markdown(
110
+ text,
111
+ extensions=[
112
+ HeadingsExtension(registry=registry, source=path),
113
+ RefsExtension(registry=registry),
114
+ ],
115
+ )
116
+ ```
117
+
118
+ If you enable both extensions by name instead (e.g. from a
119
+ `mkdocs.yml`/`zensical.toml`-style config, where you can't pass a shared
120
+ `IdRegistry` object directly), list `zendoc.headings` before `zendoc.refs`:
121
+ `zendoc.refs` looks for an already-registered `zendoc.headings` instance on
122
+ the current `Markdown` object and reuses its registry automatically, so a
123
+ per-page site-generator integration (a plugin owning one shared registry
124
+ across the whole build) is the way to get cross-page resolution without
125
+ constructing extension instances by hand.
@@ -0,0 +1,51 @@
1
+ # zendoc
2
+
3
+ zendoc is a family of [Python-Markdown](https://python-markdown.github.io/)
4
+ extensions for section cross-references and bibliography/citation handling,
5
+ in the spirit of [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/):
6
+ each extension is independent and enabled separately, so you only pay for
7
+ what you use.
8
+
9
+ zendoc was factored out of
10
+ [zendoc-template](https://github.com/buckwem/zendoc-template) so it can be
11
+ installed and reused independently of that template - see
12
+ [zendoc-template#25](https://github.com/buckwem/zendoc-template/issues/25)
13
+ for the tracking issue and original motivation.
14
+
15
+ ## Extensions
16
+
17
+ | Extension | Description |
18
+ |---|---|
19
+ | [zendoc.headings](extensions/headings.md) | Gives every heading an id and a hierarchical section number ("1", "1.1", "1.2", "2", ...). |
20
+ | [zendoc.refs](extensions/refs.md) | `\ref{id}` section cross-references, resolving to the target's current number - similar in spirit to LaTeX's `\ref`. |
21
+
22
+ Bibliography/citation handling is planned but not implemented yet.
23
+
24
+ ## Quick example
25
+
26
+ ```python
27
+ import markdown
28
+
29
+ html = markdown.markdown(
30
+ """
31
+ # Introduction
32
+
33
+ See \\ref{background} for context.
34
+
35
+ ## Background
36
+
37
+ ...
38
+ """,
39
+ extensions=["zendoc.headings", "zendoc.refs"],
40
+ )
41
+ ```
42
+
43
+ `\ref{background}` resolves to `1.1` - the current section number of the
44
+ heading it points to - and stays correct if headings are reordered, because
45
+ numbering is recomputed fresh on every conversion.
46
+
47
+ ## Status
48
+
49
+ Early. `zendoc.headings` and `zendoc.refs` are implemented and tested;
50
+ citation-key management is not yet built. See the
51
+ [Release Notes](about/changelog.md) for what's landed so far.
@@ -0,0 +1,60 @@
1
+ # Installation
2
+
3
+ ## From PyPI
4
+
5
+ ```bash
6
+ pip install zendoc
7
+ ```
8
+
9
+ `zendoc` depends only on [`Markdown`](https://python-markdown.github.io/)
10
+ (>= 3.4) - no other runtime dependencies.
11
+
12
+ ## Enabling an extension
13
+
14
+ Each zendoc extension is registered as a standard Python-Markdown extension
15
+ under the `markdown.extensions` entry point group, so it can be enabled by
16
+ name, the same way you'd enable a built-in extension like `toc` or a
17
+ `pymdownx` one:
18
+
19
+ ```python
20
+ import markdown
21
+
22
+ html = markdown.markdown(text, extensions=["zendoc.headings", "zendoc.refs"])
23
+ ```
24
+
25
+ Or in a `mkdocs.yml`/`zensical.toml`-style config:
26
+
27
+ ```yaml
28
+ markdown_extensions:
29
+ - zendoc.headings
30
+ - zendoc.refs
31
+ ```
32
+
33
+ ```toml
34
+ [project.markdown_extensions.zendoc.headings]
35
+ [project.markdown_extensions.zendoc.refs]
36
+ ```
37
+
38
+ See each extension's own page for its options and for how to share one
39
+ registry across multiple pages of a site build:
40
+
41
+ - [zendoc.headings](extensions/headings.md)
42
+ - [zendoc.refs](extensions/refs.md)
43
+
44
+ ## Development install
45
+
46
+ ```bash
47
+ git clone https://github.com/buckwem/zendoc-extension
48
+ cd zendoc-extension
49
+ python -m venv .venv
50
+ source .venv/bin/activate
51
+ pip install -e ".[dev]"
52
+ pytest
53
+ ```
54
+
55
+ To build these docs locally:
56
+
57
+ ```bash
58
+ pip install -e ".[docs]"
59
+ zensical serve
60
+ ```
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "zendoc"
7
+ version = "0.1.0"
8
+ description = "A family of Python-Markdown extensions for section cross-references and bibliography/citation handling"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [
12
+ { name = "Mark Buckwell" },
13
+ ]
14
+ requires-python = ">=3.10"
15
+ dependencies = [
16
+ "Markdown>=3.4",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Intended Audience :: Developers",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Programming Language :: Python :: 3",
23
+ "Topic :: Text Processing :: Markup :: Markdown",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/buckwem/zendoc-extension"
28
+ Issues = "https://github.com/buckwem/zendoc-extension/issues"
29
+
30
+ [project.entry-points."markdown.extensions"]
31
+ "zendoc.headings" = "zendoc.headings:HeadingsExtension"
32
+ "zendoc.refs" = "zendoc.refs:RefsExtension"
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "ruff",
37
+ "mypy",
38
+ "types-Markdown",
39
+ "pytest",
40
+ "pytest-cov",
41
+ ]
42
+ docs = [
43
+ "zensical",
44
+ ]
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/zendoc"]
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
51
+
52
+ [tool.ruff]
53
+ line-length = 100
54
+
55
+ [tool.mypy]
56
+ python_version = "3.10"
57
+ strict = true
@@ -0,0 +1,16 @@
1
+ # Copyright (c) 2026 Mark Buckwell and contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """zendoc: a family of independent Python-Markdown extensions for section
5
+ cross-references and bibliography/citation handling, in the spirit of
6
+ pymdown-extensions - each is its own extension, enabled separately:
7
+
8
+ - ``zendoc.headings`` - heading ids and hierarchical section numbers.
9
+ - ``zendoc.refs`` - ``\\ref{id}`` section cross-references.
10
+
11
+ See https://github.com/buckwem/zendoc-extension for documentation.
12
+ """
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ __all__ = ["__version__"]
@@ -0,0 +1,127 @@
1
+ # Copyright (c) 2026 Mark Buckwell and contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """zendoc.headings: gives every heading an id and a hierarchical section
5
+ number, recorded in a shared :class:`~zendoc.util.IdRegistry` that other
6
+ zendoc extensions (currently :mod:`zendoc.refs`) look entries up in.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import xml.etree.ElementTree as etree
13
+
14
+ from markdown import Markdown
15
+ from markdown.extensions import Extension
16
+ from markdown.extensions.toc import TocExtension
17
+ from markdown.treeprocessors import Treeprocessor
18
+
19
+ from zendoc.util import IdRegistry
20
+
21
+ HEADING_TAGS = {"h1", "h2", "h3", "h4", "h5", "h6"}
22
+
23
+
24
+ def _slugify(text: str) -> str:
25
+ """Minimal fallback slug, used only when 'toc' hasn't already assigned an
26
+ id. Enable Python-Markdown's own 'toc' extension for slugs that match the
27
+ rest of a 'toc'-rendered document exactly (unicode handling, custom
28
+ separators, etc.) - this fallback exists only so the registry still works
29
+ if a caller genuinely doesn't want a table of contents.
30
+ """
31
+ slug = re.sub(r"[^\w\s-]", "", text).strip().lower()
32
+ return re.sub(r"\s+", "-", slug)
33
+
34
+
35
+ class HeadingsTreeprocessor(Treeprocessor):
36
+ """Records every h1-h6 element's id, and its hierarchical section number,
37
+ in a shared :class:`IdRegistry`, keyed by the current document's source
38
+ name.
39
+
40
+ Numbering is per-document: h1 is a top-level counter, h2 nests under the
41
+ nearest preceding h1 ("1.1", "1.2", ...), and so on through h6 - reset
42
+ from scratch on every call, so reordering headings within a document
43
+ always produces correct numbers on the next build. A heading with an
44
+ ``unnumbered`` class (e.g. via ``# Title {: .unnumbered }``) is still
45
+ given an id but excluded from numbering - its counter position is
46
+ skipped entirely - so its registered ``number`` is ``None``.
47
+
48
+ Runs at a lower priority than 'toc' (registered at 5) so it always reads
49
+ the final id 'toc' assigned - including one already set explicitly via
50
+ 'attr_list' - rather than racing it.
51
+ """
52
+
53
+ def __init__(self, md: Markdown, registry: IdRegistry, source: str) -> None:
54
+ super().__init__(md)
55
+ self.registry = registry
56
+ self.source = source
57
+
58
+ def run(self, root: etree.Element) -> None:
59
+ self.registry.clear_source(self.source)
60
+ counters = [0] * 6
61
+ for el in root.iter():
62
+ if el.tag not in HEADING_TAGS:
63
+ continue
64
+ text = "".join(el.itertext())
65
+ heading_id = el.get("id")
66
+ if not heading_id:
67
+ heading_id = _slugify(text)
68
+ el.set("id", heading_id)
69
+
70
+ level = int(el.tag[1])
71
+ classes = (el.get("class") or "").split()
72
+ if "unnumbered" in classes:
73
+ number = None
74
+ else:
75
+ counters[level - 1] += 1
76
+ for deeper in range(level, 6):
77
+ counters[deeper] = 0
78
+ number = ".".join(str(c) for c in counters[:level])
79
+
80
+ self.registry.register(
81
+ source=self.source,
82
+ id=heading_id,
83
+ level=level,
84
+ text=text,
85
+ number=number,
86
+ )
87
+
88
+
89
+ class HeadingsExtension(Extension):
90
+ """Python-Markdown extension assigning ids and section numbers to headings."""
91
+
92
+ def __init__(self, **kwargs: object) -> None:
93
+ # Popped rather than run through Extension's own config/setConfig:
94
+ # that machinery bool-coerces any config value whose *current*
95
+ # default is None (see markdown.util.parseBoolValue), which would
96
+ # silently corrupt a real IdRegistry object passed in explicitly.
97
+ registry = kwargs.pop("registry", None)
98
+ self.registry: IdRegistry = (
99
+ registry if isinstance(registry, IdRegistry) else IdRegistry()
100
+ )
101
+ self.config = {
102
+ "source": [
103
+ "",
104
+ "Identifier for the current document (e.g. its path), used "
105
+ "to scope this document's own entries in the registry.",
106
+ ],
107
+ }
108
+ super().__init__(**kwargs)
109
+
110
+ def extendMarkdown(self, md: Markdown) -> None:
111
+ md.registerExtension(self)
112
+ # Heading ids are 'toc''s job (including respecting one 'attr_list'
113
+ # already set) - reuse it rather than re-deriving slugs here, but
114
+ # don't clobber a caller's own 'toc' config (e.g. permalink=True) if
115
+ # they've already enabled it themselves.
116
+ if "toc" not in md.treeprocessors:
117
+ TocExtension().extendMarkdown(md)
118
+ source: str = self.getConfig("source")
119
+ md.treeprocessors.register(
120
+ HeadingsTreeprocessor(md, self.registry, source),
121
+ "zendoc-headings",
122
+ 4,
123
+ )
124
+
125
+
126
+ def makeExtension(**kwargs: object) -> HeadingsExtension:
127
+ return HeadingsExtension(**kwargs)
File without changes
@@ -0,0 +1,138 @@
1
+ # Copyright (c) 2026 Mark Buckwell and contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """zendoc.refs: ``\\ref{id}`` section cross-reference syntax, resolving to
5
+ the referenced heading's current section number - similar in spirit to
6
+ LaTeX's ``\\ref``. Builds on the id registry from :mod:`zendoc.headings`,
7
+ which is auto-enabled with matching defaults if not already present.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import xml.etree.ElementTree as etree
14
+
15
+ from markdown import Markdown
16
+ from markdown.extensions import Extension
17
+ from markdown.inlinepatterns import InlineProcessor
18
+ from markdown.treeprocessors import Treeprocessor
19
+
20
+ from zendoc.headings import HeadingsExtension
21
+ from zendoc.util import IdRegistry
22
+
23
+ REF_RE = r"\\ref\{([^}\s]+)\}"
24
+
25
+
26
+ class RefInlineProcessor(InlineProcessor):
27
+ """Matches ``\\ref{id}`` and emits an unresolved placeholder ``<a>``
28
+ carrying the referenced id in a ``data-zendoc-ref`` attribute.
29
+
30
+ Registered at a low inline-pattern priority so it runs after 'backtick'
31
+ (190) and 'escape' (180) - meaning inline code spans are already stashed
32
+ out of reach by the time this pattern runs, so ``\\ref{...}`` shown as
33
+ literal example syntax inside `` `code` `` survives untouched, the same
34
+ protection fenced code blocks already get from being stashed even
35
+ earlier, during preprocessing.
36
+
37
+ The placeholder can't be resolved to a real section number here: inline
38
+ patterns run before the current document's own headings have been
39
+ numbered (see HeadingsTreeprocessor, priority 4, which runs after this
40
+ pattern's containing 'inline' treeprocessor, priority 20) - resolution
41
+ happens later, in RefResolverTreeprocessor.
42
+ """
43
+
44
+ def handleMatch( # type: ignore[override]
45
+ self, m: re.Match[str], data: str
46
+ ) -> tuple[etree.Element, int, int]:
47
+ el = etree.Element("a")
48
+ el.set("data-zendoc-ref", m.group(1))
49
+ el.set("class", "zendoc-ref")
50
+ return el, m.start(0), m.end(0)
51
+
52
+
53
+ class RefResolverTreeprocessor(Treeprocessor):
54
+ """Resolves the placeholder ``<a data-zendoc-ref="id">`` elements left by
55
+ :class:`RefInlineProcessor` to the referenced heading's section number,
56
+ once the current document's own headings have been numbered.
57
+
58
+ Runs at a lower priority than 'zendoc-headings' (4) so every heading in
59
+ *this* document - including one defined further down the page than
60
+ where it's referenced - is already registered by the time resolution
61
+ happens. A reference to a heading in a document not yet processed in
62
+ this build (e.g. a later page in a multi-page site) can't be resolved
63
+ yet either; both cases fall back to `unresolved`, the same way an
64
+ undefined LaTeX \\ref shows "??" until a later compilation pass.
65
+ """
66
+
67
+ def __init__(self, md: Markdown, registry: IdRegistry, unresolved: str = "??") -> None:
68
+ super().__init__(md)
69
+ self.registry = registry
70
+ self.unresolved = unresolved
71
+
72
+ def run(self, root: etree.Element) -> None:
73
+ for el in root.iter("a"):
74
+ ref_id = el.get("data-zendoc-ref")
75
+ if ref_id is None:
76
+ continue
77
+ del el.attrib["data-zendoc-ref"]
78
+ record = self.registry.get(ref_id)
79
+ if record is None or record.number is None:
80
+ el.text = self.unresolved
81
+ el.set("class", "zendoc-ref zendoc-ref-unresolved")
82
+ if record is not None:
83
+ # Known heading, just unnumbered (e.g. {: .unnumbered }) -
84
+ # still a valid link target, unlike a genuinely unknown id.
85
+ el.set("href", f"#{ref_id}")
86
+ else:
87
+ el.text = record.number
88
+ el.set("href", f"#{ref_id}")
89
+
90
+
91
+ class RefsExtension(Extension):
92
+ """Python-Markdown extension providing the ``\\ref{id}`` syntax."""
93
+
94
+ def __init__(self, **kwargs: object) -> None:
95
+ # See HeadingsExtension for why this is popped rather than run
96
+ # through Extension's own config/setConfig machinery. None here
97
+ # means "discover the registry from a sibling HeadingsExtension",
98
+ # not "use an empty registry" - that distinction can't be made once
99
+ # a value has round-tripped through setConfig.
100
+ registry = kwargs.pop("registry", None)
101
+ self.registry: IdRegistry | None = registry if isinstance(registry, IdRegistry) else None
102
+ self.config = {
103
+ "unresolved": [
104
+ "??",
105
+ "Text rendered by \\ref{id} when id doesn't resolve to a "
106
+ "numbered heading - unknown id, or a heading marked "
107
+ "unnumbered.",
108
+ ],
109
+ }
110
+ super().__init__(**kwargs)
111
+
112
+ def extendMarkdown(self, md: Markdown) -> None:
113
+ md.registerExtension(self)
114
+ registry = self.registry
115
+ if registry is None:
116
+ headings_ext = next(
117
+ (ext for ext in md.registeredExtensions if isinstance(ext, HeadingsExtension)),
118
+ None,
119
+ )
120
+ if headings_ext is None:
121
+ headings_ext = HeadingsExtension()
122
+ headings_ext.extendMarkdown(md)
123
+ registry = headings_ext.registry
124
+ unresolved: str = self.getConfig("unresolved")
125
+ md.inlinePatterns.register(
126
+ RefInlineProcessor(REF_RE, md),
127
+ "zendoc-ref",
128
+ 45,
129
+ )
130
+ md.treeprocessors.register(
131
+ RefResolverTreeprocessor(md, registry, unresolved),
132
+ "zendoc-ref-resolver",
133
+ 2,
134
+ )
135
+
136
+
137
+ def makeExtension(**kwargs: object) -> RefsExtension:
138
+ return RefsExtension(**kwargs)
@@ -0,0 +1,63 @@
1
+ # Copyright (c) 2026 Mark Buckwell and contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Shared data structures used by more than one zendoc extension.
5
+
6
+ A single :class:`IdRegistry` instance is meant to be shared across every
7
+ source document in a build (one extension instance per document, e.g. one
8
+ :class:`~zendoc.headings.HeadingsExtension` call per page), so that
9
+ :mod:`zendoc.refs` (and a future citation extension) can resolve an id to
10
+ the document, heading, and current section number that defines it,
11
+ regardless of which document is currently being converted.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class HeadingRecord:
21
+ source: str
22
+ id: str
23
+ level: int
24
+ text: str
25
+ number: str | None = None
26
+
27
+
28
+ class DuplicateIdError(ValueError):
29
+ """Raised when the same id is registered from two different sources."""
30
+
31
+
32
+ class IdRegistry:
33
+ def __init__(self) -> None:
34
+ self._headings: dict[str, HeadingRecord] = {}
35
+
36
+ def register(
37
+ self, source: str, id: str, level: int, text: str, number: str | None = None
38
+ ) -> None:
39
+ existing = self._headings.get(id)
40
+ if existing is not None and existing.source != source:
41
+ raise DuplicateIdError(
42
+ f"heading id {id!r} is already registered from "
43
+ f"{existing.source!r}; cannot also register it from {source!r}"
44
+ )
45
+ self._headings[id] = HeadingRecord(
46
+ source=source, id=id, level=level, text=text, number=number
47
+ )
48
+
49
+ def get(self, id: str) -> HeadingRecord | None:
50
+ return self._headings.get(id)
51
+
52
+ def __contains__(self, id: str) -> bool:
53
+ return id in self._headings
54
+
55
+ def clear_source(self, source: str) -> None:
56
+ """Drops every entry previously registered from source.
57
+
58
+ Needed so re-converting the same document (e.g. a live-reload dev
59
+ server) can't leave a stale id behind after a heading's text - and
60
+ therefore its slug - changes between builds.
61
+ """
62
+ for stale_id in [k for k, v in self._headings.items() if v.source == source]:
63
+ del self._headings[stale_id]
@@ -0,0 +1,104 @@
1
+ # Copyright (c) 2026 Mark Buckwell and contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import markdown
5
+ import pytest
6
+
7
+ from zendoc.headings import HeadingsExtension
8
+ from zendoc.util import DuplicateIdError, IdRegistry
9
+
10
+
11
+ def _convert(text: str, registry: IdRegistry, source: str) -> str:
12
+ md = markdown.Markdown(
13
+ extensions=["attr_list", HeadingsExtension(registry=registry, source=source)]
14
+ )
15
+ return md.convert(text)
16
+
17
+
18
+ def test_heading_gets_an_id_and_is_registered() -> None:
19
+ registry = IdRegistry()
20
+ html = _convert("# Introduction\n", registry, "intro.md")
21
+ assert 'id="introduction"' in html
22
+ record = registry.get("introduction")
23
+ assert record is not None
24
+ assert record.source == "intro.md"
25
+ assert record.level == 1
26
+ assert record.text == "Introduction"
27
+ assert record.number == "1"
28
+
29
+
30
+ def test_nested_headings_get_hierarchical_numbers() -> None:
31
+ registry = IdRegistry()
32
+ _convert("# Chapter\n\n## Setup\n\n## Usage\n\n# Next Chapter\n", registry, "doc.md")
33
+ assert registry.get("chapter").number == "1" # type: ignore[union-attr]
34
+ assert registry.get("setup").number == "1.1" # type: ignore[union-attr]
35
+ assert registry.get("usage").number == "1.2" # type: ignore[union-attr]
36
+ assert registry.get("next-chapter").number == "2" # type: ignore[union-attr]
37
+
38
+
39
+ def test_unnumbered_heading_has_no_number_but_gets_an_id() -> None:
40
+ registry = IdRegistry()
41
+ _convert("# Cover Page {: .unnumbered }\n\n# Introduction\n", registry, "doc.md")
42
+ assert registry.get("cover-page").number is None # type: ignore[union-attr]
43
+ # unnumbered heading doesn't consume a counter slot
44
+ assert registry.get("introduction").number == "1" # type: ignore[union-attr]
45
+
46
+
47
+ def test_explicit_id_is_respected() -> None:
48
+ registry = IdRegistry()
49
+ _convert("# Introduction {: #custom-id }\n", registry, "intro.md")
50
+ assert registry.get("custom-id") is not None
51
+ assert registry.get("introduction") is None
52
+
53
+
54
+ def test_shared_registry_across_sources() -> None:
55
+ registry = IdRegistry()
56
+ _convert("# Introduction\n", registry, "intro.md")
57
+ _convert("# Setup\n", registry, "setup.md")
58
+ assert registry.get("introduction").source == "intro.md" # type: ignore[union-attr]
59
+ assert registry.get("setup").source == "setup.md" # type: ignore[union-attr]
60
+
61
+
62
+ def test_duplicate_id_across_sources_raises() -> None:
63
+ registry = IdRegistry()
64
+ _convert("# Introduction\n", registry, "intro.md")
65
+ with pytest.raises(DuplicateIdError):
66
+ _convert("# Introduction\n", registry, "other.md")
67
+
68
+
69
+ def test_rebuilding_same_source_does_not_raise() -> None:
70
+ registry = IdRegistry()
71
+ _convert("# Introduction\n", registry, "intro.md")
72
+ _convert("# Introduction\n", registry, "intro.md")
73
+ assert registry.get("introduction").source == "intro.md" # type: ignore[union-attr]
74
+
75
+
76
+ def test_stale_heading_cleared_on_rebuild() -> None:
77
+ registry = IdRegistry()
78
+ _convert("# Old Title\n", registry, "intro.md")
79
+ _convert("# New Title\n", registry, "intro.md")
80
+ assert registry.get("old-title") is None
81
+ new_title = registry.get("new-title")
82
+ assert new_title is not None
83
+ assert new_title.source == "intro.md"
84
+
85
+
86
+ def test_reuses_callers_own_toc_config() -> None:
87
+ registry = IdRegistry()
88
+ md = markdown.Markdown(
89
+ extensions=[
90
+ "toc",
91
+ HeadingsExtension(registry=registry, source="intro.md"),
92
+ ],
93
+ extension_configs={"toc": {"permalink": True}},
94
+ )
95
+ html = md.convert("# Introduction\n")
96
+ assert "headerlink" in html
97
+ assert registry.get("introduction") is not None
98
+
99
+
100
+ def test_entry_point_name_resolves() -> None:
101
+ md = markdown.Markdown(extensions=["zendoc.headings"])
102
+ assert md.convert("# Introduction\n") == (
103
+ '<h1 id="introduction">Introduction</h1>'
104
+ )
@@ -0,0 +1,88 @@
1
+ # Copyright (c) 2026 Mark Buckwell and contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import markdown
5
+
6
+ from zendoc.headings import HeadingsExtension
7
+ from zendoc.refs import RefsExtension
8
+ from zendoc.util import IdRegistry
9
+
10
+
11
+ def _convert(text: str, source: str = "doc.md") -> str:
12
+ """Standalone use: zendoc.refs alone, auto-enabling zendoc.headings."""
13
+ md = markdown.Markdown(extensions=["attr_list", RefsExtension()])
14
+ return md.convert(text)
15
+
16
+
17
+ def test_ref_resolves_to_section_number() -> None:
18
+ html = _convert("# Introduction\n\nSee \\ref{introduction}.\n")
19
+ assert '<a class="zendoc-ref" href="#introduction">1</a>' in html
20
+
21
+
22
+ def test_ref_resolves_nested_heading_number() -> None:
23
+ html = _convert(
24
+ "# Chapter\n\n## Setup\n\nSee \\ref{setup}.\n\n## Usage\n\nSee \\ref{usage}.\n"
25
+ )
26
+ assert '<a class="zendoc-ref" href="#setup">1.1</a>' in html
27
+ assert '<a class="zendoc-ref" href="#usage">1.2</a>' in html
28
+
29
+
30
+ def test_forward_reference_within_same_document_resolves() -> None:
31
+ html = _convert("See \\ref{introduction} below.\n\n# Introduction\n")
32
+ assert '<a class="zendoc-ref" href="#introduction">1</a>' in html
33
+
34
+
35
+ def test_unknown_id_is_unresolved() -> None:
36
+ html = _convert("See \\ref{does-not-exist}.\n")
37
+ assert '<a class="zendoc-ref zendoc-ref-unresolved">??</a>' in html
38
+
39
+
40
+ def test_unnumbered_heading_is_unresolved_but_linkable() -> None:
41
+ html = _convert("# Cover Page {: .unnumbered }\n\nSee \\ref{cover-page}.\n")
42
+ assert '<a class="zendoc-ref zendoc-ref-unresolved" href="#cover-page">??</a>' in html
43
+
44
+
45
+ def test_custom_unresolved_marker() -> None:
46
+ md = markdown.Markdown(extensions=[RefsExtension(unresolved="[MISSING]")])
47
+ html = md.convert("See \\ref{nope}.\n")
48
+ assert ">[MISSING]</a>" in html
49
+
50
+
51
+ def test_ref_inside_code_span_is_not_resolved() -> None:
52
+ html = _convert("# Introduction\n\nType `\\ref{introduction}` literally.\n")
53
+ assert "\\ref{introduction}" in html
54
+ assert "zendoc-ref" not in html
55
+
56
+
57
+ def test_ref_inside_fenced_code_block_is_not_resolved() -> None:
58
+ html = _convert("# Introduction\n\n```\n\\ref{introduction}\n```\n")
59
+ assert "\\ref{introduction}" in html
60
+ assert "zendoc-ref" not in html
61
+
62
+
63
+ def test_shares_registry_with_explicitly_enabled_headings_extension() -> None:
64
+ """zendoc.headings listed first, zendoc.refs second - the recommended
65
+ multi-page pattern: both share one registry across separate conversions."""
66
+ registry = IdRegistry()
67
+ md_page1 = markdown.Markdown(
68
+ extensions=[
69
+ HeadingsExtension(registry=registry, source="intro.md"),
70
+ RefsExtension(registry=registry),
71
+ ]
72
+ )
73
+ md_page1.convert("# Introduction\n")
74
+
75
+ md_page2 = markdown.Markdown(
76
+ extensions=[
77
+ HeadingsExtension(registry=registry, source="usage.md"),
78
+ RefsExtension(registry=registry),
79
+ ]
80
+ )
81
+ html = md_page2.convert("See \\ref{introduction}.\n")
82
+ assert '<a class="zendoc-ref" href="#introduction">1</a>' in html
83
+
84
+
85
+ def test_entry_point_names_resolve_together() -> None:
86
+ md = markdown.Markdown(extensions=["zendoc.headings", "zendoc.refs"])
87
+ html = md.convert("# Introduction\n\nSee \\ref{introduction}.\n")
88
+ assert '<a class="zendoc-ref" href="#introduction">1</a>' in html
@@ -0,0 +1,68 @@
1
+ [project]
2
+ site_name = "zendoc"
3
+ site_description = "A family of Python-Markdown extensions for section cross-references and bibliography/citation handling."
4
+ site_author = "Mark Buckwell"
5
+ site_url = "https://buckwem.github.io/zendoc-extension/"
6
+
7
+ repo_url = "https://github.com/buckwem/zendoc-extension"
8
+ repo_name = "buckwem/zendoc-extension"
9
+ edit_uri = "edit/main/docs/"
10
+
11
+ copyright = """
12
+ Copyright &copy; 2026 Mark Buckwell and contributors
13
+ """
14
+
15
+ nav = [
16
+ {"Home" = "index.md"},
17
+ {"Installation" = "installation.md"},
18
+ {"Extensions" = [
19
+ {"Headings" = "extensions/headings.md"},
20
+ {"Refs" = "extensions/refs.md"},
21
+ ]},
22
+ {"About" = [
23
+ {"Release Notes" = "about/changelog.md"},
24
+ {"License" = "about/license.md"},
25
+ ]},
26
+ ]
27
+
28
+ [project.theme]
29
+ language = "en"
30
+ features = [
31
+ "content.action.edit",
32
+ "content.code.copy",
33
+ "navigation.top",
34
+ "navigation.tracking",
35
+ "navigation.instant",
36
+ ]
37
+
38
+ [[project.theme.palette]]
39
+ scheme = "default"
40
+ toggle.icon = "lucide/sun"
41
+ toggle.name = "Switch to dark mode"
42
+
43
+ [[project.theme.palette]]
44
+ scheme = "slate"
45
+ toggle.icon = "lucide/moon"
46
+ toggle.name = "Switch to light mode"
47
+
48
+ [[project.extra.social]]
49
+ icon = "fontawesome/brands/github"
50
+ link = "https://github.com/buckwem/zendoc-extension"
51
+
52
+ # ----------------------------------------------------------------------------
53
+ # Markdown extensions used to render these docs. pymdownx.snippets is used to
54
+ # embed LICENSE (outside docs_dir) into about/license.md.
55
+ # ----------------------------------------------------------------------------
56
+ [project.markdown_extensions.toc]
57
+ permalink = true
58
+ [project.markdown_extensions.admonition]
59
+ [project.markdown_extensions.attr_list]
60
+ [project.markdown_extensions.md_in_html]
61
+ [project.markdown_extensions.pymdownx.highlight]
62
+ anchor_linenums = true
63
+ [project.markdown_extensions.pymdownx.inlinehilite]
64
+ [project.markdown_extensions.pymdownx.snippets]
65
+ base_path = ["."]
66
+ [project.markdown_extensions.pymdownx.superfences]
67
+ [project.markdown_extensions.pymdownx.tabbed]
68
+ alternate_style = true