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.
- sphinx_gated_content-0.1.0/.github/workflows/publish.yml +72 -0
- sphinx_gated_content-0.1.0/.gitignore +27 -0
- sphinx_gated_content-0.1.0/CHANGELOG.md +42 -0
- sphinx_gated_content-0.1.0/LICENSE +21 -0
- sphinx_gated_content-0.1.0/PKG-INFO +179 -0
- sphinx_gated_content-0.1.0/README.md +145 -0
- sphinx_gated_content-0.1.0/docs/conf.py +30 -0
- sphinx_gated_content-0.1.0/docs/examples.rst +41 -0
- sphinx_gated_content-0.1.0/docs/index.rst +14 -0
- sphinx_gated_content-0.1.0/docs/private_page.rst +6 -0
- sphinx_gated_content-0.1.0/docs/public_page.rst +4 -0
- sphinx_gated_content-0.1.0/docs/usage.rst +54 -0
- sphinx_gated_content-0.1.0/pyproject.toml +62 -0
- sphinx_gated_content-0.1.0/requirements.txt +8 -0
- sphinx_gated_content-0.1.0/src/sphinx_gated_content/__init__.py +4 -0
- sphinx_gated_content-0.1.0/src/sphinx_gated_content/constants.py +17 -0
- sphinx_gated_content-0.1.0/src/sphinx_gated_content/extension.py +47 -0
- sphinx_gated_content-0.1.0/src/sphinx_gated_content/html.py +107 -0
- sphinx_gated_content-0.1.0/src/sphinx_gated_content/metadata.py +32 -0
- sphinx_gated_content-0.1.0/src/sphinx_gated_content/transforms.py +42 -0
- sphinx_gated_content-0.1.0/tests/conftest.py +102 -0
- sphinx_gated_content-0.1.0/tests/test_gated_build.py +10 -0
- sphinx_gated_content-0.1.0/tests/test_import.py +7 -0
- sphinx_gated_content-0.1.0/tests/test_private_metadata.py +8 -0
- sphinx_gated_content-0.1.0/tests/test_public_build.py +15 -0
- sphinx_gated_content-0.1.0/tests/test_search_index.py +18 -0
- sphinx_gated_content-0.1.0/tests/test_sidebar_lock_themes.py +44 -0
- 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,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,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,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()
|