Skip to content

generate SUMMARY.md from an OKF index.md - #50

Merged
yuval-block merged 2 commits into
mainfrom
okf-index
Oct 19, 2026
Merged

yuval-block merged 2 commits into
mainfrom
okf-index

Conversation

@bc1cindy

@bc1cindy bc1cindy commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

moves the chapters into docs/ as an OKF bundle, with docs/index.md
as the single chapter list. contrib/book.py builds the mdbook source
from it and strips the frontmatter, same as in ephemeral-ecmh.

open PRs that add chapters will need to move them into docs/ and add
an index entry.

closes #49

Captura de Tela 2026-10-10 às 12 26 31

@oakagent

Copy link
Copy Markdown

ACK 304120b (automated): reviewed 1 commit 304120b; no findings.

@yuval-block yuval-block left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it might be good to commit to specifying stale_after

code ACK, but given i suggested this i want to make sure @arminsabouri is at least cACK on this as well

where i hope to keep going with this is:

  • make use of status fields to define what is normative
  • make supporting documents more machine readable, including eventually adding an expanded citations / literature review document for the repo as a whole, containing brief summaries of why certain things are relevant

the goal with using OKF is to make this more legibile to LLMs which i hope would help people using LLMs to work with the SDK to integrate things correctly, and OKF is a bit of semi-standardized glue to help put documents into context so LLMs can better utilize them

@yuval-block

Copy link
Copy Markdown
Collaborator

hmm, maybe a nix flake check that ensures that every doc is has status and if the status is draft also has a stale_after?

@github-actions

Copy link
Copy Markdown
range-diff for force push 304120b2d0 → 58d47ff962 October 15, 2026 00:12 UTC: 1 unchanged, 1 added

reproduce: git range-diff 8ac4434..304120b e0bd68f..58d47ff

1:  304120b2d0 = 1:  0a6966d774 Generate SUMMARY.md from an OKF index.md
-:  ---------- > 2:  58d47ff962 Check the status and stale_after of chapters

@bc1cindy

Copy link
Copy Markdown
Member Author

@yuval-block added a second commit with the status/stale_after check, please take a look when you can

@arminsabouri the docs now use OKF, markdown with yaml frontmatter so humans and LLMs can tell what each doc is and whether it's still current

stale_after is the date after which a draft should be treated as possibly outdated. i set 6 months for the scrubber (2027-04-14), ok with both of you?

and should we add a scheduled action to remind us before a draft goes stale?

Comment thread contrib/book.py Outdated
@github-actions

Copy link
Copy Markdown
range-diff for force push 58d47ff962 → 85c95d0f84 October 15, 2026 01:38 UTC: 2 modified

reproduce: git range-diff e0bd68f..58d47ff e0bd68f..85c95d0

1:  0a6966d774 ! 1:  db30824a8b Generate SUMMARY.md from an OKF index.md
2:  58d47ff962 ! 2:  85c95d0f84 Check the status and stale_after of chapters
1: 0a6966d774 ! 1: db30824a8b Generate SUMMARY.md from an OKF index.md
@@ Commit message
     docs/index.md and strips the OKF frontmatter mdbook would render as
     text. Chapters move to docs/ so the bundle excludes the root README.
 
+    Links from / start at the bundle root in docs/, per OKF v0.2 section 6.1,
+    and at the repository root in the README, as on GitHub.
+
     Closes #49
 
  ## .gitignore ##
@@ contrib/book.py (new)
 +    return FRONTMATTER.sub("", text, count=1)
 +
 +
++def resolve(base, path):
++    """The repository path of link target `path` of a file in directory
++    `base`. In docs/, a path from `/` starts at the bundle root."""
++    if path.startswith("/"):
++        base = "." if base == "." else DOCS
++    return posixpath.normpath(posixpath.join(base, path.lstrip("/")))
++
++
 +def relink(text, base, blob):
 +    """Links of a file in directory `base` of the repository, rewritten for
 +    a book whose source is docs/: targets in docs/ become relative to the
@@ contrib/book.py (new)
 +        if SCHEME.match(url):
 +            return m.group(0)
 +        path, sep, frag = url.partition("#")
-+        target = posixpath.normpath(posixpath.join(base, path))
++        target = resolve(base, path)
 +        if target == f"{DOCS}/index.md":
 +            target = f"{DOCS}/{CONTENTS}"
 +        if not target.startswith(f"{DOCS}/"):
@@ contrib/book.py (new)
 +        if line.startswith("# "):
 +            out += ["", line, ""]
 +        elif m := ENTRY.fullmatch(line):
-+            out.append(f"- [{m.group(1)}]({m.group(2)})")
++            chapter = posixpath.relpath(resolve(DOCS, m.group(2)), DOCS)
++            out.append(f"- [{m.group(1)}]({chapter})")
 +        elif line.strip():
 +            raise ValueError(f"unexpected line in index: {line!r}")
 +    return "\n".join(out) + "\n"
@@ contrib/book.py (new)
 +    index = (docs / "index.md").read_text()
 +    out.mkdir(parents=True, exist_ok=True)
 +    (out / "SUMMARY.md").write_text(summary(index))
-+    (out / CONTENTS).write_text(contents(index))
++    (out / CONTENTS).write_text(relink(contents(index), DOCS, blob))
 +    (out / "README.md").write_text(relink((root / "README.md").read_text(), ".", blob))
 +    for page in sorted(docs.rglob("*.md")):
 +        name = page.relative_to(docs)
@@ contrib/test_book.py (new)
 +# Part
 +
 +* [One](one.md) - The first chapter.
-+* [Two](sub/two.md) - The second chapter.
++* [Two](/sub/two.md) - The second chapter.
 +"""
 +
 +
@@ contrib/test_book.py (new)
 +        ("docs/sub", "../one.md", "../one.md"),
 +        ("docs/sub", "../index.md", "../contents.md"),
 +        ("docs/sub", "../../nix/site.nix", f"{BLOB}/nix/site.nix"),
++        (".", "/docs/one.md", "one.md"),
++        (".", "/flake.nix", f"{BLOB}/flake.nix"),
++        ("docs", "/one.md", "one.md"),
++        ("docs/sub", "/one.md#a", "../one.md#a"),
++        ("docs/sub", "/index.md", "../contents.md"),
 +        ("docs", "https://example.org/x", "https://example.org/x"),
 +        ("docs", "#section", "#section"),
 +    ],
@@ contrib/test_book.py (new)
 +        "# Contents\n\n"
 +        "## Part\n\n"
 +        "* [One](one.md) - The first chapter.\n"
-+        "* [Two](sub/two.md) - The second chapter.\n"
++        "* [Two](/sub/two.md) - The second chapter.\n"
 +    )
 +
 +
@@ contrib/test_book.py (new)
 +        "sub/two.md",
 +    ]
 +    assert (out / "README.md").read_text() == "# Intro\n\nSee [one](one.md).\n"
++    assert "* [Two](sub/two.md) - The second chapter." in (out / "contents.md").read_text()
 +    assert (out / "one.md").read_text() == "# One\n"
 +    assert (out / "sub" / "two.md").read_text() == "# Two\n\nAfter [one](../one.md).\n"
2: 58d47ff962 ! 2: 85c95d0f84 Check the status and stale_after of chapters
@@ contrib/book.py: docs/index.md, the bundle's OKF index, states the chapter order
 +            )
 +
 +
- def relink(text, base, blob):
-     """Links of a file in directory `base` of the repository, rewritten for
-     a book whose source is docs/: targets in docs/ become relative to the
+ def resolve(base, path):
+     """The repository path of link target `path` of a file in directory
+     `base`. In docs/, a path from `/` starts at the bundle root."""
 @@ contrib/book.py: def assemble(root, out, repository, rev):
      (out / "README.md").write_text(relink((root / "README.md").read_text(), ".", blob))
      for page in sorted(docs.rglob("*.md")):

Comment thread contrib/book.py Outdated
@github-actions

Copy link
Copy Markdown
range-diff for force push 85c95d0f84 → d5f7fb2da0 October 15, 2026 03:32 UTC: 1 modified, 1 unchanged

reproduce: git range-diff e0bd68f..85c95d0 e0bd68f..d5f7fb2

1:  db30824a8b = 1:  db30824a8b Generate SUMMARY.md from an OKF index.md
2:  85c95d0f84 ! 2:  d5f7fb2da0 Check the status and stale_after of chapters
2: 85c95d0f84 ! 2: d5f7fb2da0 Check the status and stale_after of chapters
@@ contrib/book.py: docs/index.md, the bundle's OKF index, states the chapter order
 +        raise ValueError(f"{name}: status {status!r} is not one of {STATUSES}")
 +    if status == "draft":
 +        try:
-+            stale_after = datetime.fromisoformat(fields.get("stale_after", ""))
++            stale_after = datetime.fromisoformat(
++                re.sub(r"Z$", "+00:00", fields.get("stale_after", ""))
++            )
 +        except ValueError:
 +            stale_after = None
 +        if stale_after is None or stale_after.tzinfo is None:

Comment thread contrib/book.py Outdated
@github-actions

Copy link
Copy Markdown
range-diff for force push d5f7fb2da0 → 8c984c1904 October 15, 2026 06:05 UTC: 2 modified

reproduce: git range-diff e0bd68f..d5f7fb2 e0bd68f..8c984c1

1:  db30824a8b ! 1:  e0220ff737 Generate SUMMARY.md from an OKF index.md
2:  d5f7fb2da0 ! 2:  8c984c1904 Check the status and stale_after of chapters
1: db30824a8b ! 1: e0220ff737 Generate SUMMARY.md from an OKF index.md
@@ contrib/book.py (new)
 +
 +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. The README is the introduction, and the index,
-+with its descriptions, is the contents page to which links to docs/index.md
-+lead. Frontmatter is dropped, as mdbook would render it as text, and links
-+that leave docs/ point at the repository at the built revision.
++without their descriptions, so an entry must name a page of docs/. The
++README is the introduction, and the index, with its descriptions, is the
++contents page to which links to docs/index.md lead. Frontmatter is
++dropped, as mdbook would render it as text, and links that leave docs/
++point at the repository at the built revision.
 +
 +    python3 book.py ROOT OUT --repository URL --rev REV
 +"""
@@ contrib/book.py (new)
 +        if line.startswith("# "):
 +            out += ["", line, ""]
 +        elif m := ENTRY.fullmatch(line):
-+            chapter = posixpath.relpath(resolve(DOCS, m.group(2)), DOCS)
-+            out.append(f"- [{m.group(1)}]({chapter})")
++            target = resolve(DOCS, m.group(2))
++            if (
++                SCHEME.match(m.group(2))
++                or not target.startswith(f"{DOCS}/")
++                or target in (f"{DOCS}/index.md", f"{DOCS}/README.md")
++            ):
++                raise ValueError(f"index entry is not a chapter: {line!r}")
++            out.append(f"- [{m.group(1)}]({posixpath.relpath(target, DOCS)})")
 +        elif line.strip():
 +            raise ValueError(f"unexpected line in index: {line!r}")
 +    return "\n".join(out) + "\n"
@@ contrib/test_book.py (new)
 +    )
 +
 +
-+def test_summary_rejects_what_it_cannot_place():
-+    with pytest.raises(ValueError, match="unexpected line"):
-+        book.summary("# Part\n\n- [One](one.md) - A dash is not an entry.\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"),
++        ("* [Site](https://example.org/x.md)", "not a chapter"),
++        ("* [Index](index.md)", "not a chapter"),
++        ("* [About](README.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")
 +
 +
 +def test_assembly_covers_every_chapter(tmp_path):
2: d5f7fb2da0 ! 2: 8c984c1904 Check the status and stale_after of chapters
@@ Commit message
  ## contrib/book.py ##
 @@ contrib/book.py: 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. The README is the introduction, and the index,
- with its descriptions, is the contents page to which links to docs/index.md
--lead. Frontmatter is dropped, as mdbook would render it as text, and links
--that leave docs/ point at the repository at the built revision.
-+lead. Frontmatter is checked and then dropped, as mdbook would render it as
-+text, and links that leave docs/ point at the repository at the built
-+revision.
+ without their descriptions, so an entry must name a page of docs/. The
+ README is the introduction, and the index, with its descriptions, is the
+-contents page to which links to docs/index.md lead. Frontmatter is
+-dropped, as mdbook would render it as text, and links that leave docs/
+-point at the repository at the built revision.
++contents page to which links to docs/index.md lead. Frontmatter is checked
++and then dropped, as mdbook would render it as text, and links that leave
++docs/ point at the repository at the built revision.
  
      python3 book.py ROOT OUT --repository URL --rev REV
  """
@@ contrib/book.py: docs/index.md, the bundle's OKF index, states the chapter order
  DOCS = "docs"
  CONTENTS = "contents.md"
 +STATUSES = ("draft", "stable", "deprecated")
++RESERVED = ("index.md", "log.md")
  
  
  def strip_frontmatter(text):
@@ contrib/book.py: docs/index.md, the bundle's OKF index, states the chapter order
 +
 +def check(name, text):
 +    """A page must have a type and a status, and a draft the instant from
-+    which it is stale. Whether that instant has passed is not checked, so
-+    that a build does not depend on when it runs."""
++    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."""
 +    fields = frontmatter(text)
 +    if not fields.get("type"):
 +        raise ValueError(f"{name}: no type")
@@ contrib/book.py: def assemble(root, out, repository, rev):
      (out / "README.md").write_text(relink((root / "README.md").read_text(), ".", blob))
      for page in sorted(docs.rglob("*.md")):
          name = page.relative_to(docs)
-+        if name.as_posix() == "index.md":
++        if page.name in RESERVED:
 +            continue
 +        text = page.read_text()
 +        check(name.as_posix(), text)
@@ contrib/book.py: def assemble(root, out, repository, rev):
  def main(argv=None):
 
  ## contrib/test_book.py ##
-@@ contrib/test_book.py: def test_summary_rejects_what_it_cannot_place():
-         book.summary("# Part\n\n- [One](one.md) - A dash is not an entry.\n")
+@@ contrib/test_book.py: def test_summary_rejects_what_it_cannot_place(line, error):
+         book.summary(f"# Part\n\n{line}\n")
  
  
 +@pytest.mark.parametrize(
@@ contrib/test_book.py: def test_summary_rejects_what_it_cannot_place():
 +        book.check("one.md", text)
 +
 +
++def test_assembly_skips_reserved_files_at_any_level(tmp_path):
++    root, out = tmp_path / "root", tmp_path / "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, out, "https://example.org", "abc")
++    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)

Comment thread contrib/book.py Outdated
Comment thread contrib/book.py Outdated
Comment thread contrib/book.py Outdated
Comment thread contrib/book.py
@github-actions

Copy link
Copy Markdown
range-diff for force push 8c984c1904 → fa1bb3b1f7 October 15, 2026 13:57 UTC: 2 modified

reproduce: git range-diff e0bd68f..8c984c1 e0bd68f..fa1bb3b

1:  e0220ff737 ! 1:  2a10cc506f Generate SUMMARY.md from an OKF index.md
2:  8c984c1904 ! 2:  fa1bb3b1f7 Check the status and stale_after of chapters
1: e0220ff737 ! 1: 2a10cc506f Generate SUMMARY.md from an OKF index.md
@@ contrib/book.py (new)
 +
 +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 page of docs/. The
++without their descriptions, so an entry must name a chapter. The
 +README is the introduction, and the index, with its descriptions, is the
 +contents page to which links to docs/index.md lead. Frontmatter is
-+dropped, as mdbook would render it as text, and links that leave docs/
-+point at the repository at the built revision.
++dropped, as mdbook would render it as text, and links to what the book
++leaves out point at the repository at the built revision. The bundle's
++other files are copied as they are.
 +
 +    python3 book.py ROOT OUT --repository URL --rev REV
 +"""
 +import argparse
 +import posixpath
 +import re
++import shutil
 +from pathlib import Path
 +
 +FRONTMATTER = re.compile(r"\A---\n.*?\n---\n+", re.DOTALL)
-+LINK = re.compile(r"\]\(([^)\s]+)\)")
++CONTAINER = r"^(?:[ \t>]|[-*+][ \t]|\d+[.)][ \t])*"
++LINK = re.compile(
++    r"(?P<code>" + CONTAINER + r"(?P<fence>`{3,}|~{3,})"
++    r".*?(?:^[ \t>]*(?P=fence)[`~]*[ \t]*$|\Z)"
++    r"|(?P<tick>`+)[^\n]+?(?P=tick))"
++    r"|(?P<prefix>\]\(|" + CONTAINER + r"\[[^\]^][^\]]*\]:[ \t]*)(?P<url>[^)\s]+)",
++    re.MULTILINE | re.DOTALL,
++)
 +SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:|#")
 +ENTRY = re.compile(r"\* \[([^\]]+)\]\(([^)\s]+)\)(?: - .*)?")
 +DOCS = "docs"
@@ contrib/book.py (new)
 +    return posixpath.normpath(posixpath.join(base, path.lstrip("/")))
 +
 +
-+def relink(text, base, blob):
++def relink(text, base, blob, chapters):
 +    """Links of a file in directory `base` of the repository, rewritten for
-+    a book whose source is docs/: targets in docs/ become relative to the
-+    file's place in the book, and every other target a URL under `blob`."""
++    a book whose source is docs/: targets among `chapters` and the other
++    files of docs/ become relative to the file's place in the book, and
++    every other target a URL under `blob`. Code is left as written."""
 +    here = "." if base == "." else posixpath.relpath(base, DOCS)
 +
 +    def sub(m):
-+        url = m.group(1)
++        if m["code"]:
++            return m[0]
++        prefix, url = m["prefix"], m["url"]
 +        if SCHEME.match(url):
 +            return m.group(0)
 +        path, sep, frag = url.partition("#")
 +        target = resolve(base, path)
 +        if target == f"{DOCS}/index.md":
 +            target = f"{DOCS}/{CONTENTS}"
-+        if not target.startswith(f"{DOCS}/"):
-+            return f"]({blob}/{target}{sep}{frag})"
-+        return f"]({posixpath.relpath(target, posixpath.join(DOCS, here))}{sep}{frag})"
++        elif not target.startswith(f"{DOCS}/") or (
++            target.endswith(".md") and posixpath.relpath(target, DOCS) not in chapters
++        ):
++            return f"{prefix}{blob}/{target}{sep}{frag}"
++        return f"{prefix}{posixpath.relpath(target, posixpath.join(DOCS, here))}{sep}{frag}"
 +
 +    return LINK.sub(sub, text)
 +
 +
-+def summary(index):
++def summary(index, chapters):
 +    """SUMMARY.md from an OKF index: `# Part` headings and
-+    `* [Title](file.md) - description` entries."""
++    `* [Title](file.md) - description` entries, each naming one of
++    `chapters`, the paths in docs/ of the pages the book is given."""
 +    out = ["# Summary", "", "[Introduction](README.md)", f"[Contents]({CONTENTS})"]
 +    for line in strip_frontmatter(index).splitlines():
 +        if line.startswith("# "):
 +            out += ["", line, ""]
 +        elif m := ENTRY.fullmatch(line):
-+            target = resolve(DOCS, m.group(2))
-+            if (
-+                SCHEME.match(m.group(2))
-+                or not target.startswith(f"{DOCS}/")
-+                or target in (f"{DOCS}/index.md", f"{DOCS}/README.md")
-+            ):
++            chapter = posixpath.relpath(resolve(DOCS, m.group(2)), DOCS)
++            if chapter not in chapters:
 +                raise ValueError(f"index entry is not a chapter: {line!r}")
-+            out.append(f"- [{m.group(1)}]({posixpath.relpath(target, DOCS)})")
++            out.append(f"- [{m.group(1)}]({chapter})")
 +        elif line.strip():
 +            raise ValueError(f"unexpected line in index: {line!r}")
 +    return "\n".join(out) + "\n"
@@ contrib/book.py (new)
 +    blob = f"{repository}/blob/{rev}"
 +    docs = root / DOCS
 +    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)
 +    out.mkdir(parents=True, exist_ok=True)
-+    (out / "SUMMARY.md").write_text(summary(index))
-+    (out / CONTENTS).write_text(relink(contents(index), DOCS, blob))
-+    (out / "README.md").write_text(relink((root / "README.md").read_text(), ".", blob))
-+    for page in sorted(docs.rglob("*.md")):
-+        name = page.relative_to(docs)
-+        # docs/README.md directs readers of the repository to the book; as a
-+        # chapter it would overwrite the introduction.
-+        if name.as_posix() in ("index.md", "README.md"):
-+            continue
-+        base = page.parent.relative_to(root).as_posix()
++    shutil.copytree(docs, out, ignore=shutil.ignore_patterns("*.md"), dirs_exist_ok=True)
++    (out / "SUMMARY.md").write_text(summary(index, pages))
++    (out / CONTENTS).write_text(relink(contents(index), DOCS, blob, pages))
++    (out / "README.md").write_text(relink((root / "README.md").read_text(), ".", blob, pages))
++    for name, text in pages.items():
++        base = posixpath.dirname(f"{DOCS}/{name}")
 +        (out / name).parent.mkdir(parents=True, exist_ok=True)
-+        (out / name).write_text(relink(strip_frontmatter(page.read_text()), base, blob))
++        (out / name).write_text(relink(strip_frontmatter(text), base, blob, pages))
 +
 +
 +def main(argv=None):
@@ contrib/test_book.py (new)
 +* [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():
@@ contrib/test_book.py (new)
 +        ("docs/sub", "/index.md", "../contents.md"),
 +        ("docs", "https://example.org/x", "https://example.org/x"),
 +        ("docs", "#section", "#section"),
++        ("docs", "log.md", f"{BLOB}/docs/log.md"),
++        ("docs/sub", "index.md", f"{BLOB}/docs/sub/index.md"),
++        ("docs", "README.md", f"{BLOB}/docs/README.md"),
++        (".", "docs/README.md", f"{BLOB}/docs/README.md"),
++        ("docs/sub", "fig.png", "fig.png"),
 +    ],
 +)
 +def test_links_leave_the_book_only_for_the_repository(base, link, expected):
-+    assert book.relink(f"[text]({link})", base, BLOB) == f"[text]({expected})"
++    assert book.relink(f"[text]({link})", base, BLOB, CHAPTERS) == f"[text]({expected})"
++
++
++@pytest.mark.parametrize(
++    "text, expected",
++    [
++        ('[t](../README.md "guide")', f'[t]({BLOB}/README.md "guide")'),
++        ('[t](sub/two.md#a "two")', '[t](sub/two.md#a "two")'),
++        ("[g]: ../README.md", f"[g]: {BLOB}/README.md"),
++        ('   [g]: /index.md "contents"', '   [g]: contents.md "contents"'),
++        ("[^g]: ../README.md", "[^g]: ../README.md"),
++        ("> [g]: ../README.md", f"> [g]: {BLOB}/README.md"),
++        ("- [g]: ../README.md", f"- [g]: {BLOB}/README.md"),
++    ],
++)
++def test_titled_and_reference_links_are_relinked(text, expected):
++    assert book.relink(text, "docs", BLOB, CHAPTERS) == expected
++
++
++@pytest.mark.parametrize(
++    "text",
++    [
++        "```markdown\n* [x](../README.md)\n```\n",
++        "~~~~\n[g]: ../README.md\n~~~~~\n",
++        "Write `[x](../README.md)` or ``[x](`a`.md)``.\n",
++        "```\n[x](../README.md)\n",
++        "- item\n\n    ```\n    [x](../README.md)\n    ```\n",
++        "1. ```\n   [x](../README.md)\n   ```\n",
++        "> ```\n> [x](../README.md)\n> ```\n",
++    ],
++)
++def test_code_is_not_relinked(text):
++    assert book.relink(text, "docs", BLOB, CHAPTERS) == text
++
++
++def test_links_after_code_are_relinked():
++    text = "```\n[x](../README.md)\n```\n\n`a` [y](../README.md)\n"
++    assert book.relink(text, "docs", BLOB, CHAPTERS) == (
++        f"```\n[x](../README.md)\n```\n\n`a` [y]({BLOB}/README.md)\n"
++    )
 +
 +
 +def test_summary_follows_the_index_without_descriptions():
-+    assert book.summary(INDEX) == (
++    assert book.summary(INDEX, CHAPTERS) == (
 +        "# Summary\n\n"
 +        "[Introduction](README.md)\n"
 +        "[Contents](contents.md)\n\n"
@@ contrib/test_book.py (new)
 +        ("* [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")
++        book.summary(f"# Part\n\n{line}\n", CHAPTERS)
 +
 +
 +def test_assembly_covers_every_chapter(tmp_path):
@@ contrib/test_book.py (new)
 +    assert "* [Two](sub/two.md) - The second chapter." in (out / "contents.md").read_text()
 +    assert (out / "one.md").read_text() == "# One\n"
 +    assert (out / "sub" / "two.md").read_text() == "# Two\n\nAfter [one](../one.md).\n"
++
++
++def test_assembly_copies_the_other_files_of_the_bundle(tmp_path):
++    root, out = tmp_path / "root", tmp_path / "src"
++    (root / "docs" / "references").mkdir(parents=True)
++    (root / "README.md").write_text("# Intro\n")
++    (root / "docs" / "index.md").write_text("# Part\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, out, "https://example.org", "abc")
++    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"
 
  ## docs/README.md (new) ##
 @@
2: 8c984c1904 ! 2: fa1bb3b1f7 Check the status and stale_after of chapters
@@ Commit message
  ## contrib/book.py ##
 @@ contrib/book.py: 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 page of docs/. The
+ without their descriptions, so an entry must name a chapter. The
  README is the introduction, and the index, with its descriptions, is the
 -contents page to which links to docs/index.md lead. Frontmatter is
--dropped, as mdbook would render it as text, and links that leave docs/
--point at the repository at the built revision.
+-dropped, as mdbook would render it as text, and links to what the book
+-leaves out point at the repository at the built revision. The bundle's
+-other files are copied as they are.
 +contents page to which links to docs/index.md lead. Frontmatter is checked
-+and then dropped, as mdbook would render it as text, and links that leave
-+docs/ point at the repository at the built revision.
++and then dropped, as mdbook would render it as text, and links to what the
++book leaves out point at the repository at the built revision. The
++bundle's other files are copied as they are.
  
      python3 book.py ROOT OUT --repository URL --rev REV
  """
- import argparse
+@@ contrib/book.py: import argparse
  import posixpath
  import re
+ import shutil
 +from datetime import datetime
  from pathlib import Path
  
 -FRONTMATTER = re.compile(r"\A---\n.*?\n---\n+", re.DOTALL)
 +FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n+", re.DOTALL)
- LINK = re.compile(r"\]\(([^)\s]+)\)")
- SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:|#")
+ CONTAINER = r"^(?:[ \t>]|[-*+][ \t]|\d+[.)][ \t])*"
+ LINK = re.compile(
+     r"(?P<code>" + CONTAINER + r"(?P<fence>`{3,}|~{3,})"
+@@ contrib/book.py: SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:|#")
  ENTRY = re.compile(r"\* \[([^\]]+)\]\(([^)\s]+)\)(?: - .*)?")
  DOCS = "docs"
  CONTENTS = "contents.md"
@@ contrib/book.py: docs/index.md, the bundle's OKF index, states the chapter order
      """The repository path of link target `path` of a file in directory
      `base`. In docs/, a path from `/` starts at the bundle root."""
 @@ contrib/book.py: def assemble(root, out, repository, rev):
-     (out / "README.md").write_text(relink((root / "README.md").read_text(), ".", blob))
-     for page in sorted(docs.rglob("*.md")):
-         name = page.relative_to(docs)
-+        if page.name in RESERVED:
-+            continue
-+        text = page.read_text()
-+        check(name.as_posix(), text)
-         # docs/README.md directs readers of the repository to the book; as a
-         # chapter it would overwrite the introduction.
--        if name.as_posix() in ("index.md", "README.md"):
-+        if name.as_posix() == "README.md":
-             continue
-         base = page.parent.relative_to(root).as_posix()
-         (out / name).parent.mkdir(parents=True, exist_ok=True)
--        (out / name).write_text(relink(strip_frontmatter(page.read_text()), base, blob))
-+        (out / name).write_text(relink(strip_frontmatter(text), base, blob))
- 
- 
- def main(argv=None):
+     blob = f"{repository}/blob/{rev}"
+     docs = root / DOCS
+     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)
+     out.mkdir(parents=True, exist_ok=True)
+     shutil.copytree(docs, out, ignore=shutil.ignore_patterns("*.md"), dirs_exist_ok=True)
+     (out / "SUMMARY.md").write_text(summary(index, pages))
 
  ## contrib/test_book.py ##
 @@ contrib/test_book.py: def test_summary_rejects_what_it_cannot_place(line, error):
-         book.summary(f"# Part\n\n{line}\n")
+         book.summary(f"# Part\n\n{line}\n", CHAPTERS)
  
  
 +@pytest.mark.parametrize(

Comment thread contrib/book.py Outdated
Comment thread contrib/book.py Outdated
@github-actions

Copy link
Copy Markdown
range-diff for force push fa1bb3b1f7 → fee4b8c9c4 October 15, 2026 17:09 UTC: 2 modified

reproduce: git range-diff e0bd68f..fa1bb3b e0bd68f..fee4b8c

1:  2a10cc506f ! 1:  80996f51fe Generate SUMMARY.md from an OKF index.md
2:  fa1bb3b1f7 ! 2:  fee4b8c9c4 Check the status and stale_after of chapters
1: 2a10cc506f ! 1: 80996f51fe Generate SUMMARY.md from an OKF index.md
@@ contrib/book.py (new)
 +import shutil
 +from pathlib import Path
 +
++from markdown_it import MarkdownIt
++from mdit_py_plugins.deflist import deflist_plugin
++from mdit_py_plugins.footnote import footnote_plugin
++
 +FRONTMATTER = re.compile(r"\A---\n.*?\n---\n+", re.DOTALL)
 +CONTAINER = r"^(?:[ \t>]|[-*+][ \t]|\d+[.)][ \t])*"
 +LINK = re.compile(
-+    r"(?P<code>" + CONTAINER + r"(?P<fence>`{3,}|~{3,})"
-+    r".*?(?:^[ \t>]*(?P=fence)[`~]*[ \t]*$|\Z)"
-+    r"|(?P<tick>`+)[^\n]+?(?P=tick))"
++    r"(?P<tick>`+).+?(?P=tick)"
 +    r"|(?P<prefix>\]\(|" + CONTAINER + r"\[[^\]^][^\]]*\]:[ \t]*)(?P<url>[^)\s]+)",
 +    re.MULTILINE | re.DOTALL,
 +)
++# The blocks of a page as mdbook parses them, so that code blocks are left as
++# written and a code span runs within one block.
++MARKDOWN = MarkdownIt("commonmark").enable("table").use(deflist_plugin).use(footnote_plugin)
 +SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:|#")
 +ENTRY = re.compile(r"\* \[([^\]]+)\]\(([^)\s]+)\)(?: - .*)?")
 +DOCS = "docs"
@@ contrib/book.py (new)
 +    here = "." if base == "." else posixpath.relpath(base, DOCS)
 +
 +    def sub(m):
-+        if m["code"]:
++        if m["tick"]:
 +            return m[0]
 +        prefix, url = m["prefix"], m["url"]
 +        if SCHEME.match(url):
@@ contrib/book.py (new)
 +            return f"{prefix}{blob}/{target}{sep}{frag}"
 +        return f"{prefix}{posixpath.relpath(target, posixpath.join(DOCS, here))}{sep}{frag}"
 +
-+    return LINK.sub(sub, text)
++    lines = text.splitlines(keepends=True)
++    blocks = {
++        t.map[0]: t
++        for t in MARKDOWN.parse(text)
++        if t.type in ("fence", "code_block", "inline") and t.map
++    }
++    out, i = [], 0
++    while i < len(lines):
++        block = blocks.get(i)
++        end = max(block.map[1], i + 1) if block else i + 1
++        chunk = "".join(lines[i:end])
++        out.append(chunk if block and block.type != "inline" else LINK.sub(sub, chunk))
++        i = end
++    return "".join(out)
++
++
++def entries(index):
++    """The paths in docs/ that the entries of an OKF index name: the
++    chapters of the book, as mdbook renders no other page."""
++    return {
++        posixpath.relpath(resolve(DOCS, m.group(2)), DOCS)
++        for m in map(ENTRY.fullmatch, strip_frontmatter(index).splitlines())
++        if m
++    }
 +
 +
-+def summary(index, chapters):
++def summary(index, pages):
 +    """SUMMARY.md from an OKF index: `# Part` headings and
 +    `* [Title](file.md) - description` entries, each naming one of
-+    `chapters`, the paths in docs/ of the pages the book is given."""
++    `pages`, the paths in docs/ of the pages the book is given."""
 +    out = ["# Summary", "", "[Introduction](README.md)", f"[Contents]({CONTENTS})"]
 +    for line in strip_frontmatter(index).splitlines():
 +        if line.startswith("# "):
 +            out += ["", line, ""]
 +        elif m := ENTRY.fullmatch(line):
 +            chapter = posixpath.relpath(resolve(DOCS, m.group(2)), DOCS)
-+            if chapter not in chapters:
++            if chapter not in pages:
 +                raise ValueError(f"index entry is not a chapter: {line!r}")
 +            out.append(f"- [{m.group(1)}]({chapter})")
 +        elif line.strip():
@@ contrib/book.py (new)
 +    out.mkdir(parents=True, exist_ok=True)
 +    shutil.copytree(docs, out, ignore=shutil.ignore_patterns("*.md"), dirs_exist_ok=True)
 +    (out / "SUMMARY.md").write_text(summary(index, pages))
-+    (out / CONTENTS).write_text(relink(contents(index), DOCS, blob, pages))
-+    (out / "README.md").write_text(relink((root / "README.md").read_text(), ".", blob, pages))
++    listed = entries(index)
++    (out / CONTENTS).write_text(relink(contents(index), DOCS, blob, listed))
++    (out / "README.md").write_text(relink((root / "README.md").read_text(), ".", blob, listed))
 +    for name, text in pages.items():
 +        base = posixpath.dirname(f"{DOCS}/{name}")
 +        (out / name).parent.mkdir(parents=True, exist_ok=True)
-+        (out / name).write_text(relink(strip_frontmatter(text), base, blob, pages))
++        (out / name).write_text(relink(strip_frontmatter(text), base, blob, listed))
 +
 +
 +def main(argv=None):
@@ contrib/test_book.py (new)
 +        "```markdown\n* [x](../README.md)\n```\n",
 +        "~~~~\n[g]: ../README.md\n~~~~~\n",
 +        "Write `[x](../README.md)` or ``[x](`a`.md)``.\n",
++        "Write `[x](\n../README.md)`.\n",
++        "> Write `[x]\n> (../README.md)`.\n",
 +        "```\n[x](../README.md)\n",
 +        "- item\n\n    ```\n    [x](../README.md)\n    ```\n",
 +        "1. ```\n   [x](../README.md)\n   ```\n",
 +        "> ```\n> [x](../README.md)\n> ```\n",
++        "Text.\n\n    [x](../README.md)\n",
++        "    ```\n    [x](../README.md)\n",
 +    ],
 +)
 +def test_code_is_not_relinked(text):
@@ contrib/test_book.py (new)
 +    )
 +
 +
++def test_a_code_span_ends_with_its_paragraph():
++    text = "A `tick.\n\n[x](../README.md) and `a`.\n"
++    assert book.relink(text, "docs", BLOB, CHAPTERS) == text.replace(
++        "../README.md", f"{BLOB}/README.md"
++    )
++
++
++@pytest.mark.parametrize(
++    "text",
++    [
++        "    ```\n[x](../README.md)\n",
++        "- item\n\n    [x](../README.md)\n",
++        "Text.\n    [x](../README.md)\n",
++        "Term\n: definition\n\n    [x](../README.md)\n",
++        "Note[^1].\n\n[^1]: note\n\n    [x](../README.md)\n",
++    ],
++)
++def test_indented_text_that_is_not_code_is_relinked(text):
++    assert book.relink(text, "docs", BLOB, CHAPTERS) == text.replace(
++        "../README.md", f"{BLOB}/README.md"
++    )
++
++
++def test_entries_are_the_chapters_the_index_names():
++    assert book.entries(INDEX) == CHAPTERS
++
++
 +def test_summary_follows_the_index_without_descriptions():
 +    assert book.summary(INDEX, CHAPTERS) == (
 +        "# Summary\n\n"
@@ contrib/test_book.py (new)
 +    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_links_a_page_left_out_of_the_index_to_the_repository(tmp_path):
++    root, out = tmp_path / "root", tmp_path / "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) - The first chapter.\n")
++    page = "---\ntype: Chapter\nstatus: stable\n---\n\n"
++    (root / "docs" / "one.md").write_text(page + "[x](x.md)\n")
++    (root / "docs" / "x.md").write_text(page + "# X\n")
++    book.assemble(root, out, "https://example.org", "abc")
++    assert (out / "one.md").read_text() == "[x](https://example.org/blob/abc/docs/x.md)\n"
 
  ## docs/README.md (new) ##
 @@
@@ nix/site.nix
 +      # Links out of the book name the revision the site is built from; a
 +      # build of a modified tree names the commit it modifies.
 +      rev = inputs.self.rev or (lib.removeSuffix "-dirty" (inputs.self.dirtyRev or "main"));
++      markdown = ps: [
++        ps.markdown-it-py
++        ps.mdit-py-plugins
++      ];
        site = pkgs.stdenvNoCC.mkDerivation {
          name = "fungi-docs";
          src = ../.;
+@@
+           mdbook-graphviz
+           mdbook-katex
+           graphviz
+-          python3
++          (python3.withPackages markdown)
+         ]);
+ 
+         buildPhase = ''
 @@
            # mermaid measures text with getBBox and rejects a zero-sized box, so
            # the build sandbox has to carry a font of its own.
@@ nix/site.nix
          inherit site;
 +        book =
 +          pkgs.runCommand "book-tests"
-+            { nativeBuildInputs = [ (pkgs.python3.withPackages (ps: [ ps.pytest ])) ]; }
++            { nativeBuildInputs = [ (pkgs.python3.withPackages (ps: markdown ps ++ [ ps.pytest ])) ]; }
 +            ''
 +              cd ${../contrib}
 +              pytest -p no:cacheprovider
2: fa1bb3b1f7 ! 2: fee4b8c9c4 Check the status and stale_after of chapters
@@ contrib/book.py: import argparse
 +from datetime import datetime
  from pathlib import Path
  
+ from markdown_it import MarkdownIt
+ from mdit_py_plugins.deflist import deflist_plugin
+ from mdit_py_plugins.footnote import footnote_plugin
+ 
 -FRONTMATTER = re.compile(r"\A---\n.*?\n---\n+", re.DOTALL)
 +FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n+", re.DOTALL)
  CONTAINER = r"^(?:[ \t>]|[-*+][ \t]|\d+[.)][ \t])*"
  LINK = re.compile(
-     r"(?P<code>" + CONTAINER + r"(?P<fence>`{3,}|~{3,})"
+     r"(?P<tick>`+).+?(?P=tick)"
 @@ contrib/book.py: SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:|#")
  ENTRY = re.compile(r"\* \[([^\]]+)\]\(([^)\s]+)\)(?: - .*)?")
  DOCS = "docs"

@bc1cindy

Copy link
Copy Markdown
Member Author

fixed by detecting code blocks with markdown-it-py instead of a regex, which can't tell indented code from a list item

also, links to pages not in the index now go to the repo

this adds two packages to the build (markdown-it-py and mdit-py-plugins, from nixpkgs). is that OK?

@bc1cindy bc1cindy mentioned this pull request Oct 15, 2026
@bc1cindy
bc1cindy added this pull request to stack #58 October 15, 2026 20:41
Comment thread contrib/book.py Outdated
Comment thread contrib/book.py Outdated
Comment thread contrib/book.py Outdated
@github-actions

Copy link
Copy Markdown
range-diff for force push fee4b8c9c4 → 2652d48358 October 16, 2026 02:14 UTC: 2 modified

reproduce: git range-diff e0bd68f..fee4b8c e0bd68f..2652d48

1:  80996f51fe ! 1:  78f7ab11a4 Generate SUMMARY.md from an OKF index.md
2:  fee4b8c9c4 ! 2:  2652d48358 Check the status and stale_after of chapters
1: 80996f51fe ! 1: 78f7ab11a4 Generate SUMMARY.md from an OKF index.md
@@ contrib/book.py (new)
 +import re
 +import shutil
 +from pathlib import Path
++from urllib.parse import unquote
 +
 +from markdown_it import MarkdownIt
 +from mdit_py_plugins.deflist import deflist_plugin
@@ contrib/book.py (new)
 +
 +FRONTMATTER = re.compile(r"\A---\n.*?\n---\n+", re.DOTALL)
 +CONTAINER = r"^(?:[ \t>]|[-*+][ \t]|\d+[.)][ \t])*"
++PUNCT = r"[!-/:-@\[-`{-~]"
++ESCAPE = re.compile(rf"\\({PUNCT})")
++# A link destination without angle brackets: its unescaped parentheses
++# balanced, nested no deeper than in mdbook's parser.
++CHAR = rf"\\{PUNCT}|[^()\x00-\x20]"
++BARE = CHAR
++for _ in range(33):
++    BARE = rf"{CHAR}|\((?:{BARE})*\)"
 +LINK = re.compile(
 +    r"(?P<tick>`+).+?(?P=tick)"
-+    r"|(?P<prefix>\]\(|" + CONTAINER + r"\[[^\]^][^\]]*\]:[ \t]*)(?P<url>[^)\s]+)",
++    r"|(?P<prefix>(?:\]\(|" + CONTAINER + r"\[[^\]^][^\]]*\]:)[ \t]*\n?[ \t]*(?P<angle><)?)"
++    r"(?P<url>(?(angle)(?:[^<>\\\n\r]|\\[^\n\r])*|(?!<)(?>(?:" + BARE + r")+)(?!\()))"
++    r"(?P<suffix>(?(angle)>))",
 +    re.MULTILINE | re.DOTALL,
 +)
-+# The blocks of a page as mdbook parses them, so that code blocks are left as
-+# written and a code span runs within one block.
++# The blocks of a page as mdbook parses them, so that code and HTML blocks are
++# left as written and a code span runs within one block.
 +MARKDOWN = MarkdownIt("commonmark").enable("table").use(deflist_plugin).use(footnote_plugin)
 +SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:|#")
 +ENTRY = re.compile(r"\* \[([^\]]+)\]\(([^)\s]+)\)(?: - .*)?")
@@ contrib/book.py (new)
 +    return posixpath.normpath(posixpath.join(base, path.lstrip("/")))
 +
 +
++def decode(path):
++    """`path` as the file name it stands for, its escapes and percent-encoding
++    undone."""
++    return unquote(ESCAPE.sub(r"\1", path))
++
++
 +def relink(text, base, blob, chapters):
 +    """Links of a file in directory `base` of the repository, rewritten for
 +    a book whose source is docs/: targets among `chapters` and the other
 +    files of docs/ become relative to the file's place in the book, and
-+    every other target a URL under `blob`. Code is left as written."""
++    every other target a URL under `blob`. Code and raw HTML are left as
++    written."""
 +    here = "." if base == "." else posixpath.relpath(base, DOCS)
 +
 +    def sub(m):
 +        if m["tick"]:
 +            return m[0]
-+        prefix, url = m["prefix"], m["url"]
-+        if SCHEME.match(url):
++        prefix, url, suffix = m["prefix"], m["url"], m["suffix"]
++        if not url or SCHEME.match(url):
 +            return m.group(0)
 +        path, sep, frag = url.partition("#")
 +        target = resolve(base, path)
 +        if target == f"{DOCS}/index.md":
 +            target = f"{DOCS}/{CONTENTS}"
 +        elif not target.startswith(f"{DOCS}/") or (
-+            target.endswith(".md") and posixpath.relpath(target, DOCS) not in chapters
++            target.endswith(".md")
++            and decode(posixpath.relpath(target, DOCS)) not in chapters
 +        ):
-+            return f"{prefix}{blob}/{target}{sep}{frag}"
-+        return f"{prefix}{posixpath.relpath(target, posixpath.join(DOCS, here))}{sep}{frag}"
++            return f"{prefix}{blob}/{target}{sep}{frag}{suffix}"
++        target = posixpath.relpath(target, posixpath.join(DOCS, here))
++        return f"{prefix}{target}{sep}{frag}{suffix}"
 +
 +    lines = text.splitlines(keepends=True)
 +    blocks = {
 +        t.map[0]: t
 +        for t in MARKDOWN.parse(text)
-+        if t.type in ("fence", "code_block", "inline") and t.map
++        if t.type in ("fence", "code_block", "html_block", "inline") and t.map
 +    }
 +    out, i = [], 0
 +    while i < len(lines):
@@ contrib/test_book.py (new)
 +        ("[^g]: ../README.md", "[^g]: ../README.md"),
 +        ("> [g]: ../README.md", f"> [g]: {BLOB}/README.md"),
 +        ("- [g]: ../README.md", f"- [g]: {BLOB}/README.md"),
++        ("[t]( ../README.md )", f"[t]( {BLOB}/README.md )"),
++        ("[t](\n../README.md)", f"[t](\n{BLOB}/README.md)"),
 +    ],
 +)
 +def test_titled_and_reference_links_are_relinked(text, expected):
@@ contrib/test_book.py (new)
 +
 +
 +@pytest.mark.parametrize(
++    "base, link, expected",
++    [
++        ("docs", "draft_(old).md", f"{BLOB}/docs/draft_(old).md"),
++        ("docs", "fig_(1).png#a", "fig_(1).png#a"),
++        (".", "<flake.nix>", f"<{BLOB}/flake.nix>"),
++        ("docs", "<../x y.md#a>", f"<{BLOB}/x y.md#a>"),
++        ("docs/sub", "</one.md>", "<../one.md>"),
++        ("docs", "<https://example.org/x>", "<https://example.org/x>"),
++        ("docs", "a_((b)).md", f"{BLOB}/docs/a_((b)).md"),
++        ("docs", r"a\).md", rf"{BLOB}/docs/a\).md"),
++        ("docs", r"<a\>b.md>", rf"<{BLOB}/docs/a\>b.md>"),
++        ("docs", r"one\.md", r"one\.md"),
++        ("docs", "on%65.md", "on%65.md"),
++        ("docs", "<a<b.md>", "<a<b.md>"),
++        ("docs", "(../README.md", "(../README.md"),
++        ("docs", "../a" + "(" * 33 + ")" * 33, f"{BLOB}/a" + "(" * 33 + ")" * 33),
++    ],
++)
++def test_destinations_follow_commonmark(base, link, expected):
++    assert book.relink(f"[text]({link})", base, BLOB, CHAPTERS) == f"[text]({expected})"
++
++
++@pytest.mark.parametrize(
++    "text", ["[t](../a" + "(" * 34 + ")" * 34 + ")", "[t](../a(b", "[t](<../a<b>)"]
++)
++def test_what_mdbook_does_not_link_is_left_as_written(text):
++    assert book.relink(text, "docs", BLOB, CHAPTERS) == text
++
++
++@pytest.mark.parametrize(
 +    "text",
 +    [
 +        "```markdown\n* [x](../README.md)\n```\n",
@@ contrib/test_book.py (new)
 +    )
 +
 +
++@pytest.mark.parametrize(
++    "text", ["<div>\n[x](../README.md)\n</div>\n", "<!--\n[x](../README.md)\n-->\n"]
++)
++def test_raw_html_is_not_relinked(text):
++    assert book.relink(text, "docs", BLOB, CHAPTERS) == text
++
++
++def test_markdown_between_html_blocks_is_relinked():
++    text = "<details>\n\n[x](../README.md)\n\n</details>\n"
++    assert book.relink(text, "docs", BLOB, CHAPTERS) == (
++        f"<details>\n\n[x]({BLOB}/README.md)\n\n</details>\n"
++    )
++
++
 +def test_a_code_span_ends_with_its_paragraph():
 +    text = "A `tick.\n\n[x](../README.md) and `a`.\n"
 +    assert book.relink(text, "docs", BLOB, CHAPTERS) == text.replace(
@@ docs/index.md (new)
 +
 +* [PSBT Scrubber](bip-drafts/scrubber.md) - Which PSBT fields can be shared with trusted and untrusted parties such that privacy is preserved.
 
+ ## nix/devshell.nix ##
+@@
+ { ... }:
+ {
+   perSystem =
+-    { pkgs, mmdc, ... }:
++    { pkgs, mmdc, markdown, ... }:
+     {
+       devShells.default = pkgs.mkShell {
+         packages = [ mmdc ] ++ (with pkgs; [
+@@
+           mdbook-graphviz
+           mdbook-katex
+           graphviz
+-          python3
++          (python3.withPackages (ps: markdown ps ++ [ ps.pytest ]))
+         ]);
+       };
+     };
+
  ## nix/site.nix ##
 @@
 -{ ... }:
 +{ inputs, lib, ... }:
  {
    perSystem =
-     { pkgs, mmdc, ... }:
+-    { pkgs, mmdc, ... }:
++    { pkgs, mmdc, markdown, ... }:
      let
 +      repository = "https://github.com/fungi-protocol/docs";
 +      # Links out of the book name the revision the site is built from; a
 +      # build of a modified tree names the commit it modifies.
 +      rev = inputs.self.rev or (lib.removeSuffix "-dirty" (inputs.self.dirtyRev or "main"));
-+      markdown = ps: [
-+        ps.markdown-it-py
-+        ps.mdit-py-plugins
-+      ];
        site = pkgs.stdenvNoCC.mkDerivation {
          name = "fungi-docs";
          src = ../.;
@@ nix/site.nix
 +            '';
        };
  
++      # The Python packages contrib/book.py imports.
++      _module.args.markdown = ps: [
++        ps.markdown-it-py
++        ps.mdit-py-plugins
++      ];
++
        packages = {
+         inherit site;
+         default = site;
 
  ## src/README.md (deleted) ##
 @@
2: fee4b8c9c4 ! 2: 2652d48358 Check the status and stale_after of chapters
@@ contrib/book.py: import argparse
  import shutil
 +from datetime import datetime
  from pathlib import Path
+ from urllib.parse import unquote
  
- from markdown_it import MarkdownIt
+@@ contrib/book.py: from markdown_it import MarkdownIt
  from mdit_py_plugins.deflist import deflist_plugin
  from mdit_py_plugins.footnote import footnote_plugin
  
 -FRONTMATTER = re.compile(r"\A---\n.*?\n---\n+", re.DOTALL)
 +FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n+", re.DOTALL)
  CONTAINER = r"^(?:[ \t>]|[-*+][ \t]|\d+[.)][ \t])*"
- LINK = re.compile(
-     r"(?P<tick>`+).+?(?P=tick)"
+ PUNCT = r"[!-/:-@\[-`{-~]"
+ ESCAPE = re.compile(rf"\\({PUNCT})")
 @@ contrib/book.py: SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:|#")
  ENTRY = re.compile(r"\* \[([^\]]+)\]\(([^)\s]+)\)(?: - .*)?")
  DOCS = "docs"

@github-actions

Copy link
Copy Markdown
range-diff for force push 56c5f8db8b → 5453876f97 October 17, 2026 22:51 UTC: 2 modified, 1 dropped

reproduce: git range-diff 32edbfe..56c5f8d 32edbfe..5453876

1:  51f44ce565 ! 1:  b751a00653 Generate SUMMARY.md from an OKF index.md
2:  4ebfb3f3bb < -:  ---------- Point links out of the book at the repository
3:  56c5f8db8b ! 2:  5453876f97 Check the status and stale_after of chapters
1: 51f44ce565 ! 1: b751a00653 Generate SUMMARY.md from an OKF index.md
@@ Commit message
     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. An index entry that names no chapter or
+    mdbook never sees OKF metadata. It replaces src/ on each run, so an asset
+    deleted from docs/ does not linger in a preview, and refuses a target
+    that would hold the sources. 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.
 
@@ contrib/book.py (new)
 +
 +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 and every
-+chapter must have an entry. The README is the introduction, and the
-+index, with its descriptions, is the contents page to which links to
-+docs/index.md lead. Frontmatter is dropped, as mdbook would render it as
-+text. The bundle's other files are copied as they are.
++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 OUT
 +"""
@@ contrib/book.py (new)
 +    return FRONTMATTER.sub("", text, count=1)
 +
 +
-+def resolve(base, path):
-+    """The repository path of link target `path` of a file in directory
-+    `base`. In docs/, a path from `/` starts at the bundle root."""
-+    if path.startswith("/"):
-+        base = "." if base == "." else DOCS
-+    return posixpath.normpath(posixpath.join(base, path.lstrip("/")))
-+
-+
 +def summary(index, pages):
 +    """SUMMARY.md from an OKF index: `# Part` headings and
 +    `* [Title](file.md) - description` entries, naming each of `pages`,
@@ contrib/book.py (new)
 +        if line.startswith("# "):
 +            out += ["", line, ""]
 +        elif m := ENTRY.fullmatch(line):
-+            chapter = posixpath.relpath(resolve(DOCS, m.group(2)), DOCS)
++            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:
@@ contrib/book.py (new)
 +        pages.pop(name, None)
 +    if clash := sorted(pages.keys() & {"SUMMARY.md", CONTENTS}):
 +        raise ValueError(f"generated by book.py: {', '.join(clash)}")
-+    out.mkdir(parents=True, exist_ok=True)
-+    shutil.copytree(
-+        docs, out, ignore=shutil.ignore_patterns("*.md"), dirs_exist_ok=True
-+    )
++    if root.resolve().is_relative_to(out.resolve()) or out.resolve().is_relative_to(
++        docs.resolve()
++    ):
++        raise ValueError(f"{out} would hold the sources")
++    if out.exists():
++        shutil.rmtree(out)
++    shutil.copytree(docs, out, ignore=shutil.ignore_patterns("*.md"))
 +    (out / "SUMMARY.md").write_text(summary(index, pages))
 +    (out / CONTENTS).write_text(contents(index))
 +    shutil.copyfile(root / "README.md", out / "README.md")
@@ contrib/test_book.py (new)
 +# Part
 +
 +* [One](one.md) - The first chapter.
-+* [Two](/sub/two.md) - The second chapter.
++* [Two](sub/two.md) - The second chapter.
 +"""
 +CHAPTERS = {"one.md", "sub/two.md"}
 +
@@ contrib/test_book.py (new)
 +        "# Contents\n\n"
 +        "## Part\n\n"
 +        "* [One](one.md) - The first chapter.\n"
-+        "* [Two](/sub/two.md) - The second chapter.\n"
++        "* [Two](sub/two.md) - The second chapter.\n"
 +    )
 +
 +
@@ contrib/test_book.py (new)
 +        ("- [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"),
@@ contrib/test_book.py (new)
 +    ]
 +    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 "* [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"
 +
@@ contrib/test_book.py (new)
 +    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 / "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, out)
++    (root / "docs" / "old.png").unlink()
++    book.assemble(root, out)
++    assert not (out / "old.png").exists()
++
++
++@pytest.mark.parametrize("out", [".", "..", "docs", "docs/sub"])
++def test_assembly_does_not_write_over_the_sources(tmp_path, out):
++    root = tmp_path / "root"
++    (root / "docs").mkdir(parents=True)
++    (root / "docs" / "index.md").write_text("# Part\n")
++    with pytest.raises(ValueError, match="would hold the sources"):
++        book.assemble(root, root / out)
++    assert (root / "docs" / "index.md").exists()
 
  ## docs/README.md (new) ##
 @@
3: 56c5f8db8b ! 2: 5453876f97 Check the status and stale_after of chapters
@@ Commit message
     Whether stale_after has passed is not checked, so that a build does not
     depend on when it runs.
 
-    Frontmatter values are read unquoted and without a trailing comment, as
-    YAML reads them, so `status: stable # reviewed` is stable.
+    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 ##
 @@ contrib/book.py: headings become the parts of SUMMARY.md and its entries the chapters,
- without their descriptions, so an entry must name a chapter and every
- chapter must have an entry. The README is the introduction, and the
- index, with its descriptions, is the contents page to which links to
--docs/index.md lead. Frontmatter is dropped, as mdbook would render it as
--text; contrib/relink then points links to what the book leaves out at
--the repository. The bundle's other files are copied as they are.
-+docs/index.md lead. Frontmatter is checked and then dropped, as mdbook
-+would render it as text; contrib/relink then points links to what the
-+book leaves out at the repository. The bundle's other files are copied
-+as they are.
+ 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 OUT
  """
@@ contrib/book.py: import argparse
  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)
-+# As in YAML, a # starts a comment only at the start or after a blank.
-+VALUE = re.compile(r"""\s*(?:"([^"]*)"|'([^']*)'|(.*?))\s*(?:(?<!\S)#.*)?\Z""")
  ENTRY = re.compile(r"\* \[([^\]]+)\]\(([^)\s]+)\)(?: - .*)?")
- # Shared with contrib/relink, which resolves links as resolve does.
  DOCS = "docs"
  CONTENTS = "contents.md"
 +STATUSES = ("draft", "stable", "deprecated")
@@ contrib/book.py: import argparse
  
  
 +def frontmatter(text):
-+    """The top-level keys of a page's frontmatter and their values as
-+    written, unquoted and without comments."""
 +    m = FRONTMATTER.match(text)
-+    fields = {}
-+    for line in m.group(1).splitlines() if m else ():
-+        key, sep, value = line.partition(":")
-+        if sep and key and not key[0].isspace() and key[0] not in "#-":
-+            fields[key] = "".join(VALUE.match(value).groups(""))
-+    return fields
++    fields = yaml.safe_load(m.group(1)) if m else None
++    return fields if isinstance(fields, dict) else {}
 +
 +
 +def check(name, text):
@@ contrib/book.py: import argparse
 +    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."""
-+    fields = frontmatter(text)
++    try:
++        fields = frontmatter(text)
++    except yaml.YAMLError 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":
-+        try:
-+            stale_after = datetime.fromisoformat(
-+                re.sub(r"Z$", "+00:00", fields.get("stale_after", ""))
-+            )
-+        except ValueError:
-+            stale_after = None
-+        if stale_after is None or stale_after.tzinfo is None:
++        stale_after = fields.get("stale_after")
++        if isinstance(stale_after, str):
++            try:
++                stale_after = datetime.fromisoformat(stale_after)
++            except ValueError:
++                pass
++        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 resolve(base, path):
-     """The repository path of link target `path` of a file in directory
-     `base`. In docs/, a path from `/` starts at the bundle root."""
+ def summary(index, pages):
+     """SUMMARY.md from an OKF index: `# Part` headings and
+     `* [Title](file.md) - description` entries, naming each of `pages`,
 @@ contrib/book.py: def contents(index):
  def assemble(root, out):
      docs = root / DOCS
@@ contrib/book.py: def contents(index):
 +    pages.pop("README.md", None)
      if clash := sorted(pages.keys() & {"SUMMARY.md", CONTENTS}):
          raise ValueError(f"generated by book.py: {', '.join(clash)}")
-     out.mkdir(parents=True, exist_ok=True)
+     if root.resolve().is_relative_to(out.resolve()) or out.resolve().is_relative_to(
 
  ## contrib/test_book.py ##
 @@ contrib/test_book.py: def test_summary_rejects_a_page_left_out_of_the_index():
          book.summary("# Part\n\n* [One](one.md)\n", CHAPTERS)
  
  
-+def test_frontmatter_drops_comments_outside_quotes():
-+    text = "---\na: x # c\nb: \"y # z\" # c\nc: 'w#v'\nd: C#\n---\n"
-+    assert book.frontmatter(text) == {"a": "x", "b": "y # z", "c": "w#v", "d": "C#"}
-+
-+
 +@pytest.mark.parametrize(
 +    "fields",
 +    [
@@ contrib/test_book.py: def test_summary_rejects_a_page_left_out_of_the_index():
 +        "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):
@@ contrib/test_book.py: def test_summary_rejects_a_page_left_out_of_the_index():
 +        ("---\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", "status ''"),
++        ("---\ntype: [Chapter\nstatus: stable\n---\n", "while parsing"),
 +        ("---\ntype: Chapter\nstatus: draft\n---\n", "stale_after"),
 +        (
 +            "---\ntype: Chapter\nstatus: draft\nstale_after: 2027-04-14\n---\n",
@@ docs/bip-drafts/scrubber.md: type: BIP Draft
  ---
  
  # BIP: XXX Privacy PSBT
+
+ ## nix/devshell.nix ##
+@@
+           mdbook-graphviz
+           mdbook-katex
+           graphviz
+-          (python3.withPackages (ps: [ ps.pytest ]))
++          (python3.withPackages (ps: [ ps.pytest ps.pyyaml ]))
+         ]);
+       };
+     };
+
+ ## nix/site.nix ##
+@@
+           mdbook-graphviz
+           mdbook-katex
+           graphviz
+-          python3
++          (python3.withPackages (ps: [ ps.pyyaml ]))
+         ]);
+ 
+         buildPhase = ''
+@@
+         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

Comment thread contrib/book.py Fixed
Comment thread contrib/book.py Outdated
Comment thread contrib/book.py Outdated
Comment thread contrib/book.py Outdated
@github-actions

Copy link
Copy Markdown
range-diff for force push 5453876f97 → 85a9343edf October 18, 2026 00:27 UTC: 2 modified

reproduce: git range-diff 32edbfe..5453876 32edbfe..85a9343

1:  b751a00653 ! 1:  3049f58d46 Generate SUMMARY.md from an OKF index.md
2:  5453876f97 ! 2:  85a9343edf Check the status and stale_after of chapters
1: b751a00653 ! 1: 3049f58d46 Generate SUMMARY.md from an OKF index.md
@@ Commit message
     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
+    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 replaces src/ on each run, so an asset
-    deleted from docs/ does not linger in a preview, and refuses a target
-    that would hold the sources. 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.
+    mdbook never sees OKF metadata. It replaces 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. 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.
 
  ## .gitignore ##
 @@
@@ contrib/book.py (new)
 +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 OUT
++    python3 book.py ROOT
 +"""
 +import argparse
 +import posixpath
@@ contrib/book.py (new)
 +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"
 +
 +
@@ contrib/book.py (new)
 +    return "# Contents\n\n" + body
 +
 +
-+def assemble(root, out):
-+    docs = root / DOCS
++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()
@@ contrib/book.py (new)
 +        pages.pop(name, None)
 +    if clash := sorted(pages.keys() & {"SUMMARY.md", CONTENTS}):
 +        raise ValueError(f"generated by book.py: {', '.join(clash)}")
-+    if root.resolve().is_relative_to(out.resolve()) or out.resolve().is_relative_to(
-+        docs.resolve()
-+    ):
-+        raise ValueError(f"{out} would hold the sources")
++    generated = {"SUMMARY.md": summary(index, pages), CONTENTS: contents(index)}
 +    if out.exists():
 +        shutil.rmtree(out)
 +    shutil.copytree(docs, out, ignore=shutil.ignore_patterns("*.md"))
-+    (out / "SUMMARY.md").write_text(summary(index, pages))
-+    (out / CONTENTS).write_text(contents(index))
++    for name, text in generated.items():
++        (out / name).write_text(text)
 +    shutil.copyfile(root / "README.md", out / "README.md")
 +    for name, text in pages.items():
 +        (out / name).parent.mkdir(parents=True, exist_ok=True)
@@ contrib/book.py (new)
 +def main(argv=None):
 +    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
 +    p.add_argument("root", type=Path)
-+    p.add_argument("out", type=Path)
-+    a = p.parse_args(argv)
-+    assemble(a.root, a.out)
++    assemble(p.parse_args(argv).root)
 +
 +
 +if __name__ == "__main__":
@@ contrib/test_book.py (new)
 +
 +
 +def test_assembly_covers_every_chapter(tmp_path):
-+    root, out = tmp_path / "root", tmp_path / "src"
++    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)
@@ contrib/test_book.py (new)
 +    (root / "docs" / "sub" / "two.md").write_text(
 +        "---\ntype: Chapter\n---\n\n# Two\n\nAfter [one](../one.md).\n"
 +    )
-+    book.assemble(root, out)
++    book.assemble(root)
 +    assert sorted(p.relative_to(out).as_posix() for p in out.rglob("*.md")) == [
 +        "README.md",
 +        "SUMMARY.md",
@@ contrib/test_book.py (new)
 +    (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, tmp_path / "src")
++        book.assemble(root)
 +
 +
 +def test_assembly_copies_the_other_files_of_the_bundle(tmp_path):
-+    root, out = tmp_path / "root", tmp_path / "src"
++    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")
@@ contrib/test_book.py (new)
 +        "---\ntype: Chapter\nstatus: stable\n---\n\n"
 +        "![fig](fig.png) [x](references/x.py)\n"
 +    )
-+    book.assemble(root, out)
++    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 / "src"
++    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, out)
++    book.assemble(root)
 +    (root / "docs" / "old.png").unlink()
-+    book.assemble(root, out)
++    book.assemble(root)
 +    assert not (out / "old.png").exists()
 +
 +
-+@pytest.mark.parametrize("out", [".", "..", "docs", "docs/sub"])
-+def test_assembly_does_not_write_over_the_sources(tmp_path, out):
-+    root = tmp_path / "root"
++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 / "docs" / "index.md").write_text("# Part\n")
-+    with pytest.raises(ValueError, match="would hold the sources"):
-+        book.assemble(root, root / out)
-+    assert (root / "docs" / "index.md").exists()
++    (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()
 
  ## docs/README.md (new) ##
 @@
@@ docs/README.md (new)
 +
 +```sh
 +nix develop
-+python3 contrib/book.py . src
++python3 contrib/book.py .
 +mdbook serve
 +```
 +
@@ nix/site.nix
            # 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 . src
++          python3 contrib/book.py .
            mdbook build -d $out
          '';
2: 5453876f97 ! 2: 85a9343edf Check the status and stale_after of chapters
@@ contrib/book.py: headings become the parts of SUMMARY.md and its entries the cha
 +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 OUT
+     python3 book.py ROOT
  """
 @@ contrib/book.py: import argparse
  import posixpath
@@ contrib/book.py: import argparse
 +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"
 +STATUSES = ("draft", "stable", "deprecated")
 +RESERVED = ("index.md", "log.md")
@@ contrib/book.py: import argparse
 +    runs."""
 +    try:
 +        fields = frontmatter(text)
-+    except yaml.YAMLError as e:
++    except (yaml.YAMLError, ValueError) as e:
 +        raise ValueError(f"{name}: {e}") from e
 +    if not fields.get("type"):
 +        raise ValueError(f"{name}: no type")
@@ contrib/book.py: import argparse
 +            try:
 +                stale_after = datetime.fromisoformat(stale_after)
 +            except ValueError:
-+                pass
++                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"
@@ contrib/book.py: import argparse
      """SUMMARY.md from an OKF index: `# Part` headings and
      `* [Title](file.md) - description` entries, naming each of `pages`,
 @@ contrib/book.py: def contents(index):
- def assemble(root, out):
-     docs = root / DOCS
+ 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()
@@ contrib/book.py: def contents(index):
 +    pages.pop("README.md", None)
      if clash := sorted(pages.keys() & {"SUMMARY.md", CONTENTS}):
          raise ValueError(f"generated by book.py: {', '.join(clash)}")
-     if root.resolve().is_relative_to(out.resolve()) or out.resolve().is_relative_to(
+     generated = {"SUMMARY.md": summary(index, pages), CONTENTS: contents(index)}
 
  ## contrib/test_book.py ##
 @@ contrib/test_book.py: def test_summary_rejects_a_page_left_out_of_the_index():
@@ contrib/test_book.py: def test_summary_rejects_a_page_left_out_of_the_index():
 +        ("---\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",
@@ contrib/test_book.py: def test_summary_rejects_a_page_left_out_of_the_index():
 +
 +
 +def test_assembly_skips_reserved_files_at_any_level(tmp_path):
-+    root, out = tmp_path / "root", tmp_path / "src"
++    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, out)
++    book.assemble(root)
 +    assert sorted(p.relative_to(out).as_posix() for p in out.rglob("*.md")) == [
 +        "README.md",
 +        "SUMMARY.md",
@@ contrib/test_book.py: def test_summary_rejects_a_page_left_out_of_the_index():
 +    (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, tmp_path / "src")
++        book.assemble(root)
 +
 +
  def test_assembly_covers_every_chapter(tmp_path):
-     root, out = tmp_path / "root", tmp_path / "src"
+     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)
@@ contrib/test_book.py: def test_summary_rejects_a_page_left_out_of_the_index():
 -        "---\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, out)
+     book.assemble(root)
      assert sorted(p.relative_to(out).as_posix() for p in out.rglob("*.md")) == [
 
  ## docs/README.md ##

@bc1cindy

bc1cindy commented Oct 18, 2026 •

Copy link
Copy Markdown
Member Author

dropped contrib/relink

with the stubs from #59 every page in docs/ is in the index, and the root README has no relative links, so relink had nothing left to rewrite (the site builds byte-identical without it)

a link that leaves the book now fails the lychee check in #57 instead of being rewritten, so links outside docs/ should be absolute URLs

also:

  • frontmatter is read with pyyaml instead of by hand
  • book.py always writes src/, replacing it on each run, and an index error leaves the previous one in place
  • links are relative to the page: book.py rejects an index entry from /, and a chapter link from / leaves the site, which is served from /docs/, so check links #57 fails on it

for #51: after rebasing on #50 and #57, it needs:

  • frontmatter (type, status, and stale_after if it's a draft)
  • an entry in docs/index.md
  • ../ → ./ in its links
  • no #confirmation-of-successful-payment-prior-to-signing, since the psbt-concurrent stub has no such section

with those the link check passes

Comment thread contrib/book.py Outdated
Comment thread .gitignore Outdated
@github-actions

Copy link
Copy Markdown
range-diff for force push 85a9343edf → b3b00d97c9 October 18, 2026 02:27 UTC: 2 modified

reproduce: git range-diff 32edbfe..85a9343 32edbfe..b3b00d9

1:  3049f58d46 ! 1:  89cb001f0f Generate SUMMARY.md from an OKF index.md
2:  85a9343edf ! 2:  b3b00d97c9 Check the status and stale_after of chapters
1: 3049f58d46 ! 1: 89cb001f0f Generate SUMMARY.md from an OKF index.md
@@ Commit message
     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 replaces 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. 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.
-
- ## .gitignore ##
-@@
- book/
- result
-+/src/
+    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 ##
 @@ book.toml: title = "Fungi Protocol Suite"
@@ contrib/book.py (new)
 +        pages.pop(name, None)
 +    if clash := sorted(pages.keys() & {"SUMMARY.md", CONTENTS}):
 +        raise ValueError(f"generated by book.py: {', '.join(clash)}")
-+    generated = {"SUMMARY.md": summary(index, pages), CONTENTS: contents(index)}
-+    if out.exists():
-+        shutil.rmtree(out)
-+    shutil.copytree(docs, out, ignore=shutil.ignore_patterns("*.md"))
++    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"), dirs_exist_ok=True
++    )
 +    for name, text in generated.items():
 +        (out / name).write_text(text)
-+    shutil.copyfile(root / "README.md", out / "README.md")
 +    for name, text in pages.items():
 +        (out / name).parent.mkdir(parents=True, exist_ok=True)
 +        (out / name).write_text(strip_frontmatter(text))
@@ contrib/test_book.py (new)
 +    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)
@@ contrib/test_book.py (new)
 +    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_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"
 
  ## docs/README.md (new) ##
 @@
2: 85a9343edf ! 2: b3b00d97c9 Check the status and stale_after of chapters
@@ contrib/book.py: def contents(index):
 +    pages.pop("README.md", None)
      if clash := sorted(pages.keys() & {"SUMMARY.md", CONTENTS}):
          raise ValueError(f"generated by book.py: {', '.join(clash)}")
-     generated = {"SUMMARY.md": summary(index, pages), CONTENTS: contents(index)}
+     generated = {
 
  ## contrib/test_book.py ##
 @@ contrib/test_book.py: def test_summary_rejects_a_page_left_out_of_the_index():

@github-actions

Copy link
Copy Markdown
range-diff for force push b3b00d97c9 → d7c812fb02 October 18, 2026 03:40 UTC: 1 modified, 1 unchanged

reproduce: git range-diff 32edbfe..b3b00d9 32edbfe..d7c812f

1:  89cb001f0f ! 1:  0fbd831f4e Generate SUMMARY.md from an OKF index.md
2:  b3b00d97c9 = 2:  d7c812fb02 Check the status and stale_after of chapters
1: 89cb001f0f ! 1: 0fbd831f4e Generate SUMMARY.md from an OKF index.md
@@ contrib/book.py (new)
 +        else:
 +            child.unlink()
 +    shutil.copytree(
-+        docs, out, ignore=shutil.ignore_patterns("*.md"), dirs_exist_ok=True
++        docs,
++        out,
++        ignore=shutil.ignore_patterns("*.md", ".gitignore"),
++        dirs_exist_ok=True,
 +    )
 +    for name, text in generated.items():
 +        (out / name).write_text(text)
@@ contrib/test_book.py (new)
 +    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)

Comment thread contrib/book.py Outdated
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.
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.
@github-actions

Copy link
Copy Markdown
range-diff for force push d7c812fb02 → 61987d72ec October 18, 2026 05:07 UTC: 2 modified

reproduce: git range-diff 32edbfe..d7c812f 32edbfe..61987d7

1:  0fbd831f4e ! 1:  ed9022a622 Generate SUMMARY.md from an OKF index.md
2:  d7c812fb02 ! 2:  61987d72ec Check the status and stale_after of chapters
1: 0fbd831f4e ! 1: ed9022a622 Generate SUMMARY.md from an OKF index.md
@@ contrib/book.py (new)
 +# As set in book.toml.
 +SRC = "src"
 +CONTENTS = "contents.md"
++GENERATED = ("README.md", "SUMMARY.md", CONTENTS)
 +
 +
 +def strip_frontmatter(text):
@@ contrib/book.py (new)
 +    # chapter it would overwrite the introduction.
 +    for name in ("index.md", "README.md"):
 +        pages.pop(name, None)
-+    if clash := sorted(pages.keys() & {"SUMMARY.md", CONTENTS}):
++    # 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(),
@@ contrib/test_book.py (new)
 +    assert (out / "sub" / "two.md").read_text() == "# Two\n\nAfter [one](../one.md).\n"
 +
 +
-+@pytest.mark.parametrize("name", ["SUMMARY.md", "contents.md"])
++@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)
2: d7c812fb02 ! 2: 61987d72ec Check the status and stale_after of chapters
@@ contrib/book.py: import argparse
  # 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")
  
@@ contrib/book.py: def contents(index):
 -    for name in ("index.md", "README.md"):
 -        pages.pop(name, None)
 +    pages.pop("README.md", None)
-     if clash := sorted(pages.keys() & {"SUMMARY.md", CONTENTS}):
-         raise ValueError(f"generated by book.py: {', '.join(clash)}")
-     generated = {
+     # 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}
 
  ## contrib/test_book.py ##
 @@ contrib/test_book.py: def test_summary_rejects_a_page_left_out_of_the_index():

@arminsabouri arminsabouri left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConceptACK ed9022a

@oakagent

Copy link
Copy Markdown

ACK 61987d7 (automated): reviewed 2 commits ed9022a..61987d7; no findings.

@arminsabouri
arminsabouri added this pull request to the merge queue Oct 19, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Oct 19, 2026
@yuval-block
yuval-block added this pull request to the merge queue Oct 19, 2026
@yuval-block
yuval-block removed this pull request from the merge queue due to the queue being cleared Oct 19, 2026
@yuval-block
yuval-block added this pull request to the merge queue Oct 19, 2026
@yuval-block
yuval-block removed this pull request from the merge queue due to the queue being cleared Oct 19, 2026
@yuval-block
yuval-block added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit 344b072 Oct 19, 2026
4 checks passed
@yuval-block
yuval-block deleted the okf-index branch October 19, 2026 19:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SUMAMRY.md -> index.md for OKF compatibility?

4 participants