πŸ“š 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
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
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
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
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
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">&#xf060;</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">&#xf061;</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
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
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