fr-docs 2.1.6__tar.gz → 2.2.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.
- {fr_docs-2.1.6 → fr_docs-2.2.0}/PKG-INFO +54 -1
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/build.py +92 -7
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/html_pipeline.py +189 -48
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/template.py +18 -11
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs.egg-info/PKG-INFO +54 -1
- fr_docs-2.2.0/pyproject.toml +58 -0
- fr_docs-2.1.6/pyproject.toml +0 -28
- {fr_docs-2.1.6 → fr_docs-2.2.0}/LICENSE.md +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/README.md +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/__init__.py +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/__main__.py +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/config.py +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/config_accessors.py +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/frontmatter.py +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/git.py +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/init.py +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/markdown.py +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/search.py +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/slug.py +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/syntax.py +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs/utils.py +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs.egg-info/SOURCES.txt +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs.egg-info/dependency_links.txt +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs.egg-info/entry_points.txt +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs.egg-info/requires.txt +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/fr_docs.egg-info/top_level.txt +0 -0
- {fr_docs-2.1.6 → fr_docs-2.2.0}/setup.cfg +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fr-docs
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.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,62 @@ 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
|
+
[](https://github.com/Omena0/fr-docs/actions/workflows/lint.yml)
|
|
167
|
+
[](https://github.com/Omena0/fr-docs/actions/workflows/pages.yml)
|
|
168
|
+
[](https://github.com/Omena0/fr-docs/actions/workflows/publish.yml)
|
|
169
|
+
[](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
|
+
|
|
179
|
+
## Overview
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
### Key Features
|
|
184
|
+
|
|
185
|
+
- **Config-driven**: All settings defined in `config.json`
|
|
186
|
+
- **Zero-config setup**: Works out of the box with sensible defaults
|
|
187
|
+
- **Search**: Full-text search with Zstandard-compressed index
|
|
188
|
+
- **Optimized**: HTML minification, critical CSS/JS inlining, Zstd compression
|
|
189
|
+
- **Multi-environment**: Different configs for production, staging, development
|
|
190
|
+
- **Parallel builds**: Threaded page generation for faster builds
|
|
191
|
+
- **Git integration**: Automatic metadata extraction from git commits
|
|
192
|
+
|
|
193
|
+
### Projects that use fr-docs
|
|
194
|
+
|
|
195
|
+
- [Fr-docs](https://omena0.dev/fr-docs/)
|
|
196
|
+
- [PyJavaBridge](https://omena0.dev/PyJavaBridge)
|
|
@@ -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(
|
|
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
|
-
|
|
417
|
-
|
|
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(
|
|
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,47 @@ 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(
|
|
640
|
+
f" ✓ Symlinked {len(list(prefix_dir.iterdir()))} files into {prefix}/"
|
|
641
|
+
)
|
|
642
|
+
except OSError as e:
|
|
643
|
+
print(f" ✗ Failed to create {prefix}/ symlinks: {e}")
|
|
644
|
+
|
|
560
645
|
print(f"\n✅ Built {built} pages")
|
|
561
646
|
|
|
562
647
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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,187 @@ 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 = (
|
|
358
|
+
site_prefix.rstrip("/") if site_prefix and site_prefix != "/" else None
|
|
359
|
+
)
|
|
360
|
+
backed_up = []
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
if stripped_prefix:
|
|
364
|
+
# Match both quoted (href="/fr-docs/style.css") and unquoted
|
|
365
|
+
# (href=/fr-docs/style.css) forms. The minifier strips quotes,
|
|
366
|
+
# so both must be supported.
|
|
367
|
+
prefix_pat = re.compile(
|
|
368
|
+
rf'href=(["\']?){re.escape(stripped_prefix)}/([^"\'\s>]+)\1'
|
|
369
|
+
)
|
|
370
|
+
for html_file in out_dir.glob("*.html"):
|
|
371
|
+
raw = html_file.read_text(encoding="utf-8")
|
|
372
|
+
backup = out_dir / f".critical_orig_{html_file.stem}.html"
|
|
373
|
+
backup.write_text(raw, encoding="utf-8")
|
|
374
|
+
backed_up.append(backup)
|
|
375
|
+
# Strip the site prefix so href=/fr-docs/style.css
|
|
376
|
+
# becomes href=style.css (relative to the HTML file).
|
|
377
|
+
rewritten = prefix_pat.sub(
|
|
378
|
+
lambda m: f"href={m.group(1)}{m.group(2)}{m.group(1)}",
|
|
379
|
+
raw,
|
|
380
|
+
)
|
|
381
|
+
html_file.write_text(rewritten, encoding="utf-8")
|
|
382
|
+
|
|
383
|
+
# Run critical per-file with the static engine (the only engine
|
|
384
|
+
# that resolves relative stylesheet URLs from a single file).
|
|
385
|
+
# Use --dimensions to render for both mobile (390x844, Moto G
|
|
386
|
+
# Power) and desktop (1920x1080) so the inlined critical CSS
|
|
387
|
+
# includes the @media(width<=640px) rules that mobile needs.
|
|
388
|
+
for html_file in out_dir.glob("*.html"):
|
|
389
|
+
cmd = [
|
|
390
|
+
"npx",
|
|
391
|
+
"critical",
|
|
392
|
+
str(html_file),
|
|
393
|
+
"--inline",
|
|
394
|
+
"--engine",
|
|
395
|
+
"static",
|
|
396
|
+
"--dimensions",
|
|
397
|
+
"390x844,1920x1080",
|
|
398
|
+
"--width",
|
|
399
|
+
"1920",
|
|
400
|
+
"--height",
|
|
401
|
+
"1080",
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
result = subprocess.run(
|
|
405
|
+
cmd, capture_output=True, text=True, check=False, cwd=str(out_dir)
|
|
406
|
+
)
|
|
407
|
+
if result.returncode != 0:
|
|
408
|
+
raise RuntimeError(
|
|
409
|
+
f"Critical failed for {html_file.name}:\n{result.stderr}"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
optimized = result.stdout
|
|
413
|
+
if "</html>" not in optimized:
|
|
414
|
+
raise RuntimeError(
|
|
415
|
+
f"Critical output for {html_file.name} looks truncated"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
html_file.write_text(optimized, encoding="utf-8")
|
|
419
|
+
|
|
420
|
+
finally:
|
|
421
|
+
# Restore the original absolute-path HTML for any page critical
|
|
422
|
+
# didn't inline (e.g. it failed or skipped).
|
|
423
|
+
for backup in backed_up:
|
|
424
|
+
target = out_dir / f"{backup.stem.replace('.critical_orig_', '')}.html"
|
|
425
|
+
if target.exists() and backup.exists():
|
|
426
|
+
try:
|
|
427
|
+
target_content = target.read_text(encoding="utf-8")
|
|
428
|
+
# Only restore if critical didn't inline CSS
|
|
429
|
+
if "data-critical" not in target_content:
|
|
430
|
+
target.write_text(
|
|
431
|
+
backup.read_text(encoding="utf-8"),
|
|
432
|
+
encoding="utf-8",
|
|
433
|
+
)
|
|
434
|
+
except OSError:
|
|
435
|
+
pass
|
|
436
|
+
try:
|
|
437
|
+
backup.unlink()
|
|
438
|
+
except OSError:
|
|
439
|
+
pass
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def minify_all_pages(config):
|
|
443
|
+
"""Minify every production HTML page after critical has inlined CSS."""
|
|
444
|
+
if not config.get("production", False):
|
|
445
|
+
return
|
|
446
|
+
if not cfg_minify_html(config):
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
out_dir = Path(config["_out_dir"]).resolve()
|
|
450
|
+
if not out_dir.is_dir():
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
for html_file in sorted(out_dir.glob("*.html")):
|
|
454
|
+
try:
|
|
455
|
+
raw = html_file.read_text(encoding="utf-8")
|
|
456
|
+
minified = minify_html(raw, config)
|
|
457
|
+
html_file.write_text(minified, encoding="utf-8")
|
|
458
|
+
except Exception as e: # noqa: BLE001
|
|
459
|
+
logger.warning(f"Failed to minify {html_file.name}: {e}")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def add_internal_prefetch_links(html_text, config):
|
|
463
|
+
"""Add rel="prefetch" to every internal page link in production HTML.
|
|
464
|
+
|
|
465
|
+
Prefetching the whole site up front is cheap here (a handful of small
|
|
466
|
+
pages) and makes navigation instant. We deliberately avoid
|
|
467
|
+
<link rel="preload" as="document">: Chrome's preload scanner rejects
|
|
468
|
+
dynamically-injected document preloads, and rel="prefetch" is the
|
|
469
|
+
correct hint for "fetch this page for later navigation".
|
|
470
|
+
"""
|
|
471
|
+
if not config.get("production", False):
|
|
472
|
+
return html_text
|
|
473
|
+
|
|
474
|
+
href_re = re.compile(
|
|
475
|
+
r'href\s*=\s*(?:"(?P<q1>[^"]*)"|\'(?P<q2>[^\']*)\'|(?P<uq>[^\s>]+))'
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Match the opening <a ...> tag only. attrs must not contain a bare '>'
|
|
479
|
+
# (quoted values are allowed), so the match always stops at the first
|
|
480
|
+
# unquoted '>', which is exactly the closing '>' of the <a> opening
|
|
481
|
+
# tag. This is safe because an opening <a> tag cannot legitimately
|
|
482
|
+
# contain another start tag.
|
|
483
|
+
tag_re = re.compile(
|
|
484
|
+
r'<a\s+(?P<attrs>(?:[^>"\']|"[^"]*"|\'[^\']*\')*)>',
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def _repl(m):
|
|
488
|
+
attrs = m.group("attrs")
|
|
489
|
+
# Skip links that already declare a rel attribute
|
|
490
|
+
if re.search(r"\brel\s*=", attrs, re.IGNORECASE):
|
|
491
|
+
return m.group(0)
|
|
492
|
+
hm = href_re.search(attrs)
|
|
493
|
+
if not hm:
|
|
494
|
+
return m.group(0)
|
|
495
|
+
href = hm.group("q1") or hm.group("q2") or hm.group("uq") or ""
|
|
496
|
+
if not href or href.startswith(("#", "mailto:", "tel:", "javascript:")):
|
|
497
|
+
return m.group(0)
|
|
498
|
+
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", href):
|
|
499
|
+
return m.group(0)
|
|
500
|
+
# Only prefetch same-origin HTML pages (skip assets, anchors, etc.)
|
|
501
|
+
if not re.search(r"\.(?:html?|htm)$", href, re.IGNORECASE):
|
|
502
|
+
return m.group(0)
|
|
503
|
+
# Insert rel="prefetch" right after "<a " so the closing '>' and
|
|
504
|
+
# any trailing attributes are preserved verbatim.
|
|
505
|
+
return m.group(0).replace("<a ", '<a rel="prefetch" ', 1)
|
|
506
|
+
|
|
507
|
+
return tag_re.sub(_repl, html_text)
|
|
508
|
+
|
|
509
|
+
|
|
369
510
|
logger = logging.getLogger(__name__)
|
|
370
511
|
|
|
371
512
|
|
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
3
|
+
Version: 2.2.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,62 @@ 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
|
+
[](https://github.com/Omena0/fr-docs/actions/workflows/lint.yml)
|
|
167
|
+
[](https://github.com/Omena0/fr-docs/actions/workflows/pages.yml)
|
|
168
|
+
[](https://github.com/Omena0/fr-docs/actions/workflows/publish.yml)
|
|
169
|
+
[](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
|
+
|
|
179
|
+
## Overview
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
### Key Features
|
|
184
|
+
|
|
185
|
+
- **Config-driven**: All settings defined in `config.json`
|
|
186
|
+
- **Zero-config setup**: Works out of the box with sensible defaults
|
|
187
|
+
- **Search**: Full-text search with Zstandard-compressed index
|
|
188
|
+
- **Optimized**: HTML minification, critical CSS/JS inlining, Zstd compression
|
|
189
|
+
- **Multi-environment**: Different configs for production, staging, development
|
|
190
|
+
- **Parallel builds**: Threaded page generation for faster builds
|
|
191
|
+
- **Git integration**: Automatic metadata extraction from git commits
|
|
192
|
+
|
|
193
|
+
### Projects that use fr-docs
|
|
194
|
+
|
|
195
|
+
- [Fr-docs](https://omena0.dev/fr-docs/)
|
|
196
|
+
- [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.2.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
|
+
]
|
fr_docs-2.1.6/pyproject.toml
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|