66docs/doc/<lang>/print.md, preserving the authoring syntax (admonitions, tabs,
77attr_list, icons) so that Zensical renders it with the usual styling.
88
9- To survive concatenation, every heading gets an explicit id prefixed with the
10- page id (e.g. "{#2-2-settings}" on page 2.2.-Settings), and cross-page links
11- are rewritten to same-document anchors pointing at those ids.
12-
13- Image paths are rewritten from site-root-absolute (/images/...) to a path
14- relative to the print page URL (/<lang>/print/ -> ../../images/...).
9+ Heading ids are prefixed with the page id (first h1 gets the bare page id) so
10+ that anchors stay unique after concatenation; cross-page links are rewritten
11+ to same-document anchors pointing at those ids. Site-root shortcut links
12+ (/latest, /community...) and their trailing attr_list are reduced to their
13+ label so no orphan `{ .md-button }` leaks into the rendered page.
1514
1615Usage:
17- python scripts/export_docs.py # -> print.md for every multi-page language
16+ python scripts/export_docs.py # every multi-page language
1817 python scripts/export_docs.py fr # -> docs/doc/fr/print.md only
1918 python scripts/export_docs.py fr -o t.md
2019"""
3433FRONT_MATTER_RE = re .compile (r"\A---\s*\n.*?\n---\s*\n" , re .DOTALL )
3534HEADING_RE = re .compile (r"^(#{1,6}) (.+?)\s*(\{[^}]*})?\s*$" )
3635ATTR_LIST_RE = re .compile (r"\s*(\{[^}]*})\s*$" )
37- SITE_LINK_RE = re .compile (r"\[([^]]+)]\(/[^)]*\)" )
38- DOC_LINK_RE = re .compile (
39- r"\[([^]]+)]\(([^)#\s]+?)(?:\.md)?(?:/)?(?:#([^)]*))?\)" )
36+ # Site-root shortcut link, optionally followed by an attr_list that would
37+ # otherwise leak as literal text once the link is reduced to its label.
38+ SITE_LINK_RE = re .compile (r"\[([^]]+)]\(/[^)]*\)(\s*\{[^}]*})?" )
39+ DOC_LINK_RE = re .compile (r"\[([^]]+)]\(([^)#\s]+?)(?:\.md)?(?:/)?(?:#([^)]*))?\)" )
4040# Root-absolute image paths (/images/...) become relative to /<lang>/print/.
4141IMAGE_RE = re .compile (r"(\!\[[^]]*]\()(/?images/)" )
4242
4343
4444def load_config () -> dict :
45- """Parse zensical.toml or exit with an error."""
4645 if not CONFIG_FILE .exists ():
4746 sys .exit (f"error: { CONFIG_FILE } not found" )
4847 with open (CONFIG_FILE , "rb" ) as fh :
4948 return tomllib .load (fh )
5049
5150
5251def load_nav_entries (config : dict ) -> list :
53- """Flat list of the top-level nav entries of zensical.toml."""
54- entries = config ["project" ]["nav" ]
5552 out = []
56- for entry in entries :
53+ for entry in config [ "project" ][ "nav" ] :
5754 out .extend (entry .values () if isinstance (entry , dict ) else [entry ])
5855 return out
5956
@@ -104,9 +101,8 @@ def collect_lang_counts(node, counts: dict[str, int]) -> None:
104101
105102def nav_langs () -> dict [str , int ]:
106103 """Count the pages per language found in the nav of zensical.toml."""
107- config = load_config ()
108104 counts : dict [str , int ] = {}
109- for value in load_nav_entries (config ):
105+ for value in load_nav_entries (load_config () ):
110106 collect_lang_counts (value , counts )
111107 return counts
112108
@@ -130,36 +126,61 @@ def frontmatter_path(page: Path) -> str:
130126 return match .group (1 ) if match else page .stem
131127
132128
129+ def iter_lines (text : str ):
130+ """Yield (line, heading_match); headings inside fenced code are ignored."""
131+ fence = False
132+ for line in text .splitlines ():
133+ if line .lstrip ().startswith ("```" ):
134+ fence = not fence
135+ yield line , None
136+ continue
137+ yield line , (None if fence else HEADING_RE .match (line ))
138+
139+
140+ def unique_id (base : str , used : set [str ]) -> str :
141+ """Return base, or base-1, base-2... if it was already used."""
142+ if base not in used :
143+ used .add (base )
144+ return base
145+ suffix = 1
146+ while f"{ base } -{ suffix } " in used :
147+ suffix += 1
148+ hid = f"{ base } -{ suffix } "
149+ used .add (hid )
150+ return hid
151+
152+
133153def build_anchor_map (pages : list [Path ]) -> dict [tuple [str , str | None ], str ]:
134- """Map (page key, heading or None) -> same-document anchor id.
154+ """Map (page key, heading slug or None) -> same-document anchor id.
135155
136156 The page key accepts both the file stem and the front-matter 'path' value
137157 so that links written either way keep working.
138158 """
139159 anchors : dict [tuple [str , str | None ], str ] = {}
140160 for page in pages :
141161 pid = page_id (page )
142- for key in {page .stem , frontmatter_path (page )}:
162+ keys = {page .stem , frontmatter_path (page )}
163+ for key in keys :
143164 anchors [(key , None )] = pid
165+ text = FRONT_MATTER_RE .sub ("" , page .read_text (encoding = "utf-8" ), count = 1 )
144166 used : set [str ] = set ()
145- for line in page . read_text ( encoding = "utf-8" ). splitlines ():
146- match = HEADING_RE . match ( line )
167+ first_h1 = True
168+ for _ , match in iter_lines ( text ):
147169 if not match :
148170 continue
149- hid = f"{ pid } -{ slugify (match .group (2 ))} "
150- if hid in used :
151- suffix = 1
152- while f"{ hid } -{ suffix } " in used :
153- suffix += 1
154- hid = f"{ hid } -{ suffix } "
155- used .add (hid )
156- for key in {page .stem , frontmatter_path (page )}:
171+ if first_h1 and len (match .group (1 )) == 1 :
172+ hid = pid
173+ first_h1 = False
174+ else :
175+ hid = f"{ pid } -{ slugify (match .group (2 ))} "
176+ hid = unique_id (hid , used )
177+ for key in keys :
157178 anchors [(key , slugify (match .group (2 )))] = hid
158179 return anchors
159180
160181
161182def rewrite_links (text : str , anchors : dict [tuple [str , str | None ], str ]) -> str :
162- """Rewrite cross-page Markdown links to same-document anchors."""
183+ """Rewrite cross-page links to anchors; reduce shortcut links to labels ."""
163184 def doc_link_sub (match : re .Match ) -> str :
164185 label , target , heading = match .group (1 ), match .group (2 ), match .group (3 )
165186 key = (target , slugify (heading ) if heading else None )
@@ -172,25 +193,19 @@ def doc_link_sub(match: re.Match) -> str:
172193
173194
174195def add_heading_ids (text : str , pid : str ) -> str :
175- """Give every heading an explicit attr_list id prefixed with the page id."""
196+ """Give every heading an explicit id; the first h1 becomes the page id."""
176197 used : set [str ] = set ()
198+ first_h1 = True
177199 out_lines : list [str ] = []
178- in_fence = False
179- for line in text .splitlines ():
180- if line .lstrip ().startswith ("```" ):
181- in_fence = not in_fence
182- out_lines .append (line )
183- continue
184- match = HEADING_RE .match (line ) if not in_fence else None
200+ for line , match in iter_lines (text ):
185201 if match :
186- heading_id = f"{ pid } -{ slugify (match .group (2 ))} "
187- if heading_id in used :
188- suffix = 1
189- while f"{ heading_id } -{ suffix } " in used :
190- suffix += 1
191- heading_id = f"{ heading_id } -{ suffix } "
192- used .add (heading_id )
193- out_lines .append (f"{ match .group (1 )} { match .group (2 )} {{#{ heading_id } }}" )
202+ if first_h1 and len (match .group (1 )) == 1 :
203+ hid = pid
204+ first_h1 = False
205+ else :
206+ hid = f"{ pid } -{ slugify (match .group (2 ))} "
207+ hid = unique_id (hid , used )
208+ out_lines .append (f"{ match .group (1 )} { match .group (2 )} {{#{ hid } }}" )
194209 else :
195210 out_lines .append (line )
196211 return "\n " .join (out_lines )
@@ -218,7 +233,7 @@ def build_markdown(lang: str, pages: list[Path]) -> str:
218233 """Concatenate the pages with a table of contents and a front-matter."""
219234 anchors = build_anchor_map (pages )
220235 parts = ["---" , "path: print" , "---" , "" ,
221- f"# Documentation QualCoder ({ lang .upper ()} )" , "" ,
236+ f"# Documentation QualCoder ({ lang .upper ()} ) {{#top}} " , "" ,
222237 "## Sommaire {#sommaire}" , "" ]
223238 for page in pages :
224239 parts .append (f"- [{ page_title (page )} ](#{ page_id (page )} )" )
0 commit comments