fr-docs 2.3.0__tar.gz → 3.4.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 (30) hide show
  1. {fr_docs-2.3.0 → fr_docs-3.4.0}/PKG-INFO +18 -36
  2. fr_docs-3.4.0/README.md +47 -0
  3. fr_docs-3.4.0/fr_docs/__main__.py +114 -0
  4. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs/build.py +78 -83
  5. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs/config.py +25 -6
  6. fr_docs-3.4.0/fr_docs/config_accessors.py +208 -0
  7. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs/frontmatter.py +12 -1
  8. fr_docs-3.4.0/fr_docs/git.py +214 -0
  9. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs/html_pipeline.py +268 -218
  10. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs/init.py +9 -11
  11. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs/markdown.py +20 -23
  12. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs/search.py +167 -82
  13. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs/slug.py +2 -2
  14. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs/syntax.py +45 -12
  15. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs/template.py +54 -15
  16. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs/utils.py +1 -1
  17. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs.egg-info/PKG-INFO +18 -36
  18. {fr_docs-2.3.0 → fr_docs-3.4.0}/pyproject.toml +53 -1
  19. fr_docs-2.3.0/README.md +0 -65
  20. fr_docs-2.3.0/fr_docs/__main__.py +0 -85
  21. fr_docs-2.3.0/fr_docs/config_accessors.py +0 -117
  22. fr_docs-2.3.0/fr_docs/git.py +0 -131
  23. {fr_docs-2.3.0 → fr_docs-3.4.0}/LICENSE.md +0 -0
  24. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs/__init__.py +0 -0
  25. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs.egg-info/SOURCES.txt +0 -0
  26. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs.egg-info/dependency_links.txt +0 -0
  27. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs.egg-info/entry_points.txt +0 -0
  28. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs.egg-info/requires.txt +0 -0
  29. {fr_docs-2.3.0 → fr_docs-3.4.0}/fr_docs.egg-info/top_level.txt +0 -0
  30. {fr_docs-2.3.0 → fr_docs-3.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fr-docs
3
- Version: 2.3.0
3
+ Version: 3.4.0
4
4
  Summary: Fast static documentation generator with search, versioning, and zero-config setup
5
5
  Author: Omena0
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -175,7 +175,6 @@ Generate fast, fully featured, modern documentation pages from markdown for GitH
175
175
  - [Quickstart](https://omena0.dev/fr-docs/quickstart)
176
176
  - [Configuration](https://omena0.dev/fr-docs/config.json)
177
177
  - [Showcase](https://omena0.dev/fr-docs/showcase)
178
- - [Pagespeed](https://omena0.dev/fr-docs/pagespeed) — performance metrics
179
178
 
180
179
  ## Overview
181
180
 
@@ -183,43 +182,26 @@ Fr-docs is a generic, configurable documentation builder for Python projects. It
183
182
 
184
183
  ### Key Features
185
184
 
186
- - **Config-driven**: All settings defined in `config.json`
187
- - Project name, URL, description, and sidebar structure
188
- - Search, code references, auto-linking, and related pages
189
- - Build options: workers, minification, zstd level, and HTML optimization
185
+ - **Toggleable features**: Disable any features in the config.
186
+ - **Search**: Search pages, headers, source code files, or symbols. Fuzzy matching included.
187
+ - **Markdown features**: Feature-complete markdown features, including:
188
+ - **Code highlighting**: Highlight code blocks with `fastpylight`
189
+ - **Copy code blocks**: Copy buttons on multiline code blocks, inline copy code blocks.
190
+ - **Tables**: Auto convert markdown tables
191
+ - **Frontmatter metadata**: Title and description as optional frontmatter.
192
+ - **Hover previews**: Hover a link to see the title and description, hover a code ref to see a code preview.
193
+ - **Code references**: Link to a source code files, lines, line ranges, or symbols. Automatically generates a clickable code ref.
194
+ - **Custom tags**: Highlight tags such as `[ext]` automatically across the site.
195
+ - **Auto versioned**: Automatically switch to older documentation based on github tags.<br>
196
+ <sup><sub>(requires site/ to be commited into the repo)</sup></sub>
197
+ - **Auto minification**: Minifies HTML, CSS and JS and inlines Critical CSS.
198
+ - **Same-origin fonts**: Serve fonts from the same origin. Download once before build, serve forever.
199
+ - **Page preloading**: Preload page links for faster browsing.
200
+ - **Configurable elements**: Configure project name, icon, copyright, topbar links, etc.
190
201
 
191
- - **Zero-config setup**: Works out of the box with sensible defaults
192
- - **Search**: Full-text search with Zstandard-compressed index
193
- - Symbol-level search across all source files
194
- - File index for browsing source code
195
- - Per-file source highlights with caching
196
-
197
- - **Optimized**: HTML minification, critical CSS/JS inlining, Zstd compression
198
- - Critical CSS extracted and inlined for both mobile and desktop viewports
199
- - JavaScript and CSS minified with `terser` and `html-minifier-next`
200
- - Zstd-compressed search index, source files, highlights, and metadata
201
- - Adaptive zstd level selection (scans 1..configured, picks the smallest)
202
-
203
- - **Multi-environment**: Different configs for production, staging, development
204
- - **Parallel builds**: Threaded page generation for faster builds
205
- - **Git integration**: Automatic metadata extraction from git commits
206
- - Version options derived from commit messages (e.g. `4D - fix bug` → `4.4.0`)
207
- - Commit SHA and message embedded in the version selector
208
-
209
- - **Local font serving**: Fonts downloaded at build time and served from the site's own origin
210
- - No third-party font round-trips or 404s when Google changes the URL scheme
211
- - `font-display: swap` with metric-compatible `font-ascent-descent` fallbacks
212
- - Primary body font weight preloaded for faster first paint
213
-
214
- - **Sidebar**: Collapsible sections with auto-expansion
215
- - The section containing the current page is expanded on load
216
- - Single-page sections and single-section sites are expanded by default
217
-
218
- - **Inline copy commands**: Prefix inline code with `c` to create copy-on-click links
219
- - **Code references**: Auto-link bare filename references to their source files
220
- - **Backlinks and related pages**: Automatically generated navigation between related content
221
202
 
222
203
  ### Projects that use fr-docs
223
204
 
224
205
  - [Fr-docs](https://omena0.dev/fr-docs/)
225
206
  - [PyJavaBridge](https://omena0.dev/PyJavaBridge)
207
+ <sub><sup>(Legacy)</sup></sub>
@@ -0,0 +1,47 @@
1
+
2
+ # Fr-docs
3
+
4
+ Generate fast, fully featured, modern documentation pages from markdown for GitHub pages and static hosting.
5
+
6
+ [![Lint](https://github.com/Omena0/fr-docs/actions/workflows/lint.yml/badge.svg)](https://github.com/Omena0/fr-docs/actions/workflows/lint.yml)
7
+ [![Docs](https://github.com/Omena0/fr-docs/actions/workflows/pages.yml/badge.svg)](https://github.com/Omena0/fr-docs/actions/workflows/pages.yml)
8
+ [![PyPi](https://github.com/Omena0/fr-docs/actions/workflows/publish.yml/badge.svg)](https://github.com/Omena0/fr-docs/actions/workflows/publish.yml)
9
+ [![CodeQL](https://github.com/Omena0/fr-docs/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/Omena0/fr-docs/actions/workflows/github-code-scanning/codeql)
10
+
11
+ ## Documentation
12
+
13
+ - [Getting Started](https://omena0.dev/fr-docs/)
14
+ - [Installation](https://omena0.dev/fr-docs/installation)
15
+ - [Quickstart](https://omena0.dev/fr-docs/quickstart)
16
+ - [Configuration](https://omena0.dev/fr-docs/config.json)
17
+ - [Showcase](https://omena0.dev/fr-docs/showcase)
18
+
19
+ ## Overview
20
+
21
+ Fr-docs is a generic, configurable documentation builder for Python projects. It converts Markdown source files into a searchable, optimized static HTML site with zero-config setup.
22
+
23
+ ### Key Features
24
+
25
+ - **Toggleable features**: Disable any features in the config.
26
+ - **Search**: Search pages, headers, source code files, or symbols. Fuzzy matching included.
27
+ - **Markdown features**: Feature-complete markdown features, including:
28
+ - **Code highlighting**: Highlight code blocks with `fastpylight`
29
+ - **Copy code blocks**: Copy buttons on multiline code blocks, inline copy code blocks.
30
+ - **Tables**: Auto convert markdown tables
31
+ - **Frontmatter metadata**: Title and description as optional frontmatter.
32
+ - **Hover previews**: Hover a link to see the title and description, hover a code ref to see a code preview.
33
+ - **Code references**: Link to a source code files, lines, line ranges, or symbols. Automatically generates a clickable code ref.
34
+ - **Custom tags**: Highlight tags such as `[ext]` automatically across the site.
35
+ - **Auto versioned**: Automatically switch to older documentation based on github tags.<br>
36
+ <sup><sub>(requires site/ to be commited into the repo)</sup></sub>
37
+ - **Auto minification**: Minifies HTML, CSS and JS and inlines Critical CSS.
38
+ - **Same-origin fonts**: Serve fonts from the same origin. Download once before build, serve forever.
39
+ - **Page preloading**: Preload page links for faster browsing.
40
+ - **Configurable elements**: Configure project name, icon, copyright, topbar links, etc.
41
+
42
+
43
+ ### Projects that use fr-docs
44
+
45
+ - [Fr-docs](https://omena0.dev/fr-docs/)
46
+ - [PyJavaBridge](https://omena0.dev/PyJavaBridge)
47
+ <sub><sup>(Legacy)</sup></sub>
@@ -0,0 +1,114 @@
1
+ """Entry point for fr-docs CLI.
2
+
3
+ Usage:
4
+ python -m fr_docs build [--production] [--symlink] [--config CONFIG]
5
+ fr-docs build [--production] [--symlink] [--config CONFIG]
6
+ python -m fr_docs init [DIR] [config]
7
+ fr-docs init [DIR] [config]
8
+ """
9
+
10
+ import argparse
11
+ import sys
12
+
13
+ from .build import main as build_main
14
+ from .init import main as init_main
15
+
16
+
17
+ def main() -> None:
18
+ parser = argparse.ArgumentParser(
19
+ prog="fr-docs",
20
+ description="Fast static documentation generator",
21
+ )
22
+ subparsers = parser.add_subparsers(
23
+ dest="command",
24
+ help="Available commands",
25
+ )
26
+
27
+ # Build command
28
+ build_parser = subparsers.add_parser(
29
+ "build",
30
+ help="Build the documentation site",
31
+ )
32
+ build_parser.add_argument(
33
+ "--production",
34
+ action="store_true",
35
+ help="Rewrite internal links to site-root absolute paths for deployed docs.",
36
+ )
37
+ build_parser.add_argument(
38
+ "--symlink",
39
+ action="store_true",
40
+ help=(
41
+ "Create a {site_prefix}/ symlink dir under site/ so a local "
42
+ "production build can be opened in a browser. Off by default: "
43
+ "in CI the whole docs/site/ tree is deployed as-is, and a nested "
44
+ "fr-docs/ symlink folder would show up as "
45
+ "omena0.dev/fr-docs/fr-docs/."
46
+ ),
47
+ )
48
+ build_parser.add_argument(
49
+ "--config",
50
+ help="Path to the configuration JSON file.",
51
+ )
52
+
53
+ # Init command
54
+ init_parser = subparsers.add_parser(
55
+ "init",
56
+ help="Initialize a new documentation project",
57
+ )
58
+ init_parser.add_argument(
59
+ "dir",
60
+ nargs="?",
61
+ default="docs",
62
+ help="Directory to initialize (default: docs)",
63
+ )
64
+ init_parser.add_argument(
65
+ "target",
66
+ nargs="?",
67
+ choices=["config"],
68
+ default=None,
69
+ help="Initialize only the configuration.",
70
+ )
71
+
72
+ args = parser.parse_args(sys.argv[1:])
73
+
74
+ if args.command == "build":
75
+ build_argv: list[str] = []
76
+
77
+ if args.production:
78
+ build_argv.append("--production")
79
+
80
+ if args.symlink:
81
+ build_argv.append("--symlink")
82
+
83
+ if args.config:
84
+ build_argv.extend(["--config", args.config])
85
+
86
+ build_main(build_argv)
87
+ return
88
+
89
+ if args.command == "init":
90
+ # `fr-docs init config` means "initialize config in ./docs".
91
+ if args.dir == "config" and args.target is None:
92
+ init_main("docs", True)
93
+ return
94
+
95
+ init_main(args.dir, args.target == "config")
96
+ return
97
+
98
+ # Default to build for backwards compatibility.
99
+ if len(sys.argv) > 1 and sys.argv[1] in (
100
+ "--production",
101
+ "--symlink",
102
+ "--config",
103
+ "--help",
104
+ "-h",
105
+ ):
106
+ build_main(sys.argv[1:])
107
+ return
108
+
109
+ parser.print_help()
110
+ sys.exit(1)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
@@ -6,6 +6,8 @@ Converts Markdown source files into a static HTML site using configuration.
6
6
 
7
7
  import argparse
8
8
  import concurrent.futures
9
+ import contextlib
10
+ import glob
9
11
  import json
10
12
  import os
11
13
  import re
@@ -25,10 +27,12 @@ from .config_accessors import (
25
27
  project_name,
26
28
  search_index_filename,
27
29
  sidebar,
28
- src_dir,
29
30
  workers,
30
31
  zstd_level,
31
32
  )
33
+ from .config_accessors import (
34
+ src_dir as config_src_dir,
35
+ )
32
36
  from .git import build_version_options, collect_git_metadata
33
37
  from .html_pipeline import build_page, minify_all_pages, optimize_all_pages
34
38
  from .search import build_search_index, search_include_config
@@ -42,7 +46,7 @@ from .utils import normalized_site_prefix
42
46
 
43
47
  def _compute_hash(data: str) -> str:
44
48
  """Compute xxhash hash of string data."""
45
- return xxhash.xxh3_128_hexdigest(data.encode("utf-8"))
49
+ return xxhash.xxh3_128_hexdigest(data.encode())
46
50
 
47
51
 
48
52
  def _best_zstd_level(raw: bytes, configured: int) -> int:
@@ -78,8 +82,8 @@ def _minify_static_asset(config, src: Path, dst: Path, name: str) -> None:
78
82
  terser_bin = node_modules / ".bin" / "terser"
79
83
  cmd = [
80
84
  str(terser_bin) if terser_bin.exists() else "npx",
81
- "--yes" if not terser_bin.exists() else "",
82
- *(["terser"] if not terser_bin.exists() else []),
85
+ "" if terser_bin.exists() else "--yes",
86
+ *([] if terser_bin.exists() else ["terser"]),
83
87
  str(src),
84
88
  "--compress",
85
89
  "warnings=false",
@@ -107,8 +111,8 @@ def _minify_static_asset(config, src: Path, dst: Path, name: str) -> None:
107
111
  wrapper.write_text(wrapped, encoding="utf-8")
108
112
  cmd = [
109
113
  str(minifier_bin) if minifier_bin.exists() else "npx",
110
- "--yes" if not minifier_bin.exists() else "",
111
- *(["html-minifier-next"] if not minifier_bin.exists() else ""),
114
+ "" if minifier_bin.exists() else "--yes",
115
+ *("" if minifier_bin.exists() else ["html-minifier-next"]),
112
116
  "--minify-css=true",
113
117
  "--remove-comments",
114
118
  "--output",
@@ -121,27 +125,22 @@ def _minify_static_asset(config, src: Path, dst: Path, name: str) -> None:
121
125
  )
122
126
  if result.returncode == 0 and min_out.exists():
123
127
  out = min_out.read_text(encoding="utf-8")
124
- m = re.search(r"<style>([\s\S]*)</style>", out)
125
- if m:
126
- dst.write_text(m.group(1), encoding="utf-8")
128
+ if m := re.search(r"<style>([\s\S]*)</style>", out):
129
+ dst.write_text(m[1], encoding="utf-8")
127
130
  return
128
131
  print(f" ! minify failed for {name}, copying raw: {result.stderr[-300:]}")
129
132
  finally:
130
- try:
133
+ with contextlib.suppress(OSError):
131
134
  wrapper.unlink()
132
- except OSError:
133
- pass
134
- try:
135
+ with contextlib.suppress(OSError):
135
136
  min_out.unlink()
136
- except OSError:
137
- pass
138
137
  shutil.copyfile(src, dst)
139
138
 
140
139
 
141
140
  class BuildCache:
142
141
  """Manage caching of build data to avoid unnecessary regeneration."""
143
142
 
144
- def __init__(self, cache_path: str):
143
+ def __init__(self, cache_path: str) -> None:
145
144
  self.cache_path = Path(cache_path)
146
145
  self.cache = self._load_cache()
147
146
 
@@ -150,14 +149,14 @@ class BuildCache:
150
149
  if not self.cache_path.exists():
151
150
  return {}
152
151
  try:
153
- with open(self.cache_path, "r", encoding="utf-8") as f:
152
+ with self.cache_path.open() as f:
154
153
  return json.load(f)
155
154
  except json.JSONDecodeError, OSError:
156
155
  return {}
157
156
 
158
- def _save_cache(self):
157
+ def _save_cache(self) -> None:
159
158
  """Save the build cache to disk."""
160
- with open(self.cache_path, "w", encoding="utf-8") as f:
159
+ with self.cache_path.open("w") as f:
161
160
  json.dump(self.cache, f, separators=(",", ":"))
162
161
 
163
162
  def needs_regeneration(self, key: str, current_hash: str) -> bool:
@@ -165,7 +164,7 @@ class BuildCache:
165
164
  cached_hash = self.cache.get(key)
166
165
  return True if cached_hash is None else cached_hash != current_hash
167
166
 
168
- def update_cache(self, key: str, new_hash: str):
167
+ def update_cache(self, key: str, new_hash: str) -> None:
169
168
  """Update cache with new hash."""
170
169
  self.cache[key] = new_hash
171
170
  self._save_cache()
@@ -194,9 +193,39 @@ def _write_zstd_json(config, filename, value):
194
193
  return len(raw), len(compressed)
195
194
 
196
195
 
196
+ def _should_skip_file(rel_path, ignore_dirs, seen_paths):
197
+ """Check if a file should be skipped based on hidden dirs, ignore dirs, or duplicates."""
198
+ rel_parts = rel_path.parts
199
+ if any(p.startswith(".") for p in rel_parts):
200
+ return True
201
+ if any(p in ignore_dirs for p in rel_parts):
202
+ return True
203
+ rel_str = str(rel_path)
204
+ return rel_str in seen_paths
205
+
206
+
207
+ def _process_pattern(pattern, search_dir, ignore_dirs, seen_paths, source_files):
208
+ """Process a single glob pattern within a search directory."""
209
+ for match in glob.glob(str(search_dir / pattern), recursive=True):
210
+ file_path = Path(match)
211
+ if not file_path.is_file():
212
+ continue
213
+
214
+ try:
215
+ rel_path = file_path.relative_to(search_dir)
216
+ except ValueError:
217
+ continue
218
+
219
+ if _should_skip_file(rel_path, ignore_dirs, seen_paths):
220
+ continue
221
+
222
+ seen_paths.add(str(rel_path))
223
+ with contextlib.suppress(OSError, UnicodeDecodeError):
224
+ source_files[str(rel_path)] = file_path.read_text(encoding="utf-8")
225
+
226
+
197
227
  def collect_source_files(config):
198
228
  """Collect source code files for code reference feature using glob patterns."""
199
- import glob as glob_module
200
229
 
201
230
  source_files = {}
202
231
  docs_path = Path(config["_docs_dir"])
@@ -299,50 +328,19 @@ def collect_source_files(config):
299
328
  for search_dir in search_dirs:
300
329
  if not search_dir.exists():
301
330
  continue
302
- try:
331
+ with contextlib.suppress(OSError):
303
332
  for pattern in patterns:
304
- # Use glob with the search directory as base
305
- for match in glob_module.glob(
306
- str(search_dir / pattern), recursive=True
307
- ):
308
- file_path = Path(match)
309
- if not file_path.is_file():
310
- continue
311
-
312
- # Get relative path from search_dir for key
313
- try:
314
- rel_path = file_path.relative_to(search_dir)
315
- except ValueError:
316
- continue
317
-
318
- rel_str = str(rel_path)
319
-
320
- # Skip hidden directories in the relative path
321
- rel_parts = rel_path.parts
322
- if any(p.startswith(".") for p in rel_parts):
323
- continue
324
- if any(p in ignore_dirs for p in rel_parts):
325
- continue
326
-
327
- # Deduplicate by relative path string
328
- if rel_str in seen_paths:
329
- continue
330
- seen_paths.add(rel_str)
331
-
332
- try:
333
- content = file_path.read_text(encoding="utf-8")
334
- source_files[rel_str] = content
335
- except OSError, UnicodeDecodeError:
336
- pass
337
- except OSError:
338
- pass
333
+ _process_pattern(
334
+ pattern, search_dir, ignore_dirs, seen_paths, source_files
335
+ )
339
336
 
340
337
  return source_files
341
338
 
342
339
 
343
- def main(argv=None):
340
+ def main(argv=None) -> None:
344
341
  if argv is not None and argv and argv[0] == "build":
345
342
  argv = argv[1:]
343
+
346
344
  elif argv is None and (
347
345
  Path(sys.argv[0]).name == "fr-docs"
348
346
  and len(sys.argv) > 1
@@ -375,7 +373,7 @@ def main(argv=None):
375
373
  config["production"] = bool(args.production)
376
374
 
377
375
  print(f"📖 Building {project_name(config)} docs...")
378
- print(f" Source: {src_dir(config)}")
376
+ print(f" Source: {config_src_dir(config)}")
379
377
  print(f" Output: {out_dir(config)}")
380
378
  print(f" Mode: {'production' if args.production else 'development'}")
381
379
  if args.production:
@@ -395,7 +393,7 @@ def main(argv=None):
395
393
 
396
394
  # Copy static assets (minifying JS/CSS when production)
397
395
  docs_path = Path(config["_docs_dir"])
398
- os.makedirs(Path(config["_out_dir"]), exist_ok=True)
396
+ Path(config["_out_dir"]).mkdir(parents=True, exist_ok=True)
399
397
  for legacy_name in (
400
398
  "file_index.json",
401
399
  "git_meta.json",
@@ -414,7 +412,7 @@ def main(argv=None):
414
412
  else:
415
413
  shutil.copyfile(src, dst)
416
414
  except FileNotFoundError:
417
- print(f"File not found: {os.getcwd()}, {src}->{dst}")
415
+ print(f"File not found: {Path.cwd()}, {src}->{dst}")
418
416
  except OSError as e:
419
417
  print(f"OSError: {e}")
420
418
 
@@ -433,13 +431,12 @@ def main(argv=None):
433
431
  slugs = get_all_slugs(config)
434
432
 
435
433
  # Also check for any .md files not in the sidebar
436
- if os.path.isdir(config["_src_dir"]):
437
- for dirpath, _dirnames, filenames in os.walk(config["_src_dir"]):
434
+ src_dir = Path(config["_src_dir"])
435
+ if src_dir.is_dir():
436
+ for dirpath, _dirnames, filenames in os.walk(src_dir):
438
437
  for fname in filenames:
439
438
  if fname.endswith(".md"):
440
- rel = os.path.relpath(
441
- os.path.join(dirpath, fname), config["_src_dir"]
442
- )
439
+ rel = os.path.relpath(Path(dirpath, fname), src_dir)
443
440
  s = rel[:-3]
444
441
  if s not in slugs:
445
442
  slugs.append(s)
@@ -453,16 +450,13 @@ def main(argv=None):
453
450
  config["_search_index"] = search_index
454
451
 
455
452
  # Compress search index
456
- search_json = json.dumps(search_index, separators=(",", ":"))
457
- search_raw = search_json.encode("utf-8")
458
- cctx = zstandard.ZstdCompressor(
453
+ search_raw = json.dumps(search_index, separators=(",", ":")).encode("utf-8")
454
+ compressed = zstandard.ZstdCompressor(
459
455
  level=_best_zstd_level(search_raw, zstd_level(config))
460
- )
461
- compressed = cctx.compress(search_raw)
462
- search_index_path = os.path.join(config["_out_dir"], search_index_filename(config))
463
- os.makedirs(os.path.dirname(search_index_path), exist_ok=True)
464
- with open(search_index_path, "wb") as sf:
465
- sf.write(compressed)
456
+ ).compress(search_raw)
457
+ search_index_path = Path(config["_out_dir"], search_index_filename(config))
458
+ search_index_path.parent.mkdir(parents=True, exist_ok=True)
459
+ search_index_path.write_bytes(compressed)
466
460
 
467
461
  if args.production:
468
462
  config["_search_index_inline"] = ""
@@ -471,7 +465,7 @@ def main(argv=None):
471
465
 
472
466
  config["_search_index_inline"] = (
473
467
  '<script id="zstd-data" type="text/plain">'
474
- f"{base64.b64encode(compressed).decode('ascii')}"
468
+ f" {base64.b64encode(compressed).decode('ascii')}"
475
469
  "</script>"
476
470
  )
477
471
 
@@ -523,10 +517,9 @@ def main(argv=None):
523
517
  cache.update_cache(cache_key, content_hash)
524
518
  else:
525
519
  # Load from per-file cache
526
- with open(highlight_cache_file, "rb") as f:
527
- compressed = f.read()
520
+ compressed = highlight_cache_file.read_bytes()
528
521
  decompressed = zstandard.ZstdDecompressor().decompress(compressed)
529
- source_highlights[path] = json.loads(decompressed.decode("utf-8"))
522
+ source_highlights[path] = json.loads(decompressed.decode())
530
523
 
531
524
  # Write the highlights to zstd
532
525
  if source_highlights:
@@ -576,8 +569,8 @@ def main(argv=None):
576
569
  # Build pages
577
570
  slugs_to_build = []
578
571
  for slug in slugs:
579
- src = os.path.join(config["_src_dir"], f"{slug}.md")
580
- if os.path.exists(src):
572
+ src = Path(config["_src_dir"], f"{slug}.md")
573
+ if src.exists():
581
574
  slugs_to_build.append(slug)
582
575
 
583
576
  if slugs_to_build:
@@ -617,7 +610,7 @@ def main(argv=None):
617
610
  except Exception as e: # noqa: BLE001
618
611
  print(f" ✗ Minification failed: {e}")
619
612
 
620
- # Create a {site_prefix}/ directory under site/ and symlink every
613
+ # Create a {site_prefix}/ directory under site/ and symlink every
621
614
  # file into it, so a production build works locally (the HTML
622
615
  # references absolute paths like /fr-docs/style.css). Without
623
616
  # this, running `python -m fr_docs build --production` and then
@@ -636,7 +629,9 @@ def main(argv=None):
636
629
  for f in sorted(Path(config["_out_dir"]).iterdir()):
637
630
  if f.is_file() and not f.name.startswith("."):
638
631
  (prefix_dir / f.name).symlink_to(f.resolve())
639
- print(f" ✓ Symlinked {len(list(prefix_dir.iterdir()))} files into {prefix}/")
632
+ print(
633
+ f" ✓ Symlinked {len(list(prefix_dir.iterdir()))} files into {prefix}/"
634
+ )
640
635
  except OSError as e:
641
636
  print(f" ✗ Failed to create {prefix}/ symlinks: {e}")
642
637
 
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env python3
2
2
  """Configuration loading for the fr-docs documentation builder."""
3
3
 
4
+ from __future__ import annotations
5
+
4
6
  import json
5
7
  from pathlib import Path
6
8
 
@@ -58,7 +60,10 @@ def _merge(base, override):
58
60
 
59
61
 
60
62
  def load_config(config_path=None):
61
- """Load and validate the documentation builder configuration."""
63
+ """Load and validate the documentation builder configuration.\n
64
+ :param config_path: Optional path to config file
65
+ :type config_path: str | Path | None
66
+ """
62
67
  if config_path is None:
63
68
  config_path = Path.cwd() / "config.json"
64
69
  if not config_path.exists():
@@ -71,17 +76,31 @@ def load_config(config_path=None):
71
76
  config = DEFAULT_CONFIG
72
77
  project_name_specified = False
73
78
  if config_path.exists():
74
- with open(config_path, "r", encoding="utf-8") as f:
79
+ with open(config_path, encoding="utf-8") as f:
75
80
  loaded = json.load(f)
76
81
  config = _merge(DEFAULT_CONFIG, loaded)
77
82
  project_name_specified = "project_name" in loaded
78
83
 
79
84
  config_path = config_path.resolve()
80
- docs_dir = (
81
- Path(config["docs_dir"]).resolve() if config["docs_dir"] else config_path.parent
82
- )
85
+ if docs_dir_raw := config["docs_dir"]:
86
+ docs_dir_path = Path(docs_dir_raw)
87
+ docs_dir = (
88
+ docs_dir_path
89
+ if docs_dir_path.is_absolute()
90
+ else (config_path.parent / docs_dir_path).resolve()
91
+ )
92
+
93
+ else:
94
+ docs_dir = config_path.parent
95
+
83
96
  src_dir = docs_dir / config["src_dir"] if config["src_dir"] else docs_dir / "src"
84
- out_dir = docs_dir / config["out_dir"] if config["out_dir"] else docs_dir / "site"
97
+
98
+ out_dir_raw = (
99
+ config.get("build", {}).get("out_dir") or config.get("out_dir") or "site"
100
+ )
101
+ out_dir = (
102
+ Path(out_dir_raw) if Path(out_dir_raw).is_absolute() else docs_dir / out_dir_raw
103
+ )
85
104
 
86
105
  config["_project_name_specified"] = project_name_specified
87
106
  config["_config_path"] = str(config_path)