fr-docs 2.5.0__tar.gz → 3.1.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.5.0 → fr_docs-3.1.0}/PKG-INFO +1 -1
  2. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/__main__.py +1 -1
  3. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/build.py +19 -9
  4. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/git.py +27 -4
  5. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/html_pipeline.py +23 -13
  6. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/markdown.py +12 -6
  7. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/search.py +2 -2
  8. fr_docs-3.1.0/fr_docs/slug.py +79 -0
  9. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/template.py +2 -2
  10. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs.egg-info/PKG-INFO +1 -1
  11. {fr_docs-2.5.0 → fr_docs-3.1.0}/pyproject.toml +1 -1
  12. fr_docs-2.5.0/fr_docs/slug.py +0 -71
  13. {fr_docs-2.5.0 → fr_docs-3.1.0}/LICENSE.md +0 -0
  14. {fr_docs-2.5.0 → fr_docs-3.1.0}/README.md +0 -0
  15. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/__init__.py +0 -0
  16. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/config.py +0 -0
  17. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/config_accessors.py +0 -0
  18. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/frontmatter.py +0 -0
  19. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/init.py +0 -0
  20. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/syntax.py +0 -0
  21. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs/utils.py +0 -0
  22. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs.egg-info/SOURCES.txt +0 -0
  23. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs.egg-info/dependency_links.txt +0 -0
  24. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs.egg-info/entry_points.txt +0 -0
  25. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs.egg-info/requires.txt +0 -0
  26. {fr_docs-2.5.0 → fr_docs-3.1.0}/fr_docs.egg-info/top_level.txt +0 -0
  27. {fr_docs-2.5.0 → fr_docs-3.1.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fr-docs
3
- Version: 2.5.0
3
+ Version: 3.1.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
@@ -111,4 +111,4 @@ def main() -> None:
111
111
 
112
112
 
113
113
  if __name__ == "__main__":
114
- main()
114
+ main()
@@ -626,12 +626,25 @@ def main(argv=None) -> None:
626
626
  if prefix_dir.is_symlink() or prefix_dir.exists():
627
627
  shutil.rmtree(prefix_dir, ignore_errors=True)
628
628
  prefix_dir.mkdir(parents=True, exist_ok=True)
629
- for f in sorted(Path(config["_out_dir"]).iterdir()):
630
- if f.is_file() and not f.name.startswith("."):
631
- (prefix_dir / f.name).symlink_to(f.resolve())
632
- print(
633
- f" ✓ Symlinked {len(list(prefix_dir.iterdir()))} files into {prefix}/"
634
- )
629
+ symlinked = 0
630
+ for f in sorted(Path(config["_out_dir"]).rglob("*")):
631
+ if not f.is_file() or f.name.startswith("."):
632
+ continue
633
+ # Skip files inside the prefix directory itself
634
+ try:
635
+ f.relative_to(prefix_dir)
636
+ continue
637
+ except ValueError:
638
+ pass
639
+ rel = f.relative_to(config["_out_dir"])
640
+ link_path = prefix_dir / rel
641
+ link_path.parent.mkdir(parents=True, exist_ok=True)
642
+ if link_path.is_symlink() or link_path.exists():
643
+ with contextlib.suppress(OSError):
644
+ link_path.unlink()
645
+ link_path.symlink_to(f.resolve())
646
+ symlinked += 1
647
+ print(f" ✓ Symlinked {symlinked} files into {prefix}/")
635
648
  except OSError as e:
636
649
  print(f" ✗ Failed to create {prefix}/ symlinks: {e}")
637
650
 
@@ -646,6 +659,3 @@ def get_all_slugs(config):
646
659
 
647
660
  return slugs
648
661
 
649
-
650
- if __name__ == "__main__":
651
- main()
@@ -38,7 +38,13 @@ def _parse_commit_log(log_out):
38
38
 
39
39
 
40
40
  def _build_pages_by_commit(out, slugs):
41
- """Build pages_by_commit mapping from git log output."""
41
+ """Build pages_by_commit mapping from git log output.
42
+
43
+ Each slug in ``slugs`` is a normalized slug like ``core/entity``. The
44
+ git log ``--name-only`` output lists file paths like
45
+ ``docs/src/core/entity.md``; we strip the configured source prefix
46
+ and ``.md`` suffix to recover the slug.
47
+ """
42
48
  pages_by_commit = {}
43
49
  current_commit = None
44
50
  for line in out.splitlines():
@@ -49,9 +55,26 @@ def _build_pages_by_commit(out, slugs):
49
55
  if re.fullmatch(r"[0-9a-f]{7,40}", line):
50
56
  current_commit = line
51
57
  elif current_commit:
52
- slug = Path(line).stem
53
- if slug in slugs:
54
- pages_by_commit.setdefault(current_commit, []).append(slug)
58
+ # Recover the slug from the file path: strip dir prefix and .md
59
+ slug = line
60
+ if slug.endswith(".md"):
61
+ slug = slug[:-3]
62
+ # Try to match against known slugs (handles prefix variations)
63
+ matched = None
64
+ for s in slugs:
65
+ src_path = s + ".md"
66
+ if line.endswith(src_path) or line.endswith(f"/{src_path}") or line == src_path:
67
+ matched = s
68
+ break
69
+ if matched is None:
70
+ # Fall back to basename matching for flat layouts
71
+ base = Path(line).stem
72
+ for s in slugs:
73
+ if s.rsplit("/", 1)[-1] == base:
74
+ matched = s
75
+ break
76
+ if matched is not None:
77
+ pages_by_commit.setdefault(current_commit, []).append(matched)
55
78
  return pages_by_commit
56
79
 
57
80
 
@@ -35,7 +35,7 @@ from .markdown import (
35
35
  rewrite_md_links,
36
36
  )
37
37
  from .search import search_include_config
38
- from .slug import slug_output_name
38
+ from .slug import relative_slug_path, slug_output_name
39
39
  from .syntax import (
40
40
  URL_ATTR_RE,
41
41
  format_custom_tags,
@@ -282,7 +282,7 @@ def _render_backlinks_and_related(body_md, body_html, slug, config, search_index
282
282
  and page_data.get("backlinks")
283
283
  and not no_backlinks
284
284
  ):
285
- backlinks_html = _render_backlinks(page_data["backlinks"], search_index)
285
+ backlinks_html = _render_backlinks(page_data["backlinks"], search_index, slug)
286
286
  if has_backlinks_tag:
287
287
  body_html = body_html.replace("<p><backlinks></p>", backlinks_html)
288
288
  body_html = body_html.replace("<p><backlinks></p>\n", backlinks_html)
@@ -293,7 +293,7 @@ def _render_backlinks_and_related(body_md, body_html, slug, config, search_index
293
293
  and page_data.get("related")
294
294
  and not no_related
295
295
  ):
296
- related_html = _render_related(page_data["related"], search_index)
296
+ related_html = _render_related(page_data["related"], search_index, slug)
297
297
  if has_related_tag:
298
298
  body_html = body_html.replace("<p><related></p>", related_html)
299
299
  body_html = body_html.replace("<p><related></p>\n", related_html)
@@ -332,7 +332,7 @@ def build_page(slug, config, slug_page_keys) -> None:
332
332
  body_html = rewrite_md_links(body_html, slug, slug_page_keys)
333
333
 
334
334
  if feature_enabled(config, "auto_link"):
335
- body_html = auto_link_filenames(body_html, config.get("_slug_page_keys", {}))
335
+ body_html = auto_link_filenames(body_html, config.get("_slug_page_keys", {}), slug)
336
336
 
337
337
  code_refs = []
338
338
  if feature_enabled(config, "code_references"):
@@ -378,6 +378,7 @@ def build_page(slug, config, slug_page_keys) -> None:
378
378
 
379
379
  out_name = slug_output_name(slug, config)
380
380
  out_path = Path(config["_out_dir"], out_name)
381
+ out_path.parent.mkdir(parents=True, exist_ok=True)
381
382
  out_path.write_text(out_html, encoding="utf-8")
382
383
 
383
384
 
@@ -389,9 +390,11 @@ def _strip_site_prefix(out_dir, stripped_prefix, backed_up):
389
390
  prefix_pat = re.compile(
390
391
  rf'href=(["\']?){re.escape(stripped_prefix)}/([^"\'\s>]+)\1'
391
392
  )
392
- for html_file in out_dir.glob("*.html"):
393
+ for html_file in out_dir.rglob("*.html"):
394
+ if html_file.name.startswith("."):
395
+ continue
393
396
  raw = html_file.read_text(encoding="utf-8")
394
- backup = out_dir / f".critical_orig_{html_file.stem}.html"
397
+ backup = html_file.parent / f".critical_orig_{html_file.stem}.html"
395
398
  backup.write_text(raw, encoding="utf-8")
396
399
  backed_up.append(backup)
397
400
  rewritten = prefix_pat.sub(
@@ -436,7 +439,8 @@ def _run_critical_on_file(html_file, out_dir):
436
439
  def _restore_backups(out_dir, backed_up):
437
440
  """Restore original HTML for pages critical didn't inline, and clean up backups."""
438
441
  for backup in backed_up:
439
- target = out_dir / f"{backup.stem.replace('.critical_orig_', '')}.html"
442
+ # Backup is stored alongside the original: <dir>/.critical_orig_<stem>.html
443
+ target = backup.parent / f"{backup.stem.replace('.critical_orig_', '')}.html"
440
444
  if target.exists() and backup.exists():
441
445
  target_content = target.read_text(encoding="utf-8")
442
446
  if "data-critical" not in target_content:
@@ -478,7 +482,9 @@ def optimize_all_pages(config) -> None:
478
482
 
479
483
  try:
480
484
  _strip_site_prefix(out_dir, stripped_prefix, backed_up)
481
- for html_file in out_dir.glob("*.html"):
485
+ for html_file in out_dir.rglob("*.html"):
486
+ if html_file.name.startswith("."):
487
+ continue
482
488
  _run_critical_on_file(html_file, out_dir)
483
489
  finally:
484
490
  _restore_backups(out_dir, backed_up)
@@ -495,7 +501,9 @@ def minify_all_pages(config) -> None:
495
501
  if not out_dir.is_dir():
496
502
  return
497
503
 
498
- for html_file in sorted(out_dir.glob("*.html")):
504
+ for html_file in sorted(out_dir.rglob("*.html")):
505
+ if html_file.name.startswith("."):
506
+ continue
499
507
  try:
500
508
  raw = html_file.read_text(encoding="utf-8")
501
509
  minified = minify_html(raw, config)
@@ -555,7 +563,7 @@ def add_internal_prefetch_links(html_text, config):
555
563
  logger = logging.getLogger(__name__)
556
564
 
557
565
 
558
- def _render_backlinks(backlinks, search_index) -> str:
566
+ def _render_backlinks(backlinks, search_index, current_slug=""):
559
567
  """Render backlinks HTML."""
560
568
  if not backlinks:
561
569
  return ""
@@ -564,9 +572,9 @@ def _render_backlinks(backlinks, search_index) -> str:
564
572
  if page := next((p for p in search_index if p.get("slug") == bl_slug), None):
565
573
  url = page.get("url", f"{bl_slug}.html")
566
574
  title = page.get("title", bl_slug)
575
+ if not url.startswith("http"):
576
+ url = relative_slug_path(current_slug, bl_slug)
567
577
  items.append(f'<li><a href="{url}">{title}</a></li>')
568
- if not items:
569
- return ""
570
578
  return (
571
579
  f"<h2>Backlinks</h2>"
572
580
  f'<details class="backlinks-details">'
@@ -580,7 +588,7 @@ def _render_backlinks(backlinks, search_index) -> str:
580
588
  )
581
589
 
582
590
 
583
- def _render_related(related, search_index) -> str:
591
+ def _render_related(related, search_index, current_slug=""):
584
592
  """Render related pages HTML."""
585
593
  if not related:
586
594
  return ""
@@ -589,6 +597,8 @@ def _render_related(related, search_index) -> str:
589
597
  if page := next((p for p in search_index if p.get("slug") == rel_slug), None):
590
598
  url = page.get("url", f"{rel_slug}.html")
591
599
  title = page.get("title", rel_slug)
600
+ if not url.startswith("http"):
601
+ url = relative_slug_path(current_slug, rel_slug)
592
602
  items.append(f'<li><a href="{url}">{title}</a></li>')
593
603
 
594
604
  if not items:
@@ -12,6 +12,7 @@ from markdown.extensions.tables import TableExtension
12
12
 
13
13
  from .slug import (
14
14
  normalize_slug,
15
+ relative_slug_path,
15
16
  slug_output_name,
16
17
  )
17
18
  from .syntax import URL_ATTR_RE
@@ -91,7 +92,12 @@ def convert_markdown(text):
91
92
 
92
93
 
93
94
  def resolve_md_target(md_target, current_slug, slug_page_keys):
94
- """Resolve a markdown link target to an output HTML filename."""
95
+ """Resolve a markdown link target to a page-relative output HTML filename.
96
+
97
+ Returns a path relative to the current page (e.g. ``../index.html`` from
98
+ ``core/entity.html`` to ``index.html``) so links work at any depth in the
99
+ directory tree.
100
+ """
95
101
  raw = str(md_target or "").strip()
96
102
  if not raw:
97
103
  return raw
@@ -116,9 +122,9 @@ def resolve_md_target(md_target, current_slug, slug_page_keys):
116
122
 
117
123
  for candidate in candidates:
118
124
  if candidate in slug_page_keys:
119
- return slug_output_name(candidate)
125
+ return relative_slug_path(current_slug, candidate)
120
126
 
121
- return slug_output_name(normalize_slug(raw))
127
+ return relative_slug_path(current_slug, normalize_slug(raw))
122
128
 
123
129
 
124
130
  def rewrite_md_links(html_text, current_slug, slug_page_keys):
@@ -139,7 +145,7 @@ def rewrite_md_links(html_text, current_slug, slug_page_keys):
139
145
  )
140
146
 
141
147
 
142
- def auto_link_filenames(html_text, slug_page_keys):
148
+ def auto_link_filenames(html_text, slug_page_keys, current_slug=""):
143
149
  """Auto-link bare filename references like `config.json` to their .md pages.
144
150
 
145
151
  Only processes filenames INSIDE inline code tags (<code>...</code>).
@@ -148,12 +154,12 @@ def auto_link_filenames(html_text, slug_page_keys):
148
154
  if not slug_page_keys:
149
155
  return html_text
150
156
 
151
- # Build a map of filename (without .md) -> output HTML file
157
+ # Build a map of filename (without .md) -> page-relative output HTML file
152
158
  filename_map = {}
153
159
  for slug in slug_page_keys:
154
160
  filename = slug.rsplit("/", 1)[-1]
155
161
  if filename:
156
- output = slug_output_name(slug)
162
+ output = relative_slug_path(current_slug, slug)
157
163
  filename_map[filename.lower()] = output
158
164
  filename_map[f"{filename}.md".lower()] = output
159
165
 
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  from .config_accessors import feature_enabled
7
7
  from .frontmatter import parse_frontmatter
8
- from .slug import normalize_slug, slug_output_name, slug_page_key
8
+ from .slug import normalize_slug, relative_slug_path, slug_output_name
9
9
  from .utils import output_href
10
10
 
11
11
  # Symbol extraction patterns for various languages
@@ -536,7 +536,7 @@ def build_search_index(slugs, config):
536
536
  title = meta.get("title", slug.capitalize())
537
537
  sections = _parse_search_sections(body_md)
538
538
 
539
- page_key = slug_page_key(slug, config)
539
+ page_key = normalize_slug(slug)
540
540
  url = output_href(slug_output_name(slug, config), config)
541
541
  search_index.append(
542
542
  {
@@ -0,0 +1,79 @@
1
+ """Slug normalization and output-key utilities for fr-docs."""
2
+
3
+
4
+ def slug_basename(slug):
5
+ """Extract the filename part from a possibly prefixed slug (e.g. 'core/entity' → 'entity')."""
6
+ normalized = str(slug).replace("\\", "/").strip("/")
7
+ return normalized.rsplit("/", 1)[-1] if normalized else ""
8
+
9
+
10
+ def normalize_slug(slug):
11
+ """Normalize slugs to forward-slash format for cross-platform consistency.
12
+ Also resolves relative path segments like '..' and '.'.
13
+ """
14
+ # Convert to forward slashes and strip
15
+ normalized = str(slug).replace("\\", "/").strip("/")
16
+ # Resolve relative path segments
17
+ parts = []
18
+ for part in normalized.split("/"):
19
+ if part == "..":
20
+ if parts:
21
+ parts.pop()
22
+ elif part and part != ".":
23
+ parts.append(part)
24
+ return "/".join(parts)
25
+
26
+
27
+ def build_slug_page_keys(slugs):
28
+ """Build a mapping of normalized slug → normalized slug (identity mapping).
29
+
30
+ With directory-tree output, file name collisions are avoided by
31
+ preserving the directory structure in the output path, so no
32
+ disambiguation suffixes are needed.
33
+ """
34
+ return {normalize_slug(slug): normalize_slug(slug) for slug in slugs}
35
+
36
+
37
+ def slug_page_key(slug, config):
38
+ """Return the stable output key for a slug (the normalized slug)."""
39
+ return normalize_slug(slug)
40
+
41
+
42
+ def slug_output_name(slug, config=None) -> str:
43
+ """Return output HTML filename for a slug, preserving directory structure.
44
+
45
+ Slugs like 'core/entity' produce 'core/entity.html'. The 'index' slug
46
+ at the root produces 'index.html'; an 'index' nested under a directory
47
+ (e.g. 'core/index') produces 'core/index.html'.
48
+ """
49
+ if config is None:
50
+ return f"{slug}.html"
51
+ return f"{normalize_slug(slug)}.html"
52
+
53
+
54
+ def relative_slug_path(from_slug, to_slug):
55
+ """Compute the page-relative path from ``from_slug`` to ``to_slug``.
56
+
57
+ Returns a string like ``../index.html`` or ``core/entity.html`` suitable
58
+ for use as an ``href`` value in a page at ``from_slug``.
59
+ """
60
+ from_name = slug_output_name(from_slug)
61
+ to_name = slug_output_name(to_slug)
62
+
63
+ from_parts = from_name.split("/")
64
+ to_parts = to_name.split("/")
65
+
66
+ # Find common prefix
67
+ i = 0
68
+ while i < min(len(from_parts), len(to_parts)) and from_parts[i] == to_parts[i]:
69
+ i += 1
70
+
71
+ # Number of directory levels to go up (from the from_page's directory)
72
+ # from_parts has the filename as the last element; we need to go up
73
+ # from the directory containing from_name.
74
+ up = len(from_parts) - 1 - i
75
+ # Remaining path to the target
76
+ down = to_parts[i:]
77
+
78
+ parts = [".."] * up + down
79
+ return "/".join(parts) if parts else to_name
@@ -3,7 +3,7 @@
3
3
  import re
4
4
 
5
5
  from .config_accessors import feature_enabled
6
- from .slug import slug_basename, slug_output_name
6
+ from .slug import relative_slug_path
7
7
 
8
8
  TEMPLATE = """\
9
9
  <!DOCTYPE html>
@@ -141,7 +141,7 @@ def build_sidebar_html(current_slug, sidebar_config, tagged_sections=None, confi
141
141
  )
142
142
  for slug, label in pages:
143
143
  active = ' class="active"' if slug == current_slug else ""
144
- href = slug_output_name(slug_basename(slug))
144
+ href = relative_slug_path(current_slug, slug)
145
145
  display = label
146
146
  parts.append(f' <li><a href="{href}"{active}>{display}</a></li>')
147
147
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fr-docs
3
- Version: 2.5.0
3
+ Version: 3.1.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
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "fr-docs"
9
- version = "2.5.0"
9
+ version = "3.1.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"
@@ -1,71 +0,0 @@
1
- """Slug normalization and output-key utilities for fr-docs."""
2
-
3
-
4
- def slug_basename(slug):
5
- """Extract the filename part from a possibly prefixed slug (e.g. 'core/entity' → 'entity')."""
6
- normalized = str(slug).replace("\\", "/").strip("/")
7
- return normalized.rsplit("/", 1)[-1] if normalized else ""
8
-
9
-
10
- def normalize_slug(slug):
11
- """Normalize slugs to forward-slash format for cross-platform consistency.
12
- Also resolves relative path segments like '..' and '.'.
13
- """
14
- # Convert to forward slashes and strip
15
- normalized = str(slug).replace("\\", "/").strip("/")
16
- # Resolve relative path segments
17
- parts = []
18
- for part in normalized.split("/"):
19
- if part == "..":
20
- if parts:
21
- parts.pop()
22
- elif part and part != ".":
23
- parts.append(part)
24
- return "/".join(parts)
25
-
26
-
27
- def build_slug_page_keys(slugs):
28
- """Build unique output keys for slugs, disambiguating basename collisions."""
29
- groups = {}
30
- for slug in slugs:
31
- norm = normalize_slug(slug)
32
- base = slug_basename(norm).lower()
33
- groups.setdefault(base, []).append(norm)
34
-
35
- keys = {}
36
- used = set()
37
- for base, entries in sorted(groups.items()):
38
- entries = sorted(set(entries))
39
- has_collision = len(entries) > 1
40
- for norm in entries:
41
- if not has_collision:
42
- base_key = slug_basename(norm)
43
- elif base == "index" == norm:
44
- base_key = "index"
45
- else:
46
- base_key = norm.replace("/", "__")
47
-
48
- candidate = base_key
49
- suffix = 2
50
- while candidate in used:
51
- candidate = f"{base_key}-{suffix}"
52
- suffix += 1
53
-
54
- used.add(candidate)
55
- keys[norm] = candidate
56
-
57
- return keys
58
-
59
-
60
- def slug_page_key(slug, config):
61
- """Return the stable output key for a slug."""
62
- norm = normalize_slug(slug)
63
- return config.get("_slug_page_keys", {}).get(norm, slug_basename(norm))
64
-
65
-
66
- def slug_output_name(slug, config=None) -> str:
67
- """Return output HTML filename for a slug."""
68
- if config is None:
69
- return f"{slug}.html"
70
- key = slug_page_key(slug, config)
71
- return "index.html" if key == "index" else f"{key}.html"
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