sphinx-gated-content 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.
Files changed (28) hide show
  1. sphinx_gated_content-0.1.0/.github/workflows/publish.yml +72 -0
  2. sphinx_gated_content-0.1.0/.gitignore +27 -0
  3. sphinx_gated_content-0.1.0/CHANGELOG.md +42 -0
  4. sphinx_gated_content-0.1.0/LICENSE +21 -0
  5. sphinx_gated_content-0.1.0/PKG-INFO +179 -0
  6. sphinx_gated_content-0.1.0/README.md +145 -0
  7. sphinx_gated_content-0.1.0/docs/conf.py +30 -0
  8. sphinx_gated_content-0.1.0/docs/examples.rst +41 -0
  9. sphinx_gated_content-0.1.0/docs/index.rst +14 -0
  10. sphinx_gated_content-0.1.0/docs/private_page.rst +6 -0
  11. sphinx_gated_content-0.1.0/docs/public_page.rst +4 -0
  12. sphinx_gated_content-0.1.0/docs/usage.rst +54 -0
  13. sphinx_gated_content-0.1.0/pyproject.toml +62 -0
  14. sphinx_gated_content-0.1.0/requirements.txt +8 -0
  15. sphinx_gated_content-0.1.0/src/sphinx_gated_content/__init__.py +4 -0
  16. sphinx_gated_content-0.1.0/src/sphinx_gated_content/constants.py +17 -0
  17. sphinx_gated_content-0.1.0/src/sphinx_gated_content/extension.py +47 -0
  18. sphinx_gated_content-0.1.0/src/sphinx_gated_content/html.py +107 -0
  19. sphinx_gated_content-0.1.0/src/sphinx_gated_content/metadata.py +32 -0
  20. sphinx_gated_content-0.1.0/src/sphinx_gated_content/transforms.py +42 -0
  21. sphinx_gated_content-0.1.0/tests/conftest.py +102 -0
  22. sphinx_gated_content-0.1.0/tests/test_gated_build.py +10 -0
  23. sphinx_gated_content-0.1.0/tests/test_import.py +7 -0
  24. sphinx_gated_content-0.1.0/tests/test_private_metadata.py +8 -0
  25. sphinx_gated_content-0.1.0/tests/test_public_build.py +15 -0
  26. sphinx_gated_content-0.1.0/tests/test_search_index.py +18 -0
  27. sphinx_gated_content-0.1.0/tests/test_sidebar_lock_themes.py +44 -0
  28. sphinx_gated_content-0.1.0/tests/test_theme_matrix.py +51 -0
@@ -0,0 +1,72 @@
1
+ name: Publish to PyPI and Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ name: Build distribution
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v5
15
+
16
+ - uses: actions/setup-python@v6
17
+ with:
18
+ python-version: "3.x"
19
+
20
+ - name: Install build
21
+ run: python -m pip install --upgrade build
22
+
23
+ - name: Build package
24
+ run: python -m build
25
+
26
+ - name: Store distributions
27
+ uses: actions/upload-artifact@v5
28
+ with:
29
+ name: python-package-distributions
30
+ path: dist/
31
+
32
+ publish-pypi:
33
+ name: Publish to PyPI
34
+ needs: build
35
+ runs-on: ubuntu-latest
36
+
37
+ environment:
38
+ name: pypi
39
+
40
+ permissions:
41
+ id-token: write
42
+
43
+ steps:
44
+ - name: Download distributions
45
+ uses: actions/download-artifact@v5
46
+ with:
47
+ name: python-package-distributions
48
+ path: dist/
49
+
50
+ - name: Publish package
51
+ uses: pypa/gh-action-pypi-publish@release/v1
52
+
53
+ github-release:
54
+ name: Create GitHub Release
55
+ needs: build
56
+ runs-on: ubuntu-latest
57
+
58
+ permissions:
59
+ contents: write
60
+
61
+ steps:
62
+ - name: Download distributions
63
+ uses: actions/download-artifact@v5
64
+ with:
65
+ name: python-package-distributions
66
+ path: dist/
67
+
68
+ - name: Create release and upload distributions
69
+ uses: softprops/action-gh-release@v2
70
+ with:
71
+ files: dist/*
72
+ generate_release_notes: true
@@ -0,0 +1,27 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+
5
+ # Virtual envs
6
+ .venv/
7
+ venv/
8
+
9
+ # Packaging
10
+ build/
11
+ dist/
12
+ *.egg-info/
13
+
14
+ # Test
15
+ .pytest_cache/
16
+ .coverage
17
+
18
+ # Docs
19
+ docs/_build/
20
+
21
+ # IDE
22
+ .vscode/
23
+ .idea/
24
+
25
+ # OS
26
+ .DS_Store
27
+ Thumbs.db
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ This project follows Semantic Versioning.
6
+
7
+ ## [0.1.0] - 2026-06-07
8
+
9
+ ### Added
10
+
11
+ - Mark documentation pages as gated using page metadata:
12
+
13
+ ```rst
14
+ :private: true
15
+ ```
16
+
17
+ - Generate two documentation variants from the same source tree:
18
+ - **Public build**: gated pages are replaced with a configurable placeholder.
19
+ - **Gated build**: all pages are rendered normally.
20
+
21
+ - Configurable placeholder text for gated pages.
22
+
23
+ - Configurable metadata key used to identify gated content.
24
+
25
+ - Configurable lock icon for gated pages.
26
+
27
+ - Removal of gated page content from public HTML output.
28
+
29
+ - Removal of gated page content from the generated search index.
30
+
31
+ - Sidebar lock indicators for supported themes.
32
+
33
+ ### Supported themes
34
+
35
+ - Alabaster
36
+ - Read the Docs Theme (`sphinx_rtd_theme`)
37
+ - Shibuya
38
+
39
+ ### Tested with
40
+
41
+ - Python 3.9+
42
+ - Sphinx 7+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Accelerat S.r.l.
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.
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: sphinx-gated-content
3
+ Version: 0.1.0
4
+ Summary: Build public and gated versions of Sphinx documentation.
5
+ Project-URL: Homepage, https://github.com/accelerat-team/sphinx-gated-content
6
+ Project-URL: Repository, https://github.com/accelerat-team/sphinx-gated-content
7
+ Project-URL: Issues, https://github.com/accelerat-team/sphinx-gated-content/issues
8
+ Project-URL: Company, https://accelerat.eu
9
+ Author: Gabriele Serra
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: docs,documentation,gated-content,sphinx,subscriptions
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: Sphinx
15
+ Classifier: Framework :: Sphinx :: Extension
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: sphinx>=7.0
25
+ Provides-Extra: docs
26
+ Requires-Dist: shibuya; extra == 'docs'
27
+ Requires-Dist: sphinx-rtd-theme>=3; extra == 'docs'
28
+ Provides-Extra: test
29
+ Requires-Dist: beautifulsoup4>=4.12; extra == 'test'
30
+ Requires-Dist: pytest>=8; extra == 'test'
31
+ Requires-Dist: shibuya; extra == 'test'
32
+ Requires-Dist: sphinx-rtd-theme>=3; extra == 'test'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # sphinx-gated-content
36
+
37
+ Build **public** and **gated** versions of your Sphinx documentation from the same source tree.
38
+
39
+ `sphinx-gated-content` allows you to mark pages as private and generate two different documentation builds:
40
+
41
+ | Build | Visible content |
42
+ |---------|---------|
43
+ | **Public** | Public pages + placeholders for gated pages |
44
+ | **Gated** | All pages, including gated content |
45
+
46
+ This is useful for:
47
+
48
+ - Subscription-based documentation
49
+ - Freemium products
50
+ - Internal vs public documentation
51
+ - Community vs enterprise documentation
52
+ - Documentation previews
53
+
54
+ ## How it works
55
+
56
+ Mark a page as private:
57
+
58
+ ```rst
59
+ :private: true
60
+
61
+ Advanced API
62
+ ============
63
+
64
+ This content is subscription-only.
65
+ ```
66
+
67
+ Then choose which build to generate.
68
+
69
+ ### Public build
70
+
71
+ Private pages remain visible in the navigation but:
72
+
73
+ - display a lock icon (`🔒`)
74
+ - show a placeholder page instead of the real content
75
+ - are removed from the search index
76
+
77
+ ### Gated build
78
+
79
+ All pages are rendered normally.
80
+
81
+ No content is removed or replaced.
82
+
83
+ ## Installation
84
+
85
+ ```bash
86
+ pip install sphinx-gated-content
87
+ ```
88
+
89
+ ## Quick Start
90
+
91
+ Enable the extension in your `conf.py`:
92
+
93
+ ```python
94
+ import os
95
+
96
+ extensions = [
97
+ "sphinx_gated_content",
98
+ ]
99
+
100
+ gated_content_public_build = (
101
+ os.getenv("SPHINX_GATED_PUBLIC", "1") == "1"
102
+ )
103
+ ```
104
+
105
+ ### Generate a public build
106
+
107
+ ```bash
108
+ SPHINX_GATED_PUBLIC=1 \
109
+ python -m sphinx -b html docs docs/_build/public
110
+ ```
111
+
112
+ ### Generate a gated build
113
+
114
+ ```bash
115
+ SPHINX_GATED_PUBLIC=0 \
116
+ python -m sphinx -b html docs docs/_build/gated
117
+ ```
118
+
119
+ ## Configuration
120
+
121
+ ```python
122
+ gated_content_public_build = True
123
+ gated_content_private_metadata_key = "private"
124
+ gated_content_lock_icon = "🔒"
125
+ gated_content_placeholder = "This page is available with a subscription."
126
+ ```
127
+
128
+ ## Supported Themes
129
+
130
+ Currently tested with:
131
+
132
+ - Alabaster
133
+ - Read the Docs Theme (`sphinx_rtd_theme`)
134
+ - Shibuya
135
+
136
+ ## Security
137
+
138
+ `sphinx-gated-content` removes gated page content from public HTML builds and excludes it from the generated search index.
139
+
140
+ You should still verify any additional build outputs you publish, such as:
141
+
142
+ - PDFs
143
+ - EPUB files
144
+ - downloadable source archives
145
+ - custom builders
146
+ - static assets generated by third-party extensions
147
+
148
+ ## Development
149
+
150
+ Create a virtual environment:
151
+
152
+ ```bash
153
+ python -m venv .venv
154
+ source .venv/bin/activate
155
+ ```
156
+
157
+ Install dependencies:
158
+
159
+ ```bash
160
+ python -m pip install --upgrade pip
161
+ python -m pip install -r requirements.txt
162
+ python -m pip install -e .
163
+ ```
164
+
165
+ Run tests:
166
+
167
+ ```bash
168
+ python -m pytest
169
+ ```
170
+
171
+ Build the documentation:
172
+
173
+ ```bash
174
+ python -m sphinx -b html docs docs/_build/html
175
+ ```
176
+
177
+ ## License
178
+
179
+ MIT
@@ -0,0 +1,145 @@
1
+ # sphinx-gated-content
2
+
3
+ Build **public** and **gated** versions of your Sphinx documentation from the same source tree.
4
+
5
+ `sphinx-gated-content` allows you to mark pages as private and generate two different documentation builds:
6
+
7
+ | Build | Visible content |
8
+ |---------|---------|
9
+ | **Public** | Public pages + placeholders for gated pages |
10
+ | **Gated** | All pages, including gated content |
11
+
12
+ This is useful for:
13
+
14
+ - Subscription-based documentation
15
+ - Freemium products
16
+ - Internal vs public documentation
17
+ - Community vs enterprise documentation
18
+ - Documentation previews
19
+
20
+ ## How it works
21
+
22
+ Mark a page as private:
23
+
24
+ ```rst
25
+ :private: true
26
+
27
+ Advanced API
28
+ ============
29
+
30
+ This content is subscription-only.
31
+ ```
32
+
33
+ Then choose which build to generate.
34
+
35
+ ### Public build
36
+
37
+ Private pages remain visible in the navigation but:
38
+
39
+ - display a lock icon (`🔒`)
40
+ - show a placeholder page instead of the real content
41
+ - are removed from the search index
42
+
43
+ ### Gated build
44
+
45
+ All pages are rendered normally.
46
+
47
+ No content is removed or replaced.
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ pip install sphinx-gated-content
53
+ ```
54
+
55
+ ## Quick Start
56
+
57
+ Enable the extension in your `conf.py`:
58
+
59
+ ```python
60
+ import os
61
+
62
+ extensions = [
63
+ "sphinx_gated_content",
64
+ ]
65
+
66
+ gated_content_public_build = (
67
+ os.getenv("SPHINX_GATED_PUBLIC", "1") == "1"
68
+ )
69
+ ```
70
+
71
+ ### Generate a public build
72
+
73
+ ```bash
74
+ SPHINX_GATED_PUBLIC=1 \
75
+ python -m sphinx -b html docs docs/_build/public
76
+ ```
77
+
78
+ ### Generate a gated build
79
+
80
+ ```bash
81
+ SPHINX_GATED_PUBLIC=0 \
82
+ python -m sphinx -b html docs docs/_build/gated
83
+ ```
84
+
85
+ ## Configuration
86
+
87
+ ```python
88
+ gated_content_public_build = True
89
+ gated_content_private_metadata_key = "private"
90
+ gated_content_lock_icon = "🔒"
91
+ gated_content_placeholder = "This page is available with a subscription."
92
+ ```
93
+
94
+ ## Supported Themes
95
+
96
+ Currently tested with:
97
+
98
+ - Alabaster
99
+ - Read the Docs Theme (`sphinx_rtd_theme`)
100
+ - Shibuya
101
+
102
+ ## Security
103
+
104
+ `sphinx-gated-content` removes gated page content from public HTML builds and excludes it from the generated search index.
105
+
106
+ You should still verify any additional build outputs you publish, such as:
107
+
108
+ - PDFs
109
+ - EPUB files
110
+ - downloadable source archives
111
+ - custom builders
112
+ - static assets generated by third-party extensions
113
+
114
+ ## Development
115
+
116
+ Create a virtual environment:
117
+
118
+ ```bash
119
+ python -m venv .venv
120
+ source .venv/bin/activate
121
+ ```
122
+
123
+ Install dependencies:
124
+
125
+ ```bash
126
+ python -m pip install --upgrade pip
127
+ python -m pip install -r requirements.txt
128
+ python -m pip install -e .
129
+ ```
130
+
131
+ Run tests:
132
+
133
+ ```bash
134
+ python -m pytest
135
+ ```
136
+
137
+ Build the documentation:
138
+
139
+ ```bash
140
+ python -m sphinx -b html docs docs/_build/html
141
+ ```
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+ SRC = ROOT / "src"
9
+
10
+ sys.path.insert(0, str(SRC))
11
+
12
+ project = "sphinx-gated-content"
13
+ author = "Gabriele Serra"
14
+ copyright = "2026, Accelerat"
15
+
16
+ extensions = [
17
+ "sphinx_gated_content",
18
+ ]
19
+
20
+ templates_path = ["_templates"]
21
+ exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
22
+
23
+ html_title = "sphinx-gated-content"
24
+
25
+ gated_content_public_build = os.getenv("SPHINX_GATED_PUBLIC", "1") == "1"
26
+ gated_content_private_metadata_key = "private"
27
+ gated_content_lock_icon = "🔒"
28
+ gated_content_placeholder = "This page is available with a subscription."
29
+
30
+ html_theme = os.getenv("SPHINX_THEME", "alabaster")
@@ -0,0 +1,41 @@
1
+ Examples
2
+ ========
3
+
4
+ Public page
5
+ -----------
6
+
7
+ This page is public and appears normally in both public and gated builds.
8
+
9
+ Private page
10
+ ------------
11
+
12
+ A private page can be written like this:
13
+
14
+ .. code-block:: rst
15
+
16
+ :private: true
17
+
18
+ Advanced Tutorial
19
+ =================
20
+
21
+ This content is visible only in the gated build.
22
+
23
+ Custom metadata key
24
+ -------------------
25
+
26
+ You can use a different metadata key:
27
+
28
+ .. code-block:: python
29
+
30
+ gated_content_private_metadata_key = "gated_only"
31
+
32
+ Then mark pages like this:
33
+
34
+ .. code-block:: rst
35
+
36
+ :gated_only: true
37
+
38
+ Enterprise Guide
39
+ ================
40
+
41
+ This content is subscription-only.
@@ -0,0 +1,14 @@
1
+ sphinx-gated-content
2
+ =====================
3
+
4
+ Build public and "gated" versions of Sphinx documentation from the same source
5
+ tree.
6
+
7
+ .. toctree::
8
+ :maxdepth: 2
9
+ :caption: Contents
10
+
11
+ usage
12
+ examples
13
+ public_page
14
+ private_page
@@ -0,0 +1,6 @@
1
+ :private: true
2
+
3
+ Private Page
4
+ ============
5
+
6
+ This content should only appear in gated builds.
@@ -0,0 +1,4 @@
1
+ Public Page
2
+ ===========
3
+
4
+ This page should always be visible.
@@ -0,0 +1,54 @@
1
+ Usage
2
+ =====
3
+
4
+ Enable the extension in your Sphinx ``conf.py``:
5
+
6
+ .. code-block:: python
7
+
8
+ import os
9
+
10
+ extensions = [
11
+ "sphinx_gated_content",
12
+ ]
13
+
14
+ gated_content_public_build = os.getenv("SPHINX_GATED_PUBLIC", "1") == "1"
15
+
16
+ Mark private pages with reStructuredText metadata:
17
+
18
+ .. code-block:: rst
19
+
20
+ :private: true
21
+
22
+ Advanced API
23
+ ============
24
+
25
+ This page is subscription-only.
26
+
27
+ Public builds
28
+ -------------
29
+
30
+ In public builds, private pages remain part of the documentation structure, but
31
+ their content is replaced with a placeholder.
32
+
33
+ .. code-block:: bash
34
+
35
+ SPHINX_GATED_PUBLIC=1 sphinx-build -b html docs docs/_build/public
36
+
37
+ Gated builds
38
+ -------------
39
+
40
+ In gated builds, private pages are rendered normally.
41
+
42
+ .. code-block:: bash
43
+
44
+ SPHINX_GATED_PUBLIC=0 sphinx-build -b html docs docs/_build/gated
45
+
46
+ Configuration
47
+ -------------
48
+
49
+ .. code-block:: python
50
+
51
+ gated_content_public_build = True
52
+ gated_content_private_metadata_key = "private"
53
+ gated_content_lock_icon = "🔒"
54
+ gated_content_placeholder = "This page is available with a subscription."
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sphinx-gated-content"
7
+ version = "0.1.0"
8
+ description = "Build public and gated versions of Sphinx documentation."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+
13
+ authors = [
14
+ { name = "Gabriele Serra" }
15
+ ]
16
+
17
+ keywords = [
18
+ "sphinx",
19
+ "documentation",
20
+ "docs",
21
+ "gated-content",
22
+ "subscriptions",
23
+ ]
24
+
25
+ classifiers = [
26
+ "Development Status :: 4 - Beta",
27
+ "Framework :: Sphinx",
28
+ "Framework :: Sphinx :: Extension",
29
+ "License :: OSI Approved :: MIT License",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3.9",
32
+ "Programming Language :: Python :: 3.10",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3.12",
35
+ "Programming Language :: Python :: 3.13",
36
+ ]
37
+
38
+ dependencies = [
39
+ "sphinx>=7.0",
40
+ ]
41
+
42
+ [project.optional-dependencies]
43
+ test = [
44
+ "pytest>=8",
45
+ "beautifulsoup4>=4.12",
46
+ "sphinx-rtd-theme>=3",
47
+ "shibuya",
48
+ ]
49
+
50
+ docs = [
51
+ "sphinx-rtd-theme>=3",
52
+ "shibuya",
53
+ ]
54
+
55
+ [project.urls]
56
+ Homepage = "https://github.com/accelerat-team/sphinx-gated-content"
57
+ Repository = "https://github.com/accelerat-team/sphinx-gated-content"
58
+ Issues = "https://github.com/accelerat-team/sphinx-gated-content/issues"
59
+ Company = "https://accelerat.eu"
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
@@ -0,0 +1,8 @@
1
+ sphinx>=7.0
2
+ pytest>=8.0
3
+ beautifulsoup4>=4.12
4
+ sphinx-rtd-theme>=3.0
5
+ shibuya>=2026.5
6
+
7
+ build>=1.2
8
+ twine>=6.0
@@ -0,0 +1,4 @@
1
+ """Sphinx extension for building public and gated documentation variants."""
2
+ from .extension import setup
3
+
4
+ __all__ = ["setup"]
@@ -0,0 +1,17 @@
1
+ """Constants used by sphinx-gated-content."""
2
+
3
+ # Configuration variables used to control the configuration of this extension
4
+ CONFIG_PUBLIC_BUILD = "gated_content_public_build"
5
+ CONFIG_PRIVATE_METADATA_KEY = "gated_content_private_metadata_key"
6
+ CONFIG_LOCK_ICON = "gated_content_lock_icon"
7
+ CONFIG_PLACEHOLDER = "gated_content_placeholder"
8
+
9
+ # Default values assigned to configuration variables
10
+ DEFAULT_PUBLIC_BUILD = True
11
+ DEFAULT_PRIVATE_METADATA_KEY = "private"
12
+ DEFAULT_LOCK_ICON = "🔒"
13
+ DEFAULT_PLACEHOLDER = "This page is available with a subscription."
14
+
15
+ # Miscellaneous
16
+ PRIVATE_PAGES_ATTR = "gated_content_private_pages"
17
+ DEFAULT_FALLBACK_TITLE = "Gated content"
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+ from sphinx.application import Sphinx
5
+
6
+ from .metadata import collect_private_pages
7
+ from .transforms import replace_private_page_content
8
+ from .html import add_lock_context
9
+ from .constants import (
10
+ CONFIG_LOCK_ICON,
11
+ CONFIG_PLACEHOLDER,
12
+ CONFIG_PRIVATE_METADATA_KEY,
13
+ CONFIG_PUBLIC_BUILD,
14
+ DEFAULT_LOCK_ICON,
15
+ DEFAULT_PLACEHOLDER,
16
+ DEFAULT_PRIVATE_METADATA_KEY,
17
+ DEFAULT_PUBLIC_BUILD,
18
+ )
19
+
20
+ def _package_version() -> str:
21
+ """Return the installed package version."""
22
+ try:
23
+ return version("sphinx-gated-content")
24
+ except PackageNotFoundError:
25
+ return "0.0.0"
26
+
27
+
28
+ def setup(app: Sphinx) -> dict[str, object]:
29
+ """Register the sphinx-gated-content extension with Sphinx."""
30
+ app.add_config_value(CONFIG_PUBLIC_BUILD, DEFAULT_PUBLIC_BUILD, 'env')
31
+ app.add_config_value(
32
+ CONFIG_PRIVATE_METADATA_KEY,
33
+ DEFAULT_PRIVATE_METADATA_KEY,
34
+ 'env',
35
+ )
36
+ app.add_config_value(CONFIG_LOCK_ICON, DEFAULT_LOCK_ICON, 'html')
37
+ app.add_config_value(CONFIG_PLACEHOLDER, DEFAULT_PLACEHOLDER, 'html')
38
+
39
+ app.connect("doctree-read", collect_private_pages)
40
+ app.connect("doctree-resolved", replace_private_page_content)
41
+ app.connect("html-page-context", add_lock_context)
42
+
43
+ return {
44
+ "version": _package_version(),
45
+ "parallel_read_safe": True,
46
+ "parallel_write_safe": True,
47
+ }
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+ from html import escape
6
+ from typing import Any
7
+
8
+ from .constants import CONFIG_LOCK_ICON, CONFIG_PUBLIC_BUILD, PRIVATE_PAGES_ATTR
9
+
10
+ HREF_RE = re.compile(
11
+ r'(<a\b[^>]*\bhref=["\'](?P<href>[^"\']+)["\'][^>]*>)'
12
+ r'(?P<label>.*?)'
13
+ r'(</a>)',
14
+ re.IGNORECASE | re.DOTALL,
15
+ )
16
+
17
+ TOC_CONTEXT_KEYS = (
18
+ "toctree",
19
+ "generate_toctree_html",
20
+ "toc",
21
+ )
22
+
23
+
24
+ def _docname_from_href(href: str) -> str:
25
+ """Convert a generated HTML link into a Sphinx docname-like path."""
26
+ href = href.split("#", 1)[0].split("?", 1)[0].strip()
27
+
28
+ if not href or href.startswith(("http://", "https://", "mailto:")):
29
+ return ""
30
+
31
+ href = href.lstrip("./")
32
+
33
+ while href.startswith("../"):
34
+ href = href[3:]
35
+
36
+ if href.endswith("/"):
37
+ href = f"{href}index.html"
38
+
39
+ if href.endswith(".html"):
40
+ href = href[:-5]
41
+
42
+ return href.strip("/")
43
+
44
+
45
+ def _doc_matches_private_page(docname: str, private_pages: set[str]) -> bool:
46
+ """Return whether a docname corresponds to a gated page."""
47
+ if docname in private_pages:
48
+ return True
49
+
50
+ if docname.endswith("/index"):
51
+ return docname[:-6] in private_pages
52
+
53
+ return False
54
+
55
+
56
+ def _inject_locks(html: str, app) -> str:
57
+ """Add lock icons to links pointing to gated pages in public builds."""
58
+ if not getattr(app.config, CONFIG_PUBLIC_BUILD):
59
+ return html
60
+
61
+ private_pages = set(getattr(app.env, PRIVATE_PAGES_ATTR, set()))
62
+ if not private_pages:
63
+ return html
64
+
65
+ icon = escape(getattr(app.config, CONFIG_LOCK_ICON))
66
+
67
+ def replace(match: re.Match[str]) -> str:
68
+ href = match.group("href")
69
+ label = match.group("label")
70
+ docname = _docname_from_href(href)
71
+
72
+ if not _doc_matches_private_page(docname, private_pages):
73
+ return match.group(0)
74
+
75
+ if icon in label:
76
+ return match.group(0)
77
+
78
+ return f"{match.group(1)}{label} {icon}{match.group(4)}"
79
+
80
+ return HREF_RE.sub(replace, html)
81
+
82
+
83
+ def _wrap_html_function(func: Callable[..., str], app) -> Callable[..., str]:
84
+ """Wrap a Sphinx context function and inject locks into its HTML output."""
85
+
86
+ def wrapped(*args: Any, **kwargs: Any) -> str:
87
+ return _inject_locks(func(*args, **kwargs), app)
88
+
89
+ return wrapped
90
+
91
+
92
+ def add_lock_context(app, pagename, templatename, context, doctree) -> None:
93
+ """Expose gated-content context values and patch sidebar TOC HTML."""
94
+ private_pages = set(getattr(app.env, PRIVATE_PAGES_ATTR, set()))
95
+
96
+ context["gated_content_private_pages"] = private_pages
97
+ context["gated_content_is_private"] = pagename in private_pages
98
+ context["gated_content_lock_icon"] = getattr(app.config, CONFIG_LOCK_ICON)
99
+ context["gated_content_public_build"] = getattr(app.config, CONFIG_PUBLIC_BUILD)
100
+
101
+ for key in TOC_CONTEXT_KEYS:
102
+ value = context.get(key)
103
+
104
+ if callable(value):
105
+ context[key] = _wrap_html_function(value, app)
106
+ elif isinstance(value, str):
107
+ context[key] = _inject_locks(value, app)
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .constants import CONFIG_PRIVATE_METADATA_KEY, PRIVATE_PAGES_ATTR
6
+
7
+ TRUE_VALUES: set[str] = {
8
+ "1",
9
+ "true",
10
+ "yes",
11
+ "on",
12
+ }
13
+
14
+ def is_truthy(value: Any) -> bool:
15
+ """Return whether a metadata value should be treated as enabled."""
16
+ return str(value).strip().lower() in TRUE_VALUES
17
+
18
+ def collect_private_pages(app, doctree) -> None:
19
+ """Collect document names marked as gated through page metadata."""
20
+ docname = app.env.docname
21
+ key = getattr(app.config, CONFIG_PRIVATE_METADATA_KEY)
22
+
23
+ metadata = app.env.metadata.get(docname, {})
24
+ is_private = is_truthy(metadata.get(key))
25
+
26
+ if not hasattr(app.env, PRIVATE_PAGES_ATTR):
27
+ setattr(app.env, PRIVATE_PAGES_ATTR, set())
28
+
29
+ private_pages = getattr(app.env, PRIVATE_PAGES_ATTR)
30
+
31
+ if is_private:
32
+ private_pages.add(docname)
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from docutils import nodes
4
+
5
+ from .constants import (
6
+ CONFIG_PLACEHOLDER,
7
+ CONFIG_PUBLIC_BUILD,
8
+ DEFAULT_PLACEHOLDER,
9
+ DEFAULT_FALLBACK_TITLE,
10
+ PRIVATE_PAGES_ATTR,
11
+ )
12
+
13
+ def _page_title(doctree) -> str:
14
+ """Return the page title from the doctree, or a safe fallback."""
15
+ title = doctree.next_node(nodes.title)
16
+
17
+ if title is None:
18
+ return DEFAULT_FALLBACK_TITLE
19
+
20
+ return title.astext()
21
+
22
+
23
+ def replace_private_page_content(app, doctree, docname: str) -> None:
24
+ """Replace gated page content with a placeholder in public builds."""
25
+ if not getattr(app.config, CONFIG_PUBLIC_BUILD):
26
+ return
27
+
28
+ private_pages = getattr(app.env, PRIVATE_PAGES_ATTR, set())
29
+
30
+ if docname not in private_pages:
31
+ return
32
+
33
+ title_text = _page_title(doctree)
34
+ placeholder = getattr(app.config, CONFIG_PLACEHOLDER, DEFAULT_PLACEHOLDER)
35
+
36
+ doctree.clear()
37
+
38
+ section = nodes.section(ids=["gated-content"])
39
+ section += nodes.title(text=title_text)
40
+ section += nodes.paragraph(text=placeholder)
41
+
42
+ doctree += section
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ from pathlib import Path
5
+ from sphinx.testing.util import SphinxTestApp
6
+
7
+ PRIVATE_BODY = "This content is private and should not appear in public builds."
8
+ PRIVATE_SEARCH_TOKEN = "PRIVATE_UNIQUE_SEARCH_TOKEN_12345"
9
+
10
+ @pytest.fixture()
11
+ def sphinx_project(tmp_path: Path) -> Path:
12
+ srcdir = tmp_path / "src"
13
+ srcdir.mkdir()
14
+
15
+ (srcdir / "conf.py").write_text(
16
+ """
17
+ extensions = ["sphinx_gated_content"]
18
+
19
+ master_doc = "index"
20
+ project = "Test Project"
21
+
22
+ gated_content_private_metadata_key = "private"
23
+ gated_content_lock_icon = "🔒"
24
+ gated_content_placeholder = "Subscribe to read this page."
25
+ """.strip(),
26
+ encoding="utf-8",
27
+ )
28
+
29
+ (srcdir / "index.rst").write_text(
30
+ """
31
+ Test Project
32
+ ============
33
+
34
+ .. toctree::
35
+ :maxdepth: 2
36
+
37
+ public
38
+ private
39
+ """.strip(),
40
+ encoding="utf-8",
41
+ )
42
+
43
+ (srcdir / "public.rst").write_text(
44
+ """
45
+ Public Page
46
+ ===========
47
+
48
+ This content is public.
49
+ """.strip(),
50
+ encoding="utf-8",
51
+ )
52
+
53
+ (srcdir / "private.rst").write_text(
54
+ f"""
55
+ :private: true
56
+
57
+ Private Page
58
+ ============
59
+
60
+ {PRIVATE_BODY}
61
+
62
+ {PRIVATE_SEARCH_TOKEN}
63
+ """.strip(),
64
+ encoding="utf-8",
65
+ )
66
+
67
+ return srcdir
68
+
69
+
70
+ def build_sphinx(
71
+ srcdir: Path,
72
+ *,
73
+ public: bool,
74
+ theme: str = "alabaster",
75
+ ) -> SphinxTestApp:
76
+ """Build a temporary Sphinx project for extension tests."""
77
+ confoverrides = {
78
+ "html_theme": theme,
79
+ "gated_content_public_build": public,
80
+ }
81
+
82
+ app = SphinxTestApp(
83
+ buildername="html",
84
+ srcdir=srcdir,
85
+ confoverrides=confoverrides,
86
+ )
87
+ app.build()
88
+ return app
89
+
90
+
91
+ @pytest.fixture()
92
+ def public_app(sphinx_project: Path) -> SphinxTestApp:
93
+ app = build_sphinx(sphinx_project, public=True)
94
+ yield app
95
+ app.cleanup()
96
+
97
+
98
+ @pytest.fixture()
99
+ def gated_app(sphinx_project: Path) -> SphinxTestApp:
100
+ app = build_sphinx(sphinx_project, public=False)
101
+ yield app
102
+ app.cleanup()
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from tests.conftest import PRIVATE_BODY
4
+
5
+ def test_gated_build_keeps_private_content(gated_app):
6
+ html = (gated_app.outdir / "private.html").read_text(encoding="utf-8")
7
+
8
+ assert "Private Page" in html
9
+ assert PRIVATE_BODY in html
10
+ assert "Subscribe to read this page." not in html
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def test_extension_imports():
5
+ import sphinx_gated_content
6
+
7
+ assert sphinx_gated_content.setup is not None
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def test_private_page_is_collected(public_app):
5
+ private_pages = getattr(public_app.env, "gated_content_private_pages", set())
6
+
7
+ assert "private" in private_pages
8
+ assert "public" not in private_pages
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ def test_public_build_replaces_private_content(public_app):
4
+ html = (public_app.outdir / "private.html").read_text(encoding="utf-8")
5
+
6
+ assert "Private Page" in html
7
+ assert "Subscribe to read this page." in html
8
+ assert "This content is private and should not appear" not in html
9
+
10
+
11
+ def test_public_build_keeps_public_content(public_app):
12
+ html = (public_app.outdir / "public.html").read_text(encoding="utf-8")
13
+
14
+ assert "Public Page" in html
15
+ assert "This content is public." in html
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from tests.conftest import PRIVATE_SEARCH_TOKEN
4
+
5
+ def test_private_content_not_in_search_index_for_public_build(public_app):
6
+ searchindex = (
7
+ public_app.outdir / "searchindex.js"
8
+ ).read_text(encoding="utf-8")
9
+
10
+ assert PRIVATE_SEARCH_TOKEN.lower() not in searchindex.lower()
11
+
12
+
13
+ def test_private_content_in_search_index_for_gated_build(gated_app):
14
+ searchindex = (
15
+ gated_app.outdir / "searchindex.js"
16
+ ).read_text(encoding="utf-8")
17
+
18
+ assert PRIVATE_SEARCH_TOKEN.lower() in searchindex.lower()
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from tests.conftest import build_sphinx
6
+
7
+
8
+ THEMES = [
9
+ "alabaster",
10
+ "sphinx_rtd_theme",
11
+ "shibuya",
12
+ ]
13
+
14
+
15
+ @pytest.mark.parametrize("theme", THEMES)
16
+ def test_public_build_adds_lock_to_sidebar_for_theme(
17
+ sphinx_project,
18
+ theme,
19
+ ):
20
+ app = build_sphinx(sphinx_project, public=True, theme=theme)
21
+
22
+ try:
23
+ html = (app.outdir / "index.html").read_text(encoding="utf-8")
24
+
25
+ assert "Private Page" in html
26
+ assert "🔒" in html
27
+ finally:
28
+ app.cleanup()
29
+
30
+
31
+ @pytest.mark.parametrize("theme", THEMES)
32
+ def test_gated_build_does_not_add_lock_to_sidebar_for_theme(
33
+ sphinx_project,
34
+ theme,
35
+ ):
36
+ app = build_sphinx(sphinx_project, public=False, theme=theme)
37
+
38
+ try:
39
+ html = (app.outdir / "index.html").read_text(encoding="utf-8")
40
+
41
+ assert "Private Page" in html
42
+ assert "🔒" not in html
43
+ finally:
44
+ app.cleanup()
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from tests.conftest import build_sphinx, PRIVATE_BODY, PRIVATE_SEARCH_TOKEN
6
+
7
+ THEMES = [
8
+ "alabaster",
9
+ "sphinx_rtd_theme",
10
+ "shibuya",
11
+ ]
12
+
13
+ @pytest.mark.parametrize("theme", THEMES)
14
+ @pytest.mark.parametrize("public", [True, False])
15
+ def test_private_page_behavior_across_themes(
16
+ sphinx_project,
17
+ theme,
18
+ public,
19
+ ):
20
+ app = build_sphinx(
21
+ sphinx_project,
22
+ public=public,
23
+ theme=theme,
24
+ )
25
+
26
+ try:
27
+ private_html = (
28
+ app.outdir / "private.html"
29
+ ).read_text(encoding="utf-8")
30
+
31
+ searchindex = (
32
+ app.outdir / "searchindex.js"
33
+ ).read_text(encoding="utf-8")
34
+
35
+ index_html = (
36
+ app.outdir / "index.html"
37
+ ).read_text(encoding="utf-8")
38
+
39
+ if public:
40
+ assert "Subscribe to read this page." in private_html
41
+ assert PRIVATE_BODY not in private_html
42
+ assert PRIVATE_SEARCH_TOKEN.lower() not in searchindex.lower()
43
+ assert "🔒" in index_html
44
+ else:
45
+ assert "Subscribe to read this page." not in private_html
46
+ assert PRIVATE_BODY in private_html
47
+ assert PRIVATE_SEARCH_TOKEN.lower() in searchindex.lower()
48
+ assert "🔒" not in index_html
49
+
50
+ finally:
51
+ app.cleanup()