fr-docs 3.1.0__tar.gz → 3.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-3.1.0 → fr_docs-3.3.0}/PKG-INFO +1 -1
  2. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/__main__.py +18 -0
  3. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/build.py +147 -1
  4. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/config.py +1 -0
  5. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/git.py +6 -8
  6. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/html_pipeline.py +2 -2
  7. fr_docs-3.3.0/fr_docs/update.py +135 -0
  8. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/utils.py +12 -1
  9. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs.egg-info/PKG-INFO +1 -1
  10. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs.egg-info/SOURCES.txt +1 -0
  11. {fr_docs-3.1.0 → fr_docs-3.3.0}/pyproject.toml +1 -1
  12. {fr_docs-3.1.0 → fr_docs-3.3.0}/LICENSE.md +0 -0
  13. {fr_docs-3.1.0 → fr_docs-3.3.0}/README.md +0 -0
  14. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/__init__.py +0 -0
  15. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/config_accessors.py +0 -0
  16. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/frontmatter.py +0 -0
  17. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/init.py +0 -0
  18. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/markdown.py +0 -0
  19. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/search.py +0 -0
  20. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/slug.py +0 -0
  21. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/syntax.py +0 -0
  22. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs/template.py +0 -0
  23. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs.egg-info/dependency_links.txt +0 -0
  24. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs.egg-info/entry_points.txt +0 -0
  25. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs.egg-info/requires.txt +0 -0
  26. {fr_docs-3.1.0 → fr_docs-3.3.0}/fr_docs.egg-info/top_level.txt +0 -0
  27. {fr_docs-3.1.0 → fr_docs-3.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fr-docs
3
- Version: 3.1.0
3
+ Version: 3.3.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
@@ -12,6 +12,7 @@ import sys
12
12
 
13
13
  from .build import main as build_main
14
14
  from .init import main as init_main
15
+ from .update import main as update_main
15
16
 
16
17
 
17
18
  def main() -> None:
@@ -69,6 +70,16 @@ def main() -> None:
69
70
  help="Initialize only the configuration or download fonts.",
70
71
  )
71
72
 
73
+ # Update command
74
+ update_parser = subparsers.add_parser(
75
+ "update",
76
+ help="Update fr-docs resources (script.js, style.css, fonts) from the repo.",
77
+ )
78
+ update_parser.add_argument(
79
+ "--config",
80
+ help="Path to the configuration JSON file.",
81
+ )
82
+
72
83
  args = parser.parse_args(sys.argv[1:])
73
84
 
74
85
  if args.command == "build":
@@ -95,6 +106,13 @@ def main() -> None:
95
106
  init_main(args.dir, args.target)
96
107
  return
97
108
 
109
+ if args.command == "update":
110
+ update_argv: list[str] = []
111
+ if args.config:
112
+ update_argv.extend(["--config", args.config])
113
+ update_main(update_argv)
114
+ return
115
+
98
116
  # Default to build for backwards compatibility.
99
117
  if len(sys.argv) > 1 and sys.argv[1] in (
100
118
  "--production",
@@ -68,6 +68,126 @@ def _best_zstd_level(raw: bytes, configured: int) -> int:
68
68
  return best_level
69
69
 
70
70
 
71
+ def _script_version_hash(script_path):
72
+ """Return (version, sha1_hex) for a script.js file.
73
+
74
+ Looks for a ``// version: <n>`` comment on the first line. If no version
75
+ is found, returns (0, sha1_of_content).
76
+ """
77
+ try:
78
+ text = script_path.read_text(encoding="utf-8")
79
+ except OSError:
80
+ return 0, ""
81
+ first_line = text.split("\n", 1)[0].strip()
82
+ m = re.match(r"//\s*version:\s*(\d+)", first_line)
83
+ version = int(m.group(1)) if m else 0
84
+ return version, xxhash.xxh3_128_hexdigest(text.encode("utf-8"))
85
+
86
+
87
+ def _git_script_versions(repo_root, script_rel_path):
88
+ """Return a list of (version, sha1) for every historical version of the
89
+ script found in the git tree.
90
+
91
+ The script's own ``// version: <n>`` comment is the source of truth;
92
+ if a blob in git history has no version comment, its sha1 is still
93
+ recorded so we can detect whether the local file matches a known
94
+ historical version.
95
+ """
96
+ versions = []
97
+ seen_blobs = set()
98
+ try:
99
+ out = subprocess.check_output(
100
+ ["git", "log", "--all", "--pretty=format:%H", "--", script_rel_path],
101
+ cwd=repo_root,
102
+ text=True,
103
+ stderr=subprocess.DEVNULL,
104
+ ).strip()
105
+ except (subprocess.CalledProcessError, OSError):
106
+ return versions
107
+
108
+ for commit in out.splitlines():
109
+ commit = commit.strip()
110
+ if not commit:
111
+ continue
112
+ try:
113
+ blob = subprocess.check_output(
114
+ ["git", "rev-parse", f"{commit}:{script_rel_path}"],
115
+ cwd=repo_root,
116
+ text=True,
117
+ stderr=subprocess.DEVNULL,
118
+ ).strip()
119
+ except (subprocess.CalledProcessError, OSError):
120
+ continue
121
+ if not blob or blob in seen_blobs:
122
+ continue
123
+ seen_blobs.add(blob)
124
+ try:
125
+ content = subprocess.check_output(
126
+ ["git", "show", blob],
127
+ cwd=repo_root,
128
+ stderr=subprocess.DEVNULL,
129
+ ).decode("utf-8", errors="replace")
130
+ except (subprocess.CalledProcessError, OSError):
131
+ continue
132
+ first_line = content.split("\n", 1)[0].strip()
133
+ m = re.match(r"//\s*version:\s*(\d+)", first_line)
134
+ ver = int(m.group(1)) if m else 0
135
+ h = xxhash.xxh3_128_hexdigest(content.encode("utf-8"))
136
+ versions.append((ver, h))
137
+
138
+ return versions
139
+
140
+
141
+ def _resolve_script_version(repo_root, script_rel_path, local_path):
142
+ """Resolve the version and content to use for script.js.
143
+
144
+ Returns (version, content_text) where content_text is the script to write
145
+ to the output directory. If the local script has no version comment but
146
+ its hash matches a known historical version, the historical version
147
+ number is used. If the remote (repo HEAD) has a higher version, the
148
+ remote content is used instead.
149
+ """
150
+ local_version, local_hash = _script_version_hash(local_path)
151
+ remote_version, remote_hash = _script_version_hash(
152
+ Path(repo_root, script_rel_path)
153
+ )
154
+
155
+ # If local has no version but matches a known historical blob, adopt
156
+ # that version number so future comparisons work.
157
+ if local_version == 0:
158
+ hist = _git_script_versions(repo_root, script_rel_path)
159
+ for ver, h in hist:
160
+ if h == local_hash and ver > 0:
161
+ local_version = ver
162
+ break
163
+
164
+ # If remote is newer, use remote content.
165
+ if remote_version > local_version:
166
+ try:
167
+ content = Path(repo_root, script_rel_path).read_text(encoding="utf-8")
168
+ return remote_version, content
169
+ except OSError:
170
+ pass
171
+
172
+ # Otherwise use local content.
173
+ try:
174
+ content = local_path.read_text(encoding="utf-8")
175
+ except OSError:
176
+ content = ""
177
+ return local_version, content
178
+
179
+
180
+ def _write_script_js(out_path, version, content):
181
+ """Write script.js with a ``// version: <n>`` first line."""
182
+ if not content:
183
+ return
184
+ first_line = content.split("\n", 1)[0].strip()
185
+ if re.match(r"//\s*version:\s*\d+", first_line):
186
+ out_path.write_text(content, encoding="utf-8")
187
+ else:
188
+ out_path.write_text(f"// version: {version}\n{content}", encoding="utf-8")
189
+
190
+
71
191
  def _minify_static_asset(config, src: Path, dst: Path, name: str) -> None:
72
192
  """Minify a static asset in-place (JS via terser, CSS via html-minifier).
73
193
 
@@ -380,6 +500,12 @@ def main(argv=None) -> None:
380
500
  print(f" Site prefix: {normalized_site_prefix(config)}")
381
501
  print()
382
502
 
503
+ # Auto-update resources if enabled
504
+ if config.get("build", {}).get("auto_update", True):
505
+ from .update import update_resources
506
+ update_resources(config)
507
+ print()
508
+
383
509
  # Collect source files for code references
384
510
  search_includes = search_include_config(config)
385
511
  if feature_enabled(config, "code_references") or (
@@ -402,7 +528,7 @@ def main(argv=None) -> None:
402
528
  "symbol_index.json",
403
529
  ):
404
530
  Path(config["_out_dir"], legacy_name).unlink(missing_ok=True)
405
- for name in ("favicon.svg", "fonts.css", "script.js", "style.css"):
531
+ for name in ("favicon.svg", "fonts.css", "style.css"):
406
532
  src = docs_path / name
407
533
  dst = Path(config["_out_dir"]) / name
408
534
 
@@ -416,6 +542,26 @@ def main(argv=None) -> None:
416
542
  except OSError as e:
417
543
  print(f"OSError: {e}")
418
544
 
545
+ # script.js: check version comment and update from repo if needed
546
+ repo_root = Path(config["_docs_dir"]).resolve().parent
547
+ script_rel = str(Path(config["_docs_dir"]).resolve().relative_to(repo_root)) + "/script.js"
548
+ local_script = docs_path / "script.js"
549
+ out_script = Path(config["_out_dir"]) / "script.js"
550
+
551
+ if local_script.exists():
552
+ version, content = _resolve_script_version(repo_root, script_rel, local_script)
553
+ if config.get("production", False):
554
+ # Minify the resolved content
555
+ _minify_static_asset(config, local_script, out_script, "script.js")
556
+ # Re-write version header on minified output
557
+ minified = out_script.read_text(encoding="utf-8")
558
+ first_line = minified.split("\n", 1)[0].strip()
559
+ if not re.match(r"//\s*version:\s*\d+", first_line):
560
+ out_script.write_text(f"// version: {version}\n{minified}", encoding="utf-8")
561
+ else:
562
+ _write_script_js(out_script, version, content)
563
+ print(f" script.js: version {version}")
564
+
419
565
  # Copy locally-fetched fonts (if present) so the site doesn't need
420
566
  # to reach out to fonts.gstatic.com. fonts.css is minified along
421
567
  # with style.css below.
@@ -28,6 +28,7 @@ DEFAULT_CONFIG = {
28
28
  "zstd_level": 22,
29
29
  "search_index_filename": "search_index.zst",
30
30
  "git_meta_filename": "git_meta.zst",
31
+ "auto_update": True,
31
32
  },
32
33
  "versioning": {
33
34
  "commit_message_pattern": r"^\s*([0-9]+[A-Za-z])\s*[-:—–]\s*(.+)",
@@ -86,9 +86,8 @@ def _build_slug_last_commits(slugs, config, repo_root):
86
86
  out = subprocess.check_output(
87
87
  ["git", "log", "-1", "--pretty=format:%H", "--", src],
88
88
  cwd=repo_root,
89
- text=True,
90
89
  stderr=subprocess.DEVNULL,
91
- ).strip()
90
+ ).decode("utf-8", errors="replace").strip()
92
91
 
93
92
  if out and re.fullmatch(r"[0-9a-f]{7,40}", out):
94
93
  slug_last_commit[slug] = out
@@ -114,8 +113,8 @@ def collect_git_metadata(config):
114
113
  remote_url = subprocess.check_output(
115
114
  ["git", "remote", "get-url", "origin"],
116
115
  cwd=repo_root,
117
- text=True,
118
- ).strip()
116
+ stderr=subprocess.DEVNULL,
117
+ ).decode("utf-8", errors="replace").strip()
119
118
 
120
119
  if m := re.search(r"github.com[:/](.+?)(?:\.git)?$", remote_url):
121
120
  git_meta["repo"] = m[1]
@@ -123,8 +122,8 @@ def collect_git_metadata(config):
123
122
  log_out = subprocess.check_output(
124
123
  ["git", "log", "--pretty=format:%H\x01%s", "--reverse"],
125
124
  cwd=repo_root,
126
- text=True,
127
- )
125
+ stderr=subprocess.DEVNULL,
126
+ ).decode("utf-8", errors="replace")
128
127
 
129
128
  commits, versions = _parse_commit_log(log_out)
130
129
  git_meta["commits"] = commits
@@ -152,9 +151,8 @@ def collect_git_metadata(config):
152
151
  ]
153
152
  + [src_map_path(config).replace("{slug}", s) for s in slugs],
154
153
  cwd=repo_root,
155
- text=True,
156
154
  stderr=subprocess.DEVNULL,
157
- )
155
+ ).decode("utf-8", errors="replace")
158
156
 
159
157
  git_meta["pages_by_commit"] = _build_pages_by_commit(out, slugs)
160
158
  git_meta["slug_last_commit"] = _build_slug_last_commits(slugs, config, repo_root)
@@ -43,7 +43,7 @@ from .syntax import (
43
43
  process_blockquotes,
44
44
  )
45
45
  from .template import TEMPLATE, build_toc_sidebar
46
- from .utils import normalized_site_prefix, output_href
46
+ from .utils import normalized_site_prefix, output_href, output_asset_href
47
47
 
48
48
 
49
49
  def _determine_tagged_sections(config):
@@ -128,7 +128,7 @@ def _get_search_preloads_html():
128
128
  def _render_template_placeholders(config):
129
129
  """Extract common template placeholders from config."""
130
130
  return {
131
- "site_prefix": output_href("", config),
131
+ "site_prefix": output_asset_href("", config),
132
132
  "page_title": "",
133
133
  "project_name": project_name(config),
134
134
  "og_title": "",
@@ -0,0 +1,135 @@
1
+ """Update fr-docs resources (script.js, style.css, fonts) from the repo."""
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .config import load_config
10
+ from .config_accessors import out_dir, project_name, src_dir as config_src_dir
11
+ from .build import (
12
+ _resolve_script_version,
13
+ _write_script_js,
14
+ _minify_static_asset,
15
+ _script_version_hash,
16
+ )
17
+
18
+
19
+ def _fetch_remote(repo_root, rel_path):
20
+ """Fetch a file from the fr-docs repo via git show HEAD:path."""
21
+ try:
22
+ return subprocess.check_output(
23
+ ["git", "show", f"HEAD:{rel_path}"],
24
+ cwd=repo_root,
25
+ stderr=subprocess.DEVNULL,
26
+ ).decode("utf-8", errors="replace")
27
+ except (subprocess.CalledProcessError, OSError):
28
+ return None
29
+
30
+
31
+ def _git_current_commit(repo_root):
32
+ try:
33
+ return subprocess.check_output(
34
+ ["git", "rev-parse", "HEAD"],
35
+ cwd=repo_root,
36
+ stderr=subprocess.DEVNULL,
37
+ ).decode("utf-8", errors="replace").strip()
38
+ except (subprocess.CalledProcessError, OSError):
39
+ return None
40
+
41
+
42
+ def _git_remote_url(repo_root):
43
+ try:
44
+ return subprocess.check_output(
45
+ ["git", "remote", "get-url", "origin"],
46
+ cwd=repo_root,
47
+ stderr=subprocess.DEVNULL,
48
+ ).decode("utf-8", errors="replace").strip()
49
+ except (subprocess.CalledProcessError, OSError):
50
+ return None
51
+
52
+
53
+ def _is_fr_docs_repo(repo_root):
54
+ """Return True if the repo is the fr-docs repo itself."""
55
+ url = _git_remote_url(repo_root) or ""
56
+ return "Omena0/fr-docs" in url or "omena0/fr-docs" in url.lower()
57
+
58
+
59
+ def update_resources(config):
60
+ """Update script.js, style.css, favicon.svg, and fonts from the fr-docs repo.
61
+
62
+ Only runs when the current repo IS the fr-docs repo (so we can read
63
+ the source files from git). For downstream repos, the resources are
64
+ pulled from PyPI via the `fr-docs` package or copied from the docs/
65
+ directory.
66
+ """
67
+ docs_path = Path(config["_docs_dir"]).resolve()
68
+ repo_root = docs_path.parent
69
+ if not _is_fr_docs_repo(repo_root):
70
+ print(" Not the fr-docs repo; skipping remote resource update.")
71
+ return
72
+
73
+ resources = ["script.js", "style.css", "favicon.svg"]
74
+ updated = []
75
+ for name in resources:
76
+ rel = f"docs/{name}"
77
+ remote = _fetch_remote(repo_root, rel)
78
+ if remote is None:
79
+ continue
80
+ local_path = docs_path / name
81
+ if name == "script.js":
82
+ version, content = _resolve_script_version(repo_root, rel, local_path)
83
+ _write_script_js(local_path, version, content)
84
+ updated.append(f"{name} (v{version})")
85
+ else:
86
+ local_path.write_text(remote, encoding="utf-8")
87
+ updated.append(name)
88
+
89
+ # Update fonts
90
+ fonts_dir = docs_path / "fonts"
91
+ if fonts_dir.is_dir():
92
+ remote_fonts = _fetch_remote(repo_root, "docs/fonts")
93
+ # Can't fetch a directory; instead list remote files via git ls-tree
94
+ try:
95
+ out = subprocess.check_output(
96
+ ["git", "ls-tree", "-r", "--name-only", "HEAD", "docs/fonts"],
97
+ cwd=repo_root,
98
+ stderr=subprocess.DEVNULL,
99
+ ).decode("utf-8", errors="replace")
100
+ for line in out.splitlines():
101
+ fname = Path(line).name
102
+ content = _fetch_remote(repo_root, line)
103
+ if content is not None:
104
+ (fonts_dir / fname).write_text(content, encoding="utf-8")
105
+ updated.append(f"fonts/{fname}")
106
+ except (subprocess.CalledProcessError, OSError):
107
+ pass
108
+
109
+ if updated:
110
+ print(f" Updated: {', '.join(updated)}")
111
+ else:
112
+ print(" No resources updated.")
113
+
114
+
115
+ def main(argv=None) -> None:
116
+ import argparse
117
+
118
+ parser = argparse.ArgumentParser(
119
+ prog="fr-docs update",
120
+ description="Update fr-docs resources (script.js, style.css, fonts) from the repo.",
121
+ )
122
+ parser.add_argument(
123
+ "--config",
124
+ help="Path to the configuration JSON file.",
125
+ )
126
+ args = parser.parse_args(argv)
127
+
128
+ config = load_config(args.config)
129
+ print(f"🔄 Updating {project_name(config)} resources...")
130
+ update_resources(config)
131
+ print("✅ Update complete.")
132
+
133
+
134
+ if __name__ == "__main__":
135
+ main()
@@ -21,4 +21,15 @@ def output_href(path, config):
21
21
 
22
22
 
23
23
  def output_site_prefix(config: dict) -> str:
24
- return normalized_site_prefix(config) if config.get("production", False) else ""
24
+ return normalized_site_prefix(config) if config.get("production", False) else "/"
25
+
26
+
27
+ def output_asset_href(path, config):
28
+ """Return a root-relative href for a static asset.
29
+
30
+ Static assets (CSS, JS, favicon, fonts) must resolve from any page
31
+ depth, so they always use a root-relative path (e.g. ``/style.css``).
32
+ """
33
+ clean = str(path or "").lstrip("/")
34
+ prefix = normalized_site_prefix(config)
35
+ return prefix + clean if prefix else "/" + clean
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fr-docs
3
- Version: 3.1.0
3
+ Version: 3.3.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
@@ -15,6 +15,7 @@ fr_docs/search.py
15
15
  fr_docs/slug.py
16
16
  fr_docs/syntax.py
17
17
  fr_docs/template.py
18
+ fr_docs/update.py
18
19
  fr_docs/utils.py
19
20
  fr_docs.egg-info/PKG-INFO
20
21
  fr_docs.egg-info/SOURCES.txt
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "fr-docs"
9
- version = "3.1.0"
9
+ version = "3.3.0"
10
10
  description = "Fast static documentation generator with search, versioning, and zero-config setup"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.14"
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