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:

  1. Tag line (optional) — #tagname on 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.
  2. Heading — a standard markdown # Heading is 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.

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) wraps path.read_text() for both OSError and UnicodeDecodeError, returning None on failure — all callers check and handle gracefully.
  • slug_title() accepts pre-read content instead of a path, eliminating the duplicate file read read_doc previously performed.
  • Log file parent directory is created automatically at startup via Path(LOG_FILE).parent.mkdir(parents=True, exist_ok=True).
  • DOCS_DIR existence 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 — strap finds bootstrap_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 synchistory.replaceState updates ?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-sticky to 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, underscored
  • extract_all_tags — all-returned, empty, duplicates, code-block-excluded, inline-code-excluded
  • strip_tag_lines — single/multiple removals, heading-not-stripped, no-tag-unchanged
  • build_tag_index — counts correct, inline-code-tag-excluded (e.g. not_a_real_tag from 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.md extension rewritten to /docs/..., non-.md links unchanged
  • Code blockslanguage-* 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 #alpha absent from visible body text; heading h1 text 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.txt includes pylint>=3.3.0, pytest>=8.0.0, httpx>=0.28.0, beautifulsoup4>=4.12.0, djlint>=1.36.0
  • .pylintrc configured with 120-char line length, ignores .venv/__pycache__/.git
  • toc extension sets md.toc dynamically — false-positive suppressed with # pylint: disable=no-member
  • pyproject.toml configures djlint (profile="jinja", ignores H031/J018/T028) and pytest (pythonpath = ["."] for src package discovery)
  • All layout-related inline styles (style="...") are extracted into CSS classes in base.html for accessibility
  • JS-managed visibility toggles use Bootstrap's d-none class instead of inline style.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)