From 6342eaf0e2d92a835fea7e660b1efff7f2afdd96 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 17 Jul 2026 18:31:21 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01QNRTPNYvoBTQoTEWkYtCJD --- .pre-commit-config.yaml | 4 +- .../contracts/byox-ladder-2026-07-17.json | 17 + .../evidence/byox-ladder-2026-07-17.json | 39 ++ python_pkg/byox_ladder/.gitignore | 4 + python_pkg/byox_ladder/Makefile | 20 + python_pkg/byox_ladder/README.md | 48 +++ python_pkg/byox_ladder/__init__.py | 4 + python_pkg/byox_ladder/build_pages.py | 80 ++++ python_pkg/byox_ladder/category-ladder.html | 363 ++++++++++++++++++ python_pkg/byox_ladder/guide-ladder.html | 320 +++++++++++++++ python_pkg/byox_ladder/parse_guides.py | 197 ++++++++++ .../byox_ladder/templates/category.html | 356 +++++++++++++++++ .../byox_ladder/templates/guide.template.html | 313 +++++++++++++++ python_pkg/byox_ladder/tests/__init__.py | 1 + .../byox_ladder/tests/test_build_pages.py | 99 +++++ .../byox_ladder/tests/test_parse_guides.py | 129 +++++++ 16 files changed, 1993 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/contracts/byox-ladder-2026-07-17.json create mode 100644 docs/superpowers/evidence/byox-ladder-2026-07-17.json create mode 100644 python_pkg/byox_ladder/.gitignore create mode 100644 python_pkg/byox_ladder/Makefile create mode 100644 python_pkg/byox_ladder/README.md create mode 100644 python_pkg/byox_ladder/__init__.py create mode 100755 python_pkg/byox_ladder/build_pages.py create mode 100644 python_pkg/byox_ladder/category-ladder.html create mode 100644 python_pkg/byox_ladder/guide-ladder.html create mode 100755 python_pkg/byox_ladder/parse_guides.py create mode 100644 python_pkg/byox_ladder/templates/category.html create mode 100644 python_pkg/byox_ladder/templates/guide.template.html create mode 100644 python_pkg/byox_ladder/tests/__init__.py create mode 100644 python_pkg/byox_ladder/tests/test_build_pages.py create mode 100644 python_pkg/byox_ladder/tests/test_parse_guides.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d3c39142..24268c68 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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) diff --git a/docs/superpowers/contracts/byox-ladder-2026-07-17.json b/docs/superpowers/contracts/byox-ladder-2026-07-17.json new file mode 100644 index 00000000..e0ad80f1 --- /dev/null +++ b/docs/superpowers/contracts/byox-ladder-2026-07-17.json @@ -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" +} diff --git a/docs/superpowers/evidence/byox-ladder-2026-07-17.json b/docs/superpowers/evidence/byox-ladder-2026-07-17.json new file mode 100644 index 00000000..ee4a74dd --- /dev/null +++ b/docs/superpowers/evidence/byox-ladder-2026-07-17.json @@ -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 " + } + ], + "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" + ] +} diff --git a/python_pkg/byox_ladder/.gitignore b/python_pkg/byox_ladder/.gitignore new file mode 100644 index 00000000..cdad6bdd --- /dev/null +++ b/python_pkg/byox_ladder/.gitignore @@ -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 diff --git a/python_pkg/byox_ladder/Makefile b/python_pkg/byox_ladder/Makefile new file mode 100644 index 00000000..bee3e018 --- /dev/null +++ b/python_pkg/byox_ladder/Makefile @@ -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 diff --git a/python_pkg/byox_ladder/README.md b/python_pkg/byox_ladder/README.md new file mode 100644 index 00000000..07bb7168 --- /dev/null +++ b/python_pkg/byox_ladder/README.md @@ -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. diff --git a/python_pkg/byox_ladder/__init__.py b/python_pkg/byox_ladder/__init__.py new file mode 100644 index 00000000..76eb5fcc --- /dev/null +++ b/python_pkg/byox_ladder/__init__.py @@ -0,0 +1,4 @@ +"""Generate the Build-Your-Own-X difficulty ladder pages. + +See ``README.md`` for the difficulty model and regeneration steps. +""" diff --git a/python_pkg/byox_ladder/build_pages.py b/python_pkg/byox_ladder/build_pages.py new file mode 100755 index 00000000..05a49f93 --- /dev/null +++ b/python_pkg/byox_ladder/build_pages.py @@ -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 = ( + "\n" + '\n' + "\n" + '\n' + '\n' +) +SKELETON_TAIL = "\n\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 " 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()) diff --git a/python_pkg/byox_ladder/category-ladder.html b/python_pkg/byox_ladder/category-ladder.html new file mode 100644 index 00000000..72135767 --- /dev/null +++ b/python_pkg/byox_ladder/category-ladder.html @@ -0,0 +1,363 @@ + + + + + +Build Your Own X — Difficulty Ladder + + +
+
+

codecrafters-io / build-your-own-x

+

The Build-Your-Own-X Difficulty Ladder

+

All 30 project categories from the + build-your-own-x + collection, ranked easiest → hardest and grouped into four tiers. + Sort or filter, then follow a card to its tutorials.

+ +
+
PrerequisitesDomain knowledge a beginner must bring — crypto, linear algebra, OS internals.
+
ScopeHow much code a working toy version actually needs.
+
GuidanceWhether a canonical step-by-step tutorial exists to lean on.
+
+
+ +
+ + +
+
+
+
+ +
+ Difficulty is a judgment call, scored on prerequisites × scope × guidance for the + typical tutorial in each section — not the hardest possible reading. Data mirrors the + build-your-own-x README. +
+
+ + + + diff --git a/python_pkg/byox_ladder/guide-ladder.html b/python_pkg/byox_ladder/guide-ladder.html new file mode 100644 index 00000000..c445e4e5 --- /dev/null +++ b/python_pkg/byox_ladder/guide-ladder.html @@ -0,0 +1,320 @@ + + + + + +Build Your Own X — Guide Ladder + + +
+
+

codecrafters-io / build-your-own-x

+

The Build-Your-Own-X Guide Ladder

+

Every individual tutorial from the + build-your-own-x + collection — 0 guides across 30 categories — placed on a difficulty + ladder. Search, filter by tier / language / category, and click any guide to open it.

+

How difficulty is assigned: 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 within a tier — lower-level languages (C, Rust, Asm) sit a little higher than + Python or JS. The Unsorted group is the repo's own uncategorized bucket; its guides span + all levels, so they aren't ranked.

+
+ +
+ + +
+
+ +
+ 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 build-your-own-x README. +
+
+ + + + diff --git a/python_pkg/byox_ladder/parse_guides.py b/python_pkg/byox_ladder/parse_guides.py new file mode 100755 index 00000000..a5d75c7b --- /dev/null +++ b/python_pkg/byox_ladder/parse_guides.py @@ -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()) diff --git a/python_pkg/byox_ladder/templates/category.html b/python_pkg/byox_ladder/templates/category.html new file mode 100644 index 00000000..cfbfbdc1 --- /dev/null +++ b/python_pkg/byox_ladder/templates/category.html @@ -0,0 +1,356 @@ +Build Your Own X — Difficulty Ladder + + +
+
+

codecrafters-io / build-your-own-x

+

The Build-Your-Own-X Difficulty Ladder

+

All 30 project categories from the + build-your-own-x + collection, ranked easiest → hardest and grouped into four tiers. + Sort or filter, then follow a card to its tutorials.

+ +
+
PrerequisitesDomain knowledge a beginner must bring — crypto, linear algebra, OS internals.
+
ScopeHow much code a working toy version actually needs.
+
GuidanceWhether a canonical step-by-step tutorial exists to lean on.
+
+
+ +
+ + +
+
+
+
+ +
+ Difficulty is a judgment call, scored on prerequisites × scope × guidance for the + typical tutorial in each section — not the hardest possible reading. Data mirrors the + build-your-own-x README. +
+
+ + diff --git a/python_pkg/byox_ladder/templates/guide.template.html b/python_pkg/byox_ladder/templates/guide.template.html new file mode 100644 index 00000000..5f26214e --- /dev/null +++ b/python_pkg/byox_ladder/templates/guide.template.html @@ -0,0 +1,313 @@ +Build Your Own X — Guide Ladder + + +
+
+

codecrafters-io / build-your-own-x

+

The Build-Your-Own-X Guide Ladder

+

Every individual tutorial from the + build-your-own-x + collection — 0 guides across 30 categories — placed on a difficulty + ladder. Search, filter by tier / language / category, and click any guide to open it.

+

How difficulty is assigned: 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 within a tier — lower-level languages (C, Rust, Asm) sit a little higher than + Python or JS. The Unsorted group is the repo's own uncategorized bucket; its guides span + all levels, so they aren't ranked.

+
+ +
+ + +
+
+ +
+ 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 build-your-own-x README. +
+
+ + diff --git a/python_pkg/byox_ladder/tests/__init__.py b/python_pkg/byox_ladder/tests/__init__.py new file mode 100644 index 00000000..0d170a53 --- /dev/null +++ b/python_pkg/byox_ladder/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the byox_ladder package.""" diff --git a/python_pkg/byox_ladder/tests/test_build_pages.py b/python_pkg/byox_ladder/tests/test_build_pages.py new file mode 100644 index 00000000..4a54a2e5 --- /dev/null +++ b/python_pkg/byox_ladder/tests/test_build_pages.py @@ -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("

hi

") + assert out.startswith("") + assert "

hi

" in out + assert out.rstrip().endswith("") + + +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", + "x", + ) + 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("") + + +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", "no marker") + 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", "cat") + monkeypatch.setattr("python_pkg.byox_ladder.build_pages.TEMPLATES", templates) + html = build_category_page() + assert "cat" in html + assert html.startswith("") + + +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", "") + _write(templates, "category.html", "cat") + 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() diff --git a/python_pkg/byox_ladder/tests/test_parse_guides.py b/python_pkg/byox_ladder/tests/test_parse_guides.py new file mode 100644 index 00000000..167a6f99 --- /dev/null +++ b/python_pkg/byox_ladder/tests/test_parse_guides.py @@ -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()