sphinx-llm-friendly 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 (53) hide show
  1. sphinx_llm_friendly-0.1.0/.gitignore +6 -0
  2. sphinx_llm_friendly-0.1.0/LICENSE +23 -0
  3. sphinx_llm_friendly-0.1.0/PKG-INFO +100 -0
  4. sphinx_llm_friendly-0.1.0/README.rst +70 -0
  5. sphinx_llm_friendly-0.1.0/pyproject.toml +141 -0
  6. sphinx_llm_friendly-0.1.0/sphinx_llm_friendly/__init__.py +223 -0
  7. sphinx_llm_friendly-0.1.0/sphinx_llm_friendly/_build.py +90 -0
  8. sphinx_llm_friendly-0.1.0/sphinx_llm_friendly/_builder.py +99 -0
  9. sphinx_llm_friendly-0.1.0/sphinx_llm_friendly/_contexts.py +448 -0
  10. sphinx_llm_friendly-0.1.0/sphinx_llm_friendly/_escape.py +13 -0
  11. sphinx_llm_friendly-0.1.0/sphinx_llm_friendly/_exclude.py +27 -0
  12. sphinx_llm_friendly-0.1.0/sphinx_llm_friendly/_intersphinx.py +101 -0
  13. sphinx_llm_friendly-0.1.0/sphinx_llm_friendly/_llm.py +56 -0
  14. sphinx_llm_friendly-0.1.0/sphinx_llm_friendly/_llms_txt.py +81 -0
  15. sphinx_llm_friendly-0.1.0/sphinx_llm_friendly/_singlemarkdown.py +112 -0
  16. sphinx_llm_friendly-0.1.0/sphinx_llm_friendly/_translator.py +907 -0
  17. sphinx_llm_friendly-0.1.0/sphinx_llm_friendly/_writer.py +27 -0
  18. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/ExampleRSTFile.md +596 -0
  19. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/Section_course_student.md +82 -0
  20. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/auto-module.md +67 -0
  21. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/auto-summery.md +11 -0
  22. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/blocks.md +55 -0
  23. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/empty.md +1 -0
  24. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/glossaries.md +25 -0
  25. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/image-target.md +7 -0
  26. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/index.md +76 -0
  27. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/library/my_module.md +71 -0
  28. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/library/my_module.module_class.md +34 -0
  29. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/library/my_module.submodule.md +7 -0
  30. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/library/my_module.submodule.my_class.md +26 -0
  31. sphinx_llm_friendly-0.1.0/tests/expected/llm_markdown/links.md +0 -0
  32. sphinx_llm_friendly-0.1.0/tests/expected/llms-full.txt +1017 -0
  33. sphinx_llm_friendly-0.1.0/tests/my_module/__init__.py +62 -0
  34. sphinx_llm_friendly-0.1.0/tests/my_module/module_class.py +35 -0
  35. sphinx_llm_friendly-0.1.0/tests/my_module/submodule/__init__.py +3 -0
  36. sphinx_llm_friendly-0.1.0/tests/my_module/submodule/my_class.py +28 -0
  37. sphinx_llm_friendly-0.1.0/tests/source/ExampleRSTFile.rst +806 -0
  38. sphinx_llm_friendly-0.1.0/tests/source/Section_course_student.rst +128 -0
  39. sphinx_llm_friendly-0.1.0/tests/source/_templates/custom-module-template.rst +18 -0
  40. sphinx_llm_friendly-0.1.0/tests/source/auto-module.rst +5 -0
  41. sphinx_llm_friendly-0.1.0/tests/source/auto-summery.rst +29 -0
  42. sphinx_llm_friendly-0.1.0/tests/source/blocks.rst +73 -0
  43. sphinx_llm_friendly-0.1.0/tests/source/conf.py +27 -0
  44. sphinx_llm_friendly-0.1.0/tests/source/empty.rst +15 -0
  45. sphinx_llm_friendly-0.1.0/tests/source/glossaries.rst +38 -0
  46. sphinx_llm_friendly-0.1.0/tests/source/image-target.rst +11 -0
  47. sphinx_llm_friendly-0.1.0/tests/source/index.rst +15 -0
  48. sphinx_llm_friendly-0.1.0/tests/source/links.rst +638 -0
  49. sphinx_llm_friendly-0.1.0/tests/source/static/markdown.png +0 -0
  50. sphinx_llm_friendly-0.1.0/tests/test_extension.py +167 -0
  51. sphinx_llm_friendly-0.1.0/tests/test_markdown.py +106 -0
  52. sphinx_llm_friendly-0.1.0/tests/test_singlemarkdown.py +84 -0
  53. sphinx_llm_friendly-0.1.0/tests/test_translator.py +161 -0
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .tox/
4
+ build/
5
+ dist/
6
+ tests/source/library/
@@ -0,0 +1,23 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) Scrapy developers.
4
+ Copyright (c) 2023-2026, Liran Funaro.
5
+ Copyright (c) 2025 Jared Dillard
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.5
2
+ Name: sphinx-llm-friendly
3
+ Version: 0.1.0
4
+ Summary: Sphinx extension to make documentation LLM-friendly
5
+ Project-URL: Source, https://github.com/scrapy/sphinx-llm-friendly
6
+ Project-URL: Tracker, https://github.com/scrapy/sphinx-llm-friendly/issues
7
+ Project-URL: Release notes, https://github.com/scrapy/sphinx-llm-friendly/blob/main/CHANGES.rst
8
+ Author-email: Scrapy developers <opensource@zyte.com>
9
+ Maintainer-email: Scrapy developers <opensource@zyte.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: Sphinx :: Extension
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Programming Language :: Python :: 3.15
24
+ Classifier: Topic :: Documentation :: Sphinx
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: docutils
27
+ Requires-Dist: sphinx>=7.4
28
+ Requires-Dist: tabulate
29
+ Description-Content-Type: text/x-rst
30
+
31
+ ===================
32
+ sphinx-llm-friendly
33
+ ===================
34
+
35
+ `Sphinx <https://www.sphinx-doc.org/>`_ extension that makes documentation
36
+ LLM-friendly:
37
+
38
+ - A Markdown version of every page, next to its HTML version.
39
+
40
+ - `llms.txt <https://llmstxt.org/>`_, with a link to the Markdown version of
41
+ every page.
42
+
43
+ - ``llms-full.txt``, with the Markdown version of all pages.
44
+
45
+ - HTML pages point to their Markdown version with a ``<link
46
+ rel="alternate" type="text/markdown">`` tag, and get a button to copy
47
+ their Markdown version.
48
+
49
+ Setup
50
+ =====
51
+
52
+ #. Install it:
53
+
54
+ .. code-block:: shell
55
+
56
+ pip install sphinx-llm-friendly
57
+
58
+ #. Add it to ``extensions`` in ``conf.py``:
59
+
60
+ .. code-block:: python
61
+
62
+ extensions = [
63
+ # …
64
+ "sphinx_llm_friendly",
65
+ ]
66
+
67
+ #. Build your documentation:
68
+
69
+ .. code-block:: shell
70
+
71
+ sphinx-llm-friendly build docs docs/_build
72
+
73
+ The output is in ``docs/_build/all``.
74
+
75
+ It runs the ``html``, ``llm_markdown`` and ``llm_singlemarkdown`` builders
76
+ in parallel, into subdirectories of ``docs/_build``, and merges their
77
+ output. In the Markdown output, links to sites from ``intersphinx_mapping``
78
+ that serve Markdown point to the Markdown version of their pages.
79
+
80
+ From Python, call ``sphinx_llm_friendly.build(source_dir, build_dir)``,
81
+ which returns the output directory.
82
+
83
+ Configuration
84
+ =============
85
+
86
+ ``llm_friendly_exclude``
87
+ List of patterns, with the syntax of ``exclude_patterns``, of documents to
88
+ leave out of the Markdown output and ``llms.txt``. Default: ``[]``.
89
+
90
+ ``llm_friendly_llms_txt_summary``
91
+ Summary for ``llms.txt``. Default: the first paragraph of the root
92
+ document.
93
+
94
+ ``llm_friendly_llms_txt_toctree_only``
95
+ If ``True``, ``llms.txt`` only lists documents reachable through toctrees
96
+ from the root document. Default: ``False``.
97
+
98
+ Nodes from third-party extensions need Markdown handlers, registered with
99
+ ``app.add_node()`` for the ``llm_markdown`` and ``llm_singlemarkdown``
100
+ builders.
@@ -0,0 +1,70 @@
1
+ ===================
2
+ sphinx-llm-friendly
3
+ ===================
4
+
5
+ `Sphinx <https://www.sphinx-doc.org/>`_ extension that makes documentation
6
+ LLM-friendly:
7
+
8
+ - A Markdown version of every page, next to its HTML version.
9
+
10
+ - `llms.txt <https://llmstxt.org/>`_, with a link to the Markdown version of
11
+ every page.
12
+
13
+ - ``llms-full.txt``, with the Markdown version of all pages.
14
+
15
+ - HTML pages point to their Markdown version with a ``<link
16
+ rel="alternate" type="text/markdown">`` tag, and get a button to copy
17
+ their Markdown version.
18
+
19
+ Setup
20
+ =====
21
+
22
+ #. Install it:
23
+
24
+ .. code-block:: shell
25
+
26
+ pip install sphinx-llm-friendly
27
+
28
+ #. Add it to ``extensions`` in ``conf.py``:
29
+
30
+ .. code-block:: python
31
+
32
+ extensions = [
33
+ # …
34
+ "sphinx_llm_friendly",
35
+ ]
36
+
37
+ #. Build your documentation:
38
+
39
+ .. code-block:: shell
40
+
41
+ sphinx-llm-friendly build docs docs/_build
42
+
43
+ The output is in ``docs/_build/all``.
44
+
45
+ It runs the ``html``, ``llm_markdown`` and ``llm_singlemarkdown`` builders
46
+ in parallel, into subdirectories of ``docs/_build``, and merges their
47
+ output. In the Markdown output, links to sites from ``intersphinx_mapping``
48
+ that serve Markdown point to the Markdown version of their pages.
49
+
50
+ From Python, call ``sphinx_llm_friendly.build(source_dir, build_dir)``,
51
+ which returns the output directory.
52
+
53
+ Configuration
54
+ =============
55
+
56
+ ``llm_friendly_exclude``
57
+ List of patterns, with the syntax of ``exclude_patterns``, of documents to
58
+ leave out of the Markdown output and ``llms.txt``. Default: ``[]``.
59
+
60
+ ``llm_friendly_llms_txt_summary``
61
+ Summary for ``llms.txt``. Default: the first paragraph of the root
62
+ document.
63
+
64
+ ``llm_friendly_llms_txt_toctree_only``
65
+ If ``True``, ``llms.txt`` only lists documents reachable through toctrees
66
+ from the root document. Default: ``False``.
67
+
68
+ Nodes from third-party extensions need Markdown handlers, registered with
69
+ ``app.add_node()`` for the ``llm_markdown`` and ``llm_singlemarkdown``
70
+ builders.
@@ -0,0 +1,141 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sphinx-llm-friendly"
7
+ version = "0.1.0"
8
+ description = "Sphinx extension to make documentation LLM-friendly"
9
+ dependencies = [
10
+ "docutils",
11
+ "sphinx>=7.4",
12
+ "tabulate",
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Framework :: Sphinx :: Extension",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Programming Language :: Python :: 3.15",
27
+ "Topic :: Documentation :: Sphinx",
28
+ ]
29
+ license = "MIT"
30
+ license-files = ["LICENSE"]
31
+ readme = "README.rst"
32
+ requires-python = ">=3.10"
33
+ authors = [{ name = "Scrapy developers", email = "opensource@zyte.com" }]
34
+ maintainers = [{ name = "Scrapy developers", email = "opensource@zyte.com" }]
35
+
36
+ [project.scripts]
37
+ sphinx-llm-friendly = "sphinx_llm_friendly._build:main"
38
+
39
+ [project.entry-points."sphinx.builders"]
40
+ llm_markdown = "sphinx_llm_friendly"
41
+ llm_singlemarkdown = "sphinx_llm_friendly"
42
+
43
+ [project.urls]
44
+ Source = "https://github.com/scrapy/sphinx-llm-friendly"
45
+ Tracker = "https://github.com/scrapy/sphinx-llm-friendly/issues"
46
+ "Release notes" = "https://github.com/scrapy/sphinx-llm-friendly/blob/main/CHANGES.rst"
47
+
48
+ [tool.hatch.build.targets.sdist]
49
+ include = ["/sphinx_llm_friendly", "/tests"]
50
+
51
+ [tool.mypy]
52
+ strict = true
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+
57
+ [tool.bumpversion]
58
+ current_version = "0.1.0"
59
+ commit = true
60
+ tag = true
61
+ tag_name = "{new_version}"
62
+
63
+ [[tool.bumpversion.files]]
64
+ filename = "CHANGES.rst"
65
+ search = "\\(unreleased\\)$"
66
+ replace = "({now:%Y-%m-%d})"
67
+ regex = true
68
+
69
+ [tool.ruff.lint]
70
+ extend-select = [
71
+ # flake8-bugbear
72
+ "B",
73
+ # flake8-comprehensions
74
+ "C4",
75
+ # pydocstyle
76
+ "D",
77
+ # flake8-future-annotations
78
+ "FA",
79
+ # flynt
80
+ "FLY",
81
+ # refurb
82
+ "FURB",
83
+ # isort
84
+ "I",
85
+ # flake8-implicit-str-concat
86
+ "ISC",
87
+ # flake8-logging
88
+ "LOG",
89
+ # Perflint
90
+ "PERF",
91
+ # pygrep-hooks
92
+ "PGH",
93
+ # flake8-pie
94
+ "PIE",
95
+ # pylint
96
+ "PL",
97
+ # flake8-pytest-style
98
+ "PT",
99
+ # flake8-use-pathlib
100
+ "PTH",
101
+ # flake8-pyi
102
+ "PYI",
103
+ # flake8-quotes
104
+ "Q",
105
+ # flake8-return
106
+ "RET",
107
+ # flake8-raise
108
+ "RSE",
109
+ # Ruff-specific rules
110
+ "RUF",
111
+ # flake8-bandit
112
+ "S",
113
+ # flake8-simplify
114
+ "SIM",
115
+ # flake8-slots
116
+ "SLOT",
117
+ # flake8-debugger
118
+ "T10",
119
+ # flake8-type-checking
120
+ "TC",
121
+ # flake8-tidy-imports
122
+ "TID",
123
+ # pyupgrade
124
+ "UP",
125
+ # pycodestyle warnings
126
+ "W",
127
+ # flake8-2020
128
+ "YTT",
129
+ ]
130
+ ignore = [
131
+ # No public API to document
132
+ "D",
133
+ ]
134
+
135
+ [tool.ruff.lint.per-file-ignores]
136
+ "sphinx_llm_friendly/_contexts.py" = ["B008", "PT018", "S101"]
137
+ "sphinx_llm_friendly/_translator.py" = ["PLR0911", "S101"]
138
+ "tests/*" = ["S101"]
139
+
140
+ [tool.ruff.lint.pydocstyle]
141
+ convention = "pep257"
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ import posixpath
4
+ from importlib.metadata import version
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from sphinx.util.osutil import relative_uri
8
+
9
+ from ._build import build, remember_intersphinx_urls
10
+ from ._builder import MarkdownBuilder
11
+ from ._exclude import is_excluded
12
+ from ._llms_txt import write_llms_txt
13
+ from ._singlemarkdown import SingleFileMarkdownBuilder
14
+
15
+ if TYPE_CHECKING:
16
+ from docutils import nodes
17
+ from sphinx.application import Sphinx
18
+ from sphinx.util.typing import ExtensionMetadata
19
+
20
+ __all__ = ["build", "setup"]
21
+
22
+ _COPY_AS_MARKDOWN_BUTTON_JS = """
23
+ (function () {
24
+ var DEFAULT_LABEL = 'M\u2193';
25
+ var SUCCESS_LABEL = 'Copied';
26
+ var ERROR_LABEL = 'Error';
27
+
28
+ function markdownPathFromCurrentPage(pathname) {
29
+ if (pathname.endsWith('.html')) {
30
+ return pathname.slice(0, -5) + '.md';
31
+ }
32
+ if (pathname.endsWith('/')) {
33
+ return pathname + 'index.md';
34
+ }
35
+ var lastPart = pathname.split('/').pop() || '';
36
+ if (!lastPart.includes('.')) {
37
+ return pathname + '.md';
38
+ }
39
+ return pathname + '.md';
40
+ }
41
+
42
+ async function copyToClipboard(text) {
43
+ if (navigator.clipboard && window.isSecureContext) {
44
+ await navigator.clipboard.writeText(text);
45
+ return;
46
+ }
47
+ var textarea = document.createElement('textarea');
48
+ textarea.value = text;
49
+ textarea.setAttribute('readonly', 'readonly');
50
+ textarea.style.position = 'fixed';
51
+ textarea.style.left = '-9999px';
52
+ document.body.appendChild(textarea);
53
+ textarea.select();
54
+ document.execCommand('copy');
55
+ document.body.removeChild(textarea);
56
+ }
57
+
58
+ function setTemporaryLabel(button, label, previousLabel) {
59
+ var prev = typeof previousLabel !== 'undefined' ? previousLabel : button.textContent;
60
+ button.textContent = label;
61
+ window.setTimeout(function () {
62
+ button.textContent = prev;
63
+ button.disabled = false;
64
+ }, 1000);
65
+ }
66
+
67
+ async function onButtonClick(button) {
68
+ var previousLabel = button.textContent;
69
+ button.disabled = true;
70
+ button.textContent = '...';
71
+ try {
72
+ var mdPath = markdownPathFromCurrentPage(window.location.pathname);
73
+ var response = await fetch(mdPath, { credentials: 'same-origin' });
74
+ if (!response.ok) {
75
+ throw new Error('Unable to fetch markdown source');
76
+ }
77
+ var markdown = await response.text();
78
+ await copyToClipboard(markdown);
79
+ setTemporaryLabel(button, SUCCESS_LABEL, previousLabel);
80
+ } catch (_error) {
81
+ setTemporaryLabel(button, ERROR_LABEL, previousLabel);
82
+ }
83
+ }
84
+
85
+ function addStyles() {
86
+ var style = document.createElement('style');
87
+ style.textContent = [
88
+ '.scrapy-copy-as-markdown {',
89
+ ' display: inline-block;',
90
+ ' margin-left: 0.25rem;',
91
+ ' border: 1px solid #c9d4de;',
92
+ ' border-radius: 0.45rem;',
93
+ ' background: #ffffff;',
94
+ ' color: #233a50;',
95
+ ' font: inherit;',
96
+ ' font-size: 0.875rem;',
97
+ ' line-height: 1;',
98
+ ' padding: 0.25rem 0.25rem;',
99
+ ' box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);',
100
+ ' cursor: pointer;',
101
+ '}',
102
+ '.scrapy-copy-as-markdown:hover {',
103
+ ' background: #f3f7fb;',
104
+ '}',
105
+ '.scrapy-copy-as-markdown:disabled {',
106
+ ' opacity: 0.75;',
107
+ ' cursor: default;',
108
+ '}',
109
+ '.scrapy-copy-as-markdown-title-wrapper {',
110
+ ' display: flex;',
111
+ ' align-items: center;',
112
+ ' justify-content: space-between;',
113
+ ' gap: 1rem;',
114
+ ' width: 100%;',
115
+ '}',
116
+ '.scrapy-copy-as-markdown-title-wrapper h1 {',
117
+ ' margin: 0;',
118
+ '}',
119
+ ].join('\\n');
120
+ document.head.appendChild(style);
121
+ }
122
+
123
+ function addButton() {
124
+ if (!document.body || document.querySelector('.scrapy-copy-as-markdown')) {
125
+ return;
126
+ }
127
+
128
+ addStyles();
129
+ var button = document.createElement('button');
130
+ button.type = 'button';
131
+ button.className = 'scrapy-copy-as-markdown';
132
+ button.title = 'Copy this page as Markdown';
133
+ button.setAttribute('aria-label', 'Copy this page as Markdown');
134
+ button.textContent = DEFAULT_LABEL;
135
+ button.addEventListener('click', function () {
136
+ onButtonClick(button);
137
+ });
138
+
139
+ var h1 = document.querySelector('#main h1') || document.querySelector('h1');
140
+ if (h1 && h1.parentNode) {
141
+ var parent = h1.parentNode;
142
+ var wrapper = document.createElement('div');
143
+ wrapper.className = 'scrapy-copy-as-markdown-title-wrapper';
144
+ parent.replaceChild(wrapper, h1);
145
+ wrapper.appendChild(h1);
146
+ wrapper.appendChild(button);
147
+ } else {
148
+ // fallback: insert at top of body but keep within the first container
149
+ var container = document.body.firstElementChild || document.body;
150
+ container.insertBefore(button, container.firstChild);
151
+ }
152
+ }
153
+
154
+ if (document.readyState === 'loading') {
155
+ document.addEventListener('DOMContentLoaded', addButton);
156
+ } else {
157
+ addButton();
158
+ }
159
+ })();
160
+ """
161
+
162
+
163
+ def _on_builder_inited(app: Sphinx) -> None:
164
+ if isinstance(app.builder, MarkdownBuilder):
165
+ app.config.exclude_patterns = [
166
+ *app.config.exclude_patterns,
167
+ *app.config.llm_friendly_exclude,
168
+ ]
169
+ elif app.builder.format == "html":
170
+ app.add_js_file(None, body=_COPY_AS_MARKDOWN_BUTTON_JS)
171
+
172
+
173
+ def _add_markdown_alternate_link(
174
+ app: Sphinx,
175
+ pagename: str,
176
+ templatename: str,
177
+ context: dict[str, Any],
178
+ doctree: nodes.document | None,
179
+ ) -> None:
180
+ if (
181
+ app.builder.format != "html"
182
+ or pagename not in app.env.found_docs
183
+ or is_excluded(app, pagename)
184
+ ):
185
+ return
186
+ html_uri = app.builder.get_target_uri(pagename)
187
+ md_uri = f"{posixpath.splitext(html_uri)[0]}.md"
188
+ href = relative_uri(html_uri, md_uri)
189
+ context["metatags"] = context.get("metatags", "") + (
190
+ f'\n<link rel="alternate" type="text/markdown" href="{href}">'
191
+ )
192
+
193
+
194
+ def _on_build_finished(app: Sphinx, exception: Exception | None) -> None:
195
+ if exception is None and app.builder.name in {"html", "dirhtml"}:
196
+ write_llms_txt(app)
197
+
198
+
199
+ def setup(app: Sphinx) -> ExtensionMetadata:
200
+ app.add_builder(MarkdownBuilder)
201
+ app.add_builder(SingleFileMarkdownBuilder)
202
+
203
+ app.add_config_value("llm_friendly_exclude", [], "env", types=frozenset({list}))
204
+ app.add_config_value(
205
+ "llm_friendly_llms_txt_summary",
206
+ None,
207
+ "html",
208
+ types=frozenset({str, type(None)}),
209
+ )
210
+ app.add_config_value(
211
+ "llm_friendly_llms_txt_toctree_only", False, "html", types=frozenset({bool})
212
+ )
213
+
214
+ app.connect("builder-inited", _on_builder_inited)
215
+ app.connect("html-page-context", _add_markdown_alternate_link)
216
+ app.connect("build-finished", _on_build_finished)
217
+ app.connect("build-finished", remember_intersphinx_urls)
218
+
219
+ return {
220
+ "version": version("sphinx-llm-friendly"),
221
+ "parallel_read_safe": True,
222
+ "parallel_write_safe": True,
223
+ }
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import concurrent.futures
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from sphinx.cmd.build import main as sphinx_build_main
10
+
11
+ from ._intersphinx import rewrite_intersphinx_links
12
+
13
+ if TYPE_CHECKING:
14
+ from sphinx.application import Sphinx
15
+
16
+ _BUILDERS = ("html", "llm_markdown", "llm_singlemarkdown")
17
+
18
+ _intersphinx_urls: set[str] = set()
19
+
20
+
21
+ def remember_intersphinx_urls(app: Sphinx, exception: Exception | None) -> None:
22
+ """Store the site URLs of ``intersphinx_mapping``, for :func:`_run_builder`
23
+ to report them to the parent process.
24
+ """
25
+ mapping = getattr(app.config, "intersphinx_mapping", {})
26
+ _intersphinx_urls.clear()
27
+ _intersphinx_urls.update(uri for _name, (uri, _inventories) in mapping.values())
28
+
29
+
30
+ def _run_builder(builder: str, source_dir: Path, build_dir: Path) -> set[str]:
31
+ exit_code = sphinx_build_main(
32
+ ["-b", builder, str(source_dir), str(build_dir / builder)]
33
+ )
34
+ if exit_code != 0:
35
+ msg = f"sphinx builder failed for '{builder}' with exit code {exit_code}"
36
+ raise RuntimeError(msg)
37
+ return set(_intersphinx_urls)
38
+
39
+
40
+ def build(source_dir: Path, build_dir: Path) -> Path:
41
+ """Build the documentation in *source_dir* as HTML and Markdown, and
42
+ return the directory inside *build_dir* where the outputs are merged.
43
+ """
44
+ build_dir.mkdir(parents=True, exist_ok=True)
45
+ intersphinx_urls: set[str] = set()
46
+ with concurrent.futures.ProcessPoolExecutor(max_workers=len(_BUILDERS)) as pool:
47
+ futures = [
48
+ pool.submit(_run_builder, builder, source_dir, build_dir)
49
+ for builder in _BUILDERS
50
+ ]
51
+ for future in concurrent.futures.as_completed(futures):
52
+ intersphinx_urls |= future.result()
53
+
54
+ output_dir = build_dir / "all"
55
+ output_dir.mkdir(parents=True, exist_ok=True)
56
+ shutil.copytree(build_dir / "html", output_dir, dirs_exist_ok=True)
57
+ shutil.copytree(build_dir / "llm_markdown", output_dir, dirs_exist_ok=True)
58
+ shutil.copy2(
59
+ build_dir / "llm_singlemarkdown" / "index.md", output_dir / "llms-full.txt"
60
+ )
61
+ rewrite_intersphinx_links(
62
+ [
63
+ *sorted(output_dir.rglob("*.md")),
64
+ output_dir / "llms.txt",
65
+ output_dir / "llms-full.txt",
66
+ ],
67
+ intersphinx_urls,
68
+ )
69
+ return output_dir
70
+
71
+
72
+ def main(argv: list[str] | None = None) -> int:
73
+ parser = argparse.ArgumentParser(prog="sphinx-llm-friendly")
74
+ subparsers = parser.add_subparsers(dest="command", required=True)
75
+ build_parser = subparsers.add_parser(
76
+ "build", help="build HTML and Markdown documentation"
77
+ )
78
+ build_parser.add_argument("source_dir", type=Path)
79
+ build_parser.add_argument("build_dir", type=Path)
80
+ args = parser.parse_args(argv)
81
+ try:
82
+ output_dir = build(args.source_dir, args.build_dir)
83
+ except RuntimeError as error:
84
+ parser.exit(1, f"{error}\n")
85
+ print(f"\nDocumentation generated in {output_dir}.")
86
+ return 0
87
+
88
+
89
+ if __name__ == "__main__":
90
+ raise SystemExit(main())