From ed9022a622ea80d4d3498213808bacc57b20daf9 Mon Sep 17 00:00:00 2001 From: bc1cindy Date: Sat, 17 Oct 2026 12:12:31 -0300 Subject: [PATCH 1/2] Generate SUMMARY.md from an OKF index.md mdbook's chapter list and the OKF index serve the same purpose, one for readers and one for agents; generating the former from the latter keeps them from drifting apart (#49). bip-drafts/ joins the stubs in docs/, an OKF bundle whose index.md names the chapters in order. src/ becomes build output: book.py writes SUMMARY.md, a contents page and the chapters, frontmatter stripped, so mdbook never sees OKF metadata. It empties src/, where book.toml expects it, on each run, so an asset deleted from docs/ does not linger in a preview; an index error leaves the previous src/ in place. src/ is emptied rather than replaced, as mdbook serve watches the directory itself, and ignores itself through its own .gitignore, as mdbook serve skips what the root one matches. An index entry that names no chapter or repeats one, a chapter left out of the index, and a page named like one book.py writes fail the build. --- book.toml | 3 + contrib/book.py | 117 +++++++++++ contrib/test_book.py | 215 ++++++++++++++++++++ docs/README.md | 20 ++ {bip-drafts => docs/bip-drafts}/scrubber.md | 7 + docs/index.md | 14 ++ nix/devshell.nix | 2 +- nix/site.nix | 9 + src/README.md | 1 - src/SUMMARY.md | 7 - src/bip-drafts | 1 - 11 files changed, 386 insertions(+), 10 deletions(-) create mode 100644 contrib/book.py create mode 100644 contrib/test_book.py create mode 100644 docs/README.md rename {bip-drafts => docs/bip-drafts}/scrubber.md (99%) create mode 100644 docs/index.md delete mode 100644 src/README.md delete mode 100644 src/SUMMARY.md delete mode 120000 src/bip-drafts diff --git a/book.toml b/book.toml index 6087acc..4d6f565 100644 --- a/book.toml +++ b/book.toml @@ -3,6 +3,9 @@ title = "Fungi Protocol Suite" language = "en" src = "src" +[build] +create-missing = false + [preprocessor.graphviz] command = "mdbook-graphviz" after = ["links"] diff --git a/contrib/book.py b/contrib/book.py new file mode 100644 index 0000000..d3f228d --- /dev/null +++ b/contrib/book.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Assemble the mdbook source from README.md and the docs/ bundle. + +docs/index.md, the bundle's OKF index, states the chapter order: its +headings become the parts of SUMMARY.md and its entries the chapters, +without their descriptions, so an entry must name a chapter, by a path +relative to docs/, and every chapter must have an entry. The README is +the introduction, and the index, with its descriptions, is the contents +page. Frontmatter is dropped, as mdbook would render it as text. The +bundle's other files are copied as they are. + + python3 book.py ROOT +""" +import argparse +import posixpath +import re +import shutil +from pathlib import Path + +FRONTMATTER = re.compile(r"\A---\n.*?\n---[ \t]*(?:\n+|\Z)", re.DOTALL) +ENTRY = re.compile(r"\* \[([^\]]+)\]\(([^)\s]+)\)(?: - .*)?") +DOCS = "docs" +# As set in book.toml. +SRC = "src" +CONTENTS = "contents.md" +GENERATED = ("README.md", "SUMMARY.md", CONTENTS) + + +def strip_frontmatter(text): + return FRONTMATTER.sub("", text, count=1) + + +def summary(index, pages): + """SUMMARY.md from an OKF index: `# Part` headings and + `* [Title](file.md) - description` entries, naming each of `pages`, + the paths in docs/ of the pages the book is given.""" + out = ["# Summary", "", "[Introduction](README.md)", f"[Contents]({CONTENTS})"] + listed = set() + for line in strip_frontmatter(index).splitlines(): + if line.startswith("# "): + out += ["", line, ""] + elif m := ENTRY.fullmatch(line): + chapter = posixpath.normpath(m.group(2)) + if chapter not in pages: + raise ValueError(f"index entry is not a chapter: {line!r}") + if chapter in listed: + raise ValueError(f"index entry repeats a chapter: {line!r}") + listed.add(chapter) + out.append(f"- [{m.group(1)}]({chapter})") + elif line.strip(): + raise ValueError(f"unexpected line in index: {line!r}") + if unlisted := sorted(set(pages) - listed): + raise ValueError(f"not in the index: {', '.join(unlisted)}") + return "\n".join(out) + "\n" + + +def contents(index): + """The contents page: the index with its descriptions, its parts one + heading level down.""" + body = re.sub(r"^# ", "## ", strip_frontmatter(index), flags=re.MULTILINE) + return "# Contents\n\n" + body + + +def assemble(root): + docs, out = root / DOCS, root / SRC + index = (docs / "index.md").read_text() + pages = { + page.relative_to(docs).as_posix(): page.read_text() + for page in sorted(docs.rglob("*.md")) + } + # docs/README.md directs readers of the repository to the book; as a + # chapter it would overwrite the introduction. + for name in ("index.md", "README.md"): + pages.pop(name, None) + # Compared without case, as a case-insensitive filesystem would let a + # chapter named like a generated page replace it. + reserved = {name.casefold() for name in GENERATED} + if clash := sorted(name for name in pages if name.casefold() in reserved): + raise ValueError(f"generated by book.py: {', '.join(clash)}") + generated = { + "README.md": (root / "README.md").read_text(), + "SUMMARY.md": summary(index, pages), + CONTENTS: contents(index), + } + # mdbook serve watches src/ itself and skips what the root .gitignore + # matches, so src/ is emptied rather than replaced, and ignores itself + # before anything else is written to it. + out.mkdir(exist_ok=True) + (out / ".gitignore").write_text("*\n") + for child in out.iterdir(): + if child.name == ".gitignore": + continue + if child.is_dir() and not child.is_symlink(): + shutil.rmtree(child) + else: + child.unlink() + shutil.copytree( + docs, + out, + ignore=shutil.ignore_patterns("*.md", ".gitignore"), + dirs_exist_ok=True, + ) + for name, text in generated.items(): + (out / name).write_text(text) + for name, text in pages.items(): + (out / name).parent.mkdir(parents=True, exist_ok=True) + (out / name).write_text(strip_frontmatter(text)) + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("root", type=Path) + assemble(p.parse_args(argv).root) + + +if __name__ == "__main__": + main() diff --git a/contrib/test_book.py b/contrib/test_book.py new file mode 100644 index 0000000..4ec301c --- /dev/null +++ b/contrib/test_book.py @@ -0,0 +1,215 @@ +import re + +import book +import pytest + +INDEX = """--- +okf_version: "0.2" +--- + +# Part + +* [One](one.md) - The first chapter. +* [Two](sub/two.md) - The second chapter. +""" +CHAPTERS = {"one.md", "sub/two.md"} + + +def test_frontmatter_is_dropped_once(): + text = "---\ntype: Chapter\n---\n\n# Title\n\n---\nnot: frontmatter\n---\n" + assert book.strip_frontmatter(text) == "# Title\n\n---\nnot: frontmatter\n---\n" + assert book.strip_frontmatter("# Title\n") == "# Title\n" + assert book.strip_frontmatter("---\ntype: Chapter\n---") == "" + text = "---\ntype: Chapter\n--- \n\n# Title\n\n---\n\nRest.\n" + assert book.strip_frontmatter(text) == "# Title\n\n---\n\nRest.\n" + + +def test_summary_follows_the_index_without_descriptions(): + assert book.summary(INDEX, CHAPTERS) == ( + "# Summary\n\n" + "[Introduction](README.md)\n" + "[Contents](contents.md)\n\n" + "# Part\n\n" + "- [One](one.md)\n" + "- [Two](sub/two.md)\n" + ) + + +def test_contents_keep_descriptions_under_one_title(): + assert book.contents(INDEX) == ( + "# Contents\n\n" + "## Part\n\n" + "* [One](one.md) - The first chapter.\n" + "* [Two](sub/two.md) - The second chapter.\n" + ) + + +@pytest.mark.parametrize( + "line, error", + [ + ("- [One](one.md) - A dash is not an entry.", "unexpected line"), + ("* [Readme](../README.md)", "not a chapter"), + ("* [Nix](/../flake.nix)", "not a chapter"), + ("* [Root](/one.md)", "not a chapter"), + ("* [Site](https://example.org/x.md)", "not a chapter"), + ("* [Index](index.md)", "not a chapter"), + ("* [About](README.md)", "not a chapter"), + ("* [Anchor](one.md#a)", "not a chapter"), + ("* [Log](log.md)", "not a chapter"), + ("* [Sub](sub/index.md)", "not a chapter"), + ("* [Dir](sub/)", "not a chapter"), + ("* [Gone](gone.md)", "not a chapter"), + ], +) +def test_summary_rejects_what_it_cannot_place(line, error): + with pytest.raises(ValueError, match=error): + book.summary(f"# Part\n\n{line}\n", CHAPTERS) + + +def test_summary_rejects_a_chapter_listed_twice(): + with pytest.raises(ValueError, match="repeats a chapter"): + book.summary("# Part\n\n* [One](one.md)\n* [Again](./one.md)\n", CHAPTERS) + + +def test_summary_rejects_a_page_left_out_of_the_index(): + with pytest.raises(ValueError, match=r"not in the index: sub/two\.md"): + book.summary("# Part\n\n* [One](one.md)\n", CHAPTERS) + + +def test_assembly_covers_every_chapter(tmp_path): + root, out = tmp_path / "root", tmp_path / "root" / "src" + (root / "docs" / "sub").mkdir(parents=True) + (root / "README.md").write_text("# Intro\n\nSee [one](docs/one.md).\n") + (root / "docs" / "index.md").write_text(INDEX) + (root / "docs" / "README.md").write_text("---\ntype: Readme\n---\n\nAbout.\n") + (root / "docs" / "one.md").write_text("---\ntype: Chapter\n---\n\n# One\n") + (root / "docs" / "sub" / "two.md").write_text( + "---\ntype: Chapter\n---\n\n# Two\n\nAfter [one](../one.md).\n" + ) + book.assemble(root) + assert sorted(p.relative_to(out).as_posix() for p in out.rglob("*.md")) == [ + "README.md", + "SUMMARY.md", + "contents.md", + "one.md", + "sub/two.md", + ] + assert (out / "README.md").read_text() == "# Intro\n\nSee [one](docs/one.md).\n" + contents = (out / "contents.md").read_text() + assert "* [Two](sub/two.md) - The second chapter." in contents + assert (out / "one.md").read_text() == "# One\n" + assert (out / "sub" / "two.md").read_text() == "# Two\n\nAfter [one](../one.md).\n" + + +@pytest.mark.parametrize( + "name", ["SUMMARY.md", "contents.md", "summary.md", "Contents.md", "readme.md"] +) +def test_assembly_rejects_a_page_named_like_one_it_generates(tmp_path, name): + root = tmp_path / "root" + (root / "docs").mkdir(parents=True) + (root / "README.md").write_text("# Intro\n") + (root / "docs" / "index.md").write_text("# Part\n") + (root / "docs" / name).write_text("---\ntype: Chapter\nstatus: stable\n---\n") + with pytest.raises(ValueError, match=re.escape(f"generated by book.py: {name}")): + book.assemble(root) + + +def test_assembly_copies_the_other_files_of_the_bundle(tmp_path): + root, out = tmp_path / "root", tmp_path / "root" / "src" + (root / "docs" / "references").mkdir(parents=True) + (root / "README.md").write_text("# Intro\n") + (root / "docs" / "index.md").write_text("# Part\n\n* [One](one.md)\n") + (root / "docs" / "fig.png").write_bytes(b"\x89PNG") + (root / "docs" / "references" / "x.py").write_text("print()\n") + (root / "docs" / "one.md").write_text( + "---\ntype: Chapter\nstatus: stable\n---\n\n" + "![fig](fig.png) [x](references/x.py)\n" + ) + book.assemble(root) + assert (out / "fig.png").read_bytes() == b"\x89PNG" + assert (out / "references" / "x.py").read_text() == "print()\n" + assert (out / "one.md").read_text() == "![fig](fig.png) [x](references/x.py)\n" + + +def test_assembly_replaces_an_earlier_one(tmp_path): + root, out = tmp_path / "root", tmp_path / "root" / "src" + (root / "docs").mkdir(parents=True) + (root / "README.md").write_text("# Intro\n") + (root / "docs" / "index.md").write_text("# Part\n\n* [One](one.md)\n") + (root / "docs" / "one.md").write_text("---\ntype: Chapter\nstatus: stable\n---\n") + (root / "docs" / "old.png").write_bytes(b"") + book.assemble(root) + (root / "docs" / "old.png").unlink() + book.assemble(root) + assert not (out / "old.png").exists() + + +def test_assembly_keeps_the_directory_it_fills(tmp_path): + root, out = tmp_path / "root", tmp_path / "root" / "src" + (root / "docs").mkdir(parents=True) + (root / "README.md").write_text("# Intro\n") + (root / "docs" / "index.md").write_text("# Part\n") + book.assemble(root) + inode = out.stat().st_ino + book.assemble(root) + assert out.stat().st_ino == inode + + +def test_assembly_keeps_the_earlier_one_when_the_index_is_wrong(tmp_path): + root, out = tmp_path / "root", tmp_path / "root" / "src" + (root / "docs").mkdir(parents=True) + (root / "README.md").write_text("# Intro\n") + (root / "docs" / "index.md").write_text("# Part\n\n* [One](one.md)\n") + (root / "docs" / "one.md").write_text("---\ntype: Chapter\nstatus: stable\n---\n") + book.assemble(root) + (root / "docs" / "two.md").write_text("---\ntype: Chapter\nstatus: stable\n---\n") + with pytest.raises(ValueError, match="not in the index"): + book.assemble(root) + assert (out / "SUMMARY.md").exists() + + +def test_assembly_keeps_the_earlier_one_without_a_readme(tmp_path): + root, out = tmp_path / "root", tmp_path / "root" / "src" + (root / "docs").mkdir(parents=True) + (root / "README.md").write_text("# Intro\n") + (root / "docs" / "index.md").write_text("# Part\n\n* [One](one.md)\n") + (root / "docs" / "one.md").write_text("---\ntype: Chapter\nstatus: stable\n---\n") + book.assemble(root) + (root / "README.md").unlink() + with pytest.raises(FileNotFoundError): + book.assemble(root) + assert (out / "one.md").exists() + + +def test_assembly_ignores_itself(tmp_path): + root, out = tmp_path / "root", tmp_path / "root" / "src" + (root / "docs").mkdir(parents=True) + (root / "README.md").write_text("# Intro\n") + (root / "docs" / "index.md").write_text("# Part\n") + book.assemble(root) + assert (out / ".gitignore").read_text() == "*\n" + + +def test_assembly_ignores_itself_whatever_the_bundle_ignores(tmp_path): + root, out = tmp_path / "root", tmp_path / "root" / "src" + (root / "docs").mkdir(parents=True) + (root / "README.md").write_text("# Intro\n") + (root / "docs" / "index.md").write_text("# Part\n") + (root / "docs" / ".gitignore").write_text(".DS_Store\n") + book.assemble(root) + assert (out / ".gitignore").read_text() == "*\n" + + +def test_assembly_ignores_itself_when_the_copy_fails(tmp_path, monkeypatch): + root, out = tmp_path / "root", tmp_path / "root" / "src" + (root / "docs").mkdir(parents=True) + (root / "README.md").write_text("# Intro\n") + (root / "docs" / "index.md").write_text("# Part\n") + + def fail(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr(book.shutil, "copytree", fail) + with pytest.raises(OSError): + book.assemble(root) + assert (out / ".gitignore").read_text() == "*\n" diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..859a69e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,20 @@ +--- +type: Readme +description: The mdbook these chapters form, its introduction and its table of contents. +--- + +This directory holds the chapters of an mdbook published at +. Its introduction is +[../README.md](../README.md), the repository's entry point, and its table of +contents is [index.md](index.md). + +To preview the book, build its source from these chapters and serve it from +the development shell, at the root of the repository: + +```sh +nix develop +python3 contrib/book.py . +mdbook serve +``` + +mdbook watches `src/`, so rerun `book.py` after editing a chapter. diff --git a/bip-drafts/scrubber.md b/docs/bip-drafts/scrubber.md similarity index 99% rename from bip-drafts/scrubber.md rename to docs/bip-drafts/scrubber.md index eb44669..1a03804 100644 --- a/bip-drafts/scrubber.md +++ b/docs/bip-drafts/scrubber.md @@ -1,3 +1,10 @@ +--- +type: BIP Draft +title: PSBT Scrubber +description: Which PSBT fields can be shared with trusted and untrusted parties such that privacy is preserved. +status: draft +--- + # BIP: XXX Privacy PSBT ## Abstract diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..18aee53 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,14 @@ +--- +okf_version: "0.2" +--- + +# BIP Drafts + +* [PSBT Scrubber](bip-drafts/scrubber.md) - Which PSBT fields can be shared with trusted and untrusted parties such that privacy is preserved. + +# In Review + +* [Collaborative transaction privacy](collaborative_txn_privacy.md) - Under review in #7. +* [Cost function framework for Bitcoin wallets](cost_function_abstract.md) - Under review in #15. +* [Concurrent PSBT Construction](psbt-concurrent.md) - Under review in #16. +* [Market-based coalition formation for collaborative transaction construction](coalition_formation.md) - Under review in #18. diff --git a/nix/devshell.nix b/nix/devshell.nix index 5204607..9128cca 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -9,7 +9,7 @@ mdbook-graphviz mdbook-katex graphviz - python3 + (python3.withPackages (ps: [ ps.pytest ])) ]); }; }; diff --git a/nix/site.nix b/nix/site.nix index 1fab21b..7f8dab9 100644 --- a/nix/site.nix +++ b/nix/site.nix @@ -21,6 +21,7 @@ # mermaid measures text with getBBox and rejects a zero-sized box, so # the build sandbox has to carry a font of its own. export FONTCONFIG_FILE=${pkgs.makeFontsConf { fontDirectories = [ pkgs.dejavu_fonts ]; }} + python3 contrib/book.py . mdbook build -d $out ''; @@ -30,6 +31,14 @@ { checks = { inherit site; + book = + pkgs.runCommand "book-tests" + { nativeBuildInputs = [ (pkgs.python3.withPackages (ps: [ ps.pytest ])) ]; } + '' + cd ${../contrib} + pytest -p no:cacheprovider + touch $out + ''; }; packages = { diff --git a/src/README.md b/src/README.md deleted file mode 100644 index dae3167..0000000 --- a/src/README.md +++ /dev/null @@ -1 +0,0 @@ -{{#include ../README.md}} diff --git a/src/SUMMARY.md b/src/SUMMARY.md deleted file mode 100644 index d05a3ad..0000000 --- a/src/SUMMARY.md +++ /dev/null @@ -1,7 +0,0 @@ -# Summary - -[Introduction](README.md) - -# BIP Drafts - -- [PSBT Scrubber](bip-drafts/scrubber.md) diff --git a/src/bip-drafts b/src/bip-drafts deleted file mode 120000 index 6a71a82..0000000 --- a/src/bip-drafts +++ /dev/null @@ -1 +0,0 @@ -../bip-drafts \ No newline at end of file From 61987d72ecbb87ef71ac122b588650c67f5ee221 Mon Sep 17 00:00:00 2001 From: bc1cindy Date: Wed, 14 Oct 2026 20:34:45 -0300 Subject: [PATCH 2/2] Check the status and stale_after of chapters Every page must have a type and a status, and a draft a stale_after instant, though OKF makes status and stale_after optional, so that a reader can tell a draft from a settled chapter and when it lapses. Whether stale_after has passed is not checked, so that a build does not depend on when it runs. Frontmatter is read with PyYAML rather than line by line, so quoting, spacing and comments mean what they mean in YAML. An unquoted stale_after is a YAML timestamp; a quoted one is read as ISO 8601. index.md and log.md are OKF's own files at any level of the bundle, so they are skipped rather than checked as chapters. --- contrib/book.py | 57 +++++++++++++++++++++----- contrib/test_book.py | 79 +++++++++++++++++++++++++++++++++++-- docs/README.md | 1 + docs/bip-drafts/scrubber.md | 1 + nix/devshell.nix | 2 +- nix/site.nix | 4 +- 6 files changed, 129 insertions(+), 15 deletions(-) diff --git a/contrib/book.py b/contrib/book.py index d3f228d..3c199b8 100644 --- a/contrib/book.py +++ b/contrib/book.py @@ -6,8 +6,8 @@ without their descriptions, so an entry must name a chapter, by a path relative to docs/, and every chapter must have an entry. The README is the introduction, and the index, with its descriptions, is the contents -page. Frontmatter is dropped, as mdbook would render it as text. The -bundle's other files are copied as they are. +page. Frontmatter is checked and then dropped, as mdbook would render it +as text. The bundle's other files are copied as they are. python3 book.py ROOT """ @@ -15,21 +15,59 @@ import posixpath import re import shutil +from datetime import datetime from pathlib import Path -FRONTMATTER = re.compile(r"\A---\n.*?\n---[ \t]*(?:\n+|\Z)", re.DOTALL) +import yaml + +FRONTMATTER = re.compile(r"\A---\n(.*?)\n---[ \t]*(?:\n+|\Z)", re.DOTALL) ENTRY = re.compile(r"\* \[([^\]]+)\]\(([^)\s]+)\)(?: - .*)?") DOCS = "docs" # As set in book.toml. SRC = "src" CONTENTS = "contents.md" GENERATED = ("README.md", "SUMMARY.md", CONTENTS) +STATUSES = ("draft", "stable", "deprecated") +RESERVED = ("index.md", "log.md") def strip_frontmatter(text): return FRONTMATTER.sub("", text, count=1) +def frontmatter(text): + m = FRONTMATTER.match(text) + fields = yaml.safe_load(m.group(1)) if m else None + return fields if isinstance(fields, dict) else {} + + +def check(name, text): + """A page must have a type and a status, and a draft the instant from + which it is stale, where OKF makes both optional. Whether that instant + has passed is not checked, so that a build does not depend on when it + runs.""" + try: + fields = frontmatter(text) + except (yaml.YAMLError, ValueError) as e: + raise ValueError(f"{name}: {e}") from e + if not fields.get("type"): + raise ValueError(f"{name}: no type") + status = fields.get("status") + if status not in STATUSES: + raise ValueError(f"{name}: status {status!r} is not one of {STATUSES}") + if status == "draft": + stale_after = fields.get("stale_after") + if isinstance(stale_after, str): + try: + stale_after = datetime.fromisoformat(stale_after) + except ValueError: + stale_after = None + if not isinstance(stale_after, datetime) or stale_after.tzinfo is None: + raise ValueError( + f"{name}: a draft needs stale_after, an ISO 8601 instant with an offset" + ) + + def summary(index, pages): """SUMMARY.md from an OKF index: `# Part` headings and `* [Title](file.md) - description` entries, naming each of `pages`, @@ -64,14 +102,15 @@ def contents(index): def assemble(root): docs, out = root / DOCS, root / SRC index = (docs / "index.md").read_text() - pages = { - page.relative_to(docs).as_posix(): page.read_text() - for page in sorted(docs.rglob("*.md")) - } + pages = {} + for page in sorted(docs.rglob("*.md")): + if page.name not in RESERVED: + name = page.relative_to(docs).as_posix() + pages[name] = page.read_text() + check(name, pages[name]) # docs/README.md directs readers of the repository to the book; as a # chapter it would overwrite the introduction. - for name in ("index.md", "README.md"): - pages.pop(name, None) + pages.pop("README.md", None) # Compared without case, as a case-insensitive filesystem would let a # chapter named like a generated page replace it. reserved = {name.casefold() for name in GENERATED} diff --git a/contrib/test_book.py b/contrib/test_book.py index 4ec301c..0454251 100644 --- a/contrib/test_book.py +++ b/contrib/test_book.py @@ -76,15 +76,88 @@ def test_summary_rejects_a_page_left_out_of_the_index(): book.summary("# Part\n\n* [One](one.md)\n", CHAPTERS) +@pytest.mark.parametrize( + "fields", + [ + "type: Chapter\nstatus: stable", + "type: Chapter\nstatus: deprecated", + "type: Chapter\nstatus: draft\nstale_after: 2027-04-14T00:00:00Z", + "type: Chapter\nstatus: draft\nstale_after: '2027-04-14T00:00:00+02:00'", + "type: Chapter\nstatus: stable # reviewed", + "\"type\": Chapter\nstatus : stable", + ], +) +def test_check_accepts_a_status_and_a_draft_that_can_go_stale(fields): + book.check("one.md", f"---\n{fields}\n---\n\n# One\n") + + +def test_check_reads_frontmatter_that_ends_the_page(): + book.check("one.md", "---\ntype: Chapter\nstatus: stable\n---") + + +@pytest.mark.parametrize( + "text, error", + [ + ("# One\n", "no type"), + ("---\nstatus: stable\n---\n", "no type"), + ("---\ntype: Chapter\n---\n", "status None"), + ("---\ntype: Chapter\nstatus: final\n---\n", "status 'final'"), + ("---\ntype: [Chapter\nstatus: stable\n---\n", "while parsing"), + ( + "---\ntype: Chapter\nstatus: draft\nstale_after: 2027-02-30\n---\n", + "day", + ), + ("---\ntype: Chapter\nstatus: draft\n---\n", "stale_after"), + ( + "---\ntype: Chapter\nstatus: draft\nstale_after: 2027-04-14\n---\n", + "stale_after", + ), + ("---\ntype: Chapter\nstatus: draft\nstale_after: soon\n---\n", "stale_after"), + ], +) +def test_check_rejects_a_page_without_a_type_status_or_stale_after(text, error): + with pytest.raises(ValueError, match=f"one.md: .*{error}"): + book.check("one.md", text) + + +def test_assembly_skips_reserved_files_at_any_level(tmp_path): + root, out = tmp_path / "root", tmp_path / "root" / "src" + (root / "docs" / "sub").mkdir(parents=True) + (root / "README.md").write_text("# Intro\n") + (root / "docs" / "index.md").write_text("# Part\n") + (root / "docs" / "log.md").write_text("# Log\n\n## 2026-10-15\n* Created.\n") + (root / "docs" / "sub" / "index.md").write_text("# Sub\n") + book.assemble(root) + assert sorted(p.relative_to(out).as_posix() for p in out.rglob("*.md")) == [ + "README.md", + "SUMMARY.md", + "contents.md", + ] + + +def test_assembly_checks_the_readme_it_skips(tmp_path): + root = tmp_path / "root" + (root / "docs").mkdir(parents=True) + (root / "README.md").write_text("# Intro\n") + (root / "docs" / "index.md").write_text("# Part\n") + (root / "docs" / "README.md").write_text("---\ntype: Readme\n---\n") + with pytest.raises(ValueError, match=r"README\.md: status"): + book.assemble(root) + + def test_assembly_covers_every_chapter(tmp_path): root, out = tmp_path / "root", tmp_path / "root" / "src" (root / "docs" / "sub").mkdir(parents=True) (root / "README.md").write_text("# Intro\n\nSee [one](docs/one.md).\n") (root / "docs" / "index.md").write_text(INDEX) - (root / "docs" / "README.md").write_text("---\ntype: Readme\n---\n\nAbout.\n") - (root / "docs" / "one.md").write_text("---\ntype: Chapter\n---\n\n# One\n") + (root / "docs" / "README.md").write_text( + "---\ntype: Readme\nstatus: stable\n---\n\nAbout.\n" + ) + (root / "docs" / "one.md").write_text( + "---\ntype: Chapter\nstatus: stable\n---\n\n# One\n" + ) (root / "docs" / "sub" / "two.md").write_text( - "---\ntype: Chapter\n---\n\n# Two\n\nAfter [one](../one.md).\n" + "---\ntype: Chapter\nstatus: stable\n---\n\n# Two\n\nAfter [one](../one.md).\n" ) book.assemble(root) assert sorted(p.relative_to(out).as_posix() for p in out.rglob("*.md")) == [ diff --git a/docs/README.md b/docs/README.md index 859a69e..d42873a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,6 @@ --- type: Readme +status: stable description: The mdbook these chapters form, its introduction and its table of contents. --- diff --git a/docs/bip-drafts/scrubber.md b/docs/bip-drafts/scrubber.md index 1a03804..56b20a2 100644 --- a/docs/bip-drafts/scrubber.md +++ b/docs/bip-drafts/scrubber.md @@ -3,6 +3,7 @@ type: BIP Draft title: PSBT Scrubber description: Which PSBT fields can be shared with trusted and untrusted parties such that privacy is preserved. status: draft +stale_after: 2027-01-14T00:00:00Z --- # BIP: XXX Privacy PSBT diff --git a/nix/devshell.nix b/nix/devshell.nix index 9128cca..bf0c1f8 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -9,7 +9,7 @@ mdbook-graphviz mdbook-katex graphviz - (python3.withPackages (ps: [ ps.pytest ])) + (python3.withPackages (ps: [ ps.pytest ps.pyyaml ])) ]); }; }; diff --git a/nix/site.nix b/nix/site.nix index 7f8dab9..d70b712 100644 --- a/nix/site.nix +++ b/nix/site.nix @@ -12,7 +12,7 @@ mdbook-graphviz mdbook-katex graphviz - python3 + (python3.withPackages (ps: [ ps.pyyaml ])) ]); buildPhase = '' @@ -33,7 +33,7 @@ inherit site; book = pkgs.runCommand "book-tests" - { nativeBuildInputs = [ (pkgs.python3.withPackages (ps: [ ps.pytest ])) ]; } + { nativeBuildInputs = [ (pkgs.python3.withPackages (ps: [ ps.pytest ps.pyyaml ])) ]; } '' cd ${../contrib} pytest -p no:cacheprovider