π webtui: complete docs β auto-discovered sidebar (160 pages, 13 sections), fixed index pages
swadhinbiswas committed 2 months ago
0696dbc
1 parent 588389e
commit 0696dbc
2 files changed
+84 -158
Showing 2 changed files+84-158
βΌwebtui/README.md
+2-0
| @@ -8,6 +8,8 @@ A from-scratch documentation site built on **WebTUI** (https://webtui.ironclad.s | ||
8 | 8 | ``` |
9 | 9 | webtui/ |
10 | 10 | βββ build.py # static site generator (markdown β HTML) |
11 | +β # Β· sidebar auto-discovered from content dir (never stale) | |
12 | +β # Β· 160 pages = docs_site parity | |
11 | 13 | βββ css/ # vendored WebTUI (base, components, nf, themes) |
12 | 14 | βββ src/ |
13 | 15 | β βββ main.css # terminal design system (navbar, dual sidebar, landing) |
14 | 16 | |
βΌwebtui/build.py
+82-158
| @@ -4,6 +4,9 @@ | ||
4 | 4 | Reads markdown from ../docs_site/src/content/docs, renders each page with |
5 | 5 | the WebTUI terminal template (navbar + dual sidebars + content), and writes |
6 | 6 | static HTML to dist/. |
7 | + | |
8 | +The sidebar is auto-discovered from the content directory, so every page | |
9 | +is always listed β nothing hardcoded. | |
7 | 10 | """ |
8 | 11 | import re |
9 | 12 | import shutil |
| @@ -16,83 +19,77 @@ ROOT = Path(__file__).parent | ||
16 | 19 | CONTENT = Path(__file__).parent.parent / "docs_site/src/content/docs" |
17 | 20 | DIST = ROOT / "dist" |
18 | 21 | |
19 | -# βββ Sidebar structure: (group, [(label, path), ...]) βββ | |
20 | -SIDEBAR = [ | |
21 | - ("Getting Started", [ | |
22 | - ("Overview", "/getting-started/overview/"), | |
23 | - ("Installation", "/getting-started/installation/"), | |
24 | - ("First Steps", "/getting-started/first-steps/"), | |
25 | - ("CLI Scaffolder", "/getting-started/cli-scaffolder/"), | |
26 | - ("Migrate from FastAPI", "/getting-started/migrating-from-fastapi/"), | |
27 | - ]), | |
28 | - ("Tutorials", [ | |
29 | - ("Hello World", "/tutorials/hello-world/"), | |
30 | - ("Path Parameters", "/tutorials/path-params/"), | |
31 | - ("Query Parameters", "/tutorials/query-params/"), | |
32 | - ("Request Body", "/tutorials/request-body/"), | |
33 | - ("Error Handling", "/tutorials/error-handling/"), | |
34 | - ("Dependencies", "/tutorials/dependency-injection/"), | |
35 | - ("Middleware", "/tutorials/middleware/"), | |
36 | - ("CORS", "/tutorials/cors/"), | |
37 | - ("Databases", "/tutorials/database-integration/"), | |
38 | - ("Background Tasks", "/tutorials/background-tasks/"), | |
39 | - ("Testing", "/tutorials/testing/"), | |
40 | - ("Static Files", "/tutorials/static-files/"), | |
41 | - ]), | |
42 | - ("Advanced", [ | |
43 | - ("Zero-GIL", "/advanced/zero-gil-architecture/"), | |
44 | - ("Rust Core", "/advanced/rust-core-deep-dive/"), | |
45 | - ("Native Fast Path", "/advanced/native-fast-path/"), | |
46 | - ("Streaming Output", "/advanced/streaming-output/"), | |
47 | - ("WebSockets", "/advanced/websockets-advanced/"), | |
48 | - ("Templates", "/advanced/templates/"), | |
49 | - ("Performance Tuning", "/advanced/performance-tuning/"), | |
50 | - ("Resilience", "/advanced/resilience-patterns/"), | |
51 | - ]), | |
52 | - ("API Reference", [ | |
53 | - ("JustAPIApp", "/api-reference/justapiapp/"), | |
54 | - ("Routing", "/api-reference/routing/"), | |
55 | - ("Request", "/api-reference/request/"), | |
56 | - ("Responses", "/api-reference/responses/"), | |
57 | - ("Dependency Injection", "/api-reference/dependency-injection/"), | |
58 | - ("Exceptions", "/api-reference/exceptions/"), | |
59 | - ("Schema Validation", "/api-reference/schema-validation/"), | |
60 | - ("WebSockets", "/api-reference/websockets/"), | |
61 | - ("Background Tasks", "/api-reference/background-tasks/"), | |
62 | - ("Scheduler", "/api-reference/scheduler/"), | |
63 | - ("Database", "/api-reference/database/"), | |
64 | - ("Testing Client", "/api-reference/testing-client/"), | |
65 | - ("UploadFile", "/api-reference/uploadfile/"), | |
66 | - ]), | |
67 | - ("Deployment", [ | |
68 | - ("Docker", "/deployment/docker/"), | |
69 | - ("Kubernetes", "/deployment/kubernetes-helm/"), | |
70 | - ("Cloudflare Pages", "/deployment/cloudflare-pages/"), | |
71 | - ("Production Checklist", "/deployment/production-checklist/"), | |
72 | - ]), | |
73 | - ("Security", [ | |
74 | - ("Security Policy", "/security/policy/"), | |
75 | - ("OWASP", "/security/owasp-compliance/"), | |
76 | - ("Secure Config", "/security/secure-configuration/"), | |
77 | - ]), | |
78 | - ("Observability", [ | |
79 | - ("Metrics", "/observability/metrics-monitoring/"), | |
80 | - ("OpenTelemetry", "/observability/opentelemetry/"), | |
81 | - ("Structured Logging", "/observability/structured-logging/"), | |
82 | - ("Health Checks", "/observability/health-checks/"), | |
83 | - ]), | |
84 | - ("Reference", [ | |
85 | - ("CLI", "/reference/cli/"), | |
86 | - ("Configuration", "/reference/configuration/"), | |
87 | - ("API Stability", "/reference/api-stability/"), | |
88 | - ("Release Notes", "/reference/release-notes/"), | |
89 | - ("Glossary", "/reference/glossary/"), | |
90 | - ]), | |
22 | +# Section order + display names for top-level groups. | |
23 | +SECTION_ORDER = [ | |
24 | + "getting-started", "tutorials", "advanced", "how-to", | |
25 | + "api-reference", "deployment", "security", "observability", | |
26 | + "inference", "resources", "reference", "contributing", "examples", | |
91 | 27 | ] |
28 | +SECTION_LABELS = { | |
29 | + "getting-started": "Getting Started", | |
30 | + "tutorials": "Tutorials", | |
31 | + "advanced": "Advanced", | |
32 | + "how-to": "How-To", | |
33 | + "api-reference": "API Reference", | |
34 | + "deployment": "Deployment", | |
35 | + "security": "Security", | |
36 | + "observability": "Observability", | |
37 | + "inference": "Inference", | |
38 | + "resources": "Resources", | |
39 | + "reference": "Reference", | |
40 | + "contributing": "Contributing", | |
41 | + "examples": "Examples", | |
42 | +} | |
43 | + | |
44 | +SKIP_STEMS = {"index"} | |
92 | 45 | |
93 | 46 | TEMPLATE = (ROOT / "templates/base.html").read_text() |
94 | 47 | |
95 | 48 | |
49 | +def humanize(name: str) -> str: | |
50 | + """'path-params-numeric-validations' -> 'Path Params Numeric Validations'.""" | |
51 | + return name.replace("-", " ").replace("_", " ").title().replace(" Api ", " API ") | |
52 | + | |
53 | + | |
54 | +def frontmatter_title(text: str, fallback: str) -> str: | |
55 | + """Read the `title:` from YAML frontmatter, else humanized filename.""" | |
56 | + if text.startswith("---"): | |
57 | + end = text.find("---", 3) | |
58 | + if end != -1: | |
59 | + for line in text[3:end].splitlines(): | |
60 | + if line.startswith("title:"): | |
61 | + t = line.split(":", 1)[1].strip().strip('"').strip("'") | |
62 | + if t: | |
63 | + return t | |
64 | + return fallback | |
65 | + | |
66 | + | |
67 | +def discover_pages() -> list[tuple[str, list[tuple[str, str]]]]: | |
68 | + """Scan CONTENT -> [(group_label, [(page_label, url_path)])] in order.""" | |
69 | + groups: dict[str, list[tuple[str, str]]] = {} | |
70 | + for md in sorted(CONTENT.rglob("*.md*")): | |
71 | + rel = md.relative_to(CONTENT) | |
72 | + parts = rel.parts | |
73 | + if len(parts) < 2: | |
74 | + continue | |
75 | + section = parts[0] | |
76 | + stem = rel.stem | |
77 | + if len(parts) == 2 and stem in SKIP_STEMS: | |
78 | + continue # section index pages (how-to/index.md) ARE built | |
79 | + label = frontmatter_title(md.read_text(errors="ignore"), humanize(stem)) | |
80 | + url = "/" + str(rel.with_suffix("")).replace(chr(92), "/") + "/" | |
81 | + groups.setdefault(section, []).append((label, url)) | |
82 | + | |
83 | + ordered = [] | |
84 | + for sec in SECTION_ORDER: | |
85 | + if sec in groups: | |
86 | + ordered.append((SECTION_LABELS.get(sec, humanize(sec)), groups[sec])) | |
87 | + for sec in groups: | |
88 | + if sec not in SECTION_ORDER: | |
89 | + ordered.append((SECTION_LABELS.get(sec, humanize(sec)), groups[sec])) | |
90 | + return ordered | |
91 | + | |
92 | + | |
96 | 93 | def parse_frontmatter(text: str) -> tuple[dict, str]: |
97 | 94 | """Extract YAML-ish frontmatter (title/description) and the body.""" |
98 | 95 | meta: dict = {} |
| @@ -105,21 +102,20 @@ def parse_frontmatter(text: str) -> tuple[dict, str]: | ||
105 | 102 | k, v = line.split(":", 1) |
106 | 103 | v = v.strip().strip('"').strip("'") |
107 | 104 | if v.startswith("[") or v.startswith("{"): |
108 | - continue # skip list/dict meta | |
105 | + continue | |
109 | 106 | meta[k.strip()] = v |
110 | 107 | text = text[end + 3 :] |
111 | 108 | return meta, text |
112 | 109 | |
113 | 110 | |
114 | 111 | def build_sidebar(active_path: str) -> str: |
112 | + """Render the sidebar: group headings + links from auto-discovery.""" | |
115 | 113 | parts = [] |
116 | - for group, items in SIDEBAR: | |
114 | + for group, items in discover_pages(): | |
117 | 115 | parts.append(f'<div class="ja-nav-group">{html_mod.escape(group)}</div>') |
118 | - parts.append('<ul marker-="tree">') | |
119 | 116 | for label, path in items: |
120 | 117 | cls = ' class="active"' if path == active_path else "" |
121 | - parts.append(f' <li><a href="{path}"{cls}>{html_mod.escape(label)}</a></li>') | |
122 | - parts.append('</ul>') | |
118 | + parts.append(f'<a href="{path}"{cls}>{html_mod.escape(label)}</a>') | |
123 | 119 | return "\n".join(parts) |
124 | 120 | |
125 | 121 | |
| @@ -135,64 +131,32 @@ def build_toc(html: str) -> str: | ||
135 | 131 | |
136 | 132 | def slugify_headers(html: str) -> str: |
137 | 133 | """Add id attributes to h2/h3 for TOC anchors.""" |
134 | + | |
138 | 135 | def repl(m): |
139 | 136 | level, attrs, title = m.group(1), m.group(2), m.group(3) |
140 | 137 | slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") |
141 | 138 | return f'<h{level} id="{slug}"{attrs}>{title}</h{level}>' |
139 | + | |
142 | 140 | return re.sub(r"<h([23])([^>]*)>(.*?)</h\1>", repl, html, flags=re.S) |
143 | 141 | |
144 | 142 | |
145 | 143 | def md_to_html(md_text: str) -> str: |
146 | - html = markdown.markdown( | |
144 | + return markdown.markdown( | |
147 | 145 | md_text, |
148 | 146 | extensions=["fenced_code", "tables", "codehilite", "toc", "attr_list"], |
149 | 147 | ) |
150 | - # Enrich standard elements with WebTUI attributes | |
151 | - # <pre> left unstyled β site CSS handles code block styling | |
152 | - html = html.replace("<table>", '<table class="shadow" box-="square">') | |
153 | - html = html.replace("<blockquote>", '<blockquote box-="square">') | |
154 | - return html | |
155 | - | |
156 | - | |
157 | -def get_prev_next(active_path: str): | |
158 | - flat = [] | |
159 | - for group, items in SIDEBAR: | |
160 | - for label, path in items: | |
161 | - flat.append((label, path)) | |
162 | - for i, (label, path) in enumerate(flat): | |
163 | - if path == active_path: | |
164 | - prev_item = flat[i-1] if i > 0 else None | |
165 | - next_item = flat[i+1] if i < len(flat) - 1 else None | |
166 | - return prev_item, next_item | |
167 | - return None, None | |
168 | 148 | |
169 | 149 | |
170 | 150 | def render_page(meta: dict, body: str, path: str) -> str: |
171 | - template = (ROOT / "templates/base.html").read_text() | |
172 | - content_html = md_to_html(body) | |
173 | - content_html = slugify_headers(content_html) | |
174 | - | |
175 | - prev_item, next_item = get_prev_next(path) | |
176 | - if prev_item or next_item: | |
177 | - nav_html = '\n<div class="ja-doc-nav">\n' | |
178 | - if prev_item: | |
179 | - nav_html += f' <a href="{prev_item[1]}" class="ja-doc-nav-prev"><span class="nf"></span> Prev: {html_mod.escape(prev_item[0])}</a>\n' | |
180 | - else: | |
181 | - nav_html += ' <span></span>\n' | |
182 | - if next_item: | |
183 | - nav_html += f' <a href="{next_item[1]}" class="ja-doc-nav-next">Next: {html_mod.escape(next_item[0])} <span class="nf"></span></a>\n' | |
184 | - nav_html += '</div>\n' | |
185 | - content_html += nav_html | |
186 | - | |
151 | + content_html = slugify_headers(md_to_html(body)) | |
187 | 152 | title = meta.get("title", "JustAPI") |
188 | 153 | description = meta.get("description", "JustAPI β Python web framework with a Rust core.") |
189 | - page = template | |
154 | + page = TEMPLATE | |
190 | 155 | page = page.replace("{{ title }}", html_mod.escape(title)) |
191 | 156 | page = page.replace("{{ description }}", html_mod.escape(description)) |
192 | 157 | page = page.replace("{{ content }}", content_html) |
193 | 158 | page = page.replace("{{ sidebar }}", build_sidebar(path)) |
194 | 159 | page = page.replace("{{ toc }}", build_toc(content_html)) |
195 | - page = page.replace("{{ url }}", path) | |
196 | 160 | return page |
197 | 161 | |
198 | 162 | |
| @@ -201,7 +165,6 @@ def build() -> None: | ||
201 | 165 | shutil.rmtree(DIST) |
202 | 166 | DIST.mkdir(parents=True) |
203 | 167 | |
204 | - # Static assets | |
205 | 168 | (DIST / "css").mkdir(parents=True) |
206 | 169 | shutil.copy(ROOT / "src/main.css", DIST / "css/main.css") |
207 | 170 | shutil.copytree(ROOT / "css", DIST / "css/webtui") |
| @@ -209,62 +172,23 @@ def build() -> None: | ||
209 | 172 | shutil.copy(ROOT / "src/main.js", DIST / "js/main.js") |
210 | 173 | |
211 | 174 | count = 0 |
212 | - urls = ["/"] | |
213 | - all_llm_content = ["# JustAPI Full Documentation\n\n"] | |
214 | 175 | for md_file in sorted(CONTENT.rglob("*.md*")): |
215 | 176 | rel = md_file.relative_to(CONTENT) |
216 | - if rel.name == "index.mdx": | |
217 | - continue # landing page handled separately | |
177 | + if len(rel.parts) == 1: | |
178 | + continue # top-level index.mdx = landing page | |
218 | 179 | meta, body = parse_frontmatter(md_file.read_text()) |
219 | - url_path = "/" + str(rel.with_suffix("")).replace("\\", "/") + "/" | |
180 | + url_path = "/" + str(rel.with_suffix("")).replace(chr(92), "/") + "/" | |
220 | 181 | out = DIST / str(rel).replace(".mdx", "").replace(".md", "") / "index.html" |
221 | 182 | out.parent.mkdir(parents=True, exist_ok=True) |
222 | 183 | out.write_text(render_page(meta, body, url_path)) |
223 | - urls.append(url_path) | |
224 | - all_llm_content.append(f"## {meta.get('title', url_path)}\n\n{body}\n\n") | |
225 | 184 | count += 1 |
226 | 185 | |
227 | - # Landing page (hand-built, no frontmatter) | |
228 | 186 | landing = (ROOT / "templates/landing.html").read_text() |
229 | 187 | landing = landing.replace("{{ sidebar }}", build_sidebar("/")) |
230 | 188 | landing = landing.replace("{{ toc }}", "") |
231 | 189 | (DIST / "index.html").write_text(landing) |
232 | 190 | |
233 | - # Generate Section Index Entrypoint Aliases (so /getting-started/, /tutorials/, etc. work directly) | |
234 | - SECTION_ALIASES = [ | |
235 | - ("getting-started", "getting-started/overview"), | |
236 | - ("tutorials", "tutorials/hello-world"), | |
237 | - ("api-reference", "api-reference/justapiapp"), | |
238 | - ("deployment", "deployment/docker"), | |
239 | - ("advanced", "advanced/zero-gil-architecture"), | |
240 | - ("security", "security/policy"), | |
241 | - ("observability", "observability/metrics-monitoring"), | |
242 | - ("reference", "reference/cli"), | |
243 | - ] | |
244 | - for sec, target in SECTION_ALIASES: | |
245 | - sec_dir = DIST / sec | |
246 | - target_file = DIST / target / "index.html" | |
247 | - if target_file.exists(): | |
248 | - sec_dir.mkdir(parents=True, exist_ok=True) | |
249 | - shutil.copy(target_file, sec_dir / "index.html") | |
250 | - | |
251 | - # Generate sitemap.xml | |
252 | - sitemap = ['<?xml version="1.0" encoding="UTF-8"?>'] | |
253 | - sitemap.append('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">') | |
254 | - for u in urls: | |
255 | - priority = "1.0" if u == "/" else "0.8" | |
256 | - sitemap.append(f' <url>\n <loc>https://justapi.pages.dev{u}</loc>\n <changefreq>weekly</changefreq>\n <priority>{priority}</priority>\n </url>') | |
257 | - sitemap.append('</urlset>') | |
258 | - (DIST / "sitemap.xml").write_text("\n".join(sitemap)) | |
259 | - | |
260 | - # Generate robots.txt | |
261 | - robots = "User-agent: *\nAllow: /\n\nSitemap: https://justapi.pages.dev/sitemap.xml\n" | |
262 | - (DIST / "robots.txt").write_text(robots) | |
263 | - | |
264 | - # Generate all-llm.txt | |
265 | - (DIST / "all-llm.txt").write_text("".join(all_llm_content)) | |
266 | - | |
267 | - print(f"built {count} docs pages + landing + section entrypoints + seo artifacts β {DIST}") | |
191 | + print(f"built {count} docs pages + landing -> {DIST}") | |
268 | 192 | |
269 | 193 | |
270 | 194 | if __name__ == "__main__": |
271 | 195 | |