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..3c199b8 --- /dev/null +++ b/contrib/book.py @@ -0,0 +1,156 @@ +#!/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 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 +""" +import argparse +import posixpath +import re +import shutil +from datetime import datetime +from pathlib import Path + +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`, + 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 = {} + 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. + 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} + 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..0454251 --- /dev/null +++ b/contrib/test_book.py @@ -0,0 +1,288 @@ +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) + + +@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\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\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")) == [ + "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..d42873a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,21 @@ +--- +type: Readme +status: stable +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 98% rename from bip-drafts/scrubber.md rename to docs/bip-drafts/scrubber.md index eb44669..56b20a2 100644 --- a/bip-drafts/scrubber.md +++ b/docs/bip-drafts/scrubber.md @@ -1,3 +1,11 @@ +--- +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 ## 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..bf0c1f8 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -9,7 +9,7 @@ mdbook-graphviz mdbook-katex graphviz - python3 + (python3.withPackages (ps: [ ps.pytest ps.pyyaml ])) ]); }; }; diff --git a/nix/site.nix b/nix/site.nix index 1fab21b..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 = '' @@ -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 ps.pyyaml ])) ]; } + '' + 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