doukimd 1.0.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 (32) hide show
  1. doukimd-1.0.0/LICENSE +28 -0
  2. doukimd-1.0.0/PKG-INFO +88 -0
  3. doukimd-1.0.0/README.md +56 -0
  4. doukimd-1.0.0/pyproject.toml +99 -0
  5. doukimd-1.0.0/src/doukimd/__init__.py +28 -0
  6. doukimd-1.0.0/src/doukimd/__main__.py +7 -0
  7. doukimd-1.0.0/src/doukimd/api.py +300 -0
  8. doukimd-1.0.0/src/doukimd/assets.py +90 -0
  9. doukimd-1.0.0/src/doukimd/build.py +504 -0
  10. doukimd-1.0.0/src/doukimd/cli.py +74 -0
  11. doukimd-1.0.0/src/doukimd/collection.py +487 -0
  12. doukimd-1.0.0/src/doukimd/config.py +148 -0
  13. doukimd-1.0.0/src/doukimd/content.py +330 -0
  14. doukimd-1.0.0/src/doukimd/errors.py +49 -0
  15. doukimd-1.0.0/src/doukimd/html.py +189 -0
  16. doukimd-1.0.0/src/doukimd/markdown.py +432 -0
  17. doukimd-1.0.0/src/doukimd/models.py +435 -0
  18. doukimd-1.0.0/src/doukimd/navigation.py +404 -0
  19. doukimd-1.0.0/src/doukimd/plugins.py +83 -0
  20. doukimd-1.0.0/src/doukimd/py.typed +0 -0
  21. doukimd-1.0.0/src/doukimd/references.py +55 -0
  22. doukimd-1.0.0/src/doukimd/runtime.py +85 -0
  23. doukimd-1.0.0/src/doukimd/theme.py +95 -0
  24. doukimd-1.0.0/src/doukimd/themes/default/page.html +117 -0
  25. doukimd-1.0.0/src/doukimd/themes/default/palettes.json +122 -0
  26. doukimd-1.0.0/src/doukimd/themes/default/theme.css +184 -0
  27. doukimd-1.0.0/src/doukimd/themes/default/theme.js +63 -0
  28. doukimd-1.0.0/src/doukimd/themes/vendor/bootstrap/LICENSE +21 -0
  29. doukimd-1.0.0/src/doukimd/themes/vendor/bootstrap/README.md +11 -0
  30. doukimd-1.0.0/src/doukimd/themes/vendor/bootstrap/bootstrap.min.css +6 -0
  31. doukimd-1.0.0/src/doukimd/themes/vendor/radix/LICENSE +21 -0
  32. doukimd-1.0.0/src/doukimd/themes/vendor/radix/README.md +26 -0
doukimd-1.0.0/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, ARX
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
doukimd-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: doukimd
3
+ Version: 1.0.0
4
+ Summary: Markdown documentation with first-class Douki API references
5
+ License-Expression: BSD-3-Clause
6
+ License-File: LICENSE
7
+ Author: Ivan Ogasawara
8
+ Author-email: ivan.ogasawara@gmail.com
9
+ Requires-Python: >=3.10,<4
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Dist: Jinja2 (>=3.1.6,<4)
17
+ Requires-Dist: PyYAML (>=6,<7)
18
+ Requires-Dist: Pygments (>=2.18,<3)
19
+ Requires-Dist: atpublic (>=5,<6)
20
+ Requires-Dist: click (>=8.1,<9)
21
+ Requires-Dist: douki (>=0.12.1,<0.13)
22
+ Requires-Dist: griffe (>=1.15,<2)
23
+ Requires-Dist: jsonschema (>=4.23,<5)
24
+ Requires-Dist: markdown-it-py (>=3,<5)
25
+ Requires-Dist: mdit-py-plugins (>=0.4,<0.7)
26
+ Requires-Dist: pluggy (>=1.5,<2)
27
+ Requires-Dist: plum-dispatch (>=2.5,<3)
28
+ Requires-Dist: pydantic (>=2.10,<3)
29
+ Requires-Dist: typeguard (>=4.4,<5)
30
+ Description-Content-Type: text/markdown
31
+
32
+ # DoukiMD
33
+
34
+ Markdown documentation with first-class
35
+ [Douki](https://github.com/arxlang/douki) API references.
36
+
37
+ DoukiMD is an opinionated static documentation engine for projects using
38
+ Douki YAML docstrings. This initial version supports Python projects.
39
+
40
+ ## Run from source
41
+
42
+ ```console
43
+ poetry install
44
+ poetry run doukimd build --config examples/basic/doukimd.yaml
45
+ ```
46
+
47
+ Open `examples/basic/build/index.html` to view the generated site.
48
+ Use `doukimd check` to validate without writing files.
49
+
50
+ ## Included
51
+
52
+ - Markdown pages with validated YAML front matter.
53
+ - Opt-in Jinja in Markdown with project, page, data, and API metadata.
54
+ - Static Python API extraction and validation against Douki's shipped schema.
55
+ - Public re-exports, properties, data declarations, and callable variants.
56
+ - Named API source folders with per-page and per-block selection.
57
+ - Native API blocks, virtual API pages, and forward API references.
58
+ - Ordered navbar menus and product sidebars with source-scoped API trees.
59
+ - Page layouts, trusted HTML opt-in, local branding, and extra stylesheets.
60
+ - Local link and heading validation, assets, code highlighting, and JSON
61
+ symbol and search inventories.
62
+ - A bundled theme, local Jinja theme overrides, and explicit context plugins.
63
+ - Safe replacement of managed output after successful builds.
64
+
65
+ See [authoring and configuration](docs/usage.md) and the complete
66
+ [example project](examples/basic).
67
+
68
+ ## Initial scope
69
+
70
+ Python 3.10–3.14 is supported. Handwritten modules, classes, and callables
71
+ require valid Douki docstrings. Data declarations can use their annotations
72
+ and class-field documentation. Source code is never imported or rewritten.
73
+ Arx collection requires a future frontend documentation adapter and is
74
+ explicitly rejected today. Inherited members,
75
+ incremental builds, preview serving, and cross-project links are follow-ups.
76
+
77
+ The generated search data has no bundled interactive search UI yet.
78
+
79
+ ## Development
80
+
81
+ ```console
82
+ poetry run makim all.ci
83
+ poetry check --lock
84
+ ```
85
+
86
+ Infrastructure is adapted from [Douki](https://github.com/arxlang/douki).
87
+ See [contributing](docs/contributing.md) for the pinned source and workflow.
88
+
@@ -0,0 +1,56 @@
1
+ # DoukiMD
2
+
3
+ Markdown documentation with first-class
4
+ [Douki](https://github.com/arxlang/douki) API references.
5
+
6
+ DoukiMD is an opinionated static documentation engine for projects using
7
+ Douki YAML docstrings. This initial version supports Python projects.
8
+
9
+ ## Run from source
10
+
11
+ ```console
12
+ poetry install
13
+ poetry run doukimd build --config examples/basic/doukimd.yaml
14
+ ```
15
+
16
+ Open `examples/basic/build/index.html` to view the generated site.
17
+ Use `doukimd check` to validate without writing files.
18
+
19
+ ## Included
20
+
21
+ - Markdown pages with validated YAML front matter.
22
+ - Opt-in Jinja in Markdown with project, page, data, and API metadata.
23
+ - Static Python API extraction and validation against Douki's shipped schema.
24
+ - Public re-exports, properties, data declarations, and callable variants.
25
+ - Named API source folders with per-page and per-block selection.
26
+ - Native API blocks, virtual API pages, and forward API references.
27
+ - Ordered navbar menus and product sidebars with source-scoped API trees.
28
+ - Page layouts, trusted HTML opt-in, local branding, and extra stylesheets.
29
+ - Local link and heading validation, assets, code highlighting, and JSON
30
+ symbol and search inventories.
31
+ - A bundled theme, local Jinja theme overrides, and explicit context plugins.
32
+ - Safe replacement of managed output after successful builds.
33
+
34
+ See [authoring and configuration](docs/usage.md) and the complete
35
+ [example project](examples/basic).
36
+
37
+ ## Initial scope
38
+
39
+ Python 3.10–3.14 is supported. Handwritten modules, classes, and callables
40
+ require valid Douki docstrings. Data declarations can use their annotations
41
+ and class-field documentation. Source code is never imported or rewritten.
42
+ Arx collection requires a future frontend documentation adapter and is
43
+ explicitly rejected today. Inherited members,
44
+ incremental builds, preview serving, and cross-project links are follow-ups.
45
+
46
+ The generated search data has no bundled interactive search UI yet.
47
+
48
+ ## Development
49
+
50
+ ```console
51
+ poetry run makim all.ci
52
+ poetry check --lock
53
+ ```
54
+
55
+ Infrastructure is adapted from [Douki](https://github.com/arxlang/douki).
56
+ See [contributing](docs/contributing.md) for the pinned source and workflow.
@@ -0,0 +1,99 @@
1
+ [project]
2
+ name = "doukimd"
3
+ version = "1.0.0" # semantic-release
4
+ description = "Markdown documentation with first-class Douki API references"
5
+ readme = "README.md"
6
+ authors = [{name = "Ivan Ogasawara", email = "ivan.ogasawara@gmail.com"}]
7
+ license = "BSD-3-Clause"
8
+ requires-python = ">=3.10,<4"
9
+ dependencies = [
10
+ "atpublic>=5,<6",
11
+ "click>=8.1,<9",
12
+ "douki>=0.12.1,<0.13",
13
+ "griffe>=1.15,<2",
14
+ "Jinja2>=3.1.6,<4",
15
+ "jsonschema>=4.23,<5",
16
+ "markdown-it-py>=3,<5",
17
+ "mdit-py-plugins>=0.4,<0.7",
18
+ "pluggy>=1.5,<2",
19
+ "plum-dispatch>=2.5,<3",
20
+ "pydantic>=2.10,<3",
21
+ "Pygments>=2.18,<3",
22
+ "PyYAML>=6,<7",
23
+ "typeguard>=4.4,<5",
24
+ ]
25
+
26
+ [project.scripts]
27
+ doukimd = "doukimd.cli:main"
28
+
29
+ [build-system]
30
+ requires = ["poetry-core>=2,<3"]
31
+ build-backend = "poetry.core.masonry.api"
32
+
33
+ [tool.poetry]
34
+ packages = [{include = "doukimd", from = "src"}]
35
+ include = ["src/doukimd/py.typed", "src/doukimd/themes/**/*"]
36
+
37
+ [tool.poetry.group.dev.dependencies]
38
+ pytest = ">=8,<10"
39
+ pytest-cov = ">=5,<8"
40
+ pre-commit = ">=3.8,<5"
41
+ ruff = ">=0.9,<0.18"
42
+ mypy = ">=1.14,<3"
43
+ types-pyyaml = ">=6"
44
+ types-jsonschema = ">=4"
45
+ types-Pygments = ">=2"
46
+ makim = "1.27.0"
47
+ bandit = ">=1.8,<2"
48
+ vulture = ">=2.14,<3"
49
+ mccabe = ">=0.7,<0.8"
50
+
51
+ [tool.mypy]
52
+ python_version = "3.10"
53
+ strict = true
54
+ warn_unused_configs = true
55
+ files = ["src/doukimd"]
56
+
57
+ [[tool.mypy.overrides]]
58
+ module = ["doukimd.api", "doukimd.collection"]
59
+ disable_error_code = ["no-redef"]
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
63
+ addopts = "-ra -p no:xonsh"
64
+
65
+ [tool.coverage.run]
66
+ source = ["doukimd"]
67
+ branch = true
68
+
69
+ [tool.coverage.report]
70
+ show_missing = true
71
+ fail_under = 90
72
+
73
+ [tool.ruff]
74
+ target-version = "py310"
75
+ line-length = 79
76
+
77
+ [tool.ruff.lint]
78
+ select = ["E", "F", "YTT", "RUF", "I", "UP006", "UP007"]
79
+
80
+ [tool.ruff.lint.isort]
81
+ lines-between-types = 1
82
+
83
+ [tool.ruff.lint.per-file-ignores]
84
+ "src/doukimd/api.py" = ["F811"]
85
+ "src/doukimd/collection.py" = ["F811"]
86
+
87
+ [tool.ruff.format]
88
+ quote-style = "single"
89
+
90
+ [tool.bandit]
91
+ exclude_dirs = ["tests"]
92
+
93
+ [tool.vulture]
94
+ paths = ["src/doukimd"]
95
+ min_confidence = 80
96
+ ignore_decorators = ["@hookimpl", "@hookspec"]
97
+
98
+ [tool.douki]
99
+ exclude = ["tests/fixtures/*"]
@@ -0,0 +1,28 @@
1
+ """
2
+ title: DoukiMD, Markdown documentation powered by Douki.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ from public import public
10
+
11
+ from doukimd.runtime import typechecked
12
+
13
+
14
+ @public
15
+ @typechecked
16
+ def get_version() -> str:
17
+ """
18
+ title: Return the installed version or the source-tree fallback.
19
+ returns:
20
+ type: str
21
+ """
22
+ try:
23
+ return version('doukimd')
24
+ except PackageNotFoundError:
25
+ return '1.0.0' # semantic-release
26
+
27
+
28
+ __version__ = get_version()
@@ -0,0 +1,7 @@
1
+ """
2
+ title: Invoke the DoukiMD command line interface.
3
+ """
4
+
5
+ from doukimd.cli import main
6
+
7
+ main()
@@ -0,0 +1,300 @@
1
+ """
2
+ title: Collect Python declarations without importing project code.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import re
9
+
10
+ from importlib.resources import files
11
+ from pathlib import Path
12
+ from typing import cast
13
+
14
+ import griffe
15
+
16
+ from jsonschema import Draft202012Validator
17
+ from plum import dispatch
18
+ from public import private, public
19
+
20
+ from doukimd.config import project_path, read_yaml
21
+ from doukimd.errors import BuildError
22
+ from doukimd.models import ApiSymbol, Config
23
+ from doukimd.runtime import typechecked
24
+
25
+
26
+ @private
27
+ @typechecked
28
+ def source_path(node: griffe.Object) -> Path:
29
+ """
30
+ title: Reject namespace packages without a single source file.
31
+ parameters:
32
+ node:
33
+ type: griffe.Object
34
+ returns:
35
+ type: Path
36
+ """
37
+ path = node.filepath
38
+ if not isinstance(path, Path):
39
+ raise BuildError('api', f'No single source file for {node.path}')
40
+ return path.resolve()
41
+
42
+
43
+ @private
44
+ @typechecked
45
+ def documentation(node: griffe.Object, root: Path) -> dict[str, object]:
46
+ """
47
+ title: Validate a source docstring using the installed Douki schema.
48
+ parameters:
49
+ node:
50
+ type: griffe.Object
51
+ root:
52
+ type: Path
53
+ returns:
54
+ type: dict[str, object]
55
+ """
56
+ path = source_path(node)
57
+ if not path.is_relative_to(root):
58
+ raise BuildError('api', 'API source escapes the project', path)
59
+ if node.docstring is None:
60
+ raise BuildError(
61
+ 'douki', f'Missing Douki docstring: {node.path}', path
62
+ )
63
+ data = read_yaml(node.docstring.value, path)
64
+ schema = json.loads(
65
+ files('douki').joinpath('schema.json').read_text(encoding='utf-8')
66
+ )
67
+ errors = sorted(
68
+ Draft202012Validator(schema).iter_errors(data),
69
+ key=lambda error: str(list(error.path)),
70
+ )
71
+ if errors:
72
+ raise BuildError(
73
+ 'douki',
74
+ f'{node.path}: Douki schema: '
75
+ + '; '.join(error.message for error in errors),
76
+ path,
77
+ )
78
+ if isinstance(node, griffe.Function) and 'parameters' in data:
79
+ expected = {
80
+ parameter.name
81
+ for parameter in node.parameters
82
+ if not (
83
+ isinstance(node.parent, griffe.Class)
84
+ and 'staticmethod' not in node.labels
85
+ and parameter.name in {'self', 'cls'}
86
+ )
87
+ }
88
+ parameters = cast(dict[str, object], data['parameters'])
89
+ if set(parameters) != expected:
90
+ raise BuildError(
91
+ 'douki',
92
+ f'{node.path}: documented parameters do not match signature',
93
+ path,
94
+ )
95
+ return data
96
+
97
+
98
+ @private
99
+ @dispatch
100
+ @typechecked
101
+ def signature(node: object) -> str:
102
+ """
103
+ title: Reject unsupported API declarations.
104
+ parameters:
105
+ node:
106
+ type: object
107
+ returns:
108
+ type: str
109
+ """
110
+ raise BuildError('api', f'Unsupported declaration: {type(node).__name__}')
111
+
112
+
113
+ @dispatch
114
+ @typechecked
115
+ def signature(node: griffe.Module) -> str:
116
+ """
117
+ title: Display a module name.
118
+ parameters:
119
+ node:
120
+ type: griffe.Module
121
+ returns:
122
+ type: str
123
+ """
124
+ return node.path
125
+
126
+
127
+ @dispatch
128
+ @typechecked
129
+ def signature(node: griffe.Class | griffe.Function) -> str:
130
+ """
131
+ title: Preserve the source signature through Griffe.
132
+ parameters:
133
+ node:
134
+ type: griffe.Class | griffe.Function
135
+ returns:
136
+ type: str
137
+ """
138
+ return node.signature()
139
+
140
+
141
+ @dispatch
142
+ @typechecked
143
+ def signature(node: griffe.Attribute) -> str:
144
+ """
145
+ title: Display a data declaration without evaluating its value.
146
+ parameters:
147
+ node:
148
+ type: griffe.Attribute
149
+ returns:
150
+ type: str
151
+ """
152
+ annotation = f': {node.annotation}' if node.annotation else ''
153
+ value = f' = {node.value}' if node.value is not None else ''
154
+ return f'{node.name}{annotation}{value}'
155
+
156
+
157
+ @private
158
+ @typechecked
159
+ def hidden(node: griffe.Object) -> bool:
160
+ """
161
+ title: Honor explicit visibility rather than underscore naming heuristics.
162
+ parameters:
163
+ node:
164
+ type: griffe.Object
165
+ returns:
166
+ type: bool
167
+ """
168
+ for decorator in getattr(node, 'decorators', []):
169
+ name = str(decorator.value).split('(', maxsplit=1)[0]
170
+ try:
171
+ resolved = node.resolve(name)
172
+ except griffe.NameResolutionError:
173
+ resolved = name
174
+ if resolved == 'public.private':
175
+ return True
176
+ if node.docstring is not None:
177
+ data = read_yaml(node.docstring.value, source_path(node))
178
+ return data.get('visibility') in {'private', 'protected', 'internal'}
179
+ return False
180
+
181
+
182
+ @private
183
+ @typechecked
184
+ def visit(node: object, root: Path) -> list[ApiSymbol]:
185
+ """
186
+ title: Visit a declaration using the default unscoped collector.
187
+ parameters:
188
+ node:
189
+ type: object
190
+ root:
191
+ type: Path
192
+ returns:
193
+ type: list[ApiSymbol]
194
+ """
195
+ from doukimd.collection import Collector, FunctionCapture
196
+
197
+ return cast(
198
+ list[ApiSymbol],
199
+ Collector(root, root, None, FunctionCapture()).visit(
200
+ node, getattr(node, 'path', '')
201
+ ),
202
+ )
203
+
204
+
205
+ @public
206
+ @typechecked
207
+ def collect_api(root: Path, config: Config) -> dict[str, ApiSymbol]:
208
+ """
209
+ title: Collect isolated source folders without importing project code.
210
+ parameters:
211
+ root:
212
+ type: Path
213
+ config:
214
+ type: Config
215
+ returns:
216
+ type: dict[str, ApiSymbol]
217
+ """
218
+ from doukimd.collection import Collector, FunctionCapture
219
+
220
+ inventory: dict[str, ApiSymbol] = {}
221
+ names: set[str] = set()
222
+ for source in config.api.sources:
223
+ if source.name is not None:
224
+ if source.name in names:
225
+ raise BuildError('api', f'Duplicate API source: {source.name}')
226
+ names.add(source.name)
227
+ search = project_path(root, source.root)
228
+ if not search.is_dir():
229
+ raise BuildError('api', 'API source folder does not exist', search)
230
+ for path in search.rglob('*'):
231
+ if path.is_symlink() and not path.resolve().is_relative_to(search):
232
+ raise BuildError(
233
+ 'api', 'API source escapes selected folder', path
234
+ )
235
+ capture = FunctionCapture()
236
+ modules = list(source.modules)
237
+ loader_search = search
238
+ if not modules:
239
+ if (search / '__init__.py').is_file():
240
+ loader_search = search.parent
241
+ modules = [search.name]
242
+ else:
243
+ for directory in search.iterdir():
244
+ if (
245
+ directory.is_dir()
246
+ and not (directory / '__init__.py').is_file()
247
+ and any(directory.rglob('*.py'))
248
+ ):
249
+ raise BuildError(
250
+ 'api',
251
+ 'Namespace packages are unsupported',
252
+ directory,
253
+ )
254
+ modules = sorted(
255
+ [path.stem for path in search.glob('*.py')]
256
+ + [
257
+ path.name
258
+ for path in search.iterdir()
259
+ if path.is_dir() and (path / '__init__.py').is_file()
260
+ ]
261
+ )
262
+ if not modules:
263
+ raise BuildError(
264
+ 'api', 'No Python modules in source folder', search
265
+ )
266
+ loader = griffe.GriffeLoader(
267
+ search_paths=[loader_search],
268
+ allow_inspection=False,
269
+ extensions=griffe.load_extensions(capture),
270
+ )
271
+ nodes: list[griffe.Object | griffe.Alias] = []
272
+ try:
273
+ for module in modules:
274
+ if (
275
+ re.fullmatch(r'[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*', module)
276
+ is None
277
+ ):
278
+ raise BuildError('api', f'Invalid module name: {module}')
279
+ nodes.append(loader.load(module))
280
+ loader.resolve_aliases(external=False)
281
+ collector = Collector(root, search, source.name, capture)
282
+ for node in nodes:
283
+ for item in collector.visit(node, node.path):
284
+ if item.identifier in inventory:
285
+ raise BuildError(
286
+ 'api', f'Duplicate API symbol: {item.identifier}'
287
+ )
288
+ inventory[item.identifier] = item
289
+ except (
290
+ griffe.LoadingError,
291
+ griffe.NameResolutionError,
292
+ griffe.AliasResolutionError,
293
+ griffe.CyclicAliasError,
294
+ ModuleNotFoundError,
295
+ KeyError,
296
+ OSError,
297
+ SyntaxError,
298
+ ) as error:
299
+ raise BuildError('api', str(error), search) from error
300
+ return inventory
@@ -0,0 +1,90 @@
1
+ """
2
+ title: Copy explicitly selected documentation assets without source escapes.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import posixpath
8
+
9
+ from pathlib import Path
10
+ from urllib.parse import quote
11
+
12
+ from public import public
13
+
14
+ from doukimd.config import project_path
15
+ from doukimd.errors import BuildError
16
+ from doukimd.models import Config
17
+ from doukimd.runtime import typechecked
18
+
19
+
20
+ @public
21
+ @typechecked
22
+ def copy_assets(root: Path, config: Config) -> dict[str, bytes]:
23
+ """
24
+ title: Copy matched docs-relative files and reject unsafe patterns.
25
+ parameters:
26
+ root:
27
+ type: Path
28
+ config:
29
+ type: Config
30
+ returns:
31
+ type: dict[str, bytes]
32
+ """
33
+ docs = project_path(root, config.docs_dir)
34
+ result: dict[str, bytes] = {}
35
+ for pattern in config.assets:
36
+ if (
37
+ not pattern
38
+ or Path(pattern).is_absolute()
39
+ or '..' in Path(pattern).parts
40
+ or '\\' in pattern
41
+ ):
42
+ raise BuildError('assets', f'Invalid asset pattern: {pattern}')
43
+ # pathlib glob does not recurse through linked directories. Check
44
+ # their containment too, so an escape is never silently omitted.
45
+ for path in docs.rglob('*'):
46
+ if path.is_symlink() and not path.resolve().is_relative_to(docs):
47
+ raise BuildError(
48
+ 'assets', 'Asset escapes docs directory', path
49
+ )
50
+ matches: list[Path] = []
51
+ for path in docs.glob(pattern):
52
+ matches.extend(path.rglob('*') if path.is_dir() else [path])
53
+ files = [path for path in matches if path.is_file()]
54
+ if not files and pattern != 'assets/**':
55
+ raise BuildError('assets', f'No assets match: {pattern}')
56
+ for path in sorted(files):
57
+ if not path.resolve().is_relative_to(docs):
58
+ raise BuildError(
59
+ 'assets', 'Asset escapes docs directory', path
60
+ )
61
+ if path.suffix.lower() in {'.md', '.ipynb'}:
62
+ raise BuildError(
63
+ 'assets', 'Asset pattern selects source', path
64
+ )
65
+ result[path.relative_to(docs).as_posix()] = path.read_bytes()
66
+ return result
67
+
68
+
69
+ @public
70
+ @typechecked
71
+ def asset_url(
72
+ value: str | None, route: str, assets: dict[str, bytes]
73
+ ) -> str | None:
74
+ """
75
+ title: Resolve a configured docs-root asset relative to a planned page.
76
+ parameters:
77
+ value:
78
+ type: str | None
79
+ route:
80
+ type: str
81
+ assets:
82
+ type: dict[str, bytes]
83
+ returns:
84
+ type: str | None
85
+ """
86
+ if value is None:
87
+ return None
88
+ if value not in assets:
89
+ raise BuildError('assets', f'Asset is not copied: {value}')
90
+ return quote(posixpath.relpath(value, posixpath.dirname(route) or '.'))