Document Portal
Dynamic markdown-backed documentation system. Every .md file in the docs/ directory is automatically served as an HTML page with cross-links rewritten to work within the portal.
How it works
Adding a document
Drop a .md file into docs/. The filename (minus extension) becomes the URL slug:
| File | URL |
|---|---|
docs/about_me.md |
/docs/about_me |
docs/ui_themes.md |
/docs/ui_themes |
No configuration, no registration — just create the file.
Document structure
A typical doc has two parts:
- Tag line (optional) —
#tagnameon its own line (no space after#). All tag lines found in the file are used for filtering and searching. Tag lines are stripped from the rendered HTML. - Heading — a standard markdown
# Headingis used as the display title in the doc list and breadcrumb.
Example docs/my_topic.md:
# My Topic
Content here...
- Display title:
My Topic - URL:
/docs/my_topic - Searchable tag:
#my_tag
If no tag line is present, the doc won't appear in tag-based filtering but will still render and appear in the doc list.
Cross-links
Links between docs use standard markdown link syntax with the .md extension:
See [UI Themes](ui_themes.md) for details.
These are automatically rewritten at render time to point to /docs/ui_themes. The rewrite happens via a custom DocLinkExtension treeprocessor, so it works for all links in the document body.
Routes
| Route | Description |
|---|---|
GET / |
Home page — 4 random tag badges linking to filtered /docs |
GET /docs |
Doc listing with tag search, filter sidebar, and autocomplete |
GET /docs?tags=tag1,tag2 |
Pre-filtered view (AND logic, shareable URL) |
GET /docs/{slug} |
Renders a specific doc as HTML |
GET /api/tags?q= |
Autocomplete endpoint — returns matching tags with doc counts |
GET /swagger |
OpenAPI/Swagger UI |
GET /robots.txt |
Allows all crawlers, points to sitemap |
GET /sitemap.xml |
Auto-generated sitemap listing /, /docs, and every doc slug |
GET /polygon_generator |
Generate a random polygon PNG with configurable size, vertices, and outline colour |
GET /playground |
Interactive page for polygon experiments, sidebar layout with hash-based routing |
Configuration
All configuration is via environment variables loaded from .env (which is gitignored). Every variable has a sensible default.
| Variable | Default | Purpose |
|---|---|---|
BASE_DOMAIN |
always-coding.com |
Domain used in robots.txt and sitemap.xml |
SITE_NAME |
always-coding |
Brand name used in page titles, meta descriptions, and the home page heading |
DOCS_DIR |
docs |
Path to the markdown content directory |
CONTACT_EMAIL |
Email |
Email for the footer contact link |
BOOTSWATCH_CDN |
https://cdn.jsdelivr.net/npm/bootswatch@5.3.8/dist |
CDN base URL for Bootswatch CSS themes |
BOOTSTRAP_CDN |
https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist |
CDN base URL for Bootstrap JS |
HLJS_CDN |
https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1 |
CDN base URL for highlight.js |
LOG_FILE |
/tmp/application.log |
Path to the application rotating log file |
LOG_MAX_SIZE |
1048576 (1 MB) |
Max bytes per log file before rotation |
LOG_BACKUP_COUNT |
10 |
Number of rotated log files to keep |
LOG_LEVEL |
INFO |
Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL |
METRICS_LOG_FILE |
/tmp/metrics.log |
Path to the metrics rotating log file |
METRICS_LOG_MAX_SIZE |
1048576 (1 MB) |
Max bytes per metrics log file before rotation |
METRICS_LOG_BACKUP_COUNT |
10 |
Number of rotated metrics log files to keep |
METRICS_LOG_LEVEL |
INFO |
Metrics log level: DEBUG, INFO, WARNING, ERROR, CRITICAL |
SLOW_REQUEST_MS |
1000 |
Requests exceeding this duration (ms) get a [SLOW >…ms] warning in the application log |
SITE_NAME and the CDN variables are injected as Jinja2 globals in src/main.py so they're available in every template without passing them per-route.
Logging
The application maintains two separate rotating file loggers:
| Logger | File | Purpose |
|---|---|---|
application |
LOG_FILE (default /tmp/application.log) |
Request paths, errors, and general application events |
metrics |
METRICS_LOG_FILE (default /tmp/metrics.log) |
Request timing (status, duration in ms) for SRE dashboards |
Each log file uses Python's RotatingFileHandler — files rotate at LOG_MAX_SIZE / METRICS_LOG_MAX_SIZE bytes, keeping up to LOG_BACKUP_COUNT / METRICS_LOG_BACKUP_COUNT old files.
All logging is file-only — no output goes to stdout/stderr. Console loggers (uvicorn, uvicorn.error, uvicorn.access, httpx, httpcore, __main__) are suppressed by clearing their handlers and setting propagate=True, so their messages flow through the root logger's rotating file handler at WARNING level or above.
The HTTP middleware records time.perf_counter() on every request and writes to both loggers. The application log includes a request ID, client IP, query string, status code, and duration. Requests that return 4xx or exceed the SLOW_REQUEST_MS threshold are additionally logged at WARNING level:
# /tmp/application.log — normal
2026-05-17 13:19:07 [INFO] application: [abc] 127.0.0.1 GET / 200 15.3ms
# /tmp/application.log — 4xx warning
2026-05-17 13:19:08 [WARNING] application: [def] 192.168.1.1 GET /docs/nonexistent 404 1.2ms
# /tmp/application.log — slow-request warning
2026-05-17 13:19:09 [WARNING] application: [ghi] 10.0.0.1 GET /docs/large_doc 200 2100.5ms [SLOW >1000ms]
# /tmp/metrics.log
2026-05-17 13:19:07 [INFO] metrics: [abc] GET / 200 15.3ms
The request ID ([abc]) is either the value of the X-Request-ID header (when behind a reverse proxy that sets it) or a randomly generated 12-char hex string. This allows correlating metrics-log entries with application-log tracebacks via a simple grep abc /tmp/application.log.
Production observability (SRE)
The metrics log is structured for ad-hoc analysis with standard Unix tools. Below are common incident-response queries.
Format reference (field positions from the end):
timestamp [INFO] metrics: [REQ_ID] METHOD PATH STATUS DURms
$(NF-3) $(NF-2) $(NF-1) $NF
All commands assume METRICS_LOG_FILE=/tmp/metrics.log.
| Scenario | Command |
|---|---|
| Slowest requests overall | awk '{print $(NF), $0}' /tmp/metrics.log \| sort -rn \| head -10 |
| Slowest endpoint (p95 avg) | awk '{ep=$(NF-3)" "$(NF-2); ms=$(NF); gsub(/ms/,"",ms); c[ep]++; s[ep]+=ms; if(ms>m[ep])m[ep]=ms} END{for(e in c) printf "%.1f %s %.1f %d\n", s[e]/c[e], e, m[e], c[e]}' /tmp/metrics.log \| sort -rn \| head -10 |
| Error rate | awk '{s=$(NF-1); if(s==404)c["4xx"]++; else if(s~/^5/)c["5xx"]++; else c["2xx"]++} END{for(k in c) printf "%s: %.1f%%\n", k, 100*c[k]/NR}' /tmp/metrics.log |
| Top 10 5xx endpoints | awk '$(NF-1) ~ /^5/ {print $(NF-2)}' /tmp/metrics.log \| sort \| uniq -c \| sort -rn \| head -10 |
| Requests per minute | awk '{print substr($1" "$2,1,16)}' /tmp/metrics.log \| sort \| uniq -c |
| Recent 5xx spike | grep ' 5[0-9][0-9] ' /tmp/metrics.log \| tail -50 |
| P99 latency (all requests) | awk '{ms=$(NF); gsub(/ms/,"",ms); print ms+0}' /tmp/metrics.log \| sort -n \| awk '{a[NR]=$1} END{p99=a[int(NR*0.99)]; p50=a[int(NR*0.5)]; print "p50:", p50, "p99:", p99}' |
| Slowest doc render | awk '$(NF-2) ~ /^\/docs\// {print $(NF), $0}' /tmp/metrics.log \| sort -rn \| head -5 |
| Autocomplete endpoint load | grep -c ' /api/tags ' /tmp/metrics.log |
| Requests over 1 second | awk '{ms=$(NF); gsub(/ms/,"",ms); if(ms+0 > 1000) c++} END{print c+0}' /tmp/metrics.log |
| Live tail (slow/error watch) | tail -f /tmp/metrics.log \| awk '{ms=$(NF); gsub(/ms/,"",ms); if(ms+0 > 500 \|\| $(NF-1) == 500) print}' |
Correlating errors: Every request carries a unique request ID like [abc] at the start of the metrics log message. Use it to find the matching traceback in the application log:
grep abc /tmp/application.log
# or with surrounding context for the traceback:
grep -A 30 '^2026-05-17.*\[abc\]' /tmp/application.log
Quick health check:
echo "=== Metrics ==="
wc -l /tmp/metrics.log
echo "Latest request:"; tail -1 /tmp/metrics.log
echo "=== Errors (last 10) ==="; grep ' 5[0-9][0-9] ' /tmp/metrics.log | tail -10
echo "=== Mean latency ==="; awk '{ms=$(NF); gsub(/ms/,"",ms); t+=ms; c++} END{printf "%.1fms\n", t/c}' /tmp/metrics.log
echo "=== Slow requests (>1s) ==="; awk '$(NF)+0 > 1000 {print $0, "->", $(NF)}' /tmp/metrics.log | tail -5
Error handling
All file I/O, markdown conversion, template rendering, and ElementTree operations are wrapped in try/except blocks with log.exception() so failures produce a detailed traceback without crashing.
| Layer | Failure mode | Graceful behaviour |
|---|---|---|
File reads (read_md) |
OSError, UnicodeDecodeError |
Returns None; caller returns 500 or skips the doc |
| Markdown conversion | Parser error | Logs traceback, returns 500 with error message |
| Tag index / doc listing | File read failure mid-iteration | Skips the file, logs, continues with remaining docs |
| Treeprocessors | ElementTree error | Logs traceback, returns tree unmodified |
| Env var parsing | LOG_MAX_SIZE / LOG_BACKUP_COUNT non-numeric | Falls back to defaults, logs a warning |
| Module init | Templates/log dir missing | Logs and re-raises (fails fast at startup) |
| HTTP middleware (catch-all) | Any unhandled exception | Logs traceback to application, writes 500 timing to metrics, returns 500 |
Key defensive patterns:
read_md(path)wrapspath.read_text()for bothOSErrorandUnicodeDecodeError, returningNoneon failure — all callers check and handle gracefully.slug_title()accepts pre-read content instead of a path, eliminating the duplicate file readread_docpreviously performed.- Log file parent directory is created automatically at startup via
Path(LOG_FILE).parent.mkdir(parents=True, exist_ok=True). DOCS_DIRexistence is checked at startup with a warning log (allows runtime creation).
start.sh
The dev server script reads $HOST (default 127.0.0.1) and $PORT (default 8000). It passes --log-level warning to uvicorn so its console output is suppressed; all logging is handled by the application's rotating file handlers.
HOST=0.0.0.0 PORT=8080 ./start.sh
Search engine optimisation
The portal includes built-in SEO support to help search engines discover and index content.
Meta tags
Every page renders the following in <head>:
| Tag | Source | Notes |
|---|---|---|
<title> |
{% block title %} |
Per-page: {{ site_name }}, docs · {{ site_name }}, {title} · {{ site_name }} |
<meta name="description"> |
{% block meta_description %} |
Default + overridable per template |
<meta property="og:title"> |
{{ self.title() }} |
Reuses the page title |
<meta property="og:description"> |
{{ self.meta_description() }} |
Reuses the description |
<meta property="og:type"> |
Hardcoded website |
— |
<meta name="twitter:card"> |
Hardcoded summary |
— |
<link rel="canonical"> |
{{ request.url }} |
Prevents duplicate content from ?tags= variants |
Robots and sitemap
| Route | File | Content |
|---|---|---|
GET /robots.txt |
src/main.py:robots() |
Allow: / + sitemap URL |
GET /sitemap.xml |
src/main.py:sitemap() |
Iterates DOCS_DIR glob, builds <urlset> |
The sitemap includes /, /docs, and every /docs/{slug} derived from .md files. The base domain is set via the BASE_DOMAIN env var (default always-coding.com).
JSON-LD breadcrumbs
Doc pages include a BreadcrumbList structured data block injected immediately after the visible breadcrumb nav:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": 1, "name": "home", "item": "https://.../"},
{"@type": "ListItem", "position": 2, "name": "docs", "item": "https://.../docs"},
{"@type": "ListItem", "position": 3, "name": "Doc Title", "item": "https://.../docs/slug"}
]
}
</script>
This enhances search result snippets with breadcrumb trail display.
Tag search and filtering
/docs page layout
┌──────────────────────────────────────────────────────┐
│ home > docs │
│ Docs │
│ │
│ [Search tags... ] │
│ Type to search tags · Enter to select │
│ · Escape to clear · Click tags to toggle │
│ │
│ [#active_tag ✕] (shown when tags selected) │
│ Clear all filters (shown when tags selected) │
│ ──────────────────────────────────────────────────── │
│ ┌────────────────┬───────────────────────────────────┐│
│ │ Tags │ about_me ││
│ │ #about_me (1) │ code_quality ││
│ │ #bootstrap…(1) │ document_portal ││
│ │ #code_qual…(1) │ ui_themes ││
│ │ #document_…(1) │ ││
│ └────────────────┴───────────────────────────────────┘│
└──────────────────────────────────────────────────────┘
Search autocomplete
- Type in the search box to see matching tags from all docs
- Partial substring matching —
strapfindsbootstrap_themes - Suggestions show tag name with
#prefix and doc count - Already-selected tags are excluded from suggestions
- Click a suggestion (or hit Enter) to add it as a filter
Keyboard shortcuts
| Key | Action |
|---|---|
Enter |
Select the first suggestion from the autocomplete dropdown |
Escape |
Clear all active tag filters and reset the search |
Filtering behaviour
- AND logic — selecting multiple tags narrows the list to docs containing all of them
- Selected tags appear as
[#tag ✕]pills above the content - Click a pill's ✕ to remove that tag
- Click any tag in the left sidebar to toggle it on/off (active tags highlighted in blue)
- URL sync —
history.replaceStateupdates?tags=in the URL bar without page reload - localStorage — selected tags are saved and restored on return visits (URL param takes priority when present)
Autocomplete endpoint
GET /api/tags?q=boot
→ {"results": [{"tag": "bootstrap_themes", "count": 1}]}
q— optional substring filter- Tags from inside fenced code blocks and inline code are excluded to avoid false matches
- Returns JSON sorted alphabetically
Home page
The home page displays 4 randomly selected tags as badges. Each links to /docs?tags={tagname} for a filtered view.
Rendering
| Feature | Details |
|---|---|
| Markdown | Python-Markdown 3.10.2+ with fenced_code, tables, toc, and custom DocLinkExtension |
| Code blocks | Syntax-highlighted via highlight.js with CDN-delivered theme. Nginx config syntax loaded as a separate language module. |
| Code theme | Independent syntax dropdown in navbar — 12 curated themes + 245 more under "Other" |
| Tables | Full borders, padded cells, shaded header rows (Bootstrap-compatible CSS vars) |
| ToC | Sticky sidebar on doc pages with smooth-scroll anchor links (desktop only) |
| Highlight.js | Loaded from CDN, hljs.highlightAll() runs on every page |
Table of Contents sidebar
When a doc has at least one heading, a sticky Contents sidebar appears on the left (desktop only):
┌──────────────┬──────────────────────────────────┐
│ Contents │ # Theme Selection │
│ │ │
│ Theme Select │ This website uses Bootswatch... │
│ How it works │ │
│ Theme class… │ ## How it works │
│ Implement… │ │
│ Adding or… │ - A theme picker dropdown... │
│ CDN │ ... │
└──────────────┴──────────────────────────────────┘
- Clicking a ToC entry scrolls smoothly to that section
- Uses
position-stickyto follow the page as you scroll - Nested headings indented to show hierarchy
- On mobile (
<768px) the sidebar is hidden and content spans full width - Docs with no headings render full-width with no sidebar
Syntax theme selector
A syntax dropdown in the navbar lets users choose an independent highlight.js theme, separate from the Bootswatch page theme.
Layout
┌──────────────────────────────────────────────────┐
│ theme ▼ syntax ▼ │
│ ├─ Auto (follow theme) ✓ │
│ ├─ ─ ─ ─ ─ ─ ─ │
│ ├─ Dark │
│ │ ├─ Atom One Dark │
│ │ ├─ Monokai Sublime │
│ │ ├─ Nord │
│ │ ├─ Dracula │
│ │ ├─ Night Owl │
│ │ └─ Tokyo Night Dark │
│ ├─ ─ ─ ─ ─ ─ ─ │
│ ├─ Light │
│ │ ├─ Atom One Light │
│ │ ├─ GitHub │
│ │ ├─ VS │
│ │ ├─ XCode │
│ │ ├─ Tokyo Night Light │
│ │ └─ IntelliJ Light │
│ ├─ ─ ─ ─ ─ ─ ─ │
│ ├─ Other (245) │
│ │ └─ (lazy-loaded on expand) │
│ └─ Hide │
└──────────────────────────────────────────────────┘
Behaviour
| Aspect | Details |
|---|---|
| Auto mode | Default. Maps to github-dark-dimmed for dark Bootswatch themes, github for light. |
| Manual mode | Any selection overrides auto. Saved to localStorage key hljs-theme. |
| Persistence | Choice survives page reload. Bootswatch theme changes only affect hljs when set to Auto. |
| Curated set | 6 dark + 6 light popular themes, always visible in the dropdown. |
| Other themes | 245 additional themes (all non-base16 + all base16 variants) embedded as window.HLJS_OTHER; DOM generated lazily when "Show all" is clicked. |
| Active marker | Checkmark (✓) appears on the active item; restored from data-hljs-theme on page load. |
| Caveat | Theme swap is CSS-only — the <link> href is replaced. Class names are identical across all highlight.js themes, so the browser recalculates styles automatically. No need to re-run hljs.highlightAll(). |
Implementation
The full theme list is fetched at build time from the CDN and embedded as a JS array in templates/base.html. The "Other" section is populated client-side on first expand — avoids rendering 245 DOM nodes on every page load.
Implementation
Key files
| File | Purpose |
|---|---|
src/main.py |
Core app — routes, DocLinkExtension, tag helpers, env var config, middleware |
src/routers/playground.py |
Playground router — PolygonParams model, _polygon_area helper, /polygon_generator and /playground endpoints |
templates/base.html |
Layout, navbar, theme picker, highlight.js theme switching, CSS for code blocks, tables, ToC |
templates/index.html |
Home page with random tag badges and pinned #about_us |
templates/docs_index.html |
Doc listing with search, autocomplete, tag sidebar, filtering JS |
templates/doc.html |
Single-doc view with breadcrumb, ToC sidebar, content |
templates/playground.html |
Polygon playground with sidebar experiment layout, controls, and AJAX image loading |
requirements.txt |
Markdown>=3.10.2, pylint>=3.3.0, djlint>=1.36.0 |
.pylintrc |
Pylint configuration |
pyproject.toml |
djlint configuration (ignore = "H031,J018,T028") |
.env |
Environment variables (gitignored) |
lint.sh |
Run pylint src/main.py src/routers/playground.py |
lint-templates.sh |
Run djlint templates/ --profile=jinja (HTML accessibility checks) |
Python helpers
| Function | File | Description |
|---|---|---|
extract_tag(content) |
src/main.py |
Returns the first #tag from content (ignoring code blocks) |
extract_all_tags(content) |
src/main.py |
Returns every #tag from content (ignoring code blocks) |
strip_code(content) |
src/main.py |
Removes fenced code blocks and inline code before tag extraction |
strip_tag_lines(content) |
src/main.py |
Removes #tag lines from markdown before rendering |
build_tag_index() |
src/main.py |
Returns {tag: count} across all docs |
build_docs_with_tags() |
src/main.py |
Returns [(slug, title, [tags])] for all docs |
PolygonParams |
src/routers/playground.py |
Pydantic model with clamping validators (width/height ≤ 1000, vertices ≤ 100) |
_polygon_area(points) |
src/routers/playground.py |
Shoelace formula — used by polygon retry logic |
Key JS functions (in docs_index.html)
| Function | Description |
|---|---|
renderPills() |
Renders active tag pill badges with ✕ remove buttons |
renderTagSidebar() |
Renders all tags in the left sidebar, highlighting active ones |
filterDocs() |
Shows/hides docs based on AND filtering against active tags |
updateUrl() |
Updates ?tags= in URL via history.replaceState |
saveState() |
Persists active tags to localStorage |
addTag(tag) |
Adds a tag, updates pills, sidebar, filter, URL, and storage |
removeTag(tag) |
Removes a tag, updates pills, sidebar, filter, URL, and storage |
showSuggestions(results) |
Renders the autocomplete dropdown |
Edge cases
| Case | Behaviour |
|---|---|
| Missing doc | Returns 404 with "Doc not found" |
| Empty docs directory | Home shows no badges, /docs shows "No docs found" |
| No tag in file | Doc still appears in list and renders; not searchable by tag |
| No heading | Doc slug used as display title |
?tags= with invalid tag |
Ignored, shows all docs |
| 0 results after filtering | Shows "No docs match selected tags" |
| Fewer than 4 unique tags | Home shows what's available (1-3) |
| Tag in code block | Properly excluded from extraction |
| Trackpad click on suggestion | Uses mousedown (fires before blur) for reliable selection |
Testing
Approach
The test suite uses pytest with httpx (via TestClient) for HTTP integration and BeautifulSoup for HTML parsing and assertion. All tests operate on a temporary set of fixture .md files to avoid touching the real docs/ directory. The DOCS_DIR module-level global in src/main.py is monkeypatched by an autouse fixture.
Running tests
./test.sh
Or directly:
source .venv/bin/activate
python3 -m pytest tests/ -v --tb=short
Test structure (5 files, 93 tests)
conftest.py — Fixtures
An autouse fixture creates a TemporaryDirectory with 6 fixture files:
| File | Tags | Heading | Content highlights |
|---|---|---|---|
alpha.md |
#alpha |
Alpha |
Cross-link to beta.md, fenced code block with #inline_tag_inside_code (should be excluded) |
beta.md |
#beta |
Beta |
Markdown table |
gamma.md |
#gamma |
Gamma |
Inline code `#not_a_real_tag` (should be excluded) |
naked.md |
(none) | (none) | Plain text, no heading, no tags |
multi_tag.md |
#tag_one, #tag_two, #tag_three |
Multi Tag |
AND-filtering target |
callout_doc.md |
#callout_test |
Callout Doc |
NOTE and WARNING callout blocks |
The fixture then monkeypatches src.main.DOCS_DIR to the temp directory so all routes and helpers operate on it transparently. The client fixture provides a TestClient against src.main.app.
test_tags.py — Unit tests (private functions)
Tests each helper function in isolation with synthetic strings:
extract_tag— first-tag, no-tag, heading-not-matched, code-block-ignored, inline-code-ignored, hyphenated, underscoredextract_all_tags— all-returned, empty, duplicates, code-block-excluded, inline-code-excludedstrip_tag_lines— single/multiple removals, heading-not-stripped, no-tag-unchangedbuild_tag_index— counts correct, inline-code-tag-excluded (e.g.not_a_real_tagfrom gamma fixture)build_docs_with_tags— structure, titles use heading, fallback to slug, tags present/absent
test_routes.py — Route tests (HTTP via TestClient)
Tests all endpoints with real HTTP requests against the fixture docs:
- Home — status, message, random tag badges, links to filtered
/docs - Docs index — status, lists all, single-tag filter, AND filter, no-match filter, empty filter, embedded tag JSON
- Doc pages — status, 404, 404 message, title in breadcrumb, fallback-title for headingless docs
- API — health, tags all, substring query, no-match query, counts
- Swagger — swagger UI availability
test_markdown.py — Rendering tests (HTML structure via BeautifulSoup)
Verifies the output HTML from rendered docs:
- Cross-links —
.mdextension rewritten to/docs/..., non-.mdlinks unchanged - Code blocks —
language-*class present, wrapped in<pre> - Tables —
<table>renders, has rows - ToC — heading IDs generated, sidebar present, links in sidebar, empty when no headings
- Tag line stripping — tag line
#alphaabsent from visible body text; headingh1text equals display title, not tag line
test_edge_cases.py — Edge cases
Tests the system under unusual conditions:
- Empty docs dir — home shows no badges, index shows "No docs found", API returns empty
- Special characters — hyphenated tags extracted from real files
- Multi-tag doc — all tags listed, filter preserves others, AND filtering works
- No-tag doc — appears in list, renders, not in tag index or sidebar
- 404 page — no ToC on 404, breadcrumb shows "not found", querystring handled
test_integration.py — Workflow tests (read-write on fixture dir)
Tests dynamic document lifecycle by creating, editing, and deleting files at runtime within the fixture temp directory:
- Tag index populated from fixture docs
- Stray inline-code tags excluded from index
- New doc creation — appears in index and filtered views immediately
- New doc renders at its slug URL
- Edited content reflected on next request
- Edited title reflected in breadcrumb and doc list
Code quality
./lint.sh # pylint src/main.py src/routers/playground.py (targets 10/10)
./lint-templates.sh # djlint templates/ (HTML accessibility + conventions)
requirements.txtincludespylint>=3.3.0,pytest>=8.0.0,httpx>=0.28.0,beautifulsoup4>=4.12.0,djlint>=1.36.0.pylintrcconfigured with 120-char line length, ignores.venv/__pycache__/.gittocextension setsmd.tocdynamically — false-positive suppressed with# pylint: disable=no-memberpyproject.tomlconfigures djlint (profile="jinja", ignoresH031/J018/T028) and pytest (pythonpath = ["."]forsrcpackage discovery)- All layout-related inline styles (
style="...") are extracted into CSS classes inbase.htmlfor accessibility - JS-managed visibility toggles use Bootstrap's
d-noneclass instead of inlinestyle.display
Future improvements
| Area | Idea |
|---|---|
| Search | Full-text content search (not just tag names) with server-side ranking |
| Autocomplete | Keyboard navigation (arrow keys) through suggestions |
| Tags | Editable tags from within the web UI (write-back to .md file) |
| Assets | Embedding images (local file paths → /docs/assets/...) |
| Categories | Hierarchical tags or grouping by directory structure |
| Diff view | Side-by-side markdown source vs rendered HTML for editors |
| Live reload | File watcher in dev mode to re-index on .md save without server restart |
| Themes | Custom syntax highlighting theme per doc (front-matter config) |
| Search | Fuzzy/typo-tolerant tag matching in the autocomplete endpoint |
| Export | Download doc as PDF or standalone HTML |
| Validation | Broken cross-link detection (report .md links with no target file) |