|
1 | | -import sys |
2 | 1 | import os |
| 2 | +import pathlib |
| 3 | +import re |
| 4 | +import shutil |
| 5 | +import sphinx.util |
| 6 | +import sys |
| 7 | +import unicodedata |
3 | 8 | # Configuration file for the Sphinx documentation builder. |
4 | 9 | # |
5 | 10 | # For the full list of built-in configuration values, see the documentation: |
|
9 | 14 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information |
10 | 15 |
|
11 | 16 | project = 'Learning Observer' |
12 | | -copyright = '2023, Bradley Erickson' |
| 17 | +copyright = '2020-2025, Bradley Erickson' |
13 | 18 | author = 'Bradley Erickson' |
14 | 19 |
|
15 | 20 | # -- General configuration --------------------------------------------------- |
|
26 | 31 | '../modules/writing_observer/writing_observer' |
27 | 32 | ] |
28 | 33 |
|
| 34 | +autodoc2_output_dir = 'apidocs' |
| 35 | +autodoc2_member_order = 'bysource' |
| 36 | + |
29 | 37 | source_suffix = { |
30 | 38 | '.rst': 'restructuredtext', |
31 | 39 | '.md': 'markdown', |
|
40 | 48 |
|
41 | 49 | html_theme = 'alabaster' |
42 | 50 | html_static_path = ['_static'] |
| 51 | + |
| 52 | +LOGGER = sphinx.util.logging.getLogger(__name__) |
| 53 | + |
| 54 | + |
| 55 | +_MARKDOWN_IMAGE_PATTERN = re.compile(r'!\[[^\]]*\]\(([^)]+)\)') |
| 56 | +_RST_IMAGE_PATTERNS = [ |
| 57 | + re.compile(r'\.\.\s+image::\s+([^\s]+)'), |
| 58 | + re.compile(r'\.\.\s+figure::\s+([^\s]+)'), |
| 59 | +] |
| 60 | + |
| 61 | + |
| 62 | +def _extract_local_assets(text): |
| 63 | + """Return relative asset paths referenced in the provided README text.""" |
| 64 | + |
| 65 | + asset_paths = set() |
| 66 | + for match in _MARKDOWN_IMAGE_PATTERN.findall(text): |
| 67 | + asset_paths.add(match) |
| 68 | + for pattern in _RST_IMAGE_PATTERNS: |
| 69 | + asset_paths.update(pattern.findall(text)) |
| 70 | + |
| 71 | + filtered_assets = set() |
| 72 | + for raw_path in asset_paths: |
| 73 | + candidate = raw_path.strip() |
| 74 | + if not candidate: |
| 75 | + continue |
| 76 | + # Remove optional titles ("path "optional title"") and URL fragments |
| 77 | + candidate = candidate.split()[0] |
| 78 | + candidate = candidate.split('#', maxsplit=1)[0] |
| 79 | + candidate = candidate.split('?', maxsplit=1)[0] |
| 80 | + |
| 81 | + if candidate.startswith(('http://', 'https://', 'data:')): |
| 82 | + continue |
| 83 | + if candidate.startswith('#'): |
| 84 | + continue |
| 85 | + |
| 86 | + filtered_assets.add(candidate) |
| 87 | + |
| 88 | + return sorted(filtered_assets) |
| 89 | + |
| 90 | + |
| 91 | +def _copy_module_assets(readme_path, destination_dir): |
| 92 | + """Copy image assets referenced by ``readme_path`` into ``destination_dir``.""" |
| 93 | + |
| 94 | + module_dir = readme_path.parent.resolve() |
| 95 | + readme_text = readme_path.read_text(encoding='utf-8') |
| 96 | + asset_paths = _extract_local_assets(readme_text) |
| 97 | + for asset in asset_paths: |
| 98 | + relative_posix_path = pathlib.PurePosixPath(asset) |
| 99 | + if relative_posix_path.is_absolute(): |
| 100 | + LOGGER.warning( |
| 101 | + "Skipping absolute image path %s referenced in %s", asset, readme_path |
| 102 | + ) |
| 103 | + continue |
| 104 | + |
| 105 | + normalized_relative_path = pathlib.Path(*relative_posix_path.parts) |
| 106 | + source_path = (module_dir / normalized_relative_path).resolve(strict=False) |
| 107 | + |
| 108 | + try: |
| 109 | + source_path.relative_to(module_dir) |
| 110 | + except ValueError: |
| 111 | + LOGGER.warning( |
| 112 | + "Skipping image outside module directory: %s referenced in %s", |
| 113 | + asset, |
| 114 | + readme_path, |
| 115 | + ) |
| 116 | + continue |
| 117 | + |
| 118 | + if not source_path.exists(): |
| 119 | + LOGGER.warning( |
| 120 | + "Referenced image %s in %s was not found", asset, readme_path |
| 121 | + ) |
| 122 | + continue |
| 123 | + |
| 124 | + destination_path = destination_dir / normalized_relative_path |
| 125 | + destination_path.parent.mkdir(parents=True, exist_ok=True) |
| 126 | + shutil.copy2(source_path, destination_path) |
| 127 | + |
| 128 | + |
| 129 | +def _extract_readme_title(readme_path: pathlib.Path) -> str: |
| 130 | + """Return the first Markdown heading in ``readme_path``. |
| 131 | +
|
| 132 | + Defaults to the parent directory name if no heading can be found. |
| 133 | + """ |
| 134 | + |
| 135 | + try: |
| 136 | + for line in readme_path.read_text(encoding='utf-8').splitlines(): |
| 137 | + stripped = line.strip() |
| 138 | + if stripped.startswith('#'): |
| 139 | + title = stripped.lstrip('#').strip() |
| 140 | + if title: |
| 141 | + return title |
| 142 | + except OSError as exc: # pragma: no cover - filesystem error propagation |
| 143 | + LOGGER.warning("unable to read %s: %s", readme_path, exc) |
| 144 | + |
| 145 | + return readme_path.parent.name |
| 146 | + |
| 147 | + |
| 148 | +def _slugify(text: str) -> str: |
| 149 | + """Convert ``text`` to a lowercase filename-safe slug.""" |
| 150 | + |
| 151 | + normalized = unicodedata.normalize('NFKD', text) |
| 152 | + without_diacritics = ''.join(ch for ch in normalized if not unicodedata.combining(ch)) |
| 153 | + slug = re.sub(r'[^a-z0-9]+', '-', without_diacritics.casefold()).strip('-') |
| 154 | + return slug or 'module' |
| 155 | + |
| 156 | + |
| 157 | +def _copy_module_readmes(app): |
| 158 | + """Populate ``module_readmes`` with module README files and assets.""" |
| 159 | + |
| 160 | + docs_root = pathlib.Path(__file__).parent.resolve() |
| 161 | + modules_root = docs_root.parent / 'modules' |
| 162 | + destination_root = docs_root / 'module_readmes' |
| 163 | + |
| 164 | + if not modules_root.exists(): |
| 165 | + LOGGER.warning("modules directory %s was not found", modules_root) |
| 166 | + return |
| 167 | + |
| 168 | + if destination_root.exists(): |
| 169 | + shutil.rmtree(destination_root) |
| 170 | + destination_root.mkdir(parents=True, exist_ok=True) |
| 171 | + |
| 172 | + readme_info = [] |
| 173 | + for readme_path in modules_root.glob('*/README.md'): |
| 174 | + title = _extract_readme_title(readme_path) |
| 175 | + readme_info.append((title, readme_path)) |
| 176 | + |
| 177 | + readme_info.sort(key=lambda item: item[0].casefold()) |
| 178 | + |
| 179 | + for title, readme_path in readme_info: |
| 180 | + module_name = readme_path.parent.name |
| 181 | + slug = _slugify(title) |
| 182 | + module_destination = destination_root / f'{slug}--{module_name}' |
| 183 | + module_destination.mkdir(parents=True, exist_ok=True) |
| 184 | + destination_path = module_destination / "README.md" |
| 185 | + shutil.copy2(readme_path, destination_path) |
| 186 | + _copy_module_assets(readme_path, module_destination) |
| 187 | + |
| 188 | + |
| 189 | +def setup(app): |
| 190 | + app.connect('builder-inited', _copy_module_readmes) |
0 commit comments