mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21:07:03 +01:00
feat(byox_ladder): add build-your-own-x difficulty ladder generator
Ranks the codecrafters build-your-own-x projects by difficulty and emits two offline, self-contained HTML pages: a per-guide ladder (~360 tutorials) and a per-category ladder (30 categories), grouped into Beginner/Intermediate/Advanced/ Expert tiers plus an Unsorted bucket. A guide inherits its category's tier; language only nudges order within a tier. Includes a Makefile pipeline (fetch -> parse -> build) and 100%-branch-covered tests. Excludes the generated guide page from codespell since it inlines upstream titles verbatim. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNRTPNYvoBTQoTEWkYtCJD
This commit is contained in:
parent
678098e7e5
commit
6342eaf0e2
@ -293,7 +293,9 @@ repos:
|
||||
args:
|
||||
- --skip=*.json,*.lock,*-lock.yaml,*.min.js,*.min.css,.git,__pycache__,.venv,*.txt
|
||||
- --ignore-words-list=als,ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile,tabel,pary,blok,bloc,proces,serwer,parametr,adres,hart,dout,metod,tekst,synonim,grup,mosty,lokal,skalar,milion,nowe,tre,hel,alph,iif
|
||||
exclude: ^(Bash/ffmpeg-build/|LaTeX/|.*\.geojson$)
|
||||
# guide-ladder.html inlines upstream build-your-own-x tutorial titles
|
||||
# verbatim (third-party data, incl. real source typos) — not authored text.
|
||||
exclude: ^(Bash/ffmpeg-build/|LaTeX/|.*\.geojson$|python_pkg/byox_ladder/guide-ladder\.html$)
|
||||
|
||||
# ===========================================================================
|
||||
# DOCFORMATTER - Format docstrings (disabled - causes recursion errors)
|
||||
|
||||
17
docs/superpowers/contracts/byox-ladder-2026-07-17.json
Normal file
17
docs/superpowers/contracts/byox-ladder-2026-07-17.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Add byox_ladder difficulty-ladder generator",
|
||||
"objective": "Add a self-contained tool that ranks the codecrafters build-your-own-x projects by difficulty and emits two offline HTML pages: a per-guide ladder (~360 tutorials) and a per-category ladder (30 categories), grouped into Beginner/Intermediate/Advanced/Expert tiers plus an Unsorted bucket.",
|
||||
"acceptance_criteria": [
|
||||
"python_pkg/byox_ladder parses the upstream README into guides.json via parse_guides.py",
|
||||
"build_pages.py emits standalone guide-ladder.html and category-ladder.html that open via file:// with no network",
|
||||
"make build regenerates the whole pipeline (fetch -> parse -> build)",
|
||||
"Tests reach 100% branch coverage on parse_guides.py and build_pages.py",
|
||||
"All pre-commit hooks pass (ruff ALL, mypy, pylint, bandit, codespell, check-python-location)"
|
||||
],
|
||||
"out_of_scope": [
|
||||
"Vendoring the upstream README (fetched at build time, gitignored)",
|
||||
"Any change to existing python_pkg packages or repo tooling",
|
||||
"A per-guide exact 1..N ranking (difficulty is tier-based by design)"
|
||||
],
|
||||
"verifier": "pre-commit run --all-files (pre-push stage) and python3 -m pytest python_pkg/byox_ladder/tests"
|
||||
}
|
||||
39
docs/superpowers/evidence/byox-ladder-2026-07-17.json
Normal file
39
docs/superpowers/evidence/byox-ladder-2026-07-17.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"intent": "Add python_pkg/byox_ladder: a generator that ranks the build-your-own-x projects by difficulty and produces two offline HTML ladders (per-guide and per-category), committed as runnable pages.",
|
||||
"scope": [
|
||||
"python_pkg/byox_ladder/ (new package: parse_guides.py, build_pages.py, templates, built HTML, tests)",
|
||||
"docs/superpowers/contracts + evidence artifacts for this change",
|
||||
"No changes to existing packages, tooling, or the unrelated working-tree edits (i3 config, feedback-appimage)"
|
||||
],
|
||||
"changes": [
|
||||
"parse_guides.py parses the upstream README into guides.json; a guide inherits its category tier, language only nudges within-tier order",
|
||||
"build_pages.py inlines guides.json into the guide template and wraps both pages in a standalone HTML skeleton",
|
||||
"Makefile drives fetch -> parse -> build; byox-readme.md and guides.json are gitignored derived artifacts",
|
||||
"tests/ cover both modules to 100% branch coverage"
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "python3 -m pytest python_pkg/byox_ladder/tests --cov=python_pkg.byox_ladder --cov-branch",
|
||||
"result": "pass",
|
||||
"evidence": "21 passed; coverage 100.00% (parse_guides.py 69 stmts/16 branches, build_pages.py 32 stmts/4 branches, __init__ 0) with 0 missing"
|
||||
},
|
||||
{
|
||||
"command": "pre-commit run --files python_pkg/byox_ladder/*.py python_pkg/byox_ladder/tests/*.py",
|
||||
"result": "pass",
|
||||
"evidence": "ruff, ruff-format, mypy, pylint, bandit, codespell, check-python-location, and pytest-coverage all reported Passed"
|
||||
},
|
||||
{
|
||||
"command": "make -C python_pkg/byox_ladder build",
|
||||
"result": "pass",
|
||||
"evidence": "wrote guides.json (359 guides); wrote guide-ladder.html and category-ladder.html, both starting with <!doctype html>"
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"Upstream README format drift could break the regex parser; parse_guides.py exits non-zero and the committed HTML stays valid until regenerated",
|
||||
"Difficulty tiers are a subjective judgment; within-tier order is approximate by design"
|
||||
],
|
||||
"rollback": [
|
||||
"git rm -r python_pkg/byox_ladder and delete the two docs/superpowers artifacts, then commit",
|
||||
"After rollback, run pre-commit run --all-files to confirm the tree is still green"
|
||||
]
|
||||
}
|
||||
4
python_pkg/byox_ladder/.gitignore
vendored
Normal file
4
python_pkg/byox_ladder/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
# Fetched upstream README (third-party content — not vendored) and the derived
|
||||
# dataset. The built *-ladder.html pages ARE committed as the runnable output.
|
||||
byox-readme.md
|
||||
guides.json
|
||||
20
python_pkg/byox_ladder/Makefile
Normal file
20
python_pkg/byox_ladder/Makefile
Normal file
@ -0,0 +1,20 @@
|
||||
# Regenerate the Build-Your-Own-X difficulty ladders.
|
||||
#
|
||||
# make build # fetch README -> parse -> build both standalone pages
|
||||
# make clean # remove the fetched README copy and derived data
|
||||
|
||||
README_URL = https://raw.githubusercontent.com/codecrafters-io/build-your-own-x/master/README.md
|
||||
|
||||
.PHONY: build clean
|
||||
|
||||
build: guides.json
|
||||
python3 build_pages.py
|
||||
|
||||
guides.json: byox-readme.md parse_guides.py
|
||||
python3 parse_guides.py
|
||||
|
||||
byox-readme.md:
|
||||
curl -fsSL "$(README_URL)" -o byox-readme.md
|
||||
|
||||
clean:
|
||||
rm -f byox-readme.md guides.json
|
||||
48
python_pkg/byox_ladder/README.md
Normal file
48
python_pkg/byox_ladder/README.md
Normal file
@ -0,0 +1,48 @@
|
||||
# Build-Your-Own-X Difficulty Ladder
|
||||
|
||||
Two self-contained, offline HTML pages that rank the projects from
|
||||
[codecrafters-io/build-your-own-x](https://github.com/codecrafters-io/build-your-own-x)
|
||||
from easiest to hardest:
|
||||
|
||||
- **`guide-ladder.html`** — every individual tutorial (~360 guides), grouped
|
||||
into difficulty tiers and filterable by tier, language, and category.
|
||||
- **`category-ladder.html`** — the 30 top-level categories, grouped into
|
||||
Beginner / Intermediate / Advanced / Expert tiers.
|
||||
|
||||
Both are single files with all CSS, JS, and data inlined — no server, fonts, or
|
||||
network needed. Open either directly in a browser (`file://`) and it works
|
||||
offline, in your OS light/dark theme.
|
||||
|
||||
## Difficulty model
|
||||
|
||||
Difficulty is a judgment call, scored on three axes: prerequisite domain
|
||||
knowledge, minimal-version scope, and how much step-by-step guidance exists.
|
||||
|
||||
- A guide **inherits its category's tier** — the dominant signal. A Regex-Engine
|
||||
guide is easier than an Operating-System guide whatever the language.
|
||||
- **Language only nudges the order _within_ a tier**: lower-level languages
|
||||
(C, Rust, Assembly) sort a little harder than Python or JavaScript. It never
|
||||
moves a guide across a tier boundary.
|
||||
- The **Unsorted** group is the repo's own "Uncategorized" bucket. Its guides
|
||||
span every level, so they are deliberately left un-ranked rather than forced
|
||||
into a tier.
|
||||
|
||||
The exact head-to-head order inside a tier is therefore approximate by design;
|
||||
the tier boundaries are the trustworthy part.
|
||||
|
||||
## Regenerating
|
||||
|
||||
```sh
|
||||
make build # curl the README -> parse_guides.py -> build_pages.py
|
||||
make clean # remove the fetched README copy and guides.json
|
||||
```
|
||||
|
||||
The pipeline:
|
||||
|
||||
1. `parse_guides.py` — parses a local copy of the upstream README
|
||||
(`byox-readme.md`) into `guides.json`, applying the difficulty model above.
|
||||
2. `build_pages.py` — injects `guides.json` into `templates/guide.template.html`
|
||||
and wraps `templates/category.html`, writing the two standalone pages.
|
||||
|
||||
`byox-readme.md` and `guides.json` are derived artifacts and are gitignored; the
|
||||
built `*-ladder.html` pages are committed as the runnable output.
|
||||
4
python_pkg/byox_ladder/__init__.py
Normal file
4
python_pkg/byox_ladder/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Generate the Build-Your-Own-X difficulty ladder pages.
|
||||
|
||||
See ``README.md`` for the difficulty model and regeneration steps.
|
||||
"""
|
||||
80
python_pkg/byox_ladder/build_pages.py
Executable file
80
python_pkg/byox_ladder/build_pages.py
Executable file
@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the standalone HTML ladder pages from templates and parsed guide data.
|
||||
|
||||
Produces two self-contained, dependency-free pages next to this script:
|
||||
|
||||
* ``guide-ladder.html`` — every individual guide, built by injecting
|
||||
``guides.json`` (written by ``parse_guides.py``) into the guide template.
|
||||
* ``category-ladder.html`` — the 30-category ladder, a static template.
|
||||
|
||||
Both are wrapped in a minimal HTML document skeleton so they open directly via
|
||||
``file://`` with no server, fonts, or network access. Run ``make build`` to
|
||||
regenerate the whole pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
TEMPLATES = HERE / "templates"
|
||||
GUIDES_JSON = HERE / "guides.json"
|
||||
|
||||
# Placeholder in the guide template that the inlined dataset replaces.
|
||||
MARKER = "/*__GUIDES__*/"
|
||||
|
||||
# Minimal, valid document skeleton added around the published page content
|
||||
# (claude.ai injects an equivalent wrapper; local files need their own).
|
||||
SKELETON_HEAD = (
|
||||
"<!doctype html>\n"
|
||||
'<html lang="en">\n'
|
||||
"<head>\n"
|
||||
'<meta charset="utf-8">\n'
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
||||
)
|
||||
SKELETON_TAIL = "\n</html>\n"
|
||||
|
||||
|
||||
def standalone(content: str) -> str:
|
||||
"""Wrap page content in a minimal HTML document skeleton."""
|
||||
return SKELETON_HEAD + content + SKELETON_TAIL
|
||||
|
||||
|
||||
def build_guide_page() -> str:
|
||||
"""Inject ``guides.json`` into the guide template; return standalone HTML."""
|
||||
guides = json.loads(GUIDES_JSON.read_text(encoding="utf-8"))
|
||||
# Compact payload; guard any "</" so inline data can't close the script tag.
|
||||
payload = json.dumps(guides, separators=(",", ":")).replace("</", "<\\/")
|
||||
template = (TEMPLATES / "guide.template.html").read_text(encoding="utf-8")
|
||||
if MARKER not in template:
|
||||
message = f"marker {MARKER!r} not found in guide template"
|
||||
raise ValueError(message)
|
||||
return standalone(template.replace(MARKER, f"const GUIDES = {payload};"))
|
||||
|
||||
|
||||
def build_category_page() -> str:
|
||||
"""Return the standalone category ladder (static content, no data)."""
|
||||
content = (TEMPLATES / "category.html").read_text(encoding="utf-8")
|
||||
return standalone(content)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Write both standalone ladder pages next to this script; return an exit code."""
|
||||
if not GUIDES_JSON.exists():
|
||||
logger.error(
|
||||
"missing %s — run parse_guides.py first (or make build)", GUIDES_JSON
|
||||
)
|
||||
return 1
|
||||
(HERE / "guide-ladder.html").write_text(build_guide_page(), encoding="utf-8")
|
||||
(HERE / "category-ladder.html").write_text(build_category_page(), encoding="utf-8")
|
||||
logger.info("wrote guide-ladder.html and category-ladder.html")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
raise SystemExit(main())
|
||||
363
python_pkg/byox_ladder/category-ladder.html
Normal file
363
python_pkg/byox_ladder/category-ladder.html
Normal file
@ -0,0 +1,363 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Build Your Own X — Difficulty Ladder</title>
|
||||
<style>
|
||||
/* ---- Tokens: light is the base, dark + explicit theme override below ---- */
|
||||
:root{
|
||||
--bg:#f4f6f9; --panel:#ffffff; --panel-2:#eef1f6; --border:#dde3ec;
|
||||
--text:#1a2230; --muted:#5b6676; --faint:#8a94a3;
|
||||
--accent:#0e7490; --accent-ink:#ffffff; --accent-soft:#0e749018;
|
||||
--t1:#1a7f37; --t2:#1f6feb; --t3:#9a6700; --t4:#cf222e;
|
||||
--t1-soft:#1a7f3718; --t2-soft:#1f6feb18; --t3-soft:#9a670018; --t4-soft:#cf222e18;
|
||||
--shadow:0 1px 2px rgba(16,23,38,.06), 0 8px 24px rgba(16,23,38,.06);
|
||||
--font-display:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
|
||||
--font-body:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){
|
||||
:root{
|
||||
--bg:#0d1117; --panel:#161b22; --panel-2:#1c232d; --border:#2b333d;
|
||||
--text:#c9d4e0; --muted:#8b96a5; --faint:#6b7684;
|
||||
--accent:#38bdf8; --accent-ink:#05202b; --accent-soft:#38bdf815;
|
||||
--t1:#3fb950; --t2:#58a6ff; --t3:#d29922; --t4:#f85149;
|
||||
--t1-soft:#3fb95018; --t2-soft:#58a6ff18; --t3-soft:#d2992218; --t4-soft:#f8514918;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.4), 0 10px 30px rgba(0,0,0,.35);
|
||||
}
|
||||
}
|
||||
/* Viewer toggle wins over the media query, in both directions */
|
||||
:root[data-theme="light"]{
|
||||
--bg:#f4f6f9; --panel:#ffffff; --panel-2:#eef1f6; --border:#dde3ec;
|
||||
--text:#1a2230; --muted:#5b6676; --faint:#8a94a3;
|
||||
--accent:#0e7490; --accent-ink:#ffffff; --accent-soft:#0e749018;
|
||||
--t1:#1a7f37; --t2:#1f6feb; --t3:#9a6700; --t4:#cf222e;
|
||||
--t1-soft:#1a7f3718; --t2-soft:#1f6feb18; --t3-soft:#9a670018; --t4-soft:#cf222e18;
|
||||
--shadow:0 1px 2px rgba(16,23,38,.06), 0 8px 24px rgba(16,23,38,.06);
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#0d1117; --panel:#161b22; --panel-2:#1c232d; --border:#2b333d;
|
||||
--text:#c9d4e0; --muted:#8b96a5; --faint:#6b7684;
|
||||
--accent:#38bdf8; --accent-ink:#05202b; --accent-soft:#38bdf815;
|
||||
--t1:#3fb950; --t2:#58a6ff; --t3:#d29922; --t4:#f85149;
|
||||
--t1-soft:#3fb95018; --t2-soft:#58a6ff18; --t3-soft:#d2992218; --t4-soft:#f8514918;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.4), 0 10px 30px rgba(0,0,0,.35);
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{
|
||||
margin:0; background:var(--bg); color:var(--text);
|
||||
font-family:var(--font-body); line-height:1.55;
|
||||
font-size:16px; letter-spacing:.01em;
|
||||
}
|
||||
a{color:inherit}
|
||||
|
||||
.wrap{max-width:1200px; margin:0 auto; padding:clamp(20px,4vw,44px) clamp(16px,4vw,40px) 64px}
|
||||
|
||||
/* ---- Header ---- */
|
||||
header{margin-bottom:28px}
|
||||
.eyebrow{
|
||||
font-family:var(--font-display); font-size:.72rem; letter-spacing:.22em;
|
||||
text-transform:uppercase; color:var(--accent); margin:0 0 10px;
|
||||
display:flex; align-items:center; gap:10px;
|
||||
}
|
||||
.eyebrow::before{content:"//"; opacity:.55}
|
||||
h1{
|
||||
font-family:var(--font-display); font-weight:650;
|
||||
font-size:clamp(1.9rem,5.4vw,3.1rem); line-height:1.02;
|
||||
letter-spacing:-.01em; margin:0 0 14px; text-wrap:balance;
|
||||
}
|
||||
h1 .x{color:var(--accent)}
|
||||
.lede{
|
||||
max-width:60ch; color:var(--muted); font-size:1.02rem; margin:0 0 4px;
|
||||
}
|
||||
.lede a{color:var(--accent); text-underline-offset:3px}
|
||||
|
||||
/* ---- Scoring axes note ---- */
|
||||
.axes{
|
||||
display:flex; flex-wrap:wrap; gap:10px; margin-top:20px;
|
||||
}
|
||||
.axis{
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:10px;
|
||||
padding:10px 13px; flex:1 1 200px; box-shadow:var(--shadow);
|
||||
}
|
||||
.axis b{display:block; font-size:.82rem; font-family:var(--font-display); letter-spacing:.02em}
|
||||
.axis span{font-size:.8rem; color:var(--muted)}
|
||||
|
||||
/* ---- Layout ---- */
|
||||
.layout{display:grid; grid-template-columns:1fr; gap:26px; margin-top:30px}
|
||||
@media (min-width:900px){ .layout{grid-template-columns:230px 1fr; gap:34px} }
|
||||
|
||||
aside{align-self:start}
|
||||
@media (min-width:900px){ aside{position:sticky; top:22px} }
|
||||
|
||||
.ctl-group{margin-bottom:22px}
|
||||
.ctl-label{
|
||||
font-family:var(--font-display); font-size:.7rem; letter-spacing:.16em;
|
||||
text-transform:uppercase; color:var(--faint); margin:0 0 10px;
|
||||
}
|
||||
select{
|
||||
width:100%; font-family:var(--font-body); font-size:.9rem; color:var(--text);
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:9px;
|
||||
padding:9px 11px; cursor:pointer;
|
||||
}
|
||||
.chips{display:flex; flex-direction:column; gap:8px}
|
||||
.chip{
|
||||
display:flex; align-items:center; gap:9px; width:100%; cursor:pointer;
|
||||
font-family:var(--font-body); font-size:.88rem; text-align:left;
|
||||
background:var(--panel); color:var(--text);
|
||||
border:1px solid var(--border); border-radius:9px; padding:8px 11px;
|
||||
transition:background .15s, border-color .15s, opacity .15s;
|
||||
}
|
||||
.chip .dot{width:10px; height:10px; border-radius:3px; flex:none; background:var(--tc)}
|
||||
.chip .n{margin-left:auto; font-family:var(--font-display); font-size:.78rem; color:var(--muted); font-variant-numeric:tabular-nums}
|
||||
.chip[aria-pressed="false"]{opacity:.4; background:transparent}
|
||||
.chip:hover{border-color:var(--tc)}
|
||||
.chip:focus-visible, select:focus-visible, .reset:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
|
||||
.reset{
|
||||
background:none; border:none; color:var(--accent); cursor:pointer;
|
||||
font-family:var(--font-display); font-size:.76rem; letter-spacing:.04em;
|
||||
padding:6px 0; margin-top:4px;
|
||||
}
|
||||
.count{
|
||||
font-family:var(--font-display); font-size:.76rem; color:var(--faint);
|
||||
letter-spacing:.04em; margin-top:16px;
|
||||
}
|
||||
.count b{color:var(--text)}
|
||||
|
||||
/* ---- Cards ---- */
|
||||
.grid{display:grid; grid-template-columns:repeat(auto-fill,minmax(255px,1fr)); gap:16px}
|
||||
.card{
|
||||
position:relative; background:var(--panel); border:1px solid var(--border);
|
||||
border-left:4px solid var(--tc); border-radius:12px;
|
||||
padding:16px 16px 15px; box-shadow:var(--shadow);
|
||||
display:flex; flex-direction:column; gap:9px; min-width:0;
|
||||
transition:transform .16s ease, border-color .16s ease;
|
||||
}
|
||||
.card:hover{transform:translateY(-3px); border-color:var(--tc)}
|
||||
.card-top{display:flex; align-items:baseline; gap:11px}
|
||||
.rank{
|
||||
font-family:var(--font-display); font-weight:650; font-size:1.15rem;
|
||||
color:var(--tc); font-variant-numeric:tabular-nums; line-height:1;
|
||||
}
|
||||
.rank::before{content:"#"; opacity:.5; font-size:.85em}
|
||||
.name{font-weight:650; font-size:1.04rem; letter-spacing:-.01em; line-height:1.2}
|
||||
.badge{
|
||||
margin-left:auto; align-self:center; font-family:var(--font-display);
|
||||
font-size:.63rem; letter-spacing:.08em; text-transform:uppercase;
|
||||
color:var(--tc); background:var(--tc-soft); border:1px solid var(--tc);
|
||||
border-radius:999px; padding:3px 8px; white-space:nowrap;
|
||||
}
|
||||
.why{font-size:.88rem; color:var(--muted); margin:0; flex:1}
|
||||
.tags{display:flex; flex-wrap:wrap; gap:6px}
|
||||
.tag{
|
||||
font-family:var(--font-display); font-size:.68rem; color:var(--faint);
|
||||
background:var(--panel-2); border-radius:6px; padding:3px 7px; white-space:nowrap;
|
||||
}
|
||||
.repo-link{
|
||||
font-family:var(--font-display); font-size:.74rem; letter-spacing:.03em;
|
||||
color:var(--accent); text-decoration:none; margin-top:2px;
|
||||
display:inline-flex; align-items:center; gap:5px; align-self:flex-start;
|
||||
}
|
||||
.repo-link:hover{text-decoration:underline; text-underline-offset:3px}
|
||||
.repo-link::after{content:"↗"; font-size:.85em}
|
||||
|
||||
@media (prefers-reduced-motion:no-preference){
|
||||
.card{animation:pop .28s ease both}
|
||||
@keyframes pop{from{opacity:0; transform:translateY(6px)} to{opacity:1; transform:none}}
|
||||
}
|
||||
|
||||
footer{margin-top:34px; padding-top:18px; border-top:1px solid var(--border);
|
||||
font-size:.82rem; color:var(--faint)}
|
||||
footer a{color:var(--accent)}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<p class="eyebrow">codecrafters-io / build-your-own-x</p>
|
||||
<h1>The Build-Your-Own-<span class="x">X</span> Difficulty Ladder</h1>
|
||||
<p class="lede">All 30 project categories from the
|
||||
<a href="https://github.com/codecrafters-io/build-your-own-x#table-of-contents" target="_blank" rel="noopener">build-your-own-x</a>
|
||||
collection, ranked easiest → hardest and grouped into four tiers.
|
||||
Sort or filter, then follow a card to its tutorials.</p>
|
||||
|
||||
<div class="axes" aria-label="How difficulty is scored">
|
||||
<div class="axis"><b>Prerequisites</b><span>Domain knowledge a beginner must bring — crypto, linear algebra, OS internals.</span></div>
|
||||
<div class="axis"><b>Scope</b><span>How much code a working toy version actually needs.</span></div>
|
||||
<div class="axis"><b>Guidance</b><span>Whether a canonical step-by-step tutorial exists to lean on.</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<div class="ctl-group">
|
||||
<p class="ctl-label">Sort</p>
|
||||
<select id="sort" aria-label="Sort order">
|
||||
<option value="rank">Difficulty (easy → hard)</option>
|
||||
<option value="rankdesc">Difficulty (hard → easy)</option>
|
||||
<option value="az">Name (A → Z)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ctl-group">
|
||||
<p class="ctl-label">Tiers</p>
|
||||
<div class="chips" id="chips"></div>
|
||||
<button class="reset" id="reset" type="button">reset filters</button>
|
||||
</div>
|
||||
<p class="count" id="count"></p>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="grid" id="grid"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Difficulty is a judgment call, scored on prerequisites × scope × guidance for the
|
||||
<em>typical</em> tutorial in each section — not the hardest possible reading. Data mirrors the
|
||||
<a href="https://github.com/codecrafters-io/build-your-own-x" target="_blank" rel="noopener">build-your-own-x</a> README.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tier metadata: order carries difficulty, color encodes it (green -> red).
|
||||
const TIERS = {
|
||||
beginner: {label:"Beginner", color:"var(--t1)", soft:"var(--t1-soft)"},
|
||||
intermediate: {label:"Intermediate", color:"var(--t2)", soft:"var(--t2-soft)"},
|
||||
advanced: {label:"Advanced", color:"var(--t3)", soft:"var(--t3-soft)"},
|
||||
expert: {label:"Expert", color:"var(--t4)", soft:"var(--t4-soft)"},
|
||||
};
|
||||
|
||||
// GitHub slug rule: lowercase, strip backticks/punctuation, spaces -> hyphens.
|
||||
const anchor = s => "https://github.com/codecrafters-io/build-your-own-x#build-your-own-" + s;
|
||||
|
||||
const DATA = [
|
||||
{r:1, name:"Command-Line Tool", tier:"beginner", slug:"command-line-tool",
|
||||
why:"Parse arguments, do one job. The least scaffolding of anything here.", tags:["Any language","argv","stdin/stdout"]},
|
||||
{r:2, name:"Bot", tier:"beginner", slug:"bot",
|
||||
why:"Mostly glue: call an API, handle events, respond. Little novel machinery.", tags:["HTTP APIs","Webhooks","Event loop"]},
|
||||
{r:3, name:"Template Engine", tier:"beginner", slug:"template-engine",
|
||||
why:"Scan a string, substitute placeholders, handle loops. Small and self-contained.", tags:["String parsing","Tokenizing"]},
|
||||
{r:4, name:"Text Editor", tier:"beginner", slug:"text-editor",
|
||||
why:"Raw terminal mode and a screen buffer — the classic ~1k-line kilo build is heavily guided.", tags:["termios","Terminal I/O","C"]},
|
||||
{r:5, name:"Git", tier:"beginner", slug:"git",
|
||||
why:"A content-addressable object store: blobs, trees, commits, SHA-1. Simple model, big payoff.", tags:["Hashing","zlib","File formats"]},
|
||||
|
||||
{r:6, name:"Shell", tier:"intermediate", slug:"shell",
|
||||
why:"fork/exec, pipes and redirection, basic job control — real OS-process work, but bounded.", tags:["fork/exec","Pipes","POSIX"]},
|
||||
{r:7, name:"Web Server", tier:"intermediate", slug:"web-server",
|
||||
why:"Open a socket, parse HTTP, route requests. Concurrency is where it gets spicy.", tags:["Sockets","HTTP","Concurrency"]},
|
||||
{r:8, name:"Search Engine", tier:"intermediate", slug:"search-engine",
|
||||
why:"Build an inverted index and rank with tf-idf. Data-structure heavy, math-light.", tags:["Inverted index","tf-idf","Tokenizing"]},
|
||||
{r:9, name:"BitTorrent Client", tier:"intermediate", slug:"bittorrent-client",
|
||||
why:"Bencode parsing plus the tracker and peer wire protocols — a real network protocol to implement.", tags:["Bencode","TCP","Protocol"]},
|
||||
{r:10, name:"Front-end Framework / Library", tier:"intermediate", slug:"front-end-framework--library",
|
||||
why:"A virtual DOM, diffing, and a reactivity system. Conceptually tidy, JS-detail heavy.", tags:["Virtual DOM","Diffing","Reactivity"]},
|
||||
{r:11, name:"Regex Engine", tier:"intermediate", slug:"regex-engine",
|
||||
why:"Compile patterns to an NFA/DFA (Thompson construction) and simulate it. Clean but algorithmic.", tags:["NFA/DFA","Automata","Parsing"]},
|
||||
{r:12, name:"Game", tier:"intermediate", slug:"game",
|
||||
why:"Guided clones (snake, tetris, a minecraft-lite) are approachable; scope swings wide by target.", tags:["Game loop","Rendering","Input"]},
|
||||
|
||||
{r:13, name:"Programming Language", tier:"advanced", slug:"programming-language",
|
||||
why:"Lexer → parser → tree-walk interpreter. Big, but Crafting Interpreters holds your hand well.", tags:["Lexing","Parsing","Interpreter"]},
|
||||
{r:14, name:"Emulator / Virtual Machine", tier:"advanced", slug:"emulator--virtual-machine",
|
||||
why:"CHIP-8 is a weekend; an NES/GB emulator is months. Ranked at the typical middle.", tags:["Opcodes","CPU cycles","Bit-ops"]},
|
||||
{r:15, name:"Neural Network", tier:"advanced", slug:"neural-network",
|
||||
why:"Forward pass, backprop by hand, gradient descent on MNIST. The calculus is the wall.", tags:["Backprop","Linear algebra","Calculus"]},
|
||||
{r:16, name:"Memory Allocator", tier:"advanced", slug:"memory-allocator",
|
||||
why:"Implement malloc/free with free lists over sbrk/mmap. Small code, unforgiving pointer work.", tags:["Pointers","sbrk/mmap","C"]},
|
||||
{r:17, name:"Blockchain / Cryptocurrency", tier:"advanced", slug:"blockchain--cryptocurrency",
|
||||
why:"Hashing and signatures, a consensus rule, and P2P gossip all have to click together.", tags:["Crypto","Consensus","P2P"]},
|
||||
{r:18, name:"Database", tier:"advanced", slug:"database",
|
||||
why:"A storage engine (B-trees), a write-ahead log, and a query layer. Broad and deep.", tags:["B-trees","WAL","Query parsing"]},
|
||||
{r:19, name:"3D Renderer", tier:"advanced", slug:"3d-renderer",
|
||||
why:"Rasterization or ray tracing built on solid linear algebra and a graphics pipeline.", tags:["Linear algebra","Rasterizing","Shading"]},
|
||||
{r:20, name:"Physics Engine", tier:"advanced", slug:"physics-engine",
|
||||
why:"Collision detection plus stable numerical integration — subtle math bugs everywhere.", tags:["Collision","Integration","Vectors"]},
|
||||
{r:21, name:"Docker", tier:"advanced", slug:"docker",
|
||||
why:"Little code, deep Linux: namespaces, cgroups and an overlay filesystem to isolate a process.", tags:["Namespaces","cgroups","Linux"]},
|
||||
{r:22, name:"Voxel Engine", tier:"advanced", slug:"voxel-engine",
|
||||
why:"3D graphics plus chunk streaming and greedy meshing to keep a blocky world fast.", tags:["Meshing","Chunking","GPU"]},
|
||||
|
||||
{r:23, name:"Network Stack", tier:"expert", slug:"network-stack",
|
||||
why:"A working TCP/IP: the full connection state machine, retransmission and congestion control.", tags:["TCP/IP","State machine","Kernel"]},
|
||||
{r:24, name:"AI Model", tier:"expert", slug:"ai-model",
|
||||
why:"Train a transformer/LLM from scratch. The architecture is only half — the training loop is the rest.", tags:["Transformers","Training","GPUs"]},
|
||||
{r:25, name:"Visual Recognition System", tier:"expert", slug:"visual-recognition-system",
|
||||
why:"A full computer-vision + ML pipeline from pixels to predictions.", tags:["CV","CNNs","Feature extraction"]},
|
||||
{r:26, name:"Augmented Reality", tier:"expert", slug:"augmented-reality",
|
||||
why:"Fuse real-time computer vision with 3D graphics — tracking, calibration, and rendering at once.", tags:["CV","3D math","Real-time"]},
|
||||
{r:27, name:"Web Browser", tier:"expert", slug:"web-browser",
|
||||
why:"Parse HTML/CSS, run layout, paint, and drive a JS engine. Enormous surface area.", tags:["Parsing","Layout","JS engine"]},
|
||||
{r:28, name:"Processor", tier:"expert", slug:"processor",
|
||||
why:"Design a CPU in an HDL from logic gates up — this one crosses into hardware.", tags:["Digital logic","HDL","Architecture"]},
|
||||
{r:29, name:"Operating System", tier:"expert", slug:"operating-system",
|
||||
why:"Bootloader, kernel, memory management, a scheduler. The canonical hardest build.", tags:["Kernel","Bootloader","Scheduling"]},
|
||||
{r:30, name:"Distributed Systems", tier:"expert", slug:"distributed-systems",
|
||||
why:"Consensus (Raft/Paxos), replication and fault tolerance — the hardest to reason about correctly.", tags:["Consensus","Replication","Fault tolerance"]},
|
||||
];
|
||||
|
||||
const grid = document.getElementById("grid");
|
||||
const chipsEl = document.getElementById("chips");
|
||||
const countEl = document.getElementById("count");
|
||||
const sortEl = document.getElementById("sort");
|
||||
|
||||
// active tiers as a Set; all on by default
|
||||
const active = new Set(Object.keys(TIERS));
|
||||
|
||||
// Build filter chips with per-tier counts
|
||||
Object.entries(TIERS).forEach(([key, t]) => {
|
||||
const n = DATA.filter(d => d.tier === key).length;
|
||||
const b = document.createElement("button");
|
||||
b.type = "button"; b.className = "chip"; b.dataset.tier = key;
|
||||
b.setAttribute("aria-pressed", "true");
|
||||
b.style.setProperty("--tc", t.color);
|
||||
b.innerHTML = `<span class="dot"></span>${t.label}<span class="n">${n}</span>`;
|
||||
b.addEventListener("click", () => {
|
||||
active.has(key) ? active.delete(key) : active.add(key);
|
||||
b.setAttribute("aria-pressed", active.has(key));
|
||||
render();
|
||||
});
|
||||
chipsEl.appendChild(b);
|
||||
});
|
||||
|
||||
document.getElementById("reset").addEventListener("click", () => {
|
||||
active.clear(); Object.keys(TIERS).forEach(k => active.add(k));
|
||||
chipsEl.querySelectorAll(".chip").forEach(c => c.setAttribute("aria-pressed","true"));
|
||||
sortEl.value = "rank"; render();
|
||||
});
|
||||
sortEl.addEventListener("change", render);
|
||||
|
||||
function render(){
|
||||
const mode = sortEl.value;
|
||||
let rows = DATA.filter(d => active.has(d.tier));
|
||||
if (mode === "az") rows = rows.slice().sort((a,b)=> a.name.localeCompare(b.name));
|
||||
else if (mode === "rankdesc") rows = rows.slice().sort((a,b)=> b.r - a.r);
|
||||
else rows = rows.slice().sort((a,b)=> a.r - b.r);
|
||||
|
||||
grid.innerHTML = "";
|
||||
rows.forEach(d => {
|
||||
const t = TIERS[d.tier];
|
||||
const el = document.createElement("article");
|
||||
el.className = "card";
|
||||
el.style.setProperty("--tc", t.color);
|
||||
el.style.setProperty("--tc-soft", t.soft);
|
||||
el.innerHTML = `
|
||||
<div class="card-top">
|
||||
<span class="rank">${d.r}</span>
|
||||
<span class="name">${d.name}</span>
|
||||
<span class="badge">${t.label}</span>
|
||||
</div>
|
||||
<p class="why">${d.why}</p>
|
||||
<div class="tags">${d.tags.map(x=>`<span class="tag">${x}</span>`).join("")}</div>
|
||||
<a class="repo-link" href="${anchor(d.slug)}" target="_blank" rel="noopener">tutorials</a>`;
|
||||
grid.appendChild(el);
|
||||
});
|
||||
|
||||
countEl.innerHTML = `Showing <b>${rows.length}</b> of ${DATA.length} categories`;
|
||||
}
|
||||
|
||||
render();
|
||||
</script>
|
||||
|
||||
</html>
|
||||
320
python_pkg/byox_ladder/guide-ladder.html
Normal file
320
python_pkg/byox_ladder/guide-ladder.html
Normal file
File diff suppressed because one or more lines are too long
197
python_pkg/byox_ladder/parse_guides.py
Executable file
197
python_pkg/byox_ladder/parse_guides.py
Executable file
@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse the build-your-own-x README into a ranked, tiered list of guides.
|
||||
|
||||
Reads a local copy of the upstream README (``byox-readme.md`` — fetch it with the
|
||||
command documented in this folder's ``README.md``, or run ``make build``) and
|
||||
writes ``guides.json``.
|
||||
|
||||
Difficulty model: every guide inherits its category's tier — the dominant
|
||||
signal — while its language only nudges the ordering *within* that tier
|
||||
(lower-level languages such as C or Rust sort a little harder than Python or
|
||||
JavaScript). See ``README.md`` for the full rationale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
README = HERE / "byox-readme.md"
|
||||
OUTPUT = HERE / "guides.json"
|
||||
|
||||
# Category display name -> (global difficulty rank 1..30, tier). The rank order
|
||||
# mirrors the approved per-category ladder; keys match the exact backtick names
|
||||
# used in the README headers.
|
||||
CATEGORIES: dict[str, tuple[int, str]] = {
|
||||
"Command-Line Tool": (1, "beginner"),
|
||||
"Bot": (2, "beginner"),
|
||||
"Template Engine": (3, "beginner"),
|
||||
"Text Editor": (4, "beginner"),
|
||||
"Git": (5, "beginner"),
|
||||
"Shell": (6, "intermediate"),
|
||||
"Web Server": (7, "intermediate"),
|
||||
"Search Engine": (8, "intermediate"),
|
||||
"BitTorrent Client": (9, "intermediate"),
|
||||
"Front-end Framework / Library": (10, "intermediate"),
|
||||
"Regex Engine": (11, "intermediate"),
|
||||
"Game": (12, "intermediate"),
|
||||
"Programming Language": (13, "advanced"),
|
||||
"Emulator / Virtual Machine": (14, "advanced"),
|
||||
"Neural Network": (15, "advanced"),
|
||||
"Memory Allocator": (16, "advanced"),
|
||||
"Blockchain / Cryptocurrency": (17, "advanced"),
|
||||
"Database": (18, "advanced"),
|
||||
"3D Renderer": (19, "advanced"),
|
||||
"Physics Engine": (20, "advanced"),
|
||||
"Docker": (21, "advanced"),
|
||||
"Voxel Engine": (22, "advanced"),
|
||||
"Network Stack": (23, "expert"),
|
||||
"AI Model": (24, "expert"),
|
||||
"Visual Recognition System": (25, "expert"),
|
||||
"Augmented Reality": (26, "expert"),
|
||||
"Web Browser": (27, "expert"),
|
||||
"Processor": (28, "expert"),
|
||||
"Operating System": (29, "expert"),
|
||||
"Distributed Systems": (30, "expert"),
|
||||
}
|
||||
|
||||
# Rank used for the repo's "Uncategorized" bucket, sorted after every real tier.
|
||||
UNSORTED_RANK = 99
|
||||
|
||||
# Per-language ordering nudge (0.0 = high-level, higher = lower-level / more
|
||||
# effort). Only orders guides *within* a category; it never crosses a tier.
|
||||
LANG_EFFORT: dict[str, float] = {
|
||||
"assembly": 0.9,
|
||||
"asm": 0.9,
|
||||
"c": 0.8,
|
||||
"c++": 0.8,
|
||||
"cpp": 0.8,
|
||||
"rust": 0.75,
|
||||
"zig": 0.75,
|
||||
"d": 0.7,
|
||||
"nim": 0.6,
|
||||
"haskell": 0.6,
|
||||
"ocaml": 0.6,
|
||||
"go": 0.55,
|
||||
"scala": 0.55,
|
||||
"c#": 0.5,
|
||||
"java": 0.5,
|
||||
"kotlin": 0.5,
|
||||
"swift": 0.5,
|
||||
"f#": 0.5,
|
||||
"clojure": 0.45,
|
||||
"elixir": 0.4,
|
||||
"shell": 0.4,
|
||||
"bash": 0.4,
|
||||
"r": 0.4,
|
||||
"typescript": 0.35,
|
||||
"php": 0.35,
|
||||
"perl": 0.35,
|
||||
"dart": 0.35,
|
||||
"javascript": 0.3,
|
||||
"node.js": 0.3,
|
||||
"node": 0.3,
|
||||
"ruby": 0.3,
|
||||
"python": 0.3,
|
||||
"lua": 0.3,
|
||||
"any": 0.25,
|
||||
"pseudocode": 0.2,
|
||||
}
|
||||
|
||||
# Default nudge for a language not present in ``LANG_EFFORT``.
|
||||
DEFAULT_EFFORT = 0.5
|
||||
|
||||
HEADER_RE = re.compile(r"^####\s+Build your own\s+`([^`]+)`")
|
||||
UNCAT_RE = re.compile(r"^####\s+Uncategorized")
|
||||
GUIDE_RE = re.compile(r"^\*\s+\[(.+)\]\((\S+?)\)(.*)$")
|
||||
LABEL_RE = re.compile(r"^\*\*(.+?)\*\*:\s*(.*)$")
|
||||
|
||||
|
||||
def split_langs(raw: str) -> list[str]:
|
||||
"""Split a ``C# / TypeScript`` language blob into a clean list of names."""
|
||||
parts = re.split(r"[/,&]| or ", raw)
|
||||
names = [part.strip().strip("*").strip() for part in parts]
|
||||
cleaned = [name for name in names if name]
|
||||
return cleaned or ["Various"]
|
||||
|
||||
|
||||
def lang_effort(langs: list[str]) -> float:
|
||||
"""Return the easiest listed language's nudge (you pick your best one)."""
|
||||
values = [LANG_EFFORT.get(lang.lower(), DEFAULT_EFFORT) for lang in langs]
|
||||
return min(values) if values else DEFAULT_EFFORT
|
||||
|
||||
|
||||
def clean_title(rest: str) -> str:
|
||||
"""Strip surrounding markdown italics/emphasis from a guide title."""
|
||||
title = rest.strip()
|
||||
title = re.sub(r"^_+", "", title)
|
||||
title = re.sub(r"_+$", "", title)
|
||||
return title.strip().strip("*").strip()
|
||||
|
||||
|
||||
def parse_guide_line(line: str, category: str) -> dict[str, object] | None:
|
||||
"""Parse one ``* [**Lang**: _Title_](url)`` line into a guide record."""
|
||||
match = GUIDE_RE.match(line.strip())
|
||||
if match is None:
|
||||
return None
|
||||
label, url, trailer = match.group(1), match.group(2), match.group(3)
|
||||
label_match = LABEL_RE.match(label)
|
||||
if label_match is not None:
|
||||
langs = split_langs(label_match.group(1))
|
||||
title = clean_title(label_match.group(2))
|
||||
else:
|
||||
langs = ["Various"]
|
||||
title = clean_title(label)
|
||||
rank, tier = CATEGORIES.get(category, (UNSORTED_RANK, "unsorted"))
|
||||
return {
|
||||
"title": title,
|
||||
"url": url,
|
||||
"langs": langs,
|
||||
"category": category,
|
||||
"cat_rank": rank,
|
||||
"tier": tier,
|
||||
"video": "[video]" in trailer.lower(),
|
||||
"score": round(rank * 10 + lang_effort(langs), 2),
|
||||
}
|
||||
|
||||
|
||||
def parse_readme(text: str) -> list[dict[str, object]]:
|
||||
"""Parse the whole README text into a score-sorted list of guides."""
|
||||
guides: list[dict[str, object]] = []
|
||||
current: str | None = None
|
||||
for line in text.splitlines():
|
||||
header = HEADER_RE.match(line)
|
||||
if header is not None:
|
||||
current = header.group(1).strip()
|
||||
continue
|
||||
if UNCAT_RE.match(line):
|
||||
current = "Uncategorized"
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
guide = parse_guide_line(line, current)
|
||||
if guide is not None:
|
||||
guides.append(guide)
|
||||
guides.sort(key=lambda item: (item["score"], str(item["title"]).lower()))
|
||||
return guides
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Parse ``byox-readme.md`` and write ``guides.json``; return an exit code."""
|
||||
if not README.exists():
|
||||
logger.error("missing %s — fetch it first (see README.md / make build)", README)
|
||||
return 1
|
||||
guides = parse_readme(README.read_text(encoding="utf-8"))
|
||||
OUTPUT.write_text(json.dumps(guides, indent=1), encoding="utf-8")
|
||||
logger.info("wrote %s (%d guides)", OUTPUT.name, len(guides))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
raise SystemExit(main())
|
||||
356
python_pkg/byox_ladder/templates/category.html
Normal file
356
python_pkg/byox_ladder/templates/category.html
Normal file
@ -0,0 +1,356 @@
|
||||
<title>Build Your Own X — Difficulty Ladder</title>
|
||||
<style>
|
||||
/* ---- Tokens: light is the base, dark + explicit theme override below ---- */
|
||||
:root{
|
||||
--bg:#f4f6f9; --panel:#ffffff; --panel-2:#eef1f6; --border:#dde3ec;
|
||||
--text:#1a2230; --muted:#5b6676; --faint:#8a94a3;
|
||||
--accent:#0e7490; --accent-ink:#ffffff; --accent-soft:#0e749018;
|
||||
--t1:#1a7f37; --t2:#1f6feb; --t3:#9a6700; --t4:#cf222e;
|
||||
--t1-soft:#1a7f3718; --t2-soft:#1f6feb18; --t3-soft:#9a670018; --t4-soft:#cf222e18;
|
||||
--shadow:0 1px 2px rgba(16,23,38,.06), 0 8px 24px rgba(16,23,38,.06);
|
||||
--font-display:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
|
||||
--font-body:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){
|
||||
:root{
|
||||
--bg:#0d1117; --panel:#161b22; --panel-2:#1c232d; --border:#2b333d;
|
||||
--text:#c9d4e0; --muted:#8b96a5; --faint:#6b7684;
|
||||
--accent:#38bdf8; --accent-ink:#05202b; --accent-soft:#38bdf815;
|
||||
--t1:#3fb950; --t2:#58a6ff; --t3:#d29922; --t4:#f85149;
|
||||
--t1-soft:#3fb95018; --t2-soft:#58a6ff18; --t3-soft:#d2992218; --t4-soft:#f8514918;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.4), 0 10px 30px rgba(0,0,0,.35);
|
||||
}
|
||||
}
|
||||
/* Viewer toggle wins over the media query, in both directions */
|
||||
:root[data-theme="light"]{
|
||||
--bg:#f4f6f9; --panel:#ffffff; --panel-2:#eef1f6; --border:#dde3ec;
|
||||
--text:#1a2230; --muted:#5b6676; --faint:#8a94a3;
|
||||
--accent:#0e7490; --accent-ink:#ffffff; --accent-soft:#0e749018;
|
||||
--t1:#1a7f37; --t2:#1f6feb; --t3:#9a6700; --t4:#cf222e;
|
||||
--t1-soft:#1a7f3718; --t2-soft:#1f6feb18; --t3-soft:#9a670018; --t4-soft:#cf222e18;
|
||||
--shadow:0 1px 2px rgba(16,23,38,.06), 0 8px 24px rgba(16,23,38,.06);
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#0d1117; --panel:#161b22; --panel-2:#1c232d; --border:#2b333d;
|
||||
--text:#c9d4e0; --muted:#8b96a5; --faint:#6b7684;
|
||||
--accent:#38bdf8; --accent-ink:#05202b; --accent-soft:#38bdf815;
|
||||
--t1:#3fb950; --t2:#58a6ff; --t3:#d29922; --t4:#f85149;
|
||||
--t1-soft:#3fb95018; --t2-soft:#58a6ff18; --t3-soft:#d2992218; --t4-soft:#f8514918;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.4), 0 10px 30px rgba(0,0,0,.35);
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{
|
||||
margin:0; background:var(--bg); color:var(--text);
|
||||
font-family:var(--font-body); line-height:1.55;
|
||||
font-size:16px; letter-spacing:.01em;
|
||||
}
|
||||
a{color:inherit}
|
||||
|
||||
.wrap{max-width:1200px; margin:0 auto; padding:clamp(20px,4vw,44px) clamp(16px,4vw,40px) 64px}
|
||||
|
||||
/* ---- Header ---- */
|
||||
header{margin-bottom:28px}
|
||||
.eyebrow{
|
||||
font-family:var(--font-display); font-size:.72rem; letter-spacing:.22em;
|
||||
text-transform:uppercase; color:var(--accent); margin:0 0 10px;
|
||||
display:flex; align-items:center; gap:10px;
|
||||
}
|
||||
.eyebrow::before{content:"//"; opacity:.55}
|
||||
h1{
|
||||
font-family:var(--font-display); font-weight:650;
|
||||
font-size:clamp(1.9rem,5.4vw,3.1rem); line-height:1.02;
|
||||
letter-spacing:-.01em; margin:0 0 14px; text-wrap:balance;
|
||||
}
|
||||
h1 .x{color:var(--accent)}
|
||||
.lede{
|
||||
max-width:60ch; color:var(--muted); font-size:1.02rem; margin:0 0 4px;
|
||||
}
|
||||
.lede a{color:var(--accent); text-underline-offset:3px}
|
||||
|
||||
/* ---- Scoring axes note ---- */
|
||||
.axes{
|
||||
display:flex; flex-wrap:wrap; gap:10px; margin-top:20px;
|
||||
}
|
||||
.axis{
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:10px;
|
||||
padding:10px 13px; flex:1 1 200px; box-shadow:var(--shadow);
|
||||
}
|
||||
.axis b{display:block; font-size:.82rem; font-family:var(--font-display); letter-spacing:.02em}
|
||||
.axis span{font-size:.8rem; color:var(--muted)}
|
||||
|
||||
/* ---- Layout ---- */
|
||||
.layout{display:grid; grid-template-columns:1fr; gap:26px; margin-top:30px}
|
||||
@media (min-width:900px){ .layout{grid-template-columns:230px 1fr; gap:34px} }
|
||||
|
||||
aside{align-self:start}
|
||||
@media (min-width:900px){ aside{position:sticky; top:22px} }
|
||||
|
||||
.ctl-group{margin-bottom:22px}
|
||||
.ctl-label{
|
||||
font-family:var(--font-display); font-size:.7rem; letter-spacing:.16em;
|
||||
text-transform:uppercase; color:var(--faint); margin:0 0 10px;
|
||||
}
|
||||
select{
|
||||
width:100%; font-family:var(--font-body); font-size:.9rem; color:var(--text);
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:9px;
|
||||
padding:9px 11px; cursor:pointer;
|
||||
}
|
||||
.chips{display:flex; flex-direction:column; gap:8px}
|
||||
.chip{
|
||||
display:flex; align-items:center; gap:9px; width:100%; cursor:pointer;
|
||||
font-family:var(--font-body); font-size:.88rem; text-align:left;
|
||||
background:var(--panel); color:var(--text);
|
||||
border:1px solid var(--border); border-radius:9px; padding:8px 11px;
|
||||
transition:background .15s, border-color .15s, opacity .15s;
|
||||
}
|
||||
.chip .dot{width:10px; height:10px; border-radius:3px; flex:none; background:var(--tc)}
|
||||
.chip .n{margin-left:auto; font-family:var(--font-display); font-size:.78rem; color:var(--muted); font-variant-numeric:tabular-nums}
|
||||
.chip[aria-pressed="false"]{opacity:.4; background:transparent}
|
||||
.chip:hover{border-color:var(--tc)}
|
||||
.chip:focus-visible, select:focus-visible, .reset:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
|
||||
.reset{
|
||||
background:none; border:none; color:var(--accent); cursor:pointer;
|
||||
font-family:var(--font-display); font-size:.76rem; letter-spacing:.04em;
|
||||
padding:6px 0; margin-top:4px;
|
||||
}
|
||||
.count{
|
||||
font-family:var(--font-display); font-size:.76rem; color:var(--faint);
|
||||
letter-spacing:.04em; margin-top:16px;
|
||||
}
|
||||
.count b{color:var(--text)}
|
||||
|
||||
/* ---- Cards ---- */
|
||||
.grid{display:grid; grid-template-columns:repeat(auto-fill,minmax(255px,1fr)); gap:16px}
|
||||
.card{
|
||||
position:relative; background:var(--panel); border:1px solid var(--border);
|
||||
border-left:4px solid var(--tc); border-radius:12px;
|
||||
padding:16px 16px 15px; box-shadow:var(--shadow);
|
||||
display:flex; flex-direction:column; gap:9px; min-width:0;
|
||||
transition:transform .16s ease, border-color .16s ease;
|
||||
}
|
||||
.card:hover{transform:translateY(-3px); border-color:var(--tc)}
|
||||
.card-top{display:flex; align-items:baseline; gap:11px}
|
||||
.rank{
|
||||
font-family:var(--font-display); font-weight:650; font-size:1.15rem;
|
||||
color:var(--tc); font-variant-numeric:tabular-nums; line-height:1;
|
||||
}
|
||||
.rank::before{content:"#"; opacity:.5; font-size:.85em}
|
||||
.name{font-weight:650; font-size:1.04rem; letter-spacing:-.01em; line-height:1.2}
|
||||
.badge{
|
||||
margin-left:auto; align-self:center; font-family:var(--font-display);
|
||||
font-size:.63rem; letter-spacing:.08em; text-transform:uppercase;
|
||||
color:var(--tc); background:var(--tc-soft); border:1px solid var(--tc);
|
||||
border-radius:999px; padding:3px 8px; white-space:nowrap;
|
||||
}
|
||||
.why{font-size:.88rem; color:var(--muted); margin:0; flex:1}
|
||||
.tags{display:flex; flex-wrap:wrap; gap:6px}
|
||||
.tag{
|
||||
font-family:var(--font-display); font-size:.68rem; color:var(--faint);
|
||||
background:var(--panel-2); border-radius:6px; padding:3px 7px; white-space:nowrap;
|
||||
}
|
||||
.repo-link{
|
||||
font-family:var(--font-display); font-size:.74rem; letter-spacing:.03em;
|
||||
color:var(--accent); text-decoration:none; margin-top:2px;
|
||||
display:inline-flex; align-items:center; gap:5px; align-self:flex-start;
|
||||
}
|
||||
.repo-link:hover{text-decoration:underline; text-underline-offset:3px}
|
||||
.repo-link::after{content:"↗"; font-size:.85em}
|
||||
|
||||
@media (prefers-reduced-motion:no-preference){
|
||||
.card{animation:pop .28s ease both}
|
||||
@keyframes pop{from{opacity:0; transform:translateY(6px)} to{opacity:1; transform:none}}
|
||||
}
|
||||
|
||||
footer{margin-top:34px; padding-top:18px; border-top:1px solid var(--border);
|
||||
font-size:.82rem; color:var(--faint)}
|
||||
footer a{color:var(--accent)}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<p class="eyebrow">codecrafters-io / build-your-own-x</p>
|
||||
<h1>The Build-Your-Own-<span class="x">X</span> Difficulty Ladder</h1>
|
||||
<p class="lede">All 30 project categories from the
|
||||
<a href="https://github.com/codecrafters-io/build-your-own-x#table-of-contents" target="_blank" rel="noopener">build-your-own-x</a>
|
||||
collection, ranked easiest → hardest and grouped into four tiers.
|
||||
Sort or filter, then follow a card to its tutorials.</p>
|
||||
|
||||
<div class="axes" aria-label="How difficulty is scored">
|
||||
<div class="axis"><b>Prerequisites</b><span>Domain knowledge a beginner must bring — crypto, linear algebra, OS internals.</span></div>
|
||||
<div class="axis"><b>Scope</b><span>How much code a working toy version actually needs.</span></div>
|
||||
<div class="axis"><b>Guidance</b><span>Whether a canonical step-by-step tutorial exists to lean on.</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<div class="ctl-group">
|
||||
<p class="ctl-label">Sort</p>
|
||||
<select id="sort" aria-label="Sort order">
|
||||
<option value="rank">Difficulty (easy → hard)</option>
|
||||
<option value="rankdesc">Difficulty (hard → easy)</option>
|
||||
<option value="az">Name (A → Z)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ctl-group">
|
||||
<p class="ctl-label">Tiers</p>
|
||||
<div class="chips" id="chips"></div>
|
||||
<button class="reset" id="reset" type="button">reset filters</button>
|
||||
</div>
|
||||
<p class="count" id="count"></p>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="grid" id="grid"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Difficulty is a judgment call, scored on prerequisites × scope × guidance for the
|
||||
<em>typical</em> tutorial in each section — not the hardest possible reading. Data mirrors the
|
||||
<a href="https://github.com/codecrafters-io/build-your-own-x" target="_blank" rel="noopener">build-your-own-x</a> README.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tier metadata: order carries difficulty, color encodes it (green -> red).
|
||||
const TIERS = {
|
||||
beginner: {label:"Beginner", color:"var(--t1)", soft:"var(--t1-soft)"},
|
||||
intermediate: {label:"Intermediate", color:"var(--t2)", soft:"var(--t2-soft)"},
|
||||
advanced: {label:"Advanced", color:"var(--t3)", soft:"var(--t3-soft)"},
|
||||
expert: {label:"Expert", color:"var(--t4)", soft:"var(--t4-soft)"},
|
||||
};
|
||||
|
||||
// GitHub slug rule: lowercase, strip backticks/punctuation, spaces -> hyphens.
|
||||
const anchor = s => "https://github.com/codecrafters-io/build-your-own-x#build-your-own-" + s;
|
||||
|
||||
const DATA = [
|
||||
{r:1, name:"Command-Line Tool", tier:"beginner", slug:"command-line-tool",
|
||||
why:"Parse arguments, do one job. The least scaffolding of anything here.", tags:["Any language","argv","stdin/stdout"]},
|
||||
{r:2, name:"Bot", tier:"beginner", slug:"bot",
|
||||
why:"Mostly glue: call an API, handle events, respond. Little novel machinery.", tags:["HTTP APIs","Webhooks","Event loop"]},
|
||||
{r:3, name:"Template Engine", tier:"beginner", slug:"template-engine",
|
||||
why:"Scan a string, substitute placeholders, handle loops. Small and self-contained.", tags:["String parsing","Tokenizing"]},
|
||||
{r:4, name:"Text Editor", tier:"beginner", slug:"text-editor",
|
||||
why:"Raw terminal mode and a screen buffer — the classic ~1k-line kilo build is heavily guided.", tags:["termios","Terminal I/O","C"]},
|
||||
{r:5, name:"Git", tier:"beginner", slug:"git",
|
||||
why:"A content-addressable object store: blobs, trees, commits, SHA-1. Simple model, big payoff.", tags:["Hashing","zlib","File formats"]},
|
||||
|
||||
{r:6, name:"Shell", tier:"intermediate", slug:"shell",
|
||||
why:"fork/exec, pipes and redirection, basic job control — real OS-process work, but bounded.", tags:["fork/exec","Pipes","POSIX"]},
|
||||
{r:7, name:"Web Server", tier:"intermediate", slug:"web-server",
|
||||
why:"Open a socket, parse HTTP, route requests. Concurrency is where it gets spicy.", tags:["Sockets","HTTP","Concurrency"]},
|
||||
{r:8, name:"Search Engine", tier:"intermediate", slug:"search-engine",
|
||||
why:"Build an inverted index and rank with tf-idf. Data-structure heavy, math-light.", tags:["Inverted index","tf-idf","Tokenizing"]},
|
||||
{r:9, name:"BitTorrent Client", tier:"intermediate", slug:"bittorrent-client",
|
||||
why:"Bencode parsing plus the tracker and peer wire protocols — a real network protocol to implement.", tags:["Bencode","TCP","Protocol"]},
|
||||
{r:10, name:"Front-end Framework / Library", tier:"intermediate", slug:"front-end-framework--library",
|
||||
why:"A virtual DOM, diffing, and a reactivity system. Conceptually tidy, JS-detail heavy.", tags:["Virtual DOM","Diffing","Reactivity"]},
|
||||
{r:11, name:"Regex Engine", tier:"intermediate", slug:"regex-engine",
|
||||
why:"Compile patterns to an NFA/DFA (Thompson construction) and simulate it. Clean but algorithmic.", tags:["NFA/DFA","Automata","Parsing"]},
|
||||
{r:12, name:"Game", tier:"intermediate", slug:"game",
|
||||
why:"Guided clones (snake, tetris, a minecraft-lite) are approachable; scope swings wide by target.", tags:["Game loop","Rendering","Input"]},
|
||||
|
||||
{r:13, name:"Programming Language", tier:"advanced", slug:"programming-language",
|
||||
why:"Lexer → parser → tree-walk interpreter. Big, but Crafting Interpreters holds your hand well.", tags:["Lexing","Parsing","Interpreter"]},
|
||||
{r:14, name:"Emulator / Virtual Machine", tier:"advanced", slug:"emulator--virtual-machine",
|
||||
why:"CHIP-8 is a weekend; an NES/GB emulator is months. Ranked at the typical middle.", tags:["Opcodes","CPU cycles","Bit-ops"]},
|
||||
{r:15, name:"Neural Network", tier:"advanced", slug:"neural-network",
|
||||
why:"Forward pass, backprop by hand, gradient descent on MNIST. The calculus is the wall.", tags:["Backprop","Linear algebra","Calculus"]},
|
||||
{r:16, name:"Memory Allocator", tier:"advanced", slug:"memory-allocator",
|
||||
why:"Implement malloc/free with free lists over sbrk/mmap. Small code, unforgiving pointer work.", tags:["Pointers","sbrk/mmap","C"]},
|
||||
{r:17, name:"Blockchain / Cryptocurrency", tier:"advanced", slug:"blockchain--cryptocurrency",
|
||||
why:"Hashing and signatures, a consensus rule, and P2P gossip all have to click together.", tags:["Crypto","Consensus","P2P"]},
|
||||
{r:18, name:"Database", tier:"advanced", slug:"database",
|
||||
why:"A storage engine (B-trees), a write-ahead log, and a query layer. Broad and deep.", tags:["B-trees","WAL","Query parsing"]},
|
||||
{r:19, name:"3D Renderer", tier:"advanced", slug:"3d-renderer",
|
||||
why:"Rasterization or ray tracing built on solid linear algebra and a graphics pipeline.", tags:["Linear algebra","Rasterizing","Shading"]},
|
||||
{r:20, name:"Physics Engine", tier:"advanced", slug:"physics-engine",
|
||||
why:"Collision detection plus stable numerical integration — subtle math bugs everywhere.", tags:["Collision","Integration","Vectors"]},
|
||||
{r:21, name:"Docker", tier:"advanced", slug:"docker",
|
||||
why:"Little code, deep Linux: namespaces, cgroups and an overlay filesystem to isolate a process.", tags:["Namespaces","cgroups","Linux"]},
|
||||
{r:22, name:"Voxel Engine", tier:"advanced", slug:"voxel-engine",
|
||||
why:"3D graphics plus chunk streaming and greedy meshing to keep a blocky world fast.", tags:["Meshing","Chunking","GPU"]},
|
||||
|
||||
{r:23, name:"Network Stack", tier:"expert", slug:"network-stack",
|
||||
why:"A working TCP/IP: the full connection state machine, retransmission and congestion control.", tags:["TCP/IP","State machine","Kernel"]},
|
||||
{r:24, name:"AI Model", tier:"expert", slug:"ai-model",
|
||||
why:"Train a transformer/LLM from scratch. The architecture is only half — the training loop is the rest.", tags:["Transformers","Training","GPUs"]},
|
||||
{r:25, name:"Visual Recognition System", tier:"expert", slug:"visual-recognition-system",
|
||||
why:"A full computer-vision + ML pipeline from pixels to predictions.", tags:["CV","CNNs","Feature extraction"]},
|
||||
{r:26, name:"Augmented Reality", tier:"expert", slug:"augmented-reality",
|
||||
why:"Fuse real-time computer vision with 3D graphics — tracking, calibration, and rendering at once.", tags:["CV","3D math","Real-time"]},
|
||||
{r:27, name:"Web Browser", tier:"expert", slug:"web-browser",
|
||||
why:"Parse HTML/CSS, run layout, paint, and drive a JS engine. Enormous surface area.", tags:["Parsing","Layout","JS engine"]},
|
||||
{r:28, name:"Processor", tier:"expert", slug:"processor",
|
||||
why:"Design a CPU in an HDL from logic gates up — this one crosses into hardware.", tags:["Digital logic","HDL","Architecture"]},
|
||||
{r:29, name:"Operating System", tier:"expert", slug:"operating-system",
|
||||
why:"Bootloader, kernel, memory management, a scheduler. The canonical hardest build.", tags:["Kernel","Bootloader","Scheduling"]},
|
||||
{r:30, name:"Distributed Systems", tier:"expert", slug:"distributed-systems",
|
||||
why:"Consensus (Raft/Paxos), replication and fault tolerance — the hardest to reason about correctly.", tags:["Consensus","Replication","Fault tolerance"]},
|
||||
];
|
||||
|
||||
const grid = document.getElementById("grid");
|
||||
const chipsEl = document.getElementById("chips");
|
||||
const countEl = document.getElementById("count");
|
||||
const sortEl = document.getElementById("sort");
|
||||
|
||||
// active tiers as a Set; all on by default
|
||||
const active = new Set(Object.keys(TIERS));
|
||||
|
||||
// Build filter chips with per-tier counts
|
||||
Object.entries(TIERS).forEach(([key, t]) => {
|
||||
const n = DATA.filter(d => d.tier === key).length;
|
||||
const b = document.createElement("button");
|
||||
b.type = "button"; b.className = "chip"; b.dataset.tier = key;
|
||||
b.setAttribute("aria-pressed", "true");
|
||||
b.style.setProperty("--tc", t.color);
|
||||
b.innerHTML = `<span class="dot"></span>${t.label}<span class="n">${n}</span>`;
|
||||
b.addEventListener("click", () => {
|
||||
active.has(key) ? active.delete(key) : active.add(key);
|
||||
b.setAttribute("aria-pressed", active.has(key));
|
||||
render();
|
||||
});
|
||||
chipsEl.appendChild(b);
|
||||
});
|
||||
|
||||
document.getElementById("reset").addEventListener("click", () => {
|
||||
active.clear(); Object.keys(TIERS).forEach(k => active.add(k));
|
||||
chipsEl.querySelectorAll(".chip").forEach(c => c.setAttribute("aria-pressed","true"));
|
||||
sortEl.value = "rank"; render();
|
||||
});
|
||||
sortEl.addEventListener("change", render);
|
||||
|
||||
function render(){
|
||||
const mode = sortEl.value;
|
||||
let rows = DATA.filter(d => active.has(d.tier));
|
||||
if (mode === "az") rows = rows.slice().sort((a,b)=> a.name.localeCompare(b.name));
|
||||
else if (mode === "rankdesc") rows = rows.slice().sort((a,b)=> b.r - a.r);
|
||||
else rows = rows.slice().sort((a,b)=> a.r - b.r);
|
||||
|
||||
grid.innerHTML = "";
|
||||
rows.forEach(d => {
|
||||
const t = TIERS[d.tier];
|
||||
const el = document.createElement("article");
|
||||
el.className = "card";
|
||||
el.style.setProperty("--tc", t.color);
|
||||
el.style.setProperty("--tc-soft", t.soft);
|
||||
el.innerHTML = `
|
||||
<div class="card-top">
|
||||
<span class="rank">${d.r}</span>
|
||||
<span class="name">${d.name}</span>
|
||||
<span class="badge">${t.label}</span>
|
||||
</div>
|
||||
<p class="why">${d.why}</p>
|
||||
<div class="tags">${d.tags.map(x=>`<span class="tag">${x}</span>`).join("")}</div>
|
||||
<a class="repo-link" href="${anchor(d.slug)}" target="_blank" rel="noopener">tutorials</a>`;
|
||||
grid.appendChild(el);
|
||||
});
|
||||
|
||||
countEl.innerHTML = `Showing <b>${rows.length}</b> of ${DATA.length} categories`;
|
||||
}
|
||||
|
||||
render();
|
||||
</script>
|
||||
313
python_pkg/byox_ladder/templates/guide.template.html
Normal file
313
python_pkg/byox_ladder/templates/guide.template.html
Normal file
@ -0,0 +1,313 @@
|
||||
<title>Build Your Own X — Guide Ladder</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f4f6f9; --panel:#ffffff; --panel-2:#eef1f6; --border:#dde3ec;
|
||||
--text:#1a2230; --muted:#5b6676; --faint:#8a94a3;
|
||||
--accent:#0e7490; --accent-ink:#ffffff; --accent-soft:#0e749014;
|
||||
--t1:#1a7f37; --t2:#1f6feb; --t3:#9a6700; --t4:#cf222e; --t5:#7a8698;
|
||||
--t1-soft:#1a7f3714; --t2-soft:#1f6feb14; --t3-soft:#9a670014; --t4-soft:#cf222e14; --t5-soft:#7a869814;
|
||||
--shadow:0 1px 2px rgba(16,23,38,.05), 0 8px 24px rgba(16,23,38,.05);
|
||||
--font-display:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
|
||||
--font-body:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){
|
||||
:root{
|
||||
--bg:#0d1117; --panel:#161b22; --panel-2:#1c232d; --border:#2b333d;
|
||||
--text:#c9d4e0; --muted:#8b96a5; --faint:#6b7684;
|
||||
--accent:#38bdf8; --accent-ink:#05202b; --accent-soft:#38bdf814;
|
||||
--t1:#3fb950; --t2:#58a6ff; --t3:#d29922; --t4:#f85149; --t5:#8b96a5;
|
||||
--t1-soft:#3fb95016; --t2-soft:#58a6ff16; --t3-soft:#d2992216; --t4-soft:#f8514916; --t5-soft:#8b96a516;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.4), 0 10px 30px rgba(0,0,0,.3);
|
||||
}
|
||||
}
|
||||
:root[data-theme="light"]{
|
||||
--bg:#f4f6f9; --panel:#ffffff; --panel-2:#eef1f6; --border:#dde3ec;
|
||||
--text:#1a2230; --muted:#5b6676; --faint:#8a94a3;
|
||||
--accent:#0e7490; --accent-ink:#ffffff; --accent-soft:#0e749014;
|
||||
--t1:#1a7f37; --t2:#1f6feb; --t3:#9a6700; --t4:#cf222e; --t5:#7a8698;
|
||||
--t1-soft:#1a7f3714; --t2-soft:#1f6feb14; --t3-soft:#9a670014; --t4-soft:#cf222e14; --t5-soft:#7a869814;
|
||||
--shadow:0 1px 2px rgba(16,23,38,.05), 0 8px 24px rgba(16,23,38,.05);
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#0d1117; --panel:#161b22; --panel-2:#1c232d; --border:#2b333d;
|
||||
--text:#c9d4e0; --muted:#8b96a5; --faint:#6b7684;
|
||||
--accent:#38bdf8; --accent-ink:#05202b; --accent-soft:#38bdf814;
|
||||
--t1:#3fb950; --t2:#58a6ff; --t3:#d29922; --t4:#f85149; --t5:#8b96a5;
|
||||
--t1-soft:#3fb95016; --t2-soft:#58a6ff16; --t3-soft:#d2992216; --t4-soft:#f8514916; --t5-soft:#8b96a516;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.4), 0 10px 30px rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{margin:0; background:var(--bg); color:var(--text); font-family:var(--font-body);
|
||||
line-height:1.5; font-size:15.5px; letter-spacing:.01em}
|
||||
a{color:inherit; text-decoration:none}
|
||||
|
||||
.wrap{max-width:1180px; margin:0 auto; padding:clamp(20px,4vw,40px) clamp(14px,4vw,36px) 60px}
|
||||
|
||||
/* header */
|
||||
.eyebrow{font-family:var(--font-display); font-size:.7rem; letter-spacing:.2em;
|
||||
text-transform:uppercase; color:var(--accent); margin:0 0 9px; display:flex; align-items:center; gap:9px}
|
||||
.eyebrow::before{content:"//"; opacity:.55}
|
||||
h1{font-family:var(--font-display); font-weight:650; font-size:clamp(1.7rem,4.6vw,2.7rem);
|
||||
line-height:1.04; letter-spacing:-.01em; margin:0 0 12px; text-wrap:balance}
|
||||
h1 .x{color:var(--accent)}
|
||||
.lede{max-width:64ch; color:var(--muted); font-size:1rem; margin:0}
|
||||
.lede a{color:var(--accent); text-underline-offset:3px; text-decoration:underline}
|
||||
.note{margin-top:14px; font-size:.86rem; color:var(--faint); max-width:70ch}
|
||||
.note b{color:var(--muted); font-weight:600}
|
||||
|
||||
/* layout */
|
||||
.layout{display:grid; grid-template-columns:1fr; gap:22px; margin-top:26px}
|
||||
@media (min-width:860px){ .layout{grid-template-columns:250px 1fr; gap:30px} }
|
||||
aside{align-self:start}
|
||||
@media (min-width:860px){ aside{position:sticky; top:18px; max-height:calc(100vh - 34px); overflow-y:auto} }
|
||||
|
||||
.ctl{margin-bottom:18px}
|
||||
.ctl-label{font-family:var(--font-display); font-size:.68rem; letter-spacing:.15em;
|
||||
text-transform:uppercase; color:var(--faint); margin:0 0 8px}
|
||||
.field{width:100%; font-family:var(--font-body); font-size:.88rem; color:var(--text);
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:9px; padding:9px 11px}
|
||||
select.field{cursor:pointer}
|
||||
input.field::placeholder{color:var(--faint)}
|
||||
|
||||
.chips{display:flex; flex-direction:column; gap:7px}
|
||||
.chip{display:flex; align-items:center; gap:9px; width:100%; cursor:pointer;
|
||||
font-family:var(--font-body); font-size:.85rem; text-align:left; color:var(--text);
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:9px; padding:7px 10px;
|
||||
transition:background .15s, border-color .15s, opacity .15s}
|
||||
.chip .dot{width:9px; height:9px; border-radius:3px; flex:none; background:var(--tc)}
|
||||
.chip .n{margin-left:auto; font-family:var(--font-display); font-size:.74rem; color:var(--muted);
|
||||
font-variant-numeric:tabular-nums}
|
||||
.chip[aria-pressed="false"]{opacity:.4; background:transparent}
|
||||
.chip:hover{border-color:var(--tc)}
|
||||
.chip:focus-visible,.field:focus-visible,.reset:focus-visible,.row:focus-visible{
|
||||
outline:2px solid var(--accent); outline-offset:2px}
|
||||
.reset{background:none; border:none; color:var(--accent); cursor:pointer;
|
||||
font-family:var(--font-display); font-size:.74rem; letter-spacing:.04em; padding:5px 0}
|
||||
.count{font-family:var(--font-display); font-size:.75rem; color:var(--faint);
|
||||
letter-spacing:.03em; margin-top:6px}
|
||||
.count b{color:var(--text)}
|
||||
|
||||
/* list */
|
||||
main{min-width:0}
|
||||
.tierhead{display:flex; align-items:center; gap:11px; margin:22px 0 10px; padding-top:4px}
|
||||
.tierhead:first-child{margin-top:0}
|
||||
.tierhead .bar{width:26px; height:4px; border-radius:2px; background:var(--tc)}
|
||||
.tierhead h2{font-family:var(--font-display); font-size:.9rem; letter-spacing:.06em;
|
||||
text-transform:uppercase; margin:0; color:var(--tc)}
|
||||
.tierhead .sub{font-size:.78rem; color:var(--faint); margin-left:auto; font-family:var(--font-display)}
|
||||
|
||||
.list{display:flex; flex-direction:column; gap:7px}
|
||||
.row{display:grid; grid-template-columns:1fr auto; align-items:center; gap:10px 14px;
|
||||
background:var(--panel); border:1px solid var(--border); border-left:4px solid var(--tc);
|
||||
border-radius:10px; padding:11px 14px; box-shadow:var(--shadow);
|
||||
transition:transform .12s ease, border-color .12s ease}
|
||||
.row:hover{transform:translateX(2px); border-color:var(--tc)}
|
||||
.row .title{font-weight:600; font-size:.96rem; line-height:1.3}
|
||||
.row:hover .title{color:var(--accent)}
|
||||
.rowmeta{display:flex; flex-wrap:wrap; align-items:center; gap:6px; margin-top:5px}
|
||||
.cat{font-family:var(--font-display); font-size:.7rem; color:var(--muted)}
|
||||
.cat::after{content:"·"; margin-left:6px; color:var(--faint)}
|
||||
.lang{font-family:var(--font-display); font-size:.68rem; color:var(--faint);
|
||||
background:var(--panel-2); border-radius:5px; padding:2px 6px; white-space:nowrap}
|
||||
.vid{font-family:var(--font-display); font-size:.64rem; letter-spacing:.05em; text-transform:uppercase;
|
||||
color:var(--accent); border:1px solid var(--accent); border-radius:5px; padding:1px 5px}
|
||||
.badge{font-family:var(--font-display); font-size:.62rem; letter-spacing:.07em; text-transform:uppercase;
|
||||
color:var(--tc); background:var(--tc-soft); border:1px solid var(--tc); border-radius:999px;
|
||||
padding:3px 8px; white-space:nowrap; align-self:center}
|
||||
|
||||
.empty{color:var(--muted); font-size:.92rem; padding:26px 4px}
|
||||
|
||||
footer{margin-top:34px; padding-top:16px; border-top:1px solid var(--border);
|
||||
font-size:.8rem; color:var(--faint); max-width:74ch}
|
||||
footer a{color:var(--accent)}
|
||||
|
||||
@media (prefers-reduced-motion:reduce){ .row{transition:none} }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<p class="eyebrow">codecrafters-io / build-your-own-x</p>
|
||||
<h1>The Build-Your-Own-<span class="x">X</span> Guide Ladder</h1>
|
||||
<p class="lede">Every individual tutorial from the
|
||||
<a href="https://github.com/codecrafters-io/build-your-own-x" target="_blank" rel="noopener">build-your-own-x</a>
|
||||
collection — <span id="total">0</span> guides across 30 categories — placed on a difficulty
|
||||
ladder. Search, filter by tier / language / category, and click any guide to open it.</p>
|
||||
<p class="note"><b>How difficulty is assigned:</b> each guide inherits its category's tier
|
||||
(a Regex-Engine guide is easier than an OS guide, whatever the language). Language only nudges
|
||||
the order <i>within</i> a tier — lower-level languages (C, Rust, Asm) sit a little higher than
|
||||
Python or JS. The <b>Unsorted</b> group is the repo's own uncategorized bucket; its guides span
|
||||
all levels, so they aren't ranked.</p>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<div class="ctl">
|
||||
<p class="ctl-label">Search</p>
|
||||
<input id="q" class="field" type="search" placeholder="title, category, language…" autocomplete="off">
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<p class="ctl-label">Sort</p>
|
||||
<select id="sort" class="field" aria-label="Sort order">
|
||||
<option value="easy">Difficulty (easy → hard)</option>
|
||||
<option value="hard">Difficulty (hard → easy)</option>
|
||||
<option value="az">Title (A → Z)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<p class="ctl-label">Tiers</p>
|
||||
<div class="chips" id="chips"></div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<p class="ctl-label">Language</p>
|
||||
<select id="lang" class="field" aria-label="Filter by language"></select>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<p class="ctl-label">Category</p>
|
||||
<select id="cat" class="field" aria-label="Filter by category"></select>
|
||||
</div>
|
||||
<button class="reset" id="reset" type="button">reset all filters</button>
|
||||
<p class="count" id="count"></p>
|
||||
</aside>
|
||||
|
||||
<main id="main"></main>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
A per-guide expansion of the category difficulty ladder. Difficulty is a judgment call scored on
|
||||
category × language; the exact head-to-head order within a tier is approximate by design. Data
|
||||
mirrors the <a href="https://github.com/codecrafters-io/build-your-own-x" target="_blank" rel="noopener">build-your-own-x</a> README.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/*__GUIDES__*/
|
||||
|
||||
const TIERS = {
|
||||
beginner: {label:"Beginner", color:"var(--t1)", soft:"var(--t1-soft)", blurb:"app-level logic, few prerequisites"},
|
||||
intermediate: {label:"Intermediate", color:"var(--t2)", soft:"var(--t2-soft)", blurb:"data structures, networking, a real protocol"},
|
||||
advanced: {label:"Advanced", color:"var(--t3)", soft:"var(--t3-soft)", blurb:"deep domain knowledge or large scope"},
|
||||
expert: {label:"Expert", color:"var(--t4)", soft:"var(--t4-soft)", blurb:"broad + deep, little hand-holding"},
|
||||
unsorted: {label:"Unsorted", color:"var(--t5)", soft:"var(--t5-soft)", blurb:"repo's uncategorized bucket — spans all levels"},
|
||||
};
|
||||
const TIER_ORDER = ["beginner","intermediate","advanced","expert","unsorted"];
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => s.replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c]));
|
||||
|
||||
document.getElementById("total").textContent = GUIDES.length;
|
||||
|
||||
const state = {
|
||||
q:"", sort:"easy", lang:"all", cat:"all",
|
||||
tiers:new Set(TIER_ORDER),
|
||||
};
|
||||
|
||||
// ---- build tier chips with counts ----
|
||||
const chipsEl = $("chips");
|
||||
TIER_ORDER.forEach(key=>{
|
||||
const t = TIERS[key];
|
||||
const n = GUIDES.filter(g=>g.tier===key).length;
|
||||
const b = document.createElement("button");
|
||||
b.type="button"; b.className="chip"; b.dataset.tier=key;
|
||||
b.setAttribute("aria-pressed","true");
|
||||
b.style.setProperty("--tc", t.color);
|
||||
b.innerHTML = `<span class="dot"></span>${t.label}<span class="n">${n}</span>`;
|
||||
b.addEventListener("click", ()=>{
|
||||
state.tiers.has(key) ? state.tiers.delete(key) : state.tiers.add(key);
|
||||
b.setAttribute("aria-pressed", state.tiers.has(key));
|
||||
render();
|
||||
});
|
||||
chipsEl.appendChild(b);
|
||||
});
|
||||
|
||||
// ---- language select (alpha, with counts) ----
|
||||
const langCount = {};
|
||||
GUIDES.forEach(g=>g.langs.forEach(l=>langCount[l]=(langCount[l]||0)+1));
|
||||
const langEl = $("lang");
|
||||
langEl.appendChild(new Option(`All languages (${GUIDES.length})`, "all"));
|
||||
Object.keys(langCount).sort((a,b)=>a.localeCompare(b)).forEach(l=>{
|
||||
langEl.appendChild(new Option(`${l} (${langCount[l]})`, l));
|
||||
});
|
||||
|
||||
// ---- category select (in difficulty order) ----
|
||||
const catMeta = {};
|
||||
GUIDES.forEach(g=>{ if(!catMeta[g.category]) catMeta[g.category]={rank:g.cat_rank,n:0}; catMeta[g.category].n++; });
|
||||
const catEl = $("cat");
|
||||
catEl.appendChild(new Option(`All categories (30)`, "all"));
|
||||
Object.keys(catMeta).sort((a,b)=>catMeta[a].rank-catMeta[b].rank).forEach(c=>{
|
||||
catEl.appendChild(new Option(`${c} (${catMeta[c].n})`, c));
|
||||
});
|
||||
|
||||
// ---- wiring ----
|
||||
let t=null;
|
||||
$("q").addEventListener("input", e=>{ state.q=e.target.value.toLowerCase().trim();
|
||||
clearTimeout(t); t=setTimeout(render,110); });
|
||||
$("sort").addEventListener("change", e=>{ state.sort=e.target.value; render(); });
|
||||
$("lang").addEventListener("change", e=>{ state.lang=e.target.value; render(); });
|
||||
$("cat").addEventListener("change", e=>{ state.cat=e.target.value; render(); });
|
||||
$("reset").addEventListener("click", ()=>{
|
||||
state.q=""; state.sort="easy"; state.lang="all"; state.cat="all";
|
||||
state.tiers=new Set(TIER_ORDER);
|
||||
$("q").value=""; $("sort").value="easy"; $("lang").value="all"; $("cat").value="all";
|
||||
chipsEl.querySelectorAll(".chip").forEach(c=>c.setAttribute("aria-pressed","true"));
|
||||
render();
|
||||
});
|
||||
|
||||
function matches(g){
|
||||
if(!state.tiers.has(g.tier)) return false;
|
||||
if(state.lang!=="all" && !g.langs.includes(state.lang)) return false;
|
||||
if(state.cat!=="all" && g.category!==state.cat) return false;
|
||||
if(state.q){
|
||||
const hay = (g.title+" "+g.category+" "+g.langs.join(" ")).toLowerCase();
|
||||
if(!hay.includes(state.q)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function rowHtml(g){
|
||||
const t = TIERS[g.tier];
|
||||
const langs = g.langs.map(l=>`<span class="lang">${esc(l)}</span>`).join("");
|
||||
const vid = g.video ? `<span class="vid">video</span>` : "";
|
||||
return `<a class="row" href="${esc(g.url)}" target="_blank" rel="noopener"
|
||||
style="--tc:${t.color};--tc-soft:${t.soft}">
|
||||
<div>
|
||||
<div class="title">${esc(g.title)}</div>
|
||||
<div class="rowmeta"><span class="cat">${esc(g.category)}</span>${langs}${vid}</div>
|
||||
</div>
|
||||
<span class="badge">${t.label}</span>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function render(){
|
||||
let rows = GUIDES.filter(matches);
|
||||
if(state.sort==="az") rows.sort((a,b)=>a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
|
||||
else if(state.sort==="hard") rows.sort((a,b)=>b.score-a.score || a.title.localeCompare(b.title));
|
||||
else rows.sort((a,b)=>a.score-b.score || a.title.localeCompare(b.title));
|
||||
|
||||
const main = $("main");
|
||||
if(!rows.length){ main.innerHTML = `<p class="empty">No guides match these filters. Try widening them or hit <em>reset</em>.</p>`;
|
||||
$("count").innerHTML = `Showing <b>0</b> of ${GUIDES.length} guides`; return; }
|
||||
|
||||
const grouped = state.sort!=="az";
|
||||
let html = "";
|
||||
if(grouped){
|
||||
// score sort keeps tiers contiguous, so walk runs of the same tier
|
||||
for(let i=0; i<rows.length; ){
|
||||
const tier = rows[i].tier, t = TIERS[tier];
|
||||
const items = [];
|
||||
while(i<rows.length && rows[i].tier===tier){ items.push(rows[i]); i++; }
|
||||
html += `<div class="tierhead" style="--tc:${t.color}">
|
||||
<span class="bar"></span><h2>${t.label}</h2>
|
||||
<span class="sub">${items.length} guide${items.length!==1?"s":""} · ${t.blurb}</span></div>`;
|
||||
html += `<div class="list">${items.map(rowHtml).join("")}</div>`;
|
||||
}
|
||||
} else {
|
||||
html = `<div class="list">${rows.map(rowHtml).join("")}</div>`;
|
||||
}
|
||||
main.innerHTML = html;
|
||||
$("count").innerHTML = `Showing <b>${rows.length}</b> of ${GUIDES.length} guides`;
|
||||
}
|
||||
|
||||
render();
|
||||
</script>
|
||||
1
python_pkg/byox_ladder/tests/__init__.py
Normal file
1
python_pkg/byox_ladder/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the byox_ladder package."""
|
||||
99
python_pkg/byox_ladder/tests/test_build_pages.py
Normal file
99
python_pkg/byox_ladder/tests/test_build_pages.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""Unit tests for the standalone page builder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.byox_ladder.build_pages import (
|
||||
build_category_page,
|
||||
build_guide_page,
|
||||
main,
|
||||
standalone,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write(dirpath: Path, name: str, body: str) -> None:
|
||||
(dirpath / name).write_text(body, encoding="utf-8")
|
||||
|
||||
|
||||
def test_standalone_wraps_content() -> None:
|
||||
out = standalone("<p>hi</p>")
|
||||
assert out.startswith("<!doctype html>")
|
||||
assert "<p>hi</p>" in out
|
||||
assert out.rstrip().endswith("</html>")
|
||||
|
||||
|
||||
def test_build_guide_page_injects_data(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
templates = tmp_path / "templates"
|
||||
templates.mkdir()
|
||||
_write(
|
||||
templates,
|
||||
"guide.template.html",
|
||||
"<title>x</title><script>/*__GUIDES__*/</script>",
|
||||
)
|
||||
guides_json = tmp_path / "guides.json"
|
||||
guides_json.write_text('[{"title": "A"}]', encoding="utf-8")
|
||||
monkeypatch.setattr("python_pkg.byox_ladder.build_pages.TEMPLATES", templates)
|
||||
monkeypatch.setattr("python_pkg.byox_ladder.build_pages.GUIDES_JSON", guides_json)
|
||||
html = build_guide_page()
|
||||
assert "const GUIDES = [" in html
|
||||
assert html.startswith("<!doctype html>")
|
||||
|
||||
|
||||
def test_build_guide_page_missing_marker_raises(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
templates = tmp_path / "templates"
|
||||
templates.mkdir()
|
||||
_write(templates, "guide.template.html", "<title>no marker</title>")
|
||||
guides_json = tmp_path / "guides.json"
|
||||
guides_json.write_text("[]", encoding="utf-8")
|
||||
monkeypatch.setattr("python_pkg.byox_ladder.build_pages.TEMPLATES", templates)
|
||||
monkeypatch.setattr("python_pkg.byox_ladder.build_pages.GUIDES_JSON", guides_json)
|
||||
with pytest.raises(ValueError, match="marker"):
|
||||
build_guide_page()
|
||||
|
||||
|
||||
def test_build_category_page_wraps_template(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
templates = tmp_path / "templates"
|
||||
templates.mkdir()
|
||||
_write(templates, "category.html", "<title>cat</title>")
|
||||
monkeypatch.setattr("python_pkg.byox_ladder.build_pages.TEMPLATES", templates)
|
||||
html = build_category_page()
|
||||
assert "<title>cat</title>" in html
|
||||
assert html.startswith("<!doctype html>")
|
||||
|
||||
|
||||
def test_main_missing_guides_returns_error(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"python_pkg.byox_ladder.build_pages.GUIDES_JSON", tmp_path / "nope.json"
|
||||
)
|
||||
assert main() == 1
|
||||
|
||||
|
||||
def test_main_writes_both_pages(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
templates = tmp_path / "templates"
|
||||
templates.mkdir()
|
||||
_write(templates, "guide.template.html", "<script>/*__GUIDES__*/</script>")
|
||||
_write(templates, "category.html", "<title>cat</title>")
|
||||
guides_json = tmp_path / "guides.json"
|
||||
guides_json.write_text("[]", encoding="utf-8")
|
||||
monkeypatch.setattr("python_pkg.byox_ladder.build_pages.TEMPLATES", templates)
|
||||
monkeypatch.setattr("python_pkg.byox_ladder.build_pages.GUIDES_JSON", guides_json)
|
||||
monkeypatch.setattr("python_pkg.byox_ladder.build_pages.HERE", tmp_path)
|
||||
assert main() == 0
|
||||
assert (tmp_path / "guide-ladder.html").exists()
|
||||
assert (tmp_path / "category-ladder.html").exists()
|
||||
129
python_pkg/byox_ladder/tests/test_parse_guides.py
Normal file
129
python_pkg/byox_ladder/tests/test_parse_guides.py
Normal file
@ -0,0 +1,129 @@
|
||||
"""Unit tests for the guide parser and its difficulty model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.byox_ladder.parse_guides import (
|
||||
DEFAULT_EFFORT,
|
||||
clean_title,
|
||||
lang_effort,
|
||||
main,
|
||||
parse_guide_line,
|
||||
parse_readme,
|
||||
split_langs,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# A tiny README covering a real category, the Uncategorized bucket, a
|
||||
# before-any-header line, and a non-guide line under a category.
|
||||
README_SAMPLE = """intro line before any header
|
||||
#### Build your own `Git`
|
||||
* [**Go**: _Git in Go_](https://example.com/git)
|
||||
not a guide line under a category
|
||||
#### Uncategorized
|
||||
* [**C++**: _Linux Debugger_](https://example.com/dbg)
|
||||
"""
|
||||
|
||||
|
||||
def test_split_langs_multiple_separators() -> None:
|
||||
assert split_langs("C# / TypeScript & JavaScript") == [
|
||||
"C#",
|
||||
"TypeScript",
|
||||
"JavaScript",
|
||||
]
|
||||
|
||||
|
||||
def test_split_langs_or_separator() -> None:
|
||||
assert split_langs("Python or Go") == ["Python", "Go"]
|
||||
|
||||
|
||||
def test_split_langs_empty_falls_back_to_various() -> None:
|
||||
assert split_langs("**") == ["Various"]
|
||||
|
||||
|
||||
def test_lang_effort_prefers_easiest_language() -> None:
|
||||
assert lang_effort(["C", "Python"]) == 0.3
|
||||
|
||||
|
||||
def test_lang_effort_unknown_language_uses_default() -> None:
|
||||
assert lang_effort(["Cobol"]) == DEFAULT_EFFORT
|
||||
|
||||
|
||||
def test_lang_effort_empty_uses_default() -> None:
|
||||
assert lang_effort([]) == DEFAULT_EFFORT
|
||||
|
||||
|
||||
def test_clean_title_strips_emphasis() -> None:
|
||||
assert clean_title("_Hello World_") == "Hello World"
|
||||
|
||||
|
||||
def test_parse_guide_line_full_record() -> None:
|
||||
line = "* [**Go**: _Build X_](https://example.com/x) [video]"
|
||||
guide = parse_guide_line(line, "Git")
|
||||
assert guide is not None
|
||||
assert guide["title"] == "Build X"
|
||||
assert guide["langs"] == ["Go"]
|
||||
assert guide["category"] == "Git"
|
||||
assert guide["tier"] == "beginner"
|
||||
assert guide["video"] is True
|
||||
|
||||
|
||||
def test_parse_guide_line_without_language_prefix() -> None:
|
||||
guide = parse_guide_line("* [Plain title](https://example.com)", "Git")
|
||||
assert guide is not None
|
||||
assert guide["langs"] == ["Various"]
|
||||
assert guide["title"] == "Plain title"
|
||||
assert guide["video"] is False
|
||||
|
||||
|
||||
def test_parse_guide_line_unknown_category_is_unsorted() -> None:
|
||||
guide = parse_guide_line("* [**C**: _Thing_](https://example.com)", "Uncategorized")
|
||||
assert guide is not None
|
||||
assert guide["tier"] == "unsorted"
|
||||
assert guide["cat_rank"] == 99
|
||||
|
||||
|
||||
def test_parse_guide_line_non_guide_returns_none() -> None:
|
||||
assert parse_guide_line("not a bullet", "Git") is None
|
||||
|
||||
|
||||
def test_parse_readme_groups_and_sorts_by_difficulty() -> None:
|
||||
guides = parse_readme(README_SAMPLE)
|
||||
titles = [guide["title"] for guide in guides]
|
||||
assert "Git in Go" in titles
|
||||
assert "Linux Debugger" in titles
|
||||
# Git (rank 5) must sort ahead of the Uncategorized bucket (rank 99).
|
||||
assert titles.index("Git in Go") < titles.index("Linux Debugger")
|
||||
|
||||
|
||||
def test_parse_readme_ignores_lines_before_any_header() -> None:
|
||||
assert parse_readme("* [**Go**: _Orphan_](https://example.com)\n") == []
|
||||
|
||||
|
||||
def test_main_missing_readme_returns_error(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"python_pkg.byox_ladder.parse_guides.README", tmp_path / "nope.md"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"python_pkg.byox_ladder.parse_guides.OUTPUT", tmp_path / "out.json"
|
||||
)
|
||||
assert main() == 1
|
||||
|
||||
|
||||
def test_main_writes_guides_json(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
readme = tmp_path / "byox-readme.md"
|
||||
readme.write_text(README_SAMPLE, encoding="utf-8")
|
||||
output = tmp_path / "guides.json"
|
||||
monkeypatch.setattr("python_pkg.byox_ladder.parse_guides.README", readme)
|
||||
monkeypatch.setattr("python_pkg.byox_ladder.parse_guides.OUTPUT", output)
|
||||
assert main() == 0
|
||||
assert output.exists()
|
||||
Loading…
Reference in New Issue
Block a user