mirror of
https://github.com/luongnv89/claude-howto.git
synced 2026-09-26 13:20:42 +02:00
feat(website): add landing page with interactive learning roadmap (#185)
* feat(website): add landing page with interactive learning roadmap The site home was the rendered README, which neither sells the guide nor helps learners track where they are. The new landing page explains the value vs the official docs and turns the curriculum into a checklist roadmap (10 modules, 60 lessons) with progress saved in localStorage. - README now renders to guide.html; landing is index.html (English only, since translated anchors differ) - roadmap.json is validated at build time: every lesson heading must resolve to an anchor, so doc drift fails the build - light/dark theme toggle shared with the docs pages - vendor Fraunces + Manrope; font CSS cache keyed on the request URL - pin build_website.py PEP 723 deps to requirements.txt: markdown 3.11 mis-parses `</dev/tty>` in a code span and silently truncated the rendered hooks page * fix(website): address landing page review findings
This commit is contained in:
+173
-2
@@ -1,7 +1,11 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# dependencies = ["markdown", "beautifulsoup4", "jinja2"]
|
||||
# dependencies = ["markdown==3.7", "beautifulsoup4==4.12.3", "jinja2==3.1.4"]
|
||||
# ///
|
||||
|
||||
# Versions pinned to match scripts/requirements.txt — markdown 3.11+ mis-parses
|
||||
# raw `</...>` inside code spans (e.g. `read -r INPUT </dev/tty`) and silently
|
||||
# truncates the rendered page.
|
||||
"""
|
||||
Build a static website from the Claude How-To markdown files.
|
||||
|
||||
@@ -37,6 +41,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
@@ -139,6 +144,8 @@ class WebsiteConfig:
|
||||
site_title: str = "Claude Code How-To Guide"
|
||||
site_subtitle: str = "Master Claude Code in a Weekend"
|
||||
language: str = "en"
|
||||
landing: bool = False # render the marketing landing page as index.html
|
||||
roadmap_path: Path | None = None # defaults to website_templates/roadmap.json
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -160,6 +167,7 @@ class BuildState:
|
||||
|
||||
pages: list[PageInfo] = field(default_factory=list)
|
||||
source_to_url: dict[str, str] = field(default_factory=dict)
|
||||
page_anchors: dict[str, set[str]] = field(default_factory=dict)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -312,7 +320,11 @@ def collect_pages(config: WebsiteConfig, logger: logging.Logger) -> BuildState:
|
||||
seen.add(item)
|
||||
content = read_source(item_path)
|
||||
page_title = title_from_content(content, display_name)
|
||||
url = _disambiguate_url(source_to_site_url(item), used_urls, item)
|
||||
url = source_to_site_url(item)
|
||||
if config.landing and item == "README.md":
|
||||
# The landing page takes index.html; the README becomes guide.html.
|
||||
url = "guide.html"
|
||||
url = _disambiguate_url(url, used_urls, item)
|
||||
used_urls.add(url.lower())
|
||||
state.pages.append(
|
||||
PageInfo(
|
||||
@@ -767,6 +779,9 @@ def render_pages(
|
||||
soup = render_markdown_to_soup(md_content)
|
||||
toc = extract_toc_from_soup(soup)
|
||||
rewrite_links_in_soup(soup, page, state, config, logger)
|
||||
state.page_anchors[page.output_url] = {
|
||||
str(el["id"]) for el in soup.find_all(id=True)
|
||||
}
|
||||
html_content = str(soup)
|
||||
|
||||
prev_page = state.pages[idx - 1] if idx > 0 else None
|
||||
@@ -816,6 +831,159 @@ def render_pages(
|
||||
logger.info(f"Rendered {total} HTML page(s) into {config.output_path}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Landing page
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _resolve_landing_roadmap(
|
||||
data: dict[str, object], state: BuildState
|
||||
) -> tuple[list[dict[str, object]], int, int, list[str]]:
|
||||
"""Resolve roadmap module sources and lesson headings to site URLs.
|
||||
|
||||
Returns (levels, module_count, lesson_count, problems). Every module
|
||||
`source` must map to a rendered page and every lesson `heading` must
|
||||
match an element id on that page — problems are collected, not raised,
|
||||
so a single error lists all drift at once.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
seen_module_ids: set[str] = set()
|
||||
seen_lesson_ids: set[str] = set()
|
||||
resolved_levels: list[dict[str, object]] = []
|
||||
module_count = 0
|
||||
lesson_count = 0
|
||||
|
||||
levels = data.get("levels", [])
|
||||
if not isinstance(levels, list):
|
||||
raise RuntimeError("roadmap.json: 'levels' must be a list")
|
||||
|
||||
for level in levels:
|
||||
resolved_modules: list[dict[str, object]] = []
|
||||
for module in level.get("modules", []):
|
||||
mod_id = str(module.get("id", ""))
|
||||
source = str(module.get("source", ""))
|
||||
module_count += 1
|
||||
if mod_id in seen_module_ids:
|
||||
problems.append(f"duplicate module id: '{mod_id}'")
|
||||
seen_module_ids.add(mod_id)
|
||||
|
||||
url = state.source_to_url.get(source)
|
||||
if url is None:
|
||||
problems.append(
|
||||
f"module '{mod_id}': source '{source}' was not rendered"
|
||||
)
|
||||
anchors = state.page_anchors.get(url, set()) if url else set()
|
||||
|
||||
resolved_lessons: list[dict[str, object]] = []
|
||||
for lesson in module.get("lessons", []):
|
||||
lesson_id = str(lesson.get("id", ""))
|
||||
full_id = f"{mod_id}/{lesson_id}"
|
||||
lesson_count += 1
|
||||
if full_id in seen_lesson_ids:
|
||||
problems.append(f"duplicate lesson id: '{full_id}'")
|
||||
seen_lesson_ids.add(full_id)
|
||||
|
||||
heading = str(lesson.get("heading", ""))
|
||||
anchor = heading_to_anchor(heading)
|
||||
if url is not None and anchor not in anchors:
|
||||
problems.append(
|
||||
f"lesson '{full_id}': heading '{heading}' resolves to "
|
||||
f"#{anchor}, which is missing from {source}"
|
||||
)
|
||||
resolved_lessons.append(
|
||||
{
|
||||
"id": lesson_id,
|
||||
"full_id": full_id,
|
||||
"title": lesson.get("title", heading),
|
||||
"href": (
|
||||
relative_link("index.html", url, f"#{anchor}")
|
||||
if url
|
||||
else "#"
|
||||
),
|
||||
}
|
||||
)
|
||||
resolved_modules.append(
|
||||
{
|
||||
"id": mod_id,
|
||||
"number": module.get("number", ""),
|
||||
"title": module.get("title", mod_id),
|
||||
"time": module.get("time", ""),
|
||||
"tagline": module.get("tagline", ""),
|
||||
"url": relative_link("index.html", url) if url else "#",
|
||||
"lessons": resolved_lessons,
|
||||
"lesson_count": len(resolved_lessons),
|
||||
}
|
||||
)
|
||||
resolved_levels.append(
|
||||
{
|
||||
"id": level.get("id", ""),
|
||||
"name": level.get("name", ""),
|
||||
"title": level.get("title", ""),
|
||||
"summary": level.get("summary", ""),
|
||||
"modules": resolved_modules,
|
||||
}
|
||||
)
|
||||
return resolved_levels, module_count, lesson_count, problems
|
||||
|
||||
|
||||
def render_landing(
|
||||
config: WebsiteConfig,
|
||||
state: BuildState,
|
||||
env: Environment,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
"""Render the marketing landing page as `index.html`.
|
||||
|
||||
The landing is generated only for the English build — translated sites
|
||||
keep their README at index.html because their heading anchors differ.
|
||||
"""
|
||||
template_dir = Path(__file__).parent / "website_templates"
|
||||
roadmap_path = config.roadmap_path or (template_dir / "roadmap.json")
|
||||
data = json.loads(roadmap_path.read_text(encoding="utf-8"))
|
||||
|
||||
levels, module_count, lesson_count, problems = _resolve_landing_roadmap(data, state)
|
||||
if problems:
|
||||
raise RuntimeError(
|
||||
"roadmap.json does not match the rendered pages:\n - "
|
||||
+ "\n - ".join(problems)
|
||||
)
|
||||
|
||||
version = None
|
||||
readme_text = read_source(config.root_path / "README.md")
|
||||
if readme_text:
|
||||
match = re.search(r"badge/version-([\d.]+)", readme_text)
|
||||
if match:
|
||||
version = match.group(1)
|
||||
|
||||
template = env.get_template("landing.html.j2")
|
||||
rendered = template.render(
|
||||
site_title=config.site_title,
|
||||
site_subtitle=config.site_subtitle,
|
||||
levels=levels,
|
||||
module_count=module_count,
|
||||
lesson_count=lesson_count,
|
||||
version=version,
|
||||
guide_url=state.source_to_url.get("README.md", "guide.html"),
|
||||
repo_url=config.repo_url,
|
||||
branch=config.branch,
|
||||
)
|
||||
|
||||
out_file = config.output_path / "index.html"
|
||||
out_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_file.write_text(rendered, encoding="utf-8")
|
||||
|
||||
assets_dir = config.output_path / "assets"
|
||||
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name in ("landing.css", "landing.js"):
|
||||
src = template_dir / name
|
||||
if src.exists():
|
||||
shutil.copy2(src, assets_dir / name)
|
||||
else:
|
||||
logger.warning(f"Landing asset missing, skipped: {src}")
|
||||
|
||||
logger.info(f"Rendered landing page → {out_file}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Build orchestration
|
||||
# =============================================================================
|
||||
@@ -853,6 +1021,8 @@ def build_website(
|
||||
)
|
||||
|
||||
render_pages(config, state, env, logger)
|
||||
if config.landing:
|
||||
render_landing(config, state, env, logger)
|
||||
copy_assets(config, state, logger)
|
||||
|
||||
# Self-hosted vendor assets — drop all CDN dependencies.
|
||||
@@ -958,6 +1128,7 @@ def main() -> int:
|
||||
repo_url=args.repo_url,
|
||||
branch=args.branch,
|
||||
language=args.lang,
|
||||
landing=(args.lang == "en"),
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
@@ -56,7 +61,7 @@ def site_root(tmp_path: Path) -> Path:
|
||||
sc = tmp_path / "01-slash-commands"
|
||||
sc.mkdir()
|
||||
(sc / "README.md").write_text(
|
||||
"# Slash Commands\n\nMermaid time:\n\n```mermaid\nflowchart LR\nA-->B\n```\n\n"
|
||||
"# Slash Commands\n\n## Advanced Usage\n\nMermaid time:\n\n```mermaid\nflowchart LR\nA-->B\n```\n\n"
|
||||
"See [example](example.md).\n"
|
||||
)
|
||||
(sc / "example.md").write_text("# Example\n\nGo back to [overview](README.md).\n")
|
||||
@@ -518,3 +523,450 @@ class TestVendorAssets:
|
||||
|
||||
with pytest.raises(ValueError, match="non-HTTP URL"):
|
||||
_download("file:///etc/passwd", tmp_path / "out.bin")
|
||||
|
||||
def test_font_css_cache_uses_sha256_url_key(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, logger: logging.Logger
|
||||
) -> None:
|
||||
"""Font CSS variants get distinct ten-character SHA-256 cache keys."""
|
||||
import vendor_assets
|
||||
|
||||
cache_dir = tmp_path / "vendor-cache"
|
||||
monkeypatch.setattr(vendor_assets, "_vendor_cache_dir", lambda: cache_dir)
|
||||
urls = [
|
||||
vendor_assets.GOOGLE_FONTS_CSS_URL,
|
||||
vendor_assets.GOOGLE_FONTS_CSS_URL + "&variant=regression",
|
||||
]
|
||||
downloads: list[str] = []
|
||||
|
||||
def fake_download(
|
||||
url: str, dest: Path, headers: dict[str, str] | None = None
|
||||
) -> None:
|
||||
del headers
|
||||
downloads.append(url)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(f"/* {url} */", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(vendor_assets, "_download", fake_download)
|
||||
|
||||
keys: list[str] = []
|
||||
for index, url in enumerate(urls):
|
||||
monkeypatch.setattr(vendor_assets, "GOOGLE_FONTS_CSS_URL", url)
|
||||
vendor_assets.fetch_fonts(tmp_path / f"output-{index}", logger)
|
||||
key = hashlib.sha256(url.encode("utf-8")).hexdigest()[:10]
|
||||
keys.append(key)
|
||||
assert len(key) == 10
|
||||
assert (cache_dir / "fonts" / f"fonts-{key}.css").is_file()
|
||||
|
||||
assert keys[0] != keys[1]
|
||||
assert downloads == urls
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Landing page
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _write_roadmap(path: Path, modules: list[dict]) -> Path:
|
||||
data = {
|
||||
"levels": [
|
||||
{
|
||||
"id": "beginner",
|
||||
"name": "Level 1 — Beginner",
|
||||
"title": "Getting started",
|
||||
"summary": "Start here.",
|
||||
"modules": modules,
|
||||
}
|
||||
]
|
||||
}
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _roadmap_module(lessons: list[dict], **overrides) -> dict:
|
||||
module = {
|
||||
"id": "slash-commands",
|
||||
"number": "01",
|
||||
"title": "Slash Commands",
|
||||
"source": "01-slash-commands/README.md",
|
||||
"time": "30 min",
|
||||
"tagline": "Commands.",
|
||||
"lessons": lessons,
|
||||
}
|
||||
module.update(overrides)
|
||||
return module
|
||||
|
||||
|
||||
class TestLanding:
|
||||
def _config(self, site_root: Path, out_dir: Path, roadmap: Path) -> WebsiteConfig:
|
||||
return WebsiteConfig(
|
||||
root_path=site_root,
|
||||
output_path=out_dir,
|
||||
repo_url="https://github.com/example/repo",
|
||||
branch="main",
|
||||
landing=True,
|
||||
roadmap_path=roadmap,
|
||||
)
|
||||
|
||||
def test_landing_build(
|
||||
self, site_root: Path, tmp_path: Path, logger: logging.Logger
|
||||
) -> None:
|
||||
roadmap = _write_roadmap(
|
||||
tmp_path / "roadmap.json",
|
||||
[
|
||||
_roadmap_module(
|
||||
[
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "Overview",
|
||||
"heading": "Slash Commands",
|
||||
},
|
||||
{
|
||||
"id": "advanced",
|
||||
"title": "Advanced usage",
|
||||
"heading": "Advanced Usage",
|
||||
},
|
||||
]
|
||||
)
|
||||
],
|
||||
)
|
||||
out_dir = site_root / "site"
|
||||
build_website(
|
||||
self._config(site_root, out_dir, roadmap), logger, skip_vendor=True
|
||||
)
|
||||
|
||||
index = out_dir / "index.html"
|
||||
assert index.exists()
|
||||
index_html = index.read_text(encoding="utf-8")
|
||||
soup = BeautifulSoup(index_html, "html.parser")
|
||||
# The landing takes index.html
|
||||
assert soup.select_one("#roadmap") is not None
|
||||
assert soup.select_one('[data-lesson="slash-commands/overview"]') is not None
|
||||
# The header CTA and live copy status remain available in the rendered page.
|
||||
assert soup.select_one('header .nav-cta[href="#roadmap"]') is not None
|
||||
copy_status = soup.select_one("#copy-status")
|
||||
assert copy_status is not None
|
||||
assert copy_status.get("role") == "status"
|
||||
assert copy_status.get("aria-live") == "polite"
|
||||
# Lesson anchors resolve into the module page
|
||||
assert "01-slash-commands/index.html#advanced-usage" in index_html
|
||||
# Landing assets are copied and referenced alongside the site CSS
|
||||
assert (out_dir / "assets" / "landing.css").exists()
|
||||
assert (out_dir / "assets" / "landing.js").exists()
|
||||
assert soup.select_one('link[href="assets/landing.css"]') is not None
|
||||
assert soup.select_one('script[src="assets/landing.js"]') is not None
|
||||
# The landing must not pull in the docs-page stylesheet or CDNs
|
||||
assert "tailwind.css" not in index_html
|
||||
for hostile in ("cdn.tailwindcss.com", "fonts.googleapis.com"):
|
||||
assert hostile not in index_html
|
||||
|
||||
# The README moved to guide.html
|
||||
guide = out_dir / "guide.html"
|
||||
assert guide.exists()
|
||||
assert "Home Page" in guide.read_text(encoding="utf-8")
|
||||
|
||||
# Links from other pages to README.md now resolve to guide.html
|
||||
roadmap_page = (out_dir / "LEARNING-ROADMAP.html").read_text(encoding="utf-8")
|
||||
assert "guide.html#home-page" in roadmap_page
|
||||
assert "index.html#home-page" not in roadmap_page
|
||||
|
||||
def test_landing_missing_heading(
|
||||
self, site_root: Path, tmp_path: Path, logger: logging.Logger
|
||||
) -> None:
|
||||
roadmap = _write_roadmap(
|
||||
tmp_path / "roadmap.json",
|
||||
[
|
||||
_roadmap_module(
|
||||
[
|
||||
{
|
||||
"id": "ghost",
|
||||
"title": "Ghost",
|
||||
"heading": "Does Not Exist",
|
||||
}
|
||||
]
|
||||
)
|
||||
],
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Does Not Exist"):
|
||||
build_website(
|
||||
self._config(site_root, site_root / "site", roadmap),
|
||||
logger,
|
||||
skip_vendor=True,
|
||||
)
|
||||
|
||||
def test_landing_missing_source(
|
||||
self, site_root: Path, tmp_path: Path, logger: logging.Logger
|
||||
) -> None:
|
||||
roadmap = _write_roadmap(
|
||||
tmp_path / "roadmap.json",
|
||||
[
|
||||
_roadmap_module(
|
||||
[{"id": "x", "title": "X", "heading": "X"}],
|
||||
source="99-nope/README.md",
|
||||
)
|
||||
],
|
||||
)
|
||||
with pytest.raises(RuntimeError, match=r"99-nope/README\.md"):
|
||||
build_website(
|
||||
self._config(site_root, site_root / "site", roadmap),
|
||||
logger,
|
||||
skip_vendor=True,
|
||||
)
|
||||
|
||||
def test_landing_duplicate_ids(
|
||||
self, site_root: Path, tmp_path: Path, logger: logging.Logger
|
||||
) -> None:
|
||||
lesson = {"id": "overview", "title": "Overview", "heading": "Slash Commands"}
|
||||
roadmap = _write_roadmap(
|
||||
tmp_path / "roadmap.json",
|
||||
[_roadmap_module([lesson, dict(lesson)])],
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="duplicate lesson id"):
|
||||
build_website(
|
||||
self._config(site_root, site_root / "site", roadmap),
|
||||
logger,
|
||||
skip_vendor=True,
|
||||
)
|
||||
|
||||
def test_landing_real_repo(self, tmp_path: Path, logger: logging.Logger) -> None:
|
||||
"""Every heading in the shipped roadmap.json must resolve to an anchor."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
out_dir = tmp_path / "site"
|
||||
config = WebsiteConfig(
|
||||
root_path=repo_root,
|
||||
output_path=out_dir,
|
||||
landing=True,
|
||||
)
|
||||
build_website(config, logger, skip_vendor=True)
|
||||
index_html = (out_dir / "index.html").read_text(encoding="utf-8")
|
||||
assert 'id="roadmap"' in index_html
|
||||
assert 'data-lesson="slash-commands/overview"' in index_html
|
||||
assert (out_dir / "guide.html").exists()
|
||||
|
||||
def test_landing_off_keeps_readme_index(
|
||||
self, site_root: Path, logger: logging.Logger
|
||||
) -> None:
|
||||
out_dir = site_root / "site"
|
||||
config = WebsiteConfig(
|
||||
root_path=site_root,
|
||||
output_path=out_dir,
|
||||
landing=False,
|
||||
)
|
||||
build_website(config, logger, skip_vendor=True)
|
||||
index_html = (out_dir / "index.html").read_text(encoding="utf-8")
|
||||
assert "Home Page" in index_html
|
||||
assert 'id="roadmap"' not in index_html
|
||||
assert not (out_dir / "guide.html").exists()
|
||||
|
||||
|
||||
LANDING_JS_NODE_HARNESS = r"""
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const source = fs.readFileSync(process.argv[1], "utf8");
|
||||
|
||||
function element(id = null, classes = [], attrs = {}) {
|
||||
const names = new Set(classes);
|
||||
const listeners = {};
|
||||
const item = {
|
||||
id, attrs, style: {}, hidden: false, textContent: "",
|
||||
classList: {
|
||||
add(...values) { values.forEach((value) => names.add(value)); },
|
||||
remove(...values) { values.forEach((value) => names.delete(value)); },
|
||||
contains(value) { return names.has(value); },
|
||||
toggle(value, force) {
|
||||
const next = force === undefined ? !names.has(value) : force;
|
||||
if (next) names.add(value); else names.delete(value);
|
||||
return next;
|
||||
},
|
||||
},
|
||||
getAttribute(name) { return name === "id" ? item.id : item.attrs[name] ?? null; },
|
||||
setAttribute(name, value) { item.attrs[name] = String(value); },
|
||||
addEventListener(name, handler) { (listeners[name] ||= []).push(handler); },
|
||||
dispatch(name) { (listeners[name] || []).forEach((handler) => handler({ target: item })); },
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
closest: () => null,
|
||||
appendChild: () => {},
|
||||
removeChild: () => {},
|
||||
select: () => {},
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
function makeEnvironment(spec) {
|
||||
const ids = new Map();
|
||||
const register = (id, classes = []) => {
|
||||
const item = element(id, classes);
|
||||
ids.set(id, item);
|
||||
return item;
|
||||
};
|
||||
const copy = element(null, [], { "data-copy": "echo copied" });
|
||||
const label = element();
|
||||
label.classList.add("copy-label");
|
||||
label.textContent = "Copy";
|
||||
copy.querySelector = (selector) => selector === ".copy-label" ? label : null;
|
||||
const document = {
|
||||
documentElement: element(null, ["no-js"]),
|
||||
body: element(),
|
||||
getElementById: (id) => ids.get(id) || null,
|
||||
querySelectorAll: (selector) => selector === "[data-copy]" ? [copy] : [],
|
||||
createElement: () => element(),
|
||||
addEventListener: () => {},
|
||||
};
|
||||
register("top", ["nav"]);
|
||||
register("nav-toggle");
|
||||
register("nav-menu");
|
||||
register("copy-status");
|
||||
|
||||
let clipboardCalls = 0;
|
||||
let execCalls = 0;
|
||||
let copiedText = null;
|
||||
let resolveClipboard;
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
document.execCommand = () => {
|
||||
execCalls += 1;
|
||||
if (spec.exec === "throw") throw new Error("copy denied");
|
||||
return spec.exec === "true";
|
||||
};
|
||||
const clipboard = spec.clipboard ? {
|
||||
writeText(text) {
|
||||
clipboardCalls += 1;
|
||||
copiedText = text;
|
||||
if (spec.clipboard === "deferred") {
|
||||
return new Promise((resolve) => { resolveClipboard = resolve; });
|
||||
}
|
||||
return spec.clipboard === "reject"
|
||||
? Promise.reject(new Error("clipboard unavailable"))
|
||||
: Promise.resolve();
|
||||
},
|
||||
} : null;
|
||||
const navigator = { clipboard };
|
||||
const storage = new Map();
|
||||
const window = {
|
||||
innerHeight: 800,
|
||||
localStorage: {
|
||||
getItem: (key) => storage.get(key) || null,
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
},
|
||||
matchMedia: () => ({ matches: false }),
|
||||
setTimeout: (fn) => {
|
||||
const id = nextTimer++;
|
||||
timers.set(id, fn);
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
addEventListener: () => {},
|
||||
requestAnimationFrame: () => {},
|
||||
confirm: () => true,
|
||||
navigator,
|
||||
};
|
||||
return {
|
||||
copy, document, top: ids.get("top"),
|
||||
context: { console, Date, document, navigator, Promise, window },
|
||||
counters: () => ({ clipboardCalls, copiedText, execCalls }),
|
||||
resolveClipboard: () => resolveClipboard(),
|
||||
flushTimers: () => {
|
||||
const pending = Array.from(timers.values());
|
||||
timers.clear();
|
||||
pending.forEach((fn) => fn());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runCase(spec) {
|
||||
const env = makeEnvironment(spec);
|
||||
vm.runInNewContext(source, env.context, { filename: "landing.js" });
|
||||
env.copy.dispatch("click");
|
||||
return new Promise((resolve) => setImmediate(() => {
|
||||
const label = env.copy.querySelector(".copy-label");
|
||||
const status = env.document.getElementById("copy-status");
|
||||
resolve({
|
||||
name: spec.name, label: label.textContent, status: status.textContent,
|
||||
copied: env.copy.classList.contains("copied"),
|
||||
navEnhanced: env.top.classList.contains("nav-enhanced"),
|
||||
...env.counters(),
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async function runRapidClicks() {
|
||||
const spec = { clipboard: null, exec: "true" };
|
||||
const env = makeEnvironment(spec);
|
||||
vm.runInNewContext(source, env.context, { filename: "landing.js" });
|
||||
const label = env.copy.querySelector(".copy-label");
|
||||
const status = env.document.getElementById("copy-status");
|
||||
env.copy.dispatch("click");
|
||||
assert.strictEqual(label.textContent, "Copied");
|
||||
spec.exec = "false";
|
||||
env.copy.dispatch("click");
|
||||
assert.strictEqual(label.textContent, "Copy failed");
|
||||
env.flushTimers();
|
||||
assert.strictEqual(label.textContent, "Copy");
|
||||
assert.strictEqual(status.textContent, "Unable to copy to clipboard");
|
||||
assert.strictEqual(env.copy.classList.contains("copied"), false);
|
||||
|
||||
const pending = makeEnvironment({ clipboard: "deferred", exec: "false" });
|
||||
vm.runInNewContext(source, pending.context, { filename: "landing.js" });
|
||||
pending.copy.dispatch("click");
|
||||
pending.context.navigator.clipboard = null;
|
||||
pending.copy.dispatch("click");
|
||||
pending.resolveClipboard();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.strictEqual(pending.copy.querySelector(".copy-label").textContent, "Copy failed");
|
||||
assert.strictEqual(pending.document.getElementById("copy-status").textContent, "Unable to copy to clipboard");
|
||||
pending.flushTimers();
|
||||
assert.strictEqual(pending.copy.querySelector(".copy-label").textContent, "Copy");
|
||||
return { name: "rapid-clicks" };
|
||||
}
|
||||
|
||||
const cases = [
|
||||
["clipboard-success", "resolve", "false", "Copied", "Copied to clipboard", true, 0, 1, "echo copied"],
|
||||
["legacy-false", null, "false", "Copy failed", "Unable to copy to clipboard", false, 1, 0, null],
|
||||
["legacy-throw", null, "throw", "Copy failed", "Unable to copy to clipboard", false, 1, 0, null],
|
||||
["rejection-legacy-success", "reject", "true", "Copied", "Copied to clipboard", true, 1, 1, "echo copied"],
|
||||
["rejection-legacy-false", "reject", "false", "Copy failed", "Unable to copy to clipboard", false, 1, 1, "echo copied"],
|
||||
];
|
||||
Promise.all([
|
||||
...cases.map(([name, clipboard, exec]) => runCase({ name, clipboard, exec })),
|
||||
runRapidClicks(),
|
||||
])
|
||||
.then((results) => {
|
||||
results.slice(0, cases.length).forEach((result, index) => {
|
||||
const [, , , label, status, copied, execCalls, clipboardCalls, copiedText] = cases[index];
|
||||
assert.deepStrictEqual(
|
||||
[result.label, result.status, result.copied, result.execCalls, result.clipboardCalls, result.copiedText, result.navEnhanced],
|
||||
[label, status, copied, execCalls, clipboardCalls, copiedText, true], result.name,
|
||||
);
|
||||
});
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error.stack || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
"""
|
||||
|
||||
|
||||
class TestLandingJavaScript:
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node is required")
|
||||
def test_copy_fallbacks_and_mobile_nav_enhancement(self) -> None:
|
||||
node = shutil.which("node")
|
||||
assert node is not None
|
||||
landing_js = Path(__file__).parent.parent / "website_templates" / "landing.js"
|
||||
result = subprocess.run(
|
||||
[node, "-e", LANDING_JS_NODE_HARNESS, str(landing_js)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
results = json.loads(result.stdout)
|
||||
assert {result["name"] for result in results} == {
|
||||
"clipboard-success",
|
||||
"legacy-false",
|
||||
"legacy-throw",
|
||||
"rejection-legacy-success",
|
||||
"rejection-legacy-false",
|
||||
"rapid-clicks",
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use v3-style runtime configuration; Tailwind v4 deprecated that config format.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -29,8 +30,10 @@ MERMAID_URL = (
|
||||
)
|
||||
GOOGLE_FONTS_CSS_URL = (
|
||||
"https://fonts.googleapis.com/css2?"
|
||||
"family=Inter:wght@400;500;600;700;800"
|
||||
"family=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..600"
|
||||
"&family=Inter:wght@400;500;600;700;800"
|
||||
"&family=JetBrains+Mono:wght@400;500;600"
|
||||
"&family=Manrope:wght@400;500;600;700"
|
||||
"&display=swap"
|
||||
)
|
||||
GOOGLE_FONTS_UA = (
|
||||
@@ -160,7 +163,10 @@ def fetch_fonts(target_dir: Path, logger: logging.Logger) -> Path:
|
||||
"""Download Google Fonts CSS + WOFF2 files, rewrite URLs to relative paths."""
|
||||
cache = _vendor_cache_dir() / "fonts"
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
css_cache = cache / "fonts.css"
|
||||
# Key the cached CSS on the request URL so adding/removing font families
|
||||
# invalidates the cache instead of silently serving the old stylesheet.
|
||||
css_key = hashlib.sha256(GOOGLE_FONTS_CSS_URL.encode("utf-8")).hexdigest()[:10]
|
||||
css_cache = cache / f"fonts-{css_key}.css"
|
||||
files_cache = cache / "files"
|
||||
files_cache.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -201,7 +207,10 @@ def write_vendor_manifest(target_dir: Path, fonts_count: int) -> None:
|
||||
manifest = {
|
||||
"tailwind": TAILWIND_VERSION,
|
||||
"mermaid": MERMAID_VERSION,
|
||||
"fonts": {"family": ["Inter", "JetBrains Mono"], "files": fonts_count},
|
||||
"fonts": {
|
||||
"family": ["Fraunces", "Inter", "JetBrains Mono", "Manrope"],
|
||||
"files": fonts_count,
|
||||
},
|
||||
}
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
(target_dir / "manifest.json").write_text(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,473 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="no-js">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>{{ site_title }} — Master Claude Code in a Weekend</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Go from typing claude to orchestrating agents, hooks, skills, and MCP servers — visual tutorials, copy-paste templates, and a guided learning path for Claude Code."
|
||||
/>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fbfaf7" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000" />
|
||||
<link rel="icon" href="assets/resources/logos/claude-howto-logo.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="assets/vendor/fonts/fonts.css" />
|
||||
<link rel="stylesheet" href="assets/landing.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>
|
||||
<script>
|
||||
document.documentElement.classList.remove('no-js');
|
||||
document.documentElement.classList.add('js');
|
||||
</script>
|
||||
<script src="assets/landing.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#roadmap">Skip to the roadmap</a>
|
||||
|
||||
{# ============ Sticky nav ============ #}
|
||||
<header class="nav" id="top">
|
||||
<div class="nav-inner">
|
||||
<a class="nav-brand" href="index.html" aria-label="Claude How-To home">
|
||||
<img
|
||||
src="assets/resources/logos/claude-howto-logo.svg"
|
||||
alt="claude-howto"
|
||||
class="nav-logo logo-light"
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
<img
|
||||
src="assets/resources/logos/claude-howto-logo-dark.svg"
|
||||
alt="claude-howto"
|
||||
class="nav-logo logo-dark"
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
</a>
|
||||
<nav class="nav-links" id="nav-menu" aria-label="Primary">
|
||||
<a href="#why">Why</a>
|
||||
<a href="#vs-docs">vs Docs</a>
|
||||
<a href="#roadmap">Roadmap</a>
|
||||
<a href="{{ guide_url }}">Guide</a>
|
||||
<a href="{{ repo_url }}" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
</nav>
|
||||
<div class="nav-actions">
|
||||
<a class="btn btn-primary btn-sm nav-cta" href="#roadmap">Start learning</a>
|
||||
<button
|
||||
class="theme-toggle"
|
||||
id="theme-toggle"
|
||||
type="button"
|
||||
aria-label="Switch to dark theme"
|
||||
>
|
||||
<svg class="ti-sun" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<circle cx="10" cy="10" r="4" />
|
||||
<path d="M10 1.8v2.2M10 16v2.2M1.8 10H4M16 10h2.2M4.2 4.2l1.6 1.6M14.2 14.2l1.6 1.6M15.8 4.2l-1.6 1.6M5.8 14.2l-1.6 1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
<svg class="ti-moon" 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>
|
||||
<button
|
||||
class="nav-toggle"
|
||||
id="nav-toggle"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
aria-controls="nav-menu"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<path class="menu-open" d="M3 5.5h14M3 10h14M3 14.5h14" stroke-linecap="round" />
|
||||
<path class="menu-close" d="M5 5l10 10M15 5L5 15" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{# ============ Hero ============ #}
|
||||
<section class="hero">
|
||||
<div class="hero-grid">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow-row reveal" style="--d: 0s">
|
||||
<p class="eyebrow">~/claude-howto · a weekend curriculum</p>
|
||||
{% if version %}<span class="version-pill">Synced to v{{ version }}</span>{% endif %}
|
||||
</div>
|
||||
<h1 class="hero-title reveal" style="--d: 0.08s">
|
||||
Master <em>Claude Code</em><br />in a Weekend
|
||||
</h1>
|
||||
<p class="hero-sub reveal" style="--d: 0.16s">
|
||||
Go from typing <code>claude</code> to orchestrating agents, hooks,
|
||||
skills, and MCP servers — with visual tutorials, copy‑paste
|
||||
templates, and a guided learning path.
|
||||
</p>
|
||||
<div class="hero-cta reveal" style="--d: 0.24s">
|
||||
<a class="btn btn-primary" href="#roadmap">Start the roadmap</a>
|
||||
<a class="btn btn-ghost" href="{{ guide_url }}">Read the guide</a>
|
||||
</div>
|
||||
<dl class="hero-stats reveal" style="--d: 0.32s">
|
||||
<div class="stat">
|
||||
<dt>Modules</dt>
|
||||
<dd>{{ module_count }}</dd>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<dt>Time</dt>
|
||||
<dd>11–13 h</dd>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<dt>Languages</dt>
|
||||
<dd>5</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hero-term reveal"
|
||||
style="--d: 0.2s"
|
||||
role="img"
|
||||
aria-label="Terminal recording of a short Claude Code session: launching claude, running /init and /self-assessment, a hook firing, and a subagent delegating work"
|
||||
>
|
||||
<div class="term">
|
||||
<div class="term-bar">
|
||||
<span class="term-dot"></span>
|
||||
<span class="term-dot"></span>
|
||||
<span class="term-dot"></span>
|
||||
<span class="term-title">~/project — claude</span>
|
||||
</div>
|
||||
<div class="term-body" id="terminal">
|
||||
<div class="t-line" data-t="cmd"><span class="t-pp">$</span><span class="t-tt">claude</span></div>
|
||||
<div class="t-line" data-t="out" data-c="dim">Claude Code · working in ~/project</div>
|
||||
<div class="t-line" data-t="cmd"><span class="t-pp t-pp-in">></span><span class="t-tt">/init</span></div>
|
||||
<div class="t-line" data-t="out" data-c="ok">✓ wrote CLAUDE.md — 6 rules, 3 imports</div>
|
||||
<div class="t-line" data-t="cmd"><span class="t-pp t-pp-in">></span><span class="t-tt">/self-assessment</span></div>
|
||||
<div class="t-line" data-t="out" data-c="info">→ level: intermediate · start at 03-skills</div>
|
||||
<div class="t-line" data-t="cmd"><span class="t-pp t-pp-in">></span><span class="t-tt">add rate limiting to the api</span></div>
|
||||
<div class="t-line" data-t="out" data-c="warn">⚡ hook PreToolUse → ruff check . — passed</div>
|
||||
<div class="t-line" data-t="out" data-c="ok">◆ Explore agent → mapped 14 routes — done</div>
|
||||
<div class="t-line" data-t="cmd"><span class="t-pp">$</span><span class="t-tt"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ============ Why / value ============ #}
|
||||
<section class="section" id="why">
|
||||
<p class="kicker reveal">Why this guide</p>
|
||||
<h2 class="section-title reveal">The docs tell you <em>what</em>.<br />This shows you <em>how</em>.</h2>
|
||||
<div class="cards">
|
||||
<article class="card reveal">
|
||||
<div class="card-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
<path d="M10 6.5h4a3 3 0 0 1 3 3V14M14 17.5h-4a3 3 0 0 1-3-3V10" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Visual by default</h3>
|
||||
<p>
|
||||
Mermaid diagrams show how each feature works under the hood — so
|
||||
you understand <em>why</em>, not just <em>how</em>.
|
||||
</p>
|
||||
</article>
|
||||
<article class="card reveal" style="--d: 0.08s">
|
||||
<div class="card-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="8" y="8" width="12" height="12" rx="2" />
|
||||
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Copy-paste templates</h3>
|
||||
<p>
|
||||
Production-ready slash commands, CLAUDE.md files, hook scripts, MCP
|
||||
configs and plugin bundles — drop them into your project today.
|
||||
</p>
|
||||
</article>
|
||||
<article class="card reveal" style="--d: 0.16s">
|
||||
<div class="card-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="6" cy="19" r="2.5" />
|
||||
<circle cx="18" cy="5" r="2.5" />
|
||||
<path d="M8 17.5 10 15a10 10 0 0 1 6-7.5" />
|
||||
<path d="M8.5 19H15" stroke-dasharray="2 2.6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>A guided path</h3>
|
||||
<p>
|
||||
Ten modules in the order a learner needs them — beginner to power
|
||||
user in 11–13 hours. No more guessing what to learn next.
|
||||
</p>
|
||||
</article>
|
||||
<article class="card reveal" style="--d: 0.24s">
|
||||
<div class="card-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 11.5 11 14l4.5-5" />
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Self-assessment built in</h3>
|
||||
<p>
|
||||
Run <code>/self-assessment</code> or <code>/lesson-quiz</code> inside
|
||||
Claude Code to find your gaps and get a personalized path.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ============ Docs vs guide ============ #}
|
||||
<section class="section" id="vs-docs">
|
||||
<p class="kicker reveal">Official docs vs this guide</p>
|
||||
<h2 class="section-title reveal">Different jobs, same tool</h2>
|
||||
<p class="section-sub reveal">
|
||||
This guide complements the
|
||||
<a href="https://code.claude.com/docs/en/overview" target="_blank" rel="noopener noreferrer">official docs</a>
|
||||
— they are the reference; this is the curriculum.
|
||||
</p>
|
||||
<div class="versus reveal">
|
||||
<div class="versus-head" role="presentation">
|
||||
<span></span>
|
||||
<span class="v-docs">Official docs</span>
|
||||
<span class="v-guide">This guide</span>
|
||||
</div>
|
||||
<div class="versus-row">
|
||||
<span class="v-label">Format</span>
|
||||
<span class="v-docs">Reference documentation</span>
|
||||
<span class="v-guide">Visual tutorials with Mermaid diagrams</span>
|
||||
</div>
|
||||
<div class="versus-row">
|
||||
<span class="v-label">Depth</span>
|
||||
<span class="v-docs">Feature descriptions</span>
|
||||
<span class="v-guide">How it works under the hood</span>
|
||||
</div>
|
||||
<div class="versus-row">
|
||||
<span class="v-label">Examples</span>
|
||||
<span class="v-docs">Basic snippets</span>
|
||||
<span class="v-guide">Production-ready templates you use immediately</span>
|
||||
</div>
|
||||
<div class="versus-row">
|
||||
<span class="v-label">Structure</span>
|
||||
<span class="v-docs">Feature-organized</span>
|
||||
<span class="v-guide">Progressive learning path (beginner to advanced)</span>
|
||||
</div>
|
||||
<div class="versus-row">
|
||||
<span class="v-label">Onboarding</span>
|
||||
<span class="v-docs">Self-directed</span>
|
||||
<span class="v-guide">Guided roadmap with time estimates</span>
|
||||
</div>
|
||||
<div class="versus-row">
|
||||
<span class="v-label">Self-assessment</span>
|
||||
<span class="v-docs">None</span>
|
||||
<span class="v-guide">Interactive quizzes that build a personalized path</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ============ Roadmap ============ #}
|
||||
<section class="section roadmap" id="roadmap">
|
||||
<p class="kicker reveal">The roadmap</p>
|
||||
<h2 class="section-title reveal">Ten stations, one line</h2>
|
||||
<p class="section-sub reveal">
|
||||
Three levels, {{ module_count }} modules, {{ lesson_count }} lessons.
|
||||
Progress is saved in your browser — check lessons off as you go.
|
||||
</p>
|
||||
|
||||
<div class="progress-panel reveal" id="progress-panel">
|
||||
<div class="ring-wrap" role="img" aria-label="Overall progress">
|
||||
<svg class="ring" viewBox="0 0 120 120" aria-hidden="true">
|
||||
<circle class="ring-bg" cx="60" cy="60" r="52" />
|
||||
<circle class="ring-fg" id="ring-fg" cx="60" cy="60" r="52" />
|
||||
</svg>
|
||||
<div class="ring-label">
|
||||
<span class="ring-pct" id="ring-pct">0%</span>
|
||||
<span class="ring-cap">complete</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-mid">
|
||||
<p class="counts">
|
||||
<span><b id="c-done">0</b> done</span>
|
||||
<span class="counts-sep" aria-hidden="true">·</span>
|
||||
<span><b id="c-prog">0</b> in progress</span>
|
||||
<span class="counts-sep" aria-hidden="true">·</span>
|
||||
<span><b id="c-todo">{{ lesson_count }}</b> not started</span>
|
||||
</p>
|
||||
<div class="progress-actions">
|
||||
<a class="btn btn-primary btn-sm" id="continue-btn" href="#roadmap">Continue</a>
|
||||
<span class="all-done" id="all-done" hidden>All done — roadmap complete.</span>
|
||||
<button class="btn btn-ghost btn-sm" id="reset-btn" type="button">Reset progress</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subway" id="subway">
|
||||
<div class="track" aria-hidden="true"><div class="track-fill" id="track-fill"></div></div>
|
||||
{% for level in levels %}
|
||||
<section class="level" aria-labelledby="level-{{ level.id }}">
|
||||
<header class="level-head reveal">
|
||||
<p class="level-name">{{ level.name }}</p>
|
||||
<h3 class="level-title" id="level-{{ level.id }}">{{ level.title }}</h3>
|
||||
<p class="level-sum">{{ level.summary }}</p>
|
||||
</header>
|
||||
<ol class="stations">
|
||||
{% for module in level.modules %}
|
||||
<li class="station reveal" data-module="{{ module.id }}" data-status="not-started">
|
||||
<span class="node" aria-hidden="true"></span>
|
||||
<article class="station-card">
|
||||
<header class="station-head">
|
||||
<span class="station-num" aria-hidden="true">{{ module.number }}</span>
|
||||
<div class="station-meta">
|
||||
<h4 class="station-title">
|
||||
<a href="{{ module.lessons[0].href if module.lessons else module.url }}">{{ module.title }}</a>
|
||||
</h4>
|
||||
<p class="station-tag">{{ module.tagline }}</p>
|
||||
</div>
|
||||
<div class="station-side">
|
||||
<span class="station-time">{{ module.time }}</span>
|
||||
<span class="pill" data-pill>Not started</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="station-bar">
|
||||
<span class="bar" aria-hidden="true"><i data-bar></i></span>
|
||||
<span class="bar-count" data-count>0/{{ module.lesson_count }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="station-toggle"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
aria-controls="lessons-{{ module.id }}"
|
||||
>
|
||||
<span>{{ module.lesson_count }} lessons</span>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M4 6l4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="lessons" id="lessons-{{ module.id }}">
|
||||
{% for lesson in module.lessons %}
|
||||
<li class="lesson" data-lesson="{{ lesson.full_id }}" data-status="not-started">
|
||||
<button
|
||||
class="check"
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked="false"
|
||||
aria-label="Mark '{{ lesson.title }}' as done"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path class="check-dash" d="M4 8h8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||||
<path class="check-mark" d="M3.2 8.6 6.4 11.6 13 4.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<a class="lesson-link" href="{{ lesson.href }}">{{ lesson.title }}</a>
|
||||
<span class="lesson-status" data-lstatus>Not started</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ============ Get started ============ #}
|
||||
<section class="section" id="get-started">
|
||||
<p class="kicker reveal">Get started</p>
|
||||
<h2 class="section-title reveal">Fifteen minutes to your first workflow</h2>
|
||||
<ol class="steps">
|
||||
<li class="step reveal">
|
||||
<span class="step-num" aria-hidden="true">01</span>
|
||||
<h3>Clone the repo</h3>
|
||||
<p>Everything is markdown — templates included, nothing to install.</p>
|
||||
<div class="code">
|
||||
<code>git clone {{ repo_url }}.git</code>
|
||||
<button class="copy" type="button" data-copy="git clone {{ repo_url }}.git" aria-label="Copy clone command">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
|
||||
<path d="M10.5 5.5V4a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 4v5A1.5 1.5 0 0 0 4 10.5h1.5" />
|
||||
</svg>
|
||||
<span class="copy-label">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="step reveal" style="--d: 0.08s">
|
||||
<span class="step-num" aria-hidden="true">02</span>
|
||||
<h3>Take the self-assessment</h3>
|
||||
<p>Inside Claude Code, find your level and skip what you know.</p>
|
||||
<div class="code">
|
||||
<code>/self-assessment</code>
|
||||
<button class="copy" type="button" data-copy="/self-assessment" aria-label="Copy self-assessment command">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
|
||||
<path d="M10.5 5.5V4a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 4v5A1.5 1.5 0 0 0 4 10.5h1.5" />
|
||||
</svg>
|
||||
<span class="copy-label">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="step reveal" style="--d: 0.16s">
|
||||
<span class="step-num" aria-hidden="true">03</span>
|
||||
<h3>Follow the roadmap</h3>
|
||||
<p>Work through the modules at your level — progress is tracked above.</p>
|
||||
<a class="step-link" href="#roadmap">Start at your level →</a>
|
||||
</li>
|
||||
</ol>
|
||||
<span class="sr-only" id="copy-status" role="status" aria-live="polite"></span>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{# ============ Footer ============ #}
|
||||
<footer class="footer">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-col footer-brand">
|
||||
<img
|
||||
src="assets/resources/logos/claude-howto-logo.svg"
|
||||
alt=""
|
||||
class="footer-logo logo-light"
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
<img
|
||||
src="assets/resources/logos/claude-howto-logo-dark.svg"
|
||||
alt=""
|
||||
class="footer-logo logo-dark"
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
<p class="footer-note">
|
||||
A community guide to Claude Code. Markdown is the source of truth.
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<p class="footer-head">Project</p>
|
||||
<a href="{{ repo_url }}" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
<a href="{{ repo_url }}/blob/{{ branch }}/LICENSE" target="_blank" rel="noopener noreferrer">MIT license</a>
|
||||
<a href="{{ repo_url }}/blob/{{ branch }}/CHANGELOG.md" target="_blank" rel="noopener noreferrer">Changelog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<p class="footer-head">Languages</p>
|
||||
<a href="{{ repo_url }}/blob/{{ branch }}/vi/README.md" target="_blank" rel="noopener noreferrer">Tiếng Việt</a>
|
||||
<a href="{{ repo_url }}/blob/{{ branch }}/zh/README.md" target="_blank" rel="noopener noreferrer">中文</a>
|
||||
<a href="{{ repo_url }}/blob/{{ branch }}/ja/README.md" target="_blank" rel="noopener noreferrer">日本語</a>
|
||||
<a href="{{ repo_url }}/blob/{{ branch }}/uk/README.md" target="_blank" rel="noopener noreferrer">Українська</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<p class="footer-head">Docs</p>
|
||||
<a href="{{ guide_url }}">Guide</a>
|
||||
<a href="https://code.claude.com/docs/en/overview" target="_blank" rel="noopener noreferrer">Official docs</a>
|
||||
{% if version %}<span class="footer-version">Synced to v{{ version }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-base">
|
||||
<span>claude-howto · MIT licensed</span>
|
||||
<a href="#top" class="footer-top">Back to top ↑</a>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,639 @@
|
||||
/* claude-howto landing — progress tracking, terminal, scroll effects.
|
||||
Vanilla JS, no dependencies. The page is fully usable without it:
|
||||
every lesson link navigates, and interactive controls are hidden via
|
||||
the `no-js` class on <html>. */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var root = document.documentElement;
|
||||
root.classList.remove("no-js");
|
||||
root.classList.add("js");
|
||||
|
||||
var STORAGE_KEY = "claude-howto.progress.v1";
|
||||
var VALID_STATUS = { "not-started": true, "in-progress": true, "done": true };
|
||||
var STATUS_LABEL = {
|
||||
"not-started": "Not started",
|
||||
"in-progress": "In progress",
|
||||
done: "Done",
|
||||
};
|
||||
|
||||
var reducedMotion = false;
|
||||
try {
|
||||
reducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)"
|
||||
).matches;
|
||||
} catch (e) {
|
||||
/* matchMedia unavailable — treat as no preference */
|
||||
}
|
||||
|
||||
/* ================= Theme (shared with the docs pages) =================
|
||||
Same localStorage key and `dark` class on <html> as page.html.j2.
|
||||
The pre-paint inline script already applied the initial theme; this
|
||||
wires the toggle and follows the OS scheme while nothing is saved. */
|
||||
|
||||
var THEME_KEY = "claude-howto-theme";
|
||||
var themeToggle = document.getElementById("theme-toggle");
|
||||
|
||||
function currentTheme() {
|
||||
return root.classList.contains("dark") ? "dark" : "light";
|
||||
}
|
||||
|
||||
function paintThemeToggle() {
|
||||
if (!themeToggle) return;
|
||||
themeToggle.setAttribute(
|
||||
"aria-label",
|
||||
currentTheme() === "dark"
|
||||
? "Switch to light theme"
|
||||
: "Switch to dark theme"
|
||||
);
|
||||
}
|
||||
|
||||
function savedTheme() {
|
||||
try {
|
||||
return window.localStorage.getItem(THEME_KEY);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme, animate) {
|
||||
if (animate && !reducedMotion) {
|
||||
root.classList.add("theme-anim");
|
||||
window.setTimeout(function () {
|
||||
root.classList.remove("theme-anim");
|
||||
}, 350);
|
||||
}
|
||||
root.classList.toggle("dark", theme === "dark");
|
||||
paintThemeToggle();
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener("click", function () {
|
||||
var next = currentTheme() === "dark" ? "light" : "dark";
|
||||
applyTheme(next, true);
|
||||
try {
|
||||
window.localStorage.setItem(THEME_KEY, next);
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
paintThemeToggle();
|
||||
|
||||
/* No saved choice: follow prefers-color-scheme changes live. */
|
||||
try {
|
||||
var themeMq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
var followOsTheme = function (e) {
|
||||
if (!savedTheme()) applyTheme(e.matches ? "dark" : "light", true);
|
||||
};
|
||||
if (themeMq.addEventListener) {
|
||||
themeMq.addEventListener("change", followOsTheme);
|
||||
} else if (themeMq.addListener) {
|
||||
themeMq.addListener(followOsTheme);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
/* ================= Progress store ================= */
|
||||
|
||||
var mem = { v: 1, lessons: {} };
|
||||
var persist = true;
|
||||
|
||||
function loadState() {
|
||||
if (!persist) return;
|
||||
try {
|
||||
var raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
var data = JSON.parse(raw);
|
||||
if (data && data.v === 1 && data.lessons && typeof data.lessons === "object") {
|
||||
mem.lessons = data.lessons;
|
||||
}
|
||||
} catch (e) {
|
||||
persist = false;
|
||||
}
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
if (!persist) return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(mem));
|
||||
} catch (e) {
|
||||
persist = false;
|
||||
}
|
||||
}
|
||||
|
||||
var lessonEls = Array.prototype.slice.call(
|
||||
document.querySelectorAll(".lesson[data-lesson]")
|
||||
);
|
||||
var stationEls = Array.prototype.slice.call(
|
||||
document.querySelectorAll(".station[data-module]")
|
||||
);
|
||||
var knownIds = {};
|
||||
lessonEls.forEach(function (el) {
|
||||
knownIds[el.getAttribute("data-lesson")] = true;
|
||||
});
|
||||
|
||||
function lessonStatus(id) {
|
||||
var rec = mem.lessons[id];
|
||||
return rec && VALID_STATUS[rec.s] ? rec.s : "not-started";
|
||||
}
|
||||
|
||||
function setStatus(id, status) {
|
||||
if (!knownIds[id]) return;
|
||||
mem.lessons[id] = { s: status, t: Date.now() };
|
||||
saveState();
|
||||
}
|
||||
|
||||
function pruneUnknown() {
|
||||
var changed = false;
|
||||
Object.keys(mem.lessons).forEach(function (id) {
|
||||
if (!knownIds[id]) {
|
||||
delete mem.lessons[id];
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) saveState();
|
||||
}
|
||||
|
||||
/* Mark a lesson in progress when the user navigates to it. Shared by
|
||||
lesson links, module title links and the Continue/Start button. */
|
||||
function markStarted(lessonEl) {
|
||||
if (!lessonEl) return;
|
||||
var id = lessonEl.getAttribute("data-lesson");
|
||||
if (lessonStatus(id) === "not-started") {
|
||||
setStatus(id, "in-progress");
|
||||
paintAll();
|
||||
}
|
||||
}
|
||||
|
||||
/* Mark on left-click (incl. ctrl/cmd-click — navigation still proceeds)
|
||||
and on middle-click, which fires `auxclick` with button 1. */
|
||||
function bindMarkStarted(el, getLessonEl) {
|
||||
el.addEventListener("click", function () {
|
||||
markStarted(getLessonEl());
|
||||
});
|
||||
el.addEventListener("auxclick", function (e) {
|
||||
if (e.button === 1) markStarted(getLessonEl());
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= Painting ================= */
|
||||
|
||||
function paintLesson(el) {
|
||||
var id = el.getAttribute("data-lesson");
|
||||
var status = lessonStatus(id);
|
||||
el.setAttribute("data-status", status);
|
||||
var check = el.querySelector(".check");
|
||||
if (check) {
|
||||
check.setAttribute(
|
||||
"aria-checked",
|
||||
status === "done" ? "true" : status === "in-progress" ? "mixed" : "false"
|
||||
);
|
||||
}
|
||||
var label = el.querySelector("[data-lstatus]");
|
||||
if (label) label.textContent = STATUS_LABEL[status];
|
||||
}
|
||||
|
||||
function paintStation(station) {
|
||||
var rows = station.querySelectorAll(".lesson[data-lesson]");
|
||||
var total = rows.length;
|
||||
var done = 0;
|
||||
var started = 0;
|
||||
for (var i = 0; i < total; i++) {
|
||||
var s = lessonStatus(rows[i].getAttribute("data-lesson"));
|
||||
if (s === "done") {
|
||||
done++;
|
||||
started++;
|
||||
} else if (s === "in-progress") {
|
||||
started++;
|
||||
}
|
||||
}
|
||||
var status =
|
||||
total > 0 && done === total
|
||||
? "done"
|
||||
: started > 0
|
||||
? "in-progress"
|
||||
: "not-started";
|
||||
station.setAttribute("data-status", status);
|
||||
var pill = station.querySelector("[data-pill]");
|
||||
if (pill) pill.textContent = STATUS_LABEL[status];
|
||||
var count = station.querySelector("[data-count]");
|
||||
if (count) count.textContent = done + "/" + total;
|
||||
var bar = station.querySelector("[data-bar]");
|
||||
if (bar) bar.style.width = total ? (done / total) * 100 + "%" : "0%";
|
||||
}
|
||||
|
||||
var RING_R = 52;
|
||||
var RING_C = 2 * Math.PI * RING_R;
|
||||
var continueTarget = null;
|
||||
|
||||
function paintOverall() {
|
||||
var total = lessonEls.length;
|
||||
var done = 0;
|
||||
var inProgress = 0;
|
||||
var continueEl = null;
|
||||
var continueTime = -1;
|
||||
var firstTodo = null;
|
||||
|
||||
lessonEls.forEach(function (el) {
|
||||
var id = el.getAttribute("data-lesson");
|
||||
var s = lessonStatus(id);
|
||||
if (s === "done") {
|
||||
done++;
|
||||
} else if (s === "in-progress") {
|
||||
inProgress++;
|
||||
var t = mem.lessons[id] && mem.lessons[id].t ? mem.lessons[id].t : 0;
|
||||
if (t > continueTime) {
|
||||
continueTime = t;
|
||||
continueEl = el;
|
||||
}
|
||||
} else if (!firstTodo) {
|
||||
firstTodo = el;
|
||||
}
|
||||
});
|
||||
|
||||
var pct = total ? Math.round((done / total) * 100) : 0;
|
||||
var ring = document.getElementById("ring-fg");
|
||||
if (ring) {
|
||||
ring.style.strokeDashoffset = String(RING_C * (1 - done / (total || 1)));
|
||||
}
|
||||
var pctEl = document.getElementById("ring-pct");
|
||||
if (pctEl) pctEl.textContent = pct + "%";
|
||||
var doneEl = document.getElementById("c-done");
|
||||
if (doneEl) doneEl.textContent = String(done);
|
||||
var progEl = document.getElementById("c-prog");
|
||||
if (progEl) progEl.textContent = String(inProgress);
|
||||
var todoEl = document.getElementById("c-todo");
|
||||
if (todoEl) todoEl.textContent = String(total - done - inProgress);
|
||||
|
||||
var continueBtn = document.getElementById("continue-btn");
|
||||
var allDone = document.getElementById("all-done");
|
||||
var target = continueEl || firstTodo;
|
||||
continueTarget = target;
|
||||
if (continueBtn && allDone) {
|
||||
if (target && done < total) {
|
||||
var link = target.querySelector(".lesson-link");
|
||||
var title = link ? link.textContent.trim() : "";
|
||||
var station = target.closest(".station");
|
||||
var modTitle = "";
|
||||
if (station) {
|
||||
var t = station.querySelector(".station-title");
|
||||
modTitle = t ? t.textContent.trim() : "";
|
||||
}
|
||||
var verb = done + inProgress === 0 ? "Start" : "Continue";
|
||||
continueBtn.hidden = false;
|
||||
allDone.hidden = true;
|
||||
if (link) continueBtn.setAttribute("href", link.getAttribute("href"));
|
||||
continueBtn.textContent =
|
||||
verb + ": " + (modTitle ? modTitle + " · " : "") + title;
|
||||
} else {
|
||||
continueBtn.hidden = true;
|
||||
allDone.hidden = false;
|
||||
}
|
||||
}
|
||||
paintTrack();
|
||||
}
|
||||
|
||||
function paintTrack() {
|
||||
var fill = document.getElementById("track-fill");
|
||||
var subway = document.getElementById("subway");
|
||||
if (!fill || !subway) return;
|
||||
|
||||
var rect = subway.getBoundingClientRect();
|
||||
if (rect.height <= 0) return;
|
||||
|
||||
// Furthest started station keeps the line lit even above the scroll mark.
|
||||
var statusP = 0;
|
||||
stationEls.forEach(function (st) {
|
||||
if (st.getAttribute("data-status") === "not-started") return;
|
||||
var node = st.querySelector(".node") || st;
|
||||
var y = node.getBoundingClientRect().top - rect.top;
|
||||
if (y > 0) statusP = Math.max(statusP, Math.min(1, y / rect.height));
|
||||
});
|
||||
|
||||
var scrollP = Math.min(
|
||||
1,
|
||||
Math.max(0, (window.innerHeight * 0.8 - rect.top) / rect.height)
|
||||
);
|
||||
|
||||
fill.style.height = Math.max(scrollP, statusP) * 100 + "%";
|
||||
}
|
||||
|
||||
function paintAll() {
|
||||
lessonEls.forEach(paintLesson);
|
||||
stationEls.forEach(paintStation);
|
||||
paintOverall();
|
||||
}
|
||||
|
||||
/* ================= Events ================= */
|
||||
|
||||
lessonEls.forEach(function (el) {
|
||||
var id = el.getAttribute("data-lesson");
|
||||
|
||||
var check = el.querySelector(".check");
|
||||
if (check) {
|
||||
check.addEventListener("click", function () {
|
||||
var s = lessonStatus(id);
|
||||
setStatus(id, s === "done" ? "in-progress" : "done");
|
||||
paintAll();
|
||||
});
|
||||
}
|
||||
|
||||
var link = el.querySelector(".lesson-link");
|
||||
if (link) {
|
||||
bindMarkStarted(link, function () {
|
||||
return el;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function setExpanded(station, expanded) {
|
||||
var toggle = station.querySelector(".station-toggle");
|
||||
var list = station.querySelector(".lessons");
|
||||
if (!toggle || !list) return;
|
||||
toggle.setAttribute("aria-expanded", String(expanded));
|
||||
list.hidden = !expanded;
|
||||
}
|
||||
|
||||
stationEls.forEach(function (station) {
|
||||
var toggle = station.querySelector(".station-toggle");
|
||||
if (!toggle) return;
|
||||
|
||||
// Collapse by default; in-progress modules are re-expanded after the
|
||||
// first paint once their status is known (see init below).
|
||||
setExpanded(station, false);
|
||||
|
||||
toggle.addEventListener("click", function () {
|
||||
var open = toggle.getAttribute("aria-expanded") === "true";
|
||||
setExpanded(station, !open);
|
||||
});
|
||||
|
||||
// The module title links to the module page; treat it like opening its
|
||||
// first lesson (Overview).
|
||||
var titleLink = station.querySelector(".station-title a");
|
||||
if (titleLink) {
|
||||
bindMarkStarted(titleLink, function () {
|
||||
return station.querySelector(".lesson[data-lesson]");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var continueBtnEl = document.getElementById("continue-btn");
|
||||
if (continueBtnEl) {
|
||||
bindMarkStarted(continueBtnEl, function () {
|
||||
return continueTarget;
|
||||
});
|
||||
}
|
||||
|
||||
var resetBtn = document.getElementById("reset-btn");
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener("click", function () {
|
||||
if (!window.confirm("Reset all lesson progress? This cannot be undone.")) {
|
||||
return;
|
||||
}
|
||||
mem.lessons = {};
|
||||
saveState();
|
||||
paintAll();
|
||||
});
|
||||
}
|
||||
|
||||
/* Cross-tab sync + bfcache restore. */
|
||||
window.addEventListener("storage", function (e) {
|
||||
if (e.key === THEME_KEY && e.newValue) {
|
||||
applyTheme(e.newValue, true);
|
||||
return;
|
||||
}
|
||||
if (e.key !== STORAGE_KEY) return;
|
||||
mem.lessons = {};
|
||||
persist = true;
|
||||
loadState();
|
||||
pruneUnknown();
|
||||
paintAll();
|
||||
});
|
||||
|
||||
window.addEventListener("pageshow", function () {
|
||||
mem.lessons = {};
|
||||
loadState();
|
||||
pruneUnknown();
|
||||
paintAll();
|
||||
});
|
||||
|
||||
var scrollTicking = false;
|
||||
window.addEventListener(
|
||||
"scroll",
|
||||
function () {
|
||||
if (scrollTicking) return;
|
||||
scrollTicking = true;
|
||||
window.requestAnimationFrame(function () {
|
||||
paintTrack();
|
||||
scrollTicking = false;
|
||||
});
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
window.addEventListener("resize", paintTrack);
|
||||
|
||||
/* ================= Terminal typing ================= */
|
||||
|
||||
function runTerminal() {
|
||||
var term = document.getElementById("terminal");
|
||||
if (!term || reducedMotion) return;
|
||||
|
||||
var lines = Array.prototype.slice.call(term.querySelectorAll(".t-line"));
|
||||
var typed = lines.map(function (line) {
|
||||
var tt = line.querySelector(".t-tt");
|
||||
var text = tt ? tt.textContent : "";
|
||||
return { line: line, tt: tt, text: text, isCmd: !!tt };
|
||||
});
|
||||
|
||||
function reset() {
|
||||
typed.forEach(function (item) {
|
||||
item.line.style.visibility = "hidden";
|
||||
item.line.classList.remove("t-caret");
|
||||
if (item.tt) {
|
||||
item.tt.textContent = "";
|
||||
item.tt.classList.remove("t-typing");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var delays = [];
|
||||
function later(fn, ms) {
|
||||
delays.push(window.setTimeout(fn, ms));
|
||||
}
|
||||
|
||||
function play() {
|
||||
reset();
|
||||
var t = 350;
|
||||
typed.forEach(function (item) {
|
||||
if (item.isCmd) {
|
||||
// Command lines type character by character.
|
||||
var text = item.text;
|
||||
var start = t;
|
||||
for (var i = 0; i <= text.length; i++) {
|
||||
(function (it, ch) {
|
||||
later(function () {
|
||||
it.line.style.visibility = "visible";
|
||||
it.tt.textContent = text.slice(0, ch);
|
||||
it.tt.classList.add("t-typing");
|
||||
}, start + ch * 38);
|
||||
})(item, i);
|
||||
}
|
||||
t += text.length * 38 + 420;
|
||||
(function (it) {
|
||||
later(function () {
|
||||
it.tt.classList.remove("t-typing");
|
||||
}, t - 380);
|
||||
})(item);
|
||||
} else {
|
||||
// Output lines appear at once.
|
||||
(function (it, at) {
|
||||
later(function () {
|
||||
it.line.style.visibility = "visible";
|
||||
}, at);
|
||||
})(item, t);
|
||||
t += 260;
|
||||
}
|
||||
});
|
||||
// Leave the caret blinking on the final prompt, hold, then loop.
|
||||
var last = typed[typed.length - 1];
|
||||
if (last && last.tt) {
|
||||
later(function () {
|
||||
last.line.classList.add("t-caret");
|
||||
}, t);
|
||||
}
|
||||
later(play, t + 6500);
|
||||
}
|
||||
play();
|
||||
}
|
||||
|
||||
/* ================= Reveal on scroll ================= */
|
||||
|
||||
var revealEls = Array.prototype.slice.call(
|
||||
document.querySelectorAll(".reveal")
|
||||
);
|
||||
if ("IntersectionObserver" in window && !reducedMotion) {
|
||||
var io = new IntersectionObserver(
|
||||
function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add("in");
|
||||
io.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.12, rootMargin: "0px 0px -6% 0px" }
|
||||
);
|
||||
revealEls.forEach(function (el) {
|
||||
io.observe(el);
|
||||
});
|
||||
} else {
|
||||
revealEls.forEach(function (el) {
|
||||
el.classList.add("in");
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= Mobile nav ================= */
|
||||
|
||||
var navToggle = document.getElementById("nav-toggle");
|
||||
var navMenu = document.getElementById("nav-menu");
|
||||
if (navToggle && navMenu) {
|
||||
navToggle.addEventListener("click", function () {
|
||||
var open = navMenu.classList.toggle("open");
|
||||
navToggle.setAttribute("aria-expanded", String(open));
|
||||
});
|
||||
navMenu.addEventListener("click", function (e) {
|
||||
if (e.target.closest("a")) {
|
||||
navMenu.classList.remove("open");
|
||||
navToggle.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape" && navMenu.classList.contains("open")) {
|
||||
navMenu.classList.remove("open");
|
||||
navToggle.setAttribute("aria-expanded", "false");
|
||||
navToggle.focus();
|
||||
}
|
||||
});
|
||||
document.getElementById("top").classList.add("nav-enhanced");
|
||||
}
|
||||
|
||||
/* ================= Copy buttons ================= */
|
||||
|
||||
var copyStatus = document.getElementById("copy-status");
|
||||
var copyAttempt = 0;
|
||||
|
||||
function legacyCopy(text) {
|
||||
var ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
try {
|
||||
ta.select();
|
||||
return document.execCommand("copy");
|
||||
} catch (e) {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
|
||||
Array.prototype.slice
|
||||
.call(document.querySelectorAll("[data-copy]"))
|
||||
.forEach(function (btn) {
|
||||
var label = btn.querySelector(".copy-label");
|
||||
var originalLabel = label ? label.textContent : "";
|
||||
var feedbackTimer = null;
|
||||
btn.addEventListener("click", function () {
|
||||
var text = btn.getAttribute("data-copy") || "";
|
||||
var attempt = ++copyAttempt;
|
||||
if (feedbackTimer !== null) window.clearTimeout(feedbackTimer);
|
||||
feedbackTimer = null;
|
||||
if (label) label.textContent = originalLabel;
|
||||
btn.classList.remove("copied");
|
||||
if (copyStatus) copyStatus.textContent = "";
|
||||
function feedback(copied) {
|
||||
if (attempt !== copyAttempt) return;
|
||||
btn.classList.toggle("copied", copied);
|
||||
if (copyStatus) {
|
||||
copyStatus.textContent = copied
|
||||
? "Copied to clipboard"
|
||||
: "Unable to copy to clipboard";
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = copied ? "Copied" : "Copy failed";
|
||||
feedbackTimer = window.setTimeout(function () {
|
||||
label.textContent = originalLabel;
|
||||
btn.classList.remove("copied");
|
||||
feedbackTimer = null;
|
||||
}, 1400);
|
||||
}
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
try {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
function () { feedback(true); },
|
||||
function () {
|
||||
if (attempt === copyAttempt) feedback(legacyCopy(text));
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
feedback(legacyCopy(text));
|
||||
}
|
||||
} else {
|
||||
feedback(legacyCopy(text));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ================= Init ================= */
|
||||
|
||||
loadState();
|
||||
pruneUnknown();
|
||||
paintAll();
|
||||
stationEls.forEach(function (station) {
|
||||
if (station.getAttribute("data-status") === "in-progress") {
|
||||
setExpanded(station, true);
|
||||
}
|
||||
});
|
||||
paintTrack();
|
||||
runTerminal();
|
||||
})();
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"levels": [
|
||||
{
|
||||
"id": "beginner",
|
||||
"name": "Level 1 — Beginner",
|
||||
"title": "Getting started",
|
||||
"summary": "Get productive on day one: commands, memory, safe rewinds and the CLI basics.",
|
||||
"modules": [
|
||||
{
|
||||
"id": "slash-commands",
|
||||
"number": "01",
|
||||
"title": "Slash Commands",
|
||||
"source": "01-slash-commands/README.md",
|
||||
"time": "30 min",
|
||||
"tagline": "Built-in commands, custom commands and team shortcuts.",
|
||||
"lessons": [
|
||||
{"id": "overview", "title": "Overview", "heading": "Overview"},
|
||||
{"id": "built-in", "title": "Built-in commands reference", "heading": "Built-in Commands Reference"},
|
||||
{"id": "custom", "title": "Custom commands (now skills)", "heading": "Custom Commands (Now Skills)"},
|
||||
{"id": "mcp-prompts", "title": "MCP prompts as commands", "heading": "MCP Prompts as Commands"},
|
||||
{"id": "create", "title": "Create your own commands", "heading": "Creating Your Own Commands"},
|
||||
{"id": "best-practices", "title": "Best practices", "heading": "Best Practices"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "memory",
|
||||
"number": "02",
|
||||
"title": "Memory",
|
||||
"source": "02-memory/README.md",
|
||||
"time": "45 min",
|
||||
"tagline": "CLAUDE.md, rules and auto memory that persist across sessions.",
|
||||
"lessons": [
|
||||
{"id": "overview", "title": "Overview", "heading": "Overview"},
|
||||
{"id": "quick-start", "title": "Quick start: initialize memory", "heading": "Quick Start: Initializing Memory"},
|
||||
{"id": "hierarchy", "title": "The memory hierarchy", "heading": "Memory Hierarchy in Claude Code"},
|
||||
{"id": "rules", "title": "Modular rules system", "heading": "Modular Rules System"},
|
||||
{"id": "auto-memory", "title": "Auto memory", "heading": "Auto Memory"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "checkpoints",
|
||||
"number": "08",
|
||||
"title": "Checkpoints",
|
||||
"source": "08-checkpoints/README.md",
|
||||
"time": "45 min",
|
||||
"tagline": "Try bold ideas and rewind in one keystroke.",
|
||||
"lessons": [
|
||||
{"id": "overview", "title": "Overview", "heading": "Overview"},
|
||||
{"id": "accessing", "title": "Accessing checkpoints", "heading": "Accessing Checkpoints"},
|
||||
{"id": "rewind", "title": "Rewind options", "heading": "Rewind Options"},
|
||||
{"id": "patterns", "title": "Workflow patterns", "heading": "Workflow Patterns"},
|
||||
{"id": "git", "title": "Checkpoints and Git", "heading": "Integration with Git"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cli",
|
||||
"number": "10",
|
||||
"title": "CLI",
|
||||
"source": "10-cli/README.md",
|
||||
"time": "30 min",
|
||||
"tagline": "Interactive and print mode, flags and scripting.",
|
||||
"lessons": [
|
||||
{"id": "overview", "title": "Overview", "heading": "Overview"},
|
||||
{"id": "commands", "title": "CLI commands", "heading": "CLI Commands"},
|
||||
{"id": "flags", "title": "Core flags", "heading": "Core Flags"},
|
||||
{"id": "output", "title": "Output and formats", "heading": "Output & Format"},
|
||||
{"id": "use-cases", "title": "High-value use cases", "heading": "High-Value Use Cases"}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "intermediate",
|
||||
"name": "Level 2 — Intermediate",
|
||||
"title": "Building workflows",
|
||||
"summary": "Automate with skills and hooks, then connect live data and delegate to subagents.",
|
||||
"modules": [
|
||||
{
|
||||
"id": "skills",
|
||||
"number": "03",
|
||||
"title": "Skills",
|
||||
"source": "03-skills/README.md",
|
||||
"time": "1 hour",
|
||||
"tagline": "Reusable expertise Claude loads only when it needs it.",
|
||||
"lessons": [
|
||||
{"id": "overview", "title": "Overview", "heading": "Overview"},
|
||||
{"id": "progressive-disclosure", "title": "Progressive disclosure", "heading": "How Skills Work: Progressive Disclosure"},
|
||||
{"id": "create", "title": "Create custom skills", "heading": "Creating Custom Skills"},
|
||||
{"id": "invocation", "title": "Control skill invocation", "heading": "Controlling Skill Invocation"},
|
||||
{"id": "subagents", "title": "Run skills in subagents", "heading": "Running Skills in Subagents"},
|
||||
{"id": "manage", "title": "Manage skills", "heading": "Managing Skills"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hooks",
|
||||
"number": "06",
|
||||
"title": "Hooks",
|
||||
"source": "06-hooks/README.md",
|
||||
"time": "1 hour",
|
||||
"tagline": "Quality gates and automation on every tool call and event.",
|
||||
"lessons": [
|
||||
{"id": "overview", "title": "Overview", "heading": "Overview"},
|
||||
{"id": "configuration", "title": "Configuration", "heading": "Configuration"},
|
||||
{"id": "types", "title": "Hook types", "heading": "Hook Types"},
|
||||
{"id": "events", "title": "Hook events", "heading": "Hook Events"},
|
||||
{"id": "io", "title": "Hook input and output", "heading": "Hook Input and Output"},
|
||||
{"id": "prompt-hooks", "title": "Prompt-based hooks", "heading": "Prompt-Based Hooks"},
|
||||
{"id": "debugging", "title": "Debugging hooks", "heading": "Debugging"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mcp",
|
||||
"number": "05",
|
||||
"title": "MCP",
|
||||
"source": "05-mcp/README.md",
|
||||
"time": "1 hour",
|
||||
"tagline": "Connect GitHub, databases and APIs as live tools.",
|
||||
"lessons": [
|
||||
{"id": "overview", "title": "Overview", "heading": "Overview"},
|
||||
{"id": "install", "title": "Installation methods", "heading": "MCP Installation Methods"},
|
||||
{"id": "scopes", "title": "MCP scopes", "heading": "MCP Scopes"},
|
||||
{"id": "resources", "title": "Resources via @ mentions", "heading": "MCP Resources via @ Mentions"},
|
||||
{"id": "tool-search", "title": "MCP tool search", "heading": "MCP Tool Search"},
|
||||
{"id": "serve", "title": "Claude as an MCP server", "heading": "Claude as MCP Server (`claude mcp serve`)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "subagents",
|
||||
"number": "04",
|
||||
"title": "Subagents",
|
||||
"source": "04-subagents/README.md",
|
||||
"time": "1.5 hours",
|
||||
"tagline": "Delegate to specialists with their own context and tools.",
|
||||
"lessons": [
|
||||
{"id": "overview", "title": "Overview", "heading": "Overview"},
|
||||
{"id": "configuration", "title": "Configuration", "heading": "Configuration"},
|
||||
{"id": "built-in", "title": "Built-in subagents", "heading": "Built-in Subagents"},
|
||||
{"id": "chaining", "title": "Chaining subagents", "heading": "Chaining Subagents"},
|
||||
{"id": "background", "title": "Background subagents", "heading": "Background Subagents"},
|
||||
{"id": "worktree", "title": "Worktree isolation", "heading": "Worktree Isolation"},
|
||||
{"id": "teams", "title": "Agent teams", "heading": "Agent Teams (Experimental)"}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "advanced",
|
||||
"name": "Level 3 — Advanced",
|
||||
"title": "Power user",
|
||||
"summary": "Plan, run headless and on a schedule, then package everything as a plugin for your team.",
|
||||
"modules": [
|
||||
{
|
||||
"id": "advanced-features",
|
||||
"number": "09",
|
||||
"title": "Advanced Features",
|
||||
"source": "09-advanced-features/README.md",
|
||||
"time": "2–3 hours",
|
||||
"tagline": "Planning, auto mode, permissions, headless runs and more.",
|
||||
"lessons": [
|
||||
{"id": "overview", "title": "Overview", "heading": "Overview"},
|
||||
{"id": "planning", "title": "Planning mode", "heading": "Planning Mode"},
|
||||
{"id": "thinking", "title": "Extended thinking", "heading": "Extended Thinking"},
|
||||
{"id": "auto-mode", "title": "Auto mode", "heading": "Auto Mode"},
|
||||
{"id": "scheduled", "title": "Scheduled tasks", "heading": "Scheduled Tasks"},
|
||||
{"id": "permissions", "title": "Permission modes", "heading": "Permission Modes"},
|
||||
{"id": "headless", "title": "Headless mode", "heading": "Headless Mode"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "plugins",
|
||||
"number": "07",
|
||||
"title": "Plugins",
|
||||
"source": "07-plugins/README.md",
|
||||
"time": "2 hours",
|
||||
"tagline": "Bundle commands, agents, hooks and MCP into one install.",
|
||||
"lessons": [
|
||||
{"id": "overview", "title": "Overview", "heading": "Overview"},
|
||||
{"id": "architecture", "title": "Plugin architecture", "heading": "Plugin Architecture"},
|
||||
{"id": "structure", "title": "Plugin definition structure", "heading": "Plugin Definition Structure"},
|
||||
{"id": "marketplace", "title": "Plugin marketplace", "heading": "Plugin Marketplace"},
|
||||
{"id": "testing", "title": "Test a plugin", "heading": "Testing a Plugin"},
|
||||
{"id": "publishing", "title": "Publish a plugin", "heading": "Publishing a Plugin"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user