Files
claude-howto/scripts/website_templates/page.html.j2
T
Luong NGUYEN 3557d791f5 feat(scripts): add static website generator from markdown sources (#85) (#121)
* feat(scripts): add static website generator from markdown sources (#85)

Generate an elegant, mobile-friendly static site from the existing
tutorial markdown files. The markdown remains the single source of
truth — `scripts/build_website.py` reads from the same `.md` files the
EPUB builder uses, rewrites cross-references to site URLs, and rewrites
references to non-markdown repo files (`.json`, `.sh`, `.py`) to
GitHub blob URLs so users can jump to the source on github.com.

Highlights:
- Reuses the chapter ordering convention from `build_epub.py`
- Anchor algorithm mirrors `check_cross_references.heading_to_anchor`
  for parity with the validator
- Mermaid renders client-side via `mermaid.js` (no pre-render step)
- Tailwind CSS via CDN; light/dark theme toggle; sidebar nav; in-page
  TOC; prev/next page navigation; mobile responsive
- 27 unit + smoke tests covering anchors, link rewriting (including
  `<source srcset>` inside `<picture>`), Mermaid handling, and a full
  end-to-end build
- GitHub Pages deploy workflow at `.github/workflows/pages.yml`

Closes #85

* fix(website): use relative URLs in sidebar nav and avoid INDEX.html collision

Two bugs found by local browser dogfooding:

1. **Sidebar nav broke from deep pages.** `build_navigation` emitted raw
   `output_url` values (site-root-relative) which made every sidebar link
   404 from any page below the root. Moved the call inside the per-page
   render loop so each page gets nav links computed relative to its own
   URL — `01-slash-commands/index.html` from the root, `../01-slash-commands/...`
   from a depth-1 page, `../../01-slash-commands/...` from depth-2.

2. **`INDEX.md` overwrote `index.html`.** On case-insensitive filesystems
   (macOS/Windows), `INDEX.html` and `index.html` are the same file, so
   `INDEX.md` clobbered the rendered `README.md`. Added `_disambiguate_url`
   that detects case-insensitive collisions and suffixes the colliding
   page with its source stem (`INDEX-index.html`).

Added 2 tests; full suite stays at 83 passed.

* fix(scripts): skip URLs with port in localhost/127.0.0.1 skip list

`check_links.is_skipped()` did an exact-match comparison against the
host, so `http://localhost:8080` (used in scripts/README.md as a preview
example) was not skipped and CI's link check tried to fetch it, which
fails on the GitHub runner. Strip the port before comparing.

* chore(scripts): drop vestigial mypy ignore_errors for build_website

The override silenced all mypy errors for build_website, making the
"mypy: clean" claim technically vacuous. Removing it shows mypy is
actually clean — 0 issues on build_website after type annotations
were added during PR review.

* feat(website): self-host Tailwind, Mermaid, and Inter fonts

Drop all third-party CDN dependencies from rendered pages. The site
previously loaded Tailwind from cdn.tailwindcss.com (Play CDN — JIT
compile in browser, marked not-for-production), Mermaid from
cdn.jsdelivr.net, and Inter/JetBrains Mono from fonts.googleapis.com.

Replace with a vendored toolchain:

- scripts/vendor_assets.py downloads the Tailwind standalone CLI
  (Go binary, no Node toolchain), Mermaid's UMD bundle, and Google
  Fonts CSS + WOFF2 files. Cached under scripts/.vendor-cache/
  (gitignored), refetched only when missing.
- Tailwind compiles a per-build site/assets/tailwind.css with only
  the utility classes actually used by the rendered HTML.
- Mermaid and font files land in site/assets/vendor/ and load via
  relative URLs.
- Tailwind config + entry CSS live in scripts/website_templates/
  alongside the Jinja template.
- build_website grows a skip_vendor flag so the smoke test runs
  offline.
- pre-commit mypy hook gets types-Markdown so it can resolve the
  same imports as the project venv.

Verification: 86/86 pytest pass, ruff/mypy/bandit clean, full
build produces a working site with zero external requests (verified
in a headless browser — no console errors, no failed network calls,
Mermaid diagrams render).

* fix(website): use tree URLs for repo directory links (#85)

* fix(website): include additional top-level docs (#85)
2026-05-15 08:55:29 +02:00

254 lines
12 KiB
Django/Jinja

<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{{ page_title }}{{ site_title }}</title>
<meta name="description" content="{{ site_subtitle }}" />
<link rel="icon" href="{{ assets_prefix }}resources/logos/claude-howto-logo.svg" type="image/svg+xml" />
<link rel="stylesheet" href="{{ assets_prefix }}vendor/fonts/fonts.css" />
<link rel="stylesheet" href="{{ assets_prefix }}tailwind.css" />
<link rel="stylesheet" href="{{ assets_prefix }}site.css" />
<script>
// Apply the saved theme before paint to avoid flash of unstyled content.
(function () {
try {
var saved = localStorage.getItem('claude-howto-theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = saved || (prefersDark ? 'dark' : 'light');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
</script>
</head>
<body class="min-h-screen bg-slate-50 text-slate-900 antialiased dark:bg-slate-950 dark:text-slate-100">
<header class="sticky top-0 z-30 border-b border-slate-200 bg-white/85 backdrop-blur dark:border-slate-800 dark:bg-slate-950/85">
<div class="mx-auto flex max-w-7xl items-center gap-3 px-4 py-3 sm:px-6 lg:px-8">
<button
type="button"
id="nav-toggle"
class="inline-flex items-center justify-center rounded-md border border-slate-200 p-2 text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 lg:hidden"
aria-label="Open navigation"
>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M3 5h14a1 1 0 110 2H3a1 1 0 110-2zm0 4h14a1 1 0 110 2H3a1 1 0 110-2zm0 4h14a1 1 0 110 2H3a1 1 0 110-2z"/>
</svg>
</button>
<a href="{{ base_path }}index.html" class="flex items-center gap-2 font-semibold tracking-tight">
<img
src="{{ assets_prefix }}resources/logos/claude-howto-logo.svg"
alt=""
class="h-7 w-auto dark:hidden"
onerror="this.style.display='none'"
/>
<img
src="{{ assets_prefix }}resources/logos/claude-howto-logo-dark.svg"
alt=""
class="hidden h-7 w-auto dark:block"
onerror="this.style.display='none'"
/>
<span class="text-sm sm:text-base">{{ site_title }}</span>
</a>
<div class="ml-auto flex items-center gap-2">
<a
href="{{ github_source_url }}"
target="_blank"
rel="noopener noreferrer"
class="hidden items-center gap-1.5 rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 sm:inline-flex"
title="View this page's markdown source on GitHub"
>
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2 .37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
<span>Edit on GitHub</span>
</a>
<button
type="button"
id="theme-toggle"
class="inline-flex items-center justify-center rounded-md border border-slate-200 p-2 text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
aria-label="Toggle theme"
>
<svg class="hidden h-5 w-5 dark:block" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4.95 2.05a1 1 0 010 1.41l-.71.71a1 1 0 11-1.41-1.41l.71-.71a1 1 0 011.41 0zM18 9a1 1 0 110 2h-1a1 1 0 110-2h1zm-2.05 4.95a1 1 0 011.41 1.41l-.71.71a1 1 0 11-1.41-1.41l.71-.71zM10 16a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zm-4.95-2.05a1 1 0 010 1.41l-.71.71a1 1 0 11-1.41-1.41l.71-.71a1 1 0 011.41 0zM3 9a1 1 0 110 2H2a1 1 0 110-2h1zm2.05-4.95a1 1 0 011.41-1.41l.71.71a1 1 0 11-1.41 1.41l-.71-.71zM10 6a4 4 0 100 8 4 4 0 000-8z"/>
</svg>
<svg class="h-5 w-5 dark:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
</button>
</div>
</div>
</header>
<div class="mx-auto flex max-w-7xl gap-6 px-4 py-8 sm:px-6 lg:px-8">
{# ---------- Sidebar navigation ---------- #}
<aside
id="sidebar"
class="fixed inset-y-0 left-0 z-40 w-72 -translate-x-full transform overflow-y-auto border-r border-slate-200 bg-white px-4 pb-10 pt-20 transition-transform duration-200 ease-out dark:border-slate-800 dark:bg-slate-950 lg:sticky lg:top-20 lg:z-0 lg:h-[calc(100vh-5rem)] lg:translate-x-0 lg:bg-transparent lg:px-0 lg:pt-0 lg:dark:bg-transparent"
>
<nav class="space-y-6 text-sm">
{% for section in nav %}
<div>
<div class="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
{{ section.name }}
</div>
<ul class="space-y-0.5">
{% for item in section['items'] %}
<li>
<a
href="{{ item.url }}"
class="block rounded-md px-3 py-1.5 transition-colors {% if item.is_current %}bg-brand-50 font-semibold text-brand-700 dark:bg-brand-700/20 dark:text-brand-100{% else %}text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white{% endif %}"
>
{{ item.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</nav>
</aside>
{# Backdrop for mobile sidebar #}
<div
id="sidebar-backdrop"
class="fixed inset-0 z-30 hidden bg-slate-900/40 lg:hidden"
aria-hidden="true"
></div>
{# ---------- Main content ---------- #}
<main class="flex min-w-0 flex-1 flex-col">
<nav class="mb-4 text-xs text-slate-500 dark:text-slate-400" aria-label="Breadcrumb">
<ol class="flex flex-wrap items-center gap-1.5">
<li><a href="{{ base_path }}index.html" class="hover:underline">Home</a></li>
<li aria-hidden="true">/</li>
<li class="text-slate-700 dark:text-slate-200">{{ section }}</li>
{% if page_title != section %}
<li aria-hidden="true">/</li>
<li class="text-slate-700 dark:text-slate-200">{{ page_title }}</li>
{% endif %}
</ol>
</nav>
<article class="prose prose-slate max-w-none prose-headings:scroll-mt-24 prose-headings:font-semibold prose-h1:text-3xl prose-h2:text-2xl prose-h3:text-xl prose-a:text-brand-600 prose-a:no-underline hover:prose-a:underline prose-code:rounded prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:font-mono prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-img:rounded-lg prose-table:text-sm dark:prose-invert dark:prose-code:bg-slate-800 dark:prose-pre:bg-slate-900">
{{ content | safe }}
</article>
{% if prev_page or next_page %}
<div class="mt-12 grid grid-cols-1 gap-3 border-t border-slate-200 pt-6 dark:border-slate-800 sm:grid-cols-2">
{% if prev_page %}
<a
href="{{ prev_page.url }}"
class="group rounded-lg border border-slate-200 p-4 transition hover:border-brand-500 hover:bg-brand-50/50 dark:border-slate-800 dark:hover:border-brand-500 dark:hover:bg-brand-700/10"
>
<div class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">← Previous</div>
<div class="mt-1 font-semibold text-slate-900 group-hover:text-brand-700 dark:text-slate-100 dark:group-hover:text-brand-300">
{{ prev_page.title }}
</div>
</a>
{% else %}
<span></span>
{% endif %}
{% if next_page %}
<a
href="{{ next_page.url }}"
class="group rounded-lg border border-slate-200 p-4 text-right transition hover:border-brand-500 hover:bg-brand-50/50 dark:border-slate-800 dark:hover:border-brand-500 dark:hover:bg-brand-700/10"
>
<div class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">Next →</div>
<div class="mt-1 font-semibold text-slate-900 group-hover:text-brand-700 dark:text-slate-100 dark:group-hover:text-brand-300">
{{ next_page.title }}
</div>
</a>
{% endif %}
</div>
{% endif %}
<footer class="mt-12 border-t border-slate-200 pt-6 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
<p>
Content rendered from
<a href="{{ github_source_url }}" target="_blank" rel="noopener noreferrer" class="underline hover:text-brand-700 dark:hover:text-brand-300">
{{ page_title }}
</a>
on GitHub. Markdown is the single source of truth — re-run
<code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono dark:bg-slate-800">scripts/build_website.py</code>
after editing to refresh the site.
</p>
</footer>
</main>
{# ---------- In-page TOC ---------- #}
{% if toc %}
<aside class="hidden w-56 xl:block">
<div class="sticky top-24 text-sm">
<div class="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
On this page
</div>
<ul class="space-y-1">
{% for entry in toc %}
<li>
<a
href="#{{ entry.anchor }}"
class="block truncate text-slate-600 hover:text-brand-700 dark:text-slate-400 dark:hover:text-brand-300 {% if entry.level == 'h3' %}pl-3 text-xs{% endif %}"
>{{ entry.text }}</a>
</li>
{% endfor %}
</ul>
</div>
</aside>
{% endif %}
</div>
<script src="{{ assets_prefix }}vendor/mermaid/mermaid.min.js"></script>
<script>
(function () {
if (typeof mermaid === 'undefined') return;
var prefersDark = document.documentElement.classList.contains('dark');
mermaid.initialize({
startOnLoad: true,
theme: prefersDark ? 'dark' : 'default',
securityLevel: 'strict',
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
});
})();
</script>
<script>
(function () {
var toggle = document.getElementById('theme-toggle');
if (toggle) {
toggle.addEventListener('click', function () {
var isDark = document.documentElement.classList.toggle('dark');
try {
localStorage.setItem('claude-howto-theme', isDark ? 'dark' : 'light');
} catch (e) {}
});
}
var navToggle = document.getElementById('nav-toggle');
var sidebar = document.getElementById('sidebar');
var backdrop = document.getElementById('sidebar-backdrop');
function closeSidebar() {
if (!sidebar) return;
sidebar.classList.add('-translate-x-full');
if (backdrop) backdrop.classList.add('hidden');
}
function openSidebar() {
if (!sidebar) return;
sidebar.classList.remove('-translate-x-full');
if (backdrop) backdrop.classList.remove('hidden');
}
if (navToggle && sidebar) {
navToggle.addEventListener('click', function () {
if (sidebar.classList.contains('-translate-x-full')) {
openSidebar();
} else {
closeSidebar();
}
});
}
if (backdrop) backdrop.addEventListener('click', closeSidebar);
})();
</script>
</body>
</html>