fr-docs 2.1.6__tar.gz → 2.3.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 (27) hide show
  1. {fr_docs-2.1.6 → fr_docs-2.3.0}/PKG-INFO +83 -1
  2. {fr_docs-2.1.6 → fr_docs-2.3.0}/README.md +29 -0
  3. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/__main__.py +13 -2
  4. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/build.py +90 -7
  5. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/html_pipeline.py +187 -48
  6. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/template.py +18 -11
  7. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs.egg-info/PKG-INFO +83 -1
  8. fr_docs-2.3.0/pyproject.toml +58 -0
  9. fr_docs-2.1.6/pyproject.toml +0 -28
  10. {fr_docs-2.1.6 → fr_docs-2.3.0}/LICENSE.md +0 -0
  11. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/__init__.py +0 -0
  12. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/config.py +0 -0
  13. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/config_accessors.py +0 -0
  14. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/frontmatter.py +0 -0
  15. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/git.py +0 -0
  16. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/init.py +0 -0
  17. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/markdown.py +0 -0
  18. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/search.py +0 -0
  19. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/slug.py +0 -0
  20. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/syntax.py +0 -0
  21. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs/utils.py +0 -0
  22. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs.egg-info/SOURCES.txt +0 -0
  23. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs.egg-info/dependency_links.txt +0 -0
  24. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs.egg-info/entry_points.txt +0 -0
  25. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs.egg-info/requires.txt +0 -0
  26. {fr_docs-2.1.6 → fr_docs-2.3.0}/fr_docs.egg-info/top_level.txt +0 -0
  27. {fr_docs-2.1.6 → fr_docs-2.3.0}/setup.cfg +0 -0
@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fr-docs
3
- Version: 2.1.6
3
+ Version: 2.3.0
4
4
  Summary: Fast static documentation generator with search, versioning, and zero-config setup
5
+ Author: Omena0
5
6
  License: # PolyForm Noncommercial License 1.0.0
6
7
 
7
8
  <https://polyformproject.org/licenses/noncommercial/1.0.0>
@@ -134,10 +135,91 @@ License: # PolyForm Noncommercial License 1.0.0
134
135
  **Use** means anything you do with the software requiring one
135
136
  of your licenses.
136
137
 
138
+ Project-URL: Homepage, https://omena0.dev/fr-docs/
139
+ Project-URL: Documentation, https://omena0.dev/fr-docs/
140
+ Project-URL: Repository, https://github.com/Omena0/fr-docs
141
+ Project-URL: Issues, https://github.com/Omena0/fr-docs/issues
142
+ Keywords: documentation,documentation-generator,static-site-generator,markdown,github-pages,static-site,docs
143
+ Classifier: Development Status :: 5 - Production/Stable
144
+ Classifier: Intended Audience :: Developers
145
+ Classifier: License :: OSI Approved :: MIT License
146
+ Classifier: Programming Language :: Python :: 3
147
+ Classifier: Programming Language :: Python :: 3 :: Only
148
+ Classifier: Programming Language :: Python :: 3.14
149
+ Classifier: Topic :: Documentation
150
+ Classifier: Topic :: Software Development :: Documentation
151
+ Classifier: Topic :: Software Development :: Build Tools
137
152
  Requires-Python: >=3.14
153
+ Description-Content-Type: text/markdown
138
154
  License-File: LICENSE.md
139
155
  Requires-Dist: fastpylight>=0.1.18
140
156
  Requires-Dist: markdown
141
157
  Requires-Dist: xxhash>=4.0.1
142
158
  Requires-Dist: zstandard
143
159
  Dynamic: license-file
160
+
161
+
162
+ # Fr-docs
163
+
164
+ Generate fast, fully featured, modern documentation pages from markdown for GitHub pages and static hosting.
165
+
166
+ [![Lint](https://github.com/Omena0/fr-docs/actions/workflows/lint.yml/badge.svg)](https://github.com/Omena0/fr-docs/actions/workflows/lint.yml)
167
+ [![Docs](https://github.com/Omena0/fr-docs/actions/workflows/pages.yml/badge.svg)](https://github.com/Omena0/fr-docs/actions/workflows/pages.yml)
168
+ [![PyPi](https://github.com/Omena0/fr-docs/actions/workflows/publish.yml/badge.svg)](https://github.com/Omena0/fr-docs/actions/workflows/publish.yml)
169
+ [![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)
170
+
171
+ ## Documentation
172
+
173
+ - [Getting Started](https://omena0.dev/fr-docs/)
174
+ - [Installation](https://omena0.dev/fr-docs/installation)
175
+ - [Quickstart](https://omena0.dev/fr-docs/quickstart)
176
+ - [Configuration](https://omena0.dev/fr-docs/config.json)
177
+ - [Showcase](https://omena0.dev/fr-docs/showcase)
178
+ - [Pagespeed](https://omena0.dev/fr-docs/pagespeed) — performance metrics
179
+
180
+ ## Overview
181
+
182
+ 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.
183
+
184
+ ### Key Features
185
+
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
190
+
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
+
222
+ ### Projects that use fr-docs
223
+
224
+ - [Fr-docs](https://omena0.dev/fr-docs/)
225
+ - [PyJavaBridge](https://omena0.dev/PyJavaBridge)
@@ -15,6 +15,7 @@ Generate fast, fully featured, modern documentation pages from markdown for GitH
15
15
  - [Quickstart](https://omena0.dev/fr-docs/quickstart)
16
16
  - [Configuration](https://omena0.dev/fr-docs/config.json)
17
17
  - [Showcase](https://omena0.dev/fr-docs/showcase)
18
+ - [Pagespeed](https://omena0.dev/fr-docs/pagespeed) — performance metrics
18
19
 
19
20
  ## Overview
20
21
 
@@ -23,12 +24,40 @@ Fr-docs is a generic, configurable documentation builder for Python projects. It
23
24
  ### Key Features
24
25
 
25
26
  - **Config-driven**: All settings defined in `config.json`
27
+ - Project name, URL, description, and sidebar structure
28
+ - Search, code references, auto-linking, and related pages
29
+ - Build options: workers, minification, zstd level, and HTML optimization
30
+
26
31
  - **Zero-config setup**: Works out of the box with sensible defaults
27
32
  - **Search**: Full-text search with Zstandard-compressed index
33
+ - Symbol-level search across all source files
34
+ - File index for browsing source code
35
+ - Per-file source highlights with caching
36
+
28
37
  - **Optimized**: HTML minification, critical CSS/JS inlining, Zstd compression
38
+ - Critical CSS extracted and inlined for both mobile and desktop viewports
39
+ - JavaScript and CSS minified with `terser` and `html-minifier-next`
40
+ - Zstd-compressed search index, source files, highlights, and metadata
41
+ - Adaptive zstd level selection (scans 1..configured, picks the smallest)
42
+
29
43
  - **Multi-environment**: Different configs for production, staging, development
30
44
  - **Parallel builds**: Threaded page generation for faster builds
31
45
  - **Git integration**: Automatic metadata extraction from git commits
46
+ - Version options derived from commit messages (e.g. `4D - fix bug` → `4.4.0`)
47
+ - Commit SHA and message embedded in the version selector
48
+
49
+ - **Local font serving**: Fonts downloaded at build time and served from the site's own origin
50
+ - No third-party font round-trips or 404s when Google changes the URL scheme
51
+ - `font-display: swap` with metric-compatible `font-ascent-descent` fallbacks
52
+ - Primary body font weight preloaded for faster first paint
53
+
54
+ - **Sidebar**: Collapsible sections with auto-expansion
55
+ - The section containing the current page is expanded on load
56
+ - Single-page sections and single-section sites are expanded by default
57
+
58
+ - **Inline copy commands**: Prefix inline code with `c` to create copy-on-click links
59
+ - **Code references**: Auto-link bare filename references to their source files
60
+ - **Backlinks and related pages**: Automatically generated navigation between related content
32
61
 
33
62
  ### Projects that use fr-docs
34
63
 
@@ -1,8 +1,8 @@
1
1
  """Entry point for fr-docs CLI.
2
2
 
3
3
  Usage:
4
- python -m fr_docs build [--production] [--config CONFIG]
5
- fr-docs build [--production] [--config CONFIG]
4
+ python -m fr_docs build [--production] [--symlink] [--config CONFIG]
5
+ fr-docs build [--production] [--symlink] [--config CONFIG]
6
6
  python -m fr_docs init [--dir DIR]
7
7
  fr-docs init [--dir DIR]
8
8
  """
@@ -27,6 +27,14 @@ def main():
27
27
  action="store_true",
28
28
  help="Rewrite internal links to site-root absolute paths for deployed docs.",
29
29
  )
30
+ build_parser.add_argument(
31
+ "--symlink",
32
+ action="store_true",
33
+ help="Create a {site_prefix}/ symlink dir under site/ so a local "
34
+ "production build can be opened in a browser. Off by default: in "
35
+ "CI the whole docs/site/ tree is deployed as-is, and a nested "
36
+ "fr-docs/ symlink folder would show up as omena0.dev/fr-docs/fr-docs/.",
37
+ )
30
38
  build_parser.add_argument(
31
39
  "--config",
32
40
  help="Path to the configuration JSON file.",
@@ -51,6 +59,8 @@ def main():
51
59
  build_argv = []
52
60
  if args.production:
53
61
  build_argv.append("--production")
62
+ if args.symlink:
63
+ build_argv.append("--symlink")
54
64
  if args.config:
55
65
  build_argv.extend(["--config", args.config])
56
66
  build_main(build_argv)
@@ -60,6 +70,7 @@ def main():
60
70
  # Default to build for backwards compatibility
61
71
  if len(sys.argv) > 1 and sys.argv[1] in (
62
72
  "--production",
73
+ "--symlink",
63
74
  "--config",
64
75
  "--help",
65
76
  "-h",
@@ -30,7 +30,7 @@ from .config_accessors import (
30
30
  zstd_level,
31
31
  )
32
32
  from .git import build_version_options, collect_git_metadata
33
- from .html_pipeline import build_page
33
+ from .html_pipeline import build_page, minify_all_pages, optimize_all_pages
34
34
  from .search import build_search_index, search_include_config
35
35
  from .slug import (
36
36
  build_slug_page_keys,
@@ -45,6 +45,25 @@ def _compute_hash(data: str) -> str:
45
45
  return xxhash.xxh3_128_hexdigest(data.encode("utf-8"))
46
46
 
47
47
 
48
+ def _best_zstd_level(raw: bytes, configured: int) -> int:
49
+ """Pick the zstd compression level that minimizes compressed size.
50
+
51
+ Scans levels 1..configured and returns the one producing the smallest
52
+ output, breaking ties toward the lower level so decompression stays
53
+ fast. Level 22 is very slow and rarely beats lower levels on real
54
+ payloads, so this avoids paying for it when it doesn't help.
55
+ """
56
+ if len(raw) < 1024:
57
+ return min(configured, 19)
58
+
59
+ best_level, best_size = 1, len(zstandard.ZstdCompressor(level=1).compress(raw))
60
+ for level in range(2, configured + 1):
61
+ size = len(zstandard.ZstdCompressor(level=level).compress(raw))
62
+ if size < best_size:
63
+ best_level, best_size = level, size
64
+ return best_level
65
+
66
+
48
67
  def _minify_static_asset(config, src: Path, dst: Path, name: str) -> None:
49
68
  """Minify a static asset in-place (JS via terser, CSS via html-minifier).
50
69
 
@@ -166,7 +185,9 @@ def _write_zstd_json(config, filename, value):
166
185
  if src.exists():
167
186
  return len(raw), src.stat().st_size
168
187
 
169
- compressed = zstandard.ZstdCompressor(level=zstd_level(config)).compress(raw)
188
+ compressed = zstandard.ZstdCompressor(
189
+ level=_best_zstd_level(raw, zstd_level(config))
190
+ ).compress(raw)
170
191
  path = Path(config["_out_dir"]) / filename
171
192
  path.write_bytes(compressed)
172
193
  cache.update_cache(filename, raw_hash)
@@ -336,6 +357,14 @@ def main(argv=None):
336
357
  action="store_true",
337
358
  help="Rewrite internal links to site-root absolute paths for deployed docs.",
338
359
  )
360
+ parser.add_argument(
361
+ "--symlink",
362
+ action="store_true",
363
+ help="Create a {site_prefix}/ symlink dir under site/ so a local "
364
+ "production build can be opened in a browser. Off by default: in "
365
+ "CI the whole docs/site/ tree is deployed as-is, and a nested "
366
+ "fr-docs/ symlink folder would show up as omena0.dev/fr-docs/fr-docs/.",
367
+ )
339
368
  parser.add_argument(
340
369
  "--config",
341
370
  help="Path to the configuration JSON file.",
@@ -375,7 +404,7 @@ def main(argv=None):
375
404
  "symbol_index.json",
376
405
  ):
377
406
  Path(config["_out_dir"], legacy_name).unlink(missing_ok=True)
378
- for name in ("favicon.svg", "script.js", "style.css"):
407
+ for name in ("favicon.svg", "fonts.css", "script.js", "style.css"):
379
408
  src = docs_path / name
380
409
  dst = Path(config["_out_dir"]) / name
381
410
 
@@ -389,6 +418,18 @@ def main(argv=None):
389
418
  except OSError as e:
390
419
  print(f"OSError: {e}")
391
420
 
421
+ # Copy locally-fetched fonts (if present) so the site doesn't need
422
+ # to reach out to fonts.gstatic.com. fonts.css is minified along
423
+ # with style.css below.
424
+ fonts_dir = docs_path / "fonts"
425
+ if fonts_dir.is_dir():
426
+ out_fonts = Path(config["_out_dir"]) / "fonts"
427
+ out_fonts.mkdir(parents=True, exist_ok=True)
428
+ for f in fonts_dir.iterdir():
429
+ if f.is_file():
430
+ shutil.copy2(f, out_fonts / f.name)
431
+ print(f" Copied {len(list(out_fonts.iterdir()))} font files")
432
+
392
433
  slugs = get_all_slugs(config)
393
434
 
394
435
  # Also check for any .md files not in the sidebar
@@ -413,8 +454,11 @@ def main(argv=None):
413
454
 
414
455
  # Compress search index
415
456
  search_json = json.dumps(search_index, separators=(",", ":"))
416
- cctx = zstandard.ZstdCompressor(level=zstd_level(config))
417
- compressed = cctx.compress(search_json.encode("utf-8"))
457
+ search_raw = search_json.encode("utf-8")
458
+ cctx = zstandard.ZstdCompressor(
459
+ level=_best_zstd_level(search_raw, zstd_level(config))
460
+ )
461
+ compressed = cctx.compress(search_raw)
418
462
  search_index_path = os.path.join(config["_out_dir"], search_index_filename(config))
419
463
  os.makedirs(os.path.dirname(search_index_path), exist_ok=True)
420
464
  with open(search_index_path, "wb") as sf:
@@ -432,7 +476,7 @@ def main(argv=None):
432
476
  )
433
477
 
434
478
  print(
435
- f" Search index: {len(search_json.encode('utf-8')):,} bytes → {len(compressed):,} zstd ({100 * len(compressed) / len(search_json.encode('utf-8')):.1f}%)"
479
+ f" Search index: {len(search_raw):,} bytes → {len(compressed):,} zstd ({100 * len(compressed) / len(search_raw):.1f}%)"
436
480
  )
437
481
 
438
482
  # Save source files for code references
@@ -472,7 +516,7 @@ def main(argv=None):
472
516
  "utf-8"
473
517
  )
474
518
  compressed = zstandard.ZstdCompressor(
475
- level=zstd_level(config)
519
+ level=_best_zstd_level(raw, zstd_level(config))
476
520
  ).compress(raw)
477
521
  highlight_cache_file.parent.mkdir(parents=True, exist_ok=True)
478
522
  highlight_cache_file.write_bytes(compressed)
@@ -557,6 +601,45 @@ def main(argv=None):
557
601
  else:
558
602
  print("No pages found to build.")
559
603
 
604
+ if config.get("production", False):
605
+ print()
606
+ print("🔧 Optimizing HTML (critical CSS inlining)...")
607
+ try:
608
+ optimize_all_pages(config)
609
+ print(" ✓ Critical CSS inlined")
610
+ except Exception as e: # noqa: BLE001
611
+ print(f" ✗ Critical failed: {e}")
612
+
613
+ print("🔧 Minifying HTML...")
614
+ try:
615
+ minify_all_pages(config)
616
+ print(" ✓ HTML minified")
617
+ except Exception as e: # noqa: BLE001
618
+ print(f" ✗ Minification failed: {e}")
619
+
620
+ # Create a {site_prefix}/ directory under site/ and symlink every
621
+ # file into it, so a production build works locally (the HTML
622
+ # references absolute paths like /fr-docs/style.css). Without
623
+ # this, running `python -m fr_docs build --production` and then
624
+ # opening site/index.html in a browser would 404 on every asset.
625
+ #
626
+ # Off by default: in CI the whole docs/site/ tree is deployed
627
+ # as-is, and a nested fr-docs/ symlink folder would show up as
628
+ # omena0.dev/fr-docs/fr-docs/. Pass --symlink to enable it.
629
+ if args.symlink:
630
+ prefix = normalized_site_prefix(config).strip("/") or "fr-docs"
631
+ prefix_dir = Path(config["_out_dir"]) / prefix
632
+ try:
633
+ if prefix_dir.is_symlink() or prefix_dir.exists():
634
+ shutil.rmtree(prefix_dir, ignore_errors=True)
635
+ prefix_dir.mkdir(parents=True, exist_ok=True)
636
+ for f in sorted(Path(config["_out_dir"]).iterdir()):
637
+ if f.is_file() and not f.name.startswith("."):
638
+ (prefix_dir / f.name).symlink_to(f.resolve())
639
+ print(f" ✓ Symlinked {len(list(prefix_dir.iterdir()))} files into {prefix}/")
640
+ except OSError as e:
641
+ print(f" ✗ Failed to create {prefix}/ symlinks: {e}")
642
+
560
643
  print(f"\n✅ Built {built} pages")
561
644
 
562
645
 
@@ -77,7 +77,7 @@ def _render_template_placeholders(config):
77
77
  def _get_header_search_html():
78
78
  if not feature_enabled(config, "search"):
79
79
  return ""
80
- return """<svg class="header-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
80
+ return """<svg class="header-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
81
81
  <input type="text" id="header-search" placeholder="Search docs… (Ctrl+K)" autocomplete="off">
82
82
  <div id="search-results" class="search-results"></div>"""
83
83
 
@@ -149,44 +149,13 @@ def absolutize_links(html_text, page_url, config):
149
149
 
150
150
 
151
151
  def optimize_html(html_input, config):
152
- if not config.get("production", False):
153
- return html_input
154
- if not cfg_optimize_html(config):
155
- return html_input
156
-
157
- with tempfile.NamedTemporaryFile("w+", suffix=".html", delete=False) as f_in:
158
- f_in.write(html_input)
159
- in_path = Path(f_in.name)
160
-
161
- cmd = [
162
- "npx",
163
- "critical",
164
- str(in_path),
165
- "--inline",
166
- "--width",
167
- "1920",
168
- "--height",
169
- "1080",
170
- ]
171
-
172
- result = subprocess.run(cmd, capture_output=True, text=True, check=False)
173
-
174
- try:
175
- if result.returncode != 0:
176
- raise RuntimeError(f"Critical failed:\n{result.stderr}")
152
+ """Legacy single-file optimizer — kept for backwards compat.
177
153
 
178
- optimized_html = result.stdout
179
-
180
- if "</html>" not in optimized_html:
181
- raise RuntimeError("Critical output looks truncated/corrupted")
182
-
183
- return optimized_html
184
-
185
- finally:
186
- try:
187
- in_path.unlink()
188
- except OSError:
189
- pass
154
+ critical only resolves <link rel=stylesheet> URLs when run on a
155
+ directory, so this is a no-op in production. Use optimize_all_pages
156
+ instead.
157
+ """
158
+ return html_input
190
159
 
191
160
 
192
161
  def minify_html(html_input, config):
@@ -344,21 +313,12 @@ def build_page(slug, config, slug_page_keys):
344
313
 
345
314
  out_html = TEMPLATE.format(**placeholders)
346
315
 
347
- try:
348
- out_html = optimize_html(out_html, config)
349
- except Exception as e: # noqa: BLE001
350
- logger.warning(f"Failed to optimize HTML: {e}")
351
-
352
- try:
353
- out_html = minify_html(out_html, config)
354
- except Exception as e: # noqa: BLE001
355
- logger.warning(f"Failed to minify HTML: {e}")
356
-
357
316
  if config.get("production", False):
358
317
  page_url = (
359
318
  f"https://docs.local{output_href(slug_output_name(slug, config), config)}"
360
319
  )
361
320
  out_html = absolutize_links(out_html, page_url, config)
321
+ out_html = add_internal_prefetch_links(out_html, config)
362
322
 
363
323
  out_name = slug_output_name(slug, config)
364
324
  out_path = os.path.join(config["_out_dir"], out_name)
@@ -366,6 +326,185 @@ def build_page(slug, config, slug_page_keys):
366
326
  f.write(out_html)
367
327
 
368
328
 
329
+ def optimize_all_pages(config):
330
+ """Inline critical-path CSS into every production HTML page.
331
+
332
+ critical only resolves <link rel=stylesheet> URLs when run on a
333
+ single HTML file with the static engine (directory mode reports
334
+ "no stylesheets discovered" regardless of engine). Running it on a
335
+ single temp file reports "no stylesheets discovered" and silently
336
+ passes through — which on a slow 4G connection means FCP is gated
337
+ by the full CSS download (~4s for 7.6KB at 1.6Mbps).
338
+
339
+ Must be called once after all pages are written, not from within
340
+ build_page (which runs in a thread pool).
341
+ """
342
+ if not config.get("production", False):
343
+ return
344
+ if not cfg_optimize_html(config):
345
+ return
346
+
347
+ out_dir = Path(config["_out_dir"]).resolve()
348
+ if not out_dir.is_dir():
349
+ return
350
+
351
+ # critical only follows relative stylesheet URLs from each HTML
352
+ # file's location. The production template uses absolute paths
353
+ # (e.g. href=/fr-docs/style.css), so rewrite them to relative
354
+ # paths first, run critical, then restore the absolute paths in
355
+ # the inlined output.
356
+ site_prefix = normalized_site_prefix(config)
357
+ stripped_prefix = site_prefix.rstrip("/") if site_prefix and site_prefix != "/" else None
358
+ backed_up = []
359
+
360
+ try:
361
+ if stripped_prefix:
362
+ # Match both quoted (href="/fr-docs/style.css") and unquoted
363
+ # (href=/fr-docs/style.css) forms. The minifier strips quotes,
364
+ # so both must be supported.
365
+ prefix_pat = re.compile(
366
+ rf'href=(["\']?){re.escape(stripped_prefix)}/([^"\'\s>]+)\1'
367
+ )
368
+ for html_file in out_dir.glob("*.html"):
369
+ raw = html_file.read_text(encoding="utf-8")
370
+ backup = out_dir / f".critical_orig_{html_file.stem}.html"
371
+ backup.write_text(raw, encoding="utf-8")
372
+ backed_up.append(backup)
373
+ # Strip the site prefix so href=/fr-docs/style.css
374
+ # becomes href=style.css (relative to the HTML file).
375
+ rewritten = prefix_pat.sub(
376
+ lambda m: f'href={m.group(1)}{m.group(2)}{m.group(1)}',
377
+ raw,
378
+ )
379
+ html_file.write_text(rewritten, encoding="utf-8")
380
+
381
+ # Run critical per-file with the static engine (the only engine
382
+ # that resolves relative stylesheet URLs from a single file).
383
+ # Use --dimensions to render for both mobile (390x844, Moto G
384
+ # Power) and desktop (1920x1080) so the inlined critical CSS
385
+ # includes the @media(width<=640px) rules that mobile needs.
386
+ for html_file in out_dir.glob("*.html"):
387
+ cmd = [
388
+ "npx",
389
+ "critical",
390
+ str(html_file),
391
+ "--inline",
392
+ "--engine",
393
+ "static",
394
+ "--dimensions",
395
+ "390x844,1920x1080",
396
+ "--width",
397
+ "1920",
398
+ "--height",
399
+ "1080",
400
+ ]
401
+
402
+ result = subprocess.run(
403
+ cmd, capture_output=True, text=True, check=False, cwd=str(out_dir)
404
+ )
405
+ if result.returncode != 0:
406
+ raise RuntimeError(
407
+ f"Critical failed for {html_file.name}:\n{result.stderr}"
408
+ )
409
+
410
+ optimized = result.stdout
411
+ if "</html>" not in optimized:
412
+ raise RuntimeError(
413
+ f"Critical output for {html_file.name} looks truncated"
414
+ )
415
+
416
+ html_file.write_text(optimized, encoding="utf-8")
417
+
418
+ finally:
419
+ # Restore the original absolute-path HTML for any page critical
420
+ # didn't inline (e.g. it failed or skipped).
421
+ for backup in backed_up:
422
+ target = out_dir / f"{backup.stem.replace('.critical_orig_', '')}.html"
423
+ if target.exists() and backup.exists():
424
+ try:
425
+ target_content = target.read_text(encoding="utf-8")
426
+ # Only restore if critical didn't inline CSS
427
+ if "data-critical" not in target_content:
428
+ target.write_text(
429
+ backup.read_text(encoding="utf-8"),
430
+ encoding="utf-8",
431
+ )
432
+ except OSError:
433
+ pass
434
+ try:
435
+ backup.unlink()
436
+ except OSError:
437
+ pass
438
+
439
+
440
+ def minify_all_pages(config):
441
+ """Minify every production HTML page after critical has inlined CSS."""
442
+ if not config.get("production", False):
443
+ return
444
+ if not cfg_minify_html(config):
445
+ return
446
+
447
+ out_dir = Path(config["_out_dir"]).resolve()
448
+ if not out_dir.is_dir():
449
+ return
450
+
451
+ for html_file in sorted(out_dir.glob("*.html")):
452
+ try:
453
+ raw = html_file.read_text(encoding="utf-8")
454
+ minified = minify_html(raw, config)
455
+ html_file.write_text(minified, encoding="utf-8")
456
+ except Exception as e: # noqa: BLE001
457
+ logger.warning(f"Failed to minify {html_file.name}: {e}")
458
+
459
+
460
+ def add_internal_prefetch_links(html_text, config):
461
+ """Add rel="prefetch" to every internal page link in production HTML.
462
+
463
+ Prefetching the whole site up front is cheap here (a handful of small
464
+ pages) and makes navigation instant. We deliberately avoid
465
+ <link rel="preload" as="document">: Chrome's preload scanner rejects
466
+ dynamically-injected document preloads, and rel="prefetch" is the
467
+ correct hint for "fetch this page for later navigation".
468
+ """
469
+ if not config.get("production", False):
470
+ return html_text
471
+
472
+ href_re = re.compile(
473
+ r'href\s*=\s*(?:"(?P<q1>[^"]*)"|\'(?P<q2>[^\']*)\'|(?P<uq>[^\s>]+))'
474
+ )
475
+
476
+ # Match the opening <a ...> tag only. attrs must not contain a bare '>'
477
+ # (quoted values are allowed), so the match always stops at the first
478
+ # unquoted '>', which is exactly the closing '>' of the <a> opening
479
+ # tag. This is safe because an opening <a> tag cannot legitimately
480
+ # contain another start tag.
481
+ tag_re = re.compile(
482
+ r'<a\s+(?P<attrs>(?:[^>"\']|"[^"]*"|\'[^\']*\')*)>',
483
+ )
484
+
485
+ def _repl(m):
486
+ attrs = m.group("attrs")
487
+ # Skip links that already declare a rel attribute
488
+ if re.search(r'\brel\s*=', attrs, re.IGNORECASE):
489
+ return m.group(0)
490
+ hm = href_re.search(attrs)
491
+ if not hm:
492
+ return m.group(0)
493
+ href = hm.group("q1") or hm.group("q2") or hm.group("uq") or ""
494
+ if not href or href.startswith(("#", "mailto:", "tel:", "javascript:")):
495
+ return m.group(0)
496
+ if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", href):
497
+ return m.group(0)
498
+ # Only prefetch same-origin HTML pages (skip assets, anchors, etc.)
499
+ if not re.search(r"\.(?:html?|htm)$", href, re.IGNORECASE):
500
+ return m.group(0)
501
+ # Insert rel="prefetch" right after "<a " so the closing '>' and
502
+ # any trailing attributes are preserved verbatim.
503
+ return m.group(0).replace("<a ", '<a rel="prefetch" ', 1)
504
+
505
+ return tag_re.sub(_repl, html_text)
506
+
507
+
369
508
  logger = logging.getLogger(__name__)
370
509
 
371
510
 
@@ -18,16 +18,14 @@ TEMPLATE = """\
18
18
  <meta name="theme-color" content="#6366f1">
19
19
  <meta name="color-scheme" content="dark">
20
20
  <link rel="icon" href="{site_prefix}favicon.svg" type="image/svg+xml">
21
- <link rel="preconnect" href="https://fonts.googleapis.com">
22
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
23
- <link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,400..800&family=JetBrains+Mono:wght@400..700&display=swap" rel="stylesheet">
24
21
  <link rel="stylesheet" href="{site_prefix}style.css">
22
+ <link rel="stylesheet" href="{site_prefix}fonts.css" media="print" onload="this.media='all'">
25
23
  </head>
26
24
  <body>
27
25
  <!-- Header -->
28
26
  <header class="site-header">
29
27
  <button class="menu-toggle" aria-label="Toggle menu">
30
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
28
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24" aria-hidden="true">
31
29
  <path d="M3 12h18M3 6h18M3 18h18"/>
32
30
  </svg>
33
31
  </button>
@@ -65,11 +63,11 @@ TEMPLATE = """\
65
63
  </main>
66
64
 
67
65
  <!-- Back to top -->
68
- <button class="back-to-top" aria-label="Back to top">
69
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
70
- <path d="M18 15l-6-6-6 6"/>
71
- </svg>
72
- </button>
66
+ <button class="back-to-top" aria-label="Back to top">
67
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
68
+ <path d="M18 15l-6-6-6 6"/>
69
+ </svg>
70
+ </button>
73
71
 
74
72
  {search_index_inline}
75
73
  <script id="code-refs-data" type="application/json">{code_refs_json}</script>
@@ -90,7 +88,7 @@ def build_sidebar_html(current_slug, sidebar_config, ext_sections=None, config=N
90
88
  parts.extend(
91
89
  [
92
90
  '<div class="search-box">',
93
- ' <svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>',
91
+ ' <svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>',
94
92
  ' <input type="text" id="sidebar-search" placeholder="Search docs…">',
95
93
  "</div>",
96
94
  ]
@@ -99,10 +97,19 @@ def build_sidebar_html(current_slug, sidebar_config, ext_sections=None, config=N
99
97
  for section_name, pages in sidebar_config:
100
98
  key = _section_key(section_name)
101
99
  is_ext = section_name in ext_sections
100
+
101
+ # Auto-expand the section that contains the current page, and
102
+ # any section with only one page (so users can see its contents
103
+ # without clicking). If there's only one section total, expand
104
+ # it regardless.
105
+ contains_current = any(slug == current_slug for slug, _ in pages)
106
+ only_section = len(sidebar_config) == 1
107
+ collapsed = not (contains_current or only_section or len(pages) <= 1)
108
+
102
109
  parts.extend(
103
110
  (
104
111
  '<div class="sidebar-section">',
105
- f' <div class="sidebar-heading collapsed" data-section="{key}">{section_name}</div>',
112
+ f' <div class="sidebar-heading{" collapsed" if collapsed else ""}" data-section="{key}">{section_name}</div>',
106
113
  ' <ul class="sidebar-links">',
107
114
  )
108
115
  )
@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fr-docs
3
- Version: 2.1.6
3
+ Version: 2.3.0
4
4
  Summary: Fast static documentation generator with search, versioning, and zero-config setup
5
+ Author: Omena0
5
6
  License: # PolyForm Noncommercial License 1.0.0
6
7
 
7
8
  <https://polyformproject.org/licenses/noncommercial/1.0.0>
@@ -134,10 +135,91 @@ License: # PolyForm Noncommercial License 1.0.0
134
135
  **Use** means anything you do with the software requiring one
135
136
  of your licenses.
136
137
 
138
+ Project-URL: Homepage, https://omena0.dev/fr-docs/
139
+ Project-URL: Documentation, https://omena0.dev/fr-docs/
140
+ Project-URL: Repository, https://github.com/Omena0/fr-docs
141
+ Project-URL: Issues, https://github.com/Omena0/fr-docs/issues
142
+ Keywords: documentation,documentation-generator,static-site-generator,markdown,github-pages,static-site,docs
143
+ Classifier: Development Status :: 5 - Production/Stable
144
+ Classifier: Intended Audience :: Developers
145
+ Classifier: License :: OSI Approved :: MIT License
146
+ Classifier: Programming Language :: Python :: 3
147
+ Classifier: Programming Language :: Python :: 3 :: Only
148
+ Classifier: Programming Language :: Python :: 3.14
149
+ Classifier: Topic :: Documentation
150
+ Classifier: Topic :: Software Development :: Documentation
151
+ Classifier: Topic :: Software Development :: Build Tools
137
152
  Requires-Python: >=3.14
153
+ Description-Content-Type: text/markdown
138
154
  License-File: LICENSE.md
139
155
  Requires-Dist: fastpylight>=0.1.18
140
156
  Requires-Dist: markdown
141
157
  Requires-Dist: xxhash>=4.0.1
142
158
  Requires-Dist: zstandard
143
159
  Dynamic: license-file
160
+
161
+
162
+ # Fr-docs
163
+
164
+ Generate fast, fully featured, modern documentation pages from markdown for GitHub pages and static hosting.
165
+
166
+ [![Lint](https://github.com/Omena0/fr-docs/actions/workflows/lint.yml/badge.svg)](https://github.com/Omena0/fr-docs/actions/workflows/lint.yml)
167
+ [![Docs](https://github.com/Omena0/fr-docs/actions/workflows/pages.yml/badge.svg)](https://github.com/Omena0/fr-docs/actions/workflows/pages.yml)
168
+ [![PyPi](https://github.com/Omena0/fr-docs/actions/workflows/publish.yml/badge.svg)](https://github.com/Omena0/fr-docs/actions/workflows/publish.yml)
169
+ [![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)
170
+
171
+ ## Documentation
172
+
173
+ - [Getting Started](https://omena0.dev/fr-docs/)
174
+ - [Installation](https://omena0.dev/fr-docs/installation)
175
+ - [Quickstart](https://omena0.dev/fr-docs/quickstart)
176
+ - [Configuration](https://omena0.dev/fr-docs/config.json)
177
+ - [Showcase](https://omena0.dev/fr-docs/showcase)
178
+ - [Pagespeed](https://omena0.dev/fr-docs/pagespeed) — performance metrics
179
+
180
+ ## Overview
181
+
182
+ 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.
183
+
184
+ ### Key Features
185
+
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
190
+
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
+
222
+ ### Projects that use fr-docs
223
+
224
+ - [Fr-docs](https://omena0.dev/fr-docs/)
225
+ - [PyJavaBridge](https://omena0.dev/PyJavaBridge)
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = [
3
+ "setuptools>=68.0",
4
+ ]
5
+ build-backend = "setuptools.build_meta"
6
+
7
+ [project]
8
+ name = "fr-docs"
9
+ version = "2.3.0"
10
+ description = "Fast static documentation generator with search, versioning, and zero-config setup"
11
+ readme = "README.md"
12
+ requires-python = ">=3.14"
13
+ authors = [
14
+ { name = "Omena0" },
15
+ ]
16
+ keywords = [
17
+ "documentation",
18
+ "documentation-generator",
19
+ "static-site-generator",
20
+ "markdown",
21
+ "github-pages",
22
+ "static-site",
23
+ "docs",
24
+ ]
25
+ classifiers = [
26
+ "Development Status :: 5 - Production/Stable",
27
+ "Intended Audience :: Developers",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3 :: Only",
31
+ "Programming Language :: Python :: 3.14",
32
+ "Topic :: Documentation",
33
+ "Topic :: Software Development :: Documentation",
34
+ "Topic :: Software Development :: Build Tools",
35
+ ]
36
+ dependencies = [
37
+ "fastpylight>=0.1.18",
38
+ "markdown",
39
+ "xxhash>=4.0.1",
40
+ "zstandard",
41
+ ]
42
+
43
+ [project.license]
44
+ file = "LICENSE.md"
45
+
46
+ [project.urls]
47
+ Homepage = "https://omena0.dev/fr-docs/"
48
+ Documentation = "https://omena0.dev/fr-docs/"
49
+ Repository = "https://github.com/Omena0/fr-docs"
50
+ Issues = "https://github.com/Omena0/fr-docs/issues"
51
+
52
+ [project.scripts]
53
+ fr-docs = "fr_docs.build:main"
54
+
55
+ [tool.setuptools.packages.find]
56
+ include = [
57
+ "fr_docs*",
58
+ ]
@@ -1,28 +0,0 @@
1
- [build-system]
2
- requires = [
3
- "setuptools>=68.0",
4
- ]
5
- build-backend = "setuptools.build_meta"
6
-
7
- [project]
8
- name = "fr-docs"
9
- version = "2.1.6"
10
- description = "Fast static documentation generator with search, versioning, and zero-config setup"
11
- requires-python = ">=3.14"
12
- dependencies = [
13
- "fastpylight>=0.1.18",
14
- "markdown",
15
- "xxhash>=4.0.1",
16
- "zstandard",
17
- ]
18
-
19
- [project.license]
20
- file = "LICENSE.md"
21
-
22
- [project.scripts]
23
- fr-docs = "fr_docs.build:main"
24
-
25
- [tool.setuptools.packages.find]
26
- include = [
27
- "fr_docs*",
28
- ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes