Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
114 commits
Select commit Hold shift + click to select a range
323149c
Move the cost function framework stub to optimization_framework.md
nothingmuch Oct 23, 2026
22f7e64
Radix coinjoin denominations notebook
nothingmuch Aug 4, 2026
208e53a
add a notebook derivation and a devshell for editing the notebook
nothingmuch Oct 9, 2026
94555dd
re-render notebook outputs with the notebook derivation
nothingmuch Oct 9, 2026
700cfc6
count combinations with replacement as multisets
nothingmuch Oct 9, 2026
3d060fe
clarify the notebook introduction
nothingmuch Oct 9, 2026
b4a3539
count combinations of up to k denominations, as the sumsets do
nothingmuch Oct 9, 2026
89a4955
skip the median, not the 25th percentile, among the quantile dots
nothingmuch Oct 9, 2026
dd43fd5
clarify the sub-transaction counting argument
nothingmuch Oct 9, 2026
ef9510c
explain why sums are cut off above a multiple of max_denom
nothingmuch Oct 9, 2026
426bc74
explain subset sum density as an invariant of the mapping
nothingmuch Oct 9, 2026
0fc163e
render jupyter notebook chapters as mdbook pages: hide code, paginate…
bc1cindy Aug 13, 2026
8d2c892
assemble notebook chapters from docs/
nothingmuch Oct 22, 2026
9750f8a
style plots: theme-neutral, colorblind-safe, re-rendered outputs
bc1cindy Aug 13, 2026
d676778
point links to notebook chapters at their rendered pages
nothingmuch Oct 22, 2026
cb3b434
Skip separators and part titles when walking the book's chapters
nothingmuch Oct 22, 2026
0d0d1f0
nb-table: print every row of paginated tables
bc1cindy Oct 12, 2026
0460da2
keep bar value labels black over the bar fills
bc1cindy Oct 12, 2026
34a824f
outline density series to read on dark pages
bc1cindy Oct 12, 2026
48208a1
restore the density plot's legend frame
bc1cindy Oct 12, 2026
d730034
[WIP] ████████████████░░░░ Describe concrete cost-function terms and …
nothingmuch Aug 12, 2026
c181324
stop claiming six outputs close every gap above dust
nothingmuch Oct 9, 2026
959497a
[WIP] Order the concrete terms like the term chapters
nothingmuch Oct 23, 2026
dcebecc
Keep the powers below the dust threshold until they are multiplied
nothingmuch Oct 23, 2026
6e21dfc
[WIP] Move decision tree construction into its own chapter
nothingmuch Oct 23, 2026
38bba5b
Say that the gap plot's k=6 quantiles start at 2, not 1
nothingmuch Oct 23, 2026
ab58911
[WIP] Bring tree construction from the abstract chapter into its own …
nothingmuch Oct 23, 2026
c9df811
Bound the terms of a sum above the cutoff by its size alone
nothingmuch Oct 23, 2026
d2843e4
[WIP] Rename the decision tree construction chapter to strategy
nothingmuch Oct 23, 2026
224aeb3
Define the on-chain privacy problems being addressed
nothingmuch Aug 3, 2026
8240f2a
Say that a k:1 exchange changes the weight, not the fee
nothingmuch Oct 23, 2026
d8da629
[WIP] Refer to the abstract chapter for the tree level expectation
nothingmuch Oct 23, 2026
2cb810e
Edits to Collaborative Transaction Privacy (#30)
deutschbitte Sep 9, 2026
69b3ea3
Qualify the density argument by the denominations that remain
nothingmuch Oct 23, 2026
cdb5996
[WIP] Refer to the optimization framework chapter by its new title
nothingmuch Oct 23, 2026
654d4d3
[WIP] Add the randomized selection and Sybil resistance chapters
nothingmuch Oct 22, 2026
b3d9c67
Revise and clarify following editorial pass
nothingmuch Oct 9, 2026
610bb04
Show the denomination generating code in the book
nothingmuch Oct 23, 2026
d7b7849
Merge the concurrent PSBT spec onto the book structure
nothingmuch Oct 23, 2026
bf039d8
[WIP] Link the optimization framework chapter by its new file name
nothingmuch Oct 23, 2026
5f4499d
Link the optimization framework chapter by its new name
nothingmuch Oct 23, 2026
4094fe7
Move the problem definition into the mdbook
nothingmuch Oct 7, 2026
cd0283e
Show the sumset and gap computation code in the book
nothingmuch Oct 23, 2026
34b642c
Fix typos and a broken anchor in the concurrent PSBT spec
nothingmuch Oct 23, 2026
0cc90af
Abstract cost function
nothingmuch Aug 4, 2026
8dfef51
[WIP] Rename the Strategy chapter to Tactics
nothingmuch Oct 23, 2026
608d4fa
Arrange the index into the parts proposed in #19
nothingmuch Oct 23, 2026
3a7f073
[WIP] Nest indented index entries as sub-chapters
nothingmuch Oct 23, 2026
f632860
Render math before mdbook-graphviz re-serializes the markdown
nothingmuch Oct 10, 2026
09c4e61
[WIP] Mark agent-generated text in the randomized selection chapters …
nothingmuch Oct 23, 2026
c4d1d2b
Refer to the Radix CoinJoin Denominations chapter
nothingmuch Oct 23, 2026
d63f7f5
README do-over
nothingmuch Oct 20, 2026
beb76e9
Show the analysis parameters in the book
nothingmuch Oct 23, 2026
7b9c4ea
Move the concurrent PSBT spec into the book
nothingmuch Oct 23, 2026
b269082
[WIP] ████████████████████ Clarify abstract cost function
nothingmuch Aug 12, 2026
a0ca115
fixup! Abstract cost function
nothingmuch Oct 22, 2026
03426dd
[WIP] ████████████████████ Record decision tree construction notes
nothingmuch Oct 22, 2026
59427c1
[WIP] ████████████████░░░░ Describe how the decision tree is built
nothingmuch Oct 22, 2026
1db23a8
[WIP] Add the cost function's words to the spelling dictionary
nothingmuch Oct 22, 2026
8d4c7d6
fixup! [WIP] ████████████████████ Clarify abstract cost function
nothingmuch Oct 22, 2026
0e5404d
fixup! Abstract cost function
nothingmuch Oct 22, 2026
9c33d4d
fixup! Abstract cost function
nothingmuch Oct 22, 2026
9c1eadc
fixup! Abstract cost function
nothingmuch Oct 22, 2026
9a3893f
fixup! Abstract cost function
nothingmuch Oct 22, 2026
b74459a
[WIP] Order inputs before outputs within a batch
nothingmuch Oct 22, 2026
90f5867
[WIP] Model time dependence as piecewise linear offsets
nothingmuch Oct 22, 2026
8582ecc
[WIP] Say what each end of the bracket means
nothingmuch Oct 22, 2026
703637d
[WIP] Distinguish the pure and mixed strategies
nothingmuch Oct 22, 2026
34ca4e8
[WIP] Describe commitment in multiparty protocols
nothingmuch Oct 22, 2026
a919b72
[WIP] Rewrite the global privacy budget
nothingmuch Oct 22, 2026
1f536a3
[WIP] ████████████████░░░░ Penalize co-spend proposals that leak a co…
nothingmuch Oct 22, 2026
e95b036
fixup! [WIP] ████████████████░░░░ Describe how the decision tree is b…
nothingmuch Oct 23, 2026
2386f6d
fixup! [WIP] Say what each end of the bracket means
nothingmuch Oct 23, 2026
87b5746
[WIP] ████████████████████ Describe the greedy decision loop
nothingmuch Oct 23, 2026
fe001d2
[WIP] ████████████████████ Give the decision loop its own heading
nothingmuch Oct 23, 2026
6b153e4
[WIP] ██████████████████░░ Move tree construction to the strategy cha…
nothingmuch Oct 23, 2026
ae64146
[WIP] ████████████████░░░░ Rename the chapter to Optimization framework
nothingmuch Oct 23, 2026
bed2790
[WIP] ████████████████████ Introduce the strategy as generating and e…
nothingmuch Oct 23, 2026
c8dd45f
[WIP] Point the decision loop reference at the Mixed strategy chapter
nothingmuch Oct 23, 2026
31b7ca9
[WIP] ████████████████░░░░ Move the mixed strategy into its own chapter
nothingmuch Oct 23, 2026
e399c78
[WIP] Mark agent-generated text in Tactics and the concrete chapter w…
nothingmuch Oct 23, 2026
c4315b5
[WIP] ████████████████░░░░ Mark agent-generated text with 🤖
nothingmuch Oct 23, 2026
621f383
[WIP] Rename the concrete cost function chapter to Pure Strategy Eval…
nothingmuch Oct 23, 2026
8d834d7
Coalition formation
nothingmuch Aug 4, 2026
bb326d3
[WIP] Call the greedy loop a decision loop run as a greedy search
nothingmuch Oct 23, 2026
7448b7d
[WIP] Describe Tactics by what it generates
nothingmuch Oct 23, 2026
01fae42
[WIP] ████████████████░░░░ Add input value constraints to proposal co…
nothingmuch Oct 10, 2026
c8bc47c
[WIP] Move the cost function's structure into its own chapter
nothingmuch Oct 23, 2026
5b508e2
[WIP] Add a Pure Strategy Evaluation page above the structure and the…
nothingmuch Oct 23, 2026
822b2a1
[WIP] Retitle the concrete cost function page Privacy liability terms
nothingmuch Oct 23, 2026
a8cf94e
[WIP] Refer to the term chapters by their new titles
nothingmuch Oct 23, 2026
6316ae9
[WIP] Put the randomization and Sybil resistance terms first
nothingmuch Oct 23, 2026
de04c8b
Add an overview
nothingmuch Oct 13, 2026
33a4f91
Link the optimization framework chapter by its new file name
nothingmuch Oct 23, 2026
00d7591
Merge the KaTeX ordering fix under the protocol chapters
nothingmuch Oct 23, 2026
a6aba0f
[WIP] Specify the abstract transport interfaces
nothingmuch Oct 22, 2026
bfb860c
[WIP] Specify peer and broadcast channels
nothingmuch Oct 15, 2026
5a82a00
[WIP] Specify peer set reconciliation
nothingmuch Oct 14, 2026
56fbd28
[WIP] Specify contacts, devices and discovery
nothingmuch Oct 15, 2026
a6557c7
[WIP] Introduce transaction construction
nothingmuch Oct 23, 2026
b24af07
[WIP] Specify the optimistic track
nothingmuch Oct 14, 2026
a39124e
[WIP] Specify the recovery track
nothingmuch Oct 23, 2026
daf7e90
[WIP] Specify the allocation registration validity proofs
nothingmuch Oct 17, 2026
779a402
[WIP] Point the overview at the channel interfaces and asymmetric trust
nothingmuch Oct 23, 2026
07804fa
[WIP] Specify the protocol messages
nothingmuch Oct 22, 2026
25991d8
[WIP] Outline max flow based counterfactual path estimation
nothingmuch Oct 22, 2026
ea5a621
[WIP] Outline subset sum density, subset sum ambiguity and the siblin…
nothingmuch Oct 22, 2026
467d0d1
[WIP] Outline the consolidation penalty
nothingmuch Oct 22, 2026
286356a
Merge the cost function term chapters
nothingmuch Oct 22, 2026
9d9d9b0
Link the cost function term chapters
nothingmuch Oct 22, 2026
b62de4f
[WIP] Move bottom-up construction out of Tactics into its own chapter
nothingmuch Oct 23, 2026
c3143ab
Integrate the chapters under review
nothingmuch Oct 23, 2026
2aaa63f
[WIP] Serve the draft from /docs-draft/
nothingmuch Oct 23, 2026
cfdb157
Bump nixpkgs from `cf5e765` to `cd4ea8d`
dependabot[bot] Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
book/
result
.ipynb_checkpoints/
14 changes: 14 additions & 0 deletions .typos.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[default.extend-identifiers]
# Mermaid class for Alice's cluster in the intersection diagram.
aci = "aci"
# Author of The Byzantine Generals Problem.
Pease = "Pease"
# Author of Asymmetric Distributed Trust.
Cachin = "Cachin"
# Single-use reply blocks.
SURB = "SURB"
SURBs = "SURBs"

[default.extend-words]
# Groth16, the proof system named after Jens Groth.
Groth = "Groth"
49 changes: 29 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,41 +1,50 @@
# Fungi

This repository contains documents that describe a family collaborative Bitcoin transaction construction protocols. The protocols form a progression towards decentralized and market based privacy preserving transactions.
Fungi is a protocol for collaborative transactions designed to make using Bitcoin more private. Participants can also pay each other interactively in these transactions, which helps not just privacy but also blockspace efficiency.

A software development kit (SDK) is [in development](https://github.com/fungi-protocol/fungi). This repository contains the specifications and the analysis behind them.

> [!NOTE]
> **Work in progress**: most chapters are still under review. See the [open pull requests](https://github.com/fungi-protocol/docs/pulls) for material being drafted.
> **Work in progress**: most chapters are drafts under review. See the [open pull requests](https://github.com/fungi-protocol/docs/pulls) for material being drafted.

## Background

Censorship resistance and fungibility are among Bitcoin's foundational properties. Without privacy, both can be undermined largely outside the protocol's rules, whether by targeting specific individuals or through chilling effects.

Bitcoin transactions are public data, and they are connected in a graph structure. Chain analysis is the application of heuristics to this graph to infer which coins share an owner. While no heuristic is perfect, their effectiveness has been shown in numerous studies. Several heuristics can be combined for a more accurate analysis. Analysis might also draw on auxiliary information not recorded on the blockchain, such as which coins an exchange sent to which customer.

CoinJoin transactions seek to undermine some of the heuristics used to cluster coins across the transaction graph. They combine several users' inputs and outputs in one transaction in order to obscure ownership links within it. Several CoinJoin implementations exist with different approaches to their transaction structure.

## Fungi protocol suite
## Problem statement

### Honest setting
Unfortunately, CoinJoin privacy is often stated in terms of the number of indistinguishable outputs in a transaction. This tacitly ignores the fact that those coins will go on to be spent. The information revealed by the spending transactions, which are themselves related to other transactions, can dramatically affect privacy, in some cases making the CoinJoin inputs linkable to the outputs.

The *honest* setting assumes all participants can be trusted in two ways:
Recurring relationships give rise to patterns too. Transacting with the same employers, shops and friends leaves a structure in the transaction graph, even if techniques like PayJoin might hide the individual amounts and confound some of the clustering heuristics. Obscuring this structure necessitates transacting with unrelated users, chosen at random, so users who don't know or trust each other need a way to find each other and agree on a transaction. Random selection must additionally be Sybil resistant, so that the cost of an $n-1$ deanonymization attack, where the adversary controls all of the other (apparent) participants, is a sufficient deterrent.

- not to disrupt the execution of the protocol
- with privacy, which means not revealing to the adversary, ideally forgetting, any information that may be revealed by participants in the course of the protocol's execution
In general, deanonymizing a single user always makes it easier to deanonymize the other users. But sometimes the adversary might progress not just by process of elimination, but more like divide and conquer. This happens when deanonymizing that one user splits the problem of deanonymizing the rest into several smaller problems, as opposed to merely making it incrementally smaller.

In this as well as subsequent protocol, trust never means giving up control over one's funds. Input signatures in transactions enforce this safety by letting each party ensure its , requiring unanimous consent by all parties.
The [problem definition](docs/collaborative_txn_privacy.md) chapter discusses these on-chain privacy leaks in depth, and describes a particular transaction graph structure that resists deanonymization more robustly.

### Semi-honest setting
## How Fungi works

In the *semi-honest* setting, also known as *honest-but-curious*, we no longer assume peers have to be trusted to keep a secret. Instead this relies on privacy preserving transports for the communication, that allow a peer to broadcast its messages anonymously to the other participants. Because Bitcoin transactions are transparent, privacy further demands a more careful approach to transaction construction, or the actions of a peer may be revealing even if not directly attributable.
A user first tells their wallet which payments they intend to make, and the wallet keeps these in a queue. Intents can vary in their time, privacy or cost sensitivity. Making a payment quickly, cheaply and privately is a balancing act, and improving one usually means compromising on another. The wallet's job is to construct transactions that make the queued payments, balancing these goals in line with the user's preferences.

The semi-honest setting can provide some degree of confidentiality, especially against 3rd party observers, through the construction of net settlement transactions. But not anonymity. Transactions between peers that are somehow related have inherent privacy issues because such relationships constitute a social network, exhibiting community structure.
The wallet schedules and batches payments, negotiating with other participants to find terms that suit the user's priorities. The more flexible a payment's timing, the more cost-effective it can be, because there is more time for compatible proposals to be combined, so that its cost can be shared with more participants. The protocol and the strategy are designed together, and the SDK implements both.

### Byzantine fault tolerant setting
Avoiding links between coins is central to the strategy. The input and output amounts can imply such links if there aren't enough possibilities for how to group inputs and outputs so their totals match. But as mentioned above, a partition of a transaction's coins can also be evaluated based on the surrounding context in the transaction graph, not just the amounts associated with that transaction. So the strategy must consider not just whether the transaction's internal structure is sufficiently ambiguous, but also how a transaction sits in the wider transaction graph.

Overcoming the community analysis concern requires a Sybil resistant, permissionless protocol where peers with no meaningful economic relationship can find each other. This involves an additional *coalition formation* protocol that sets up transaction construction using a market based mechanism.
In Fungi, a *co-spend proposal* sets out the terms for spending a set of coins together. Proposals are made automatically according to the privacy optimization strategy. The transaction-wide terms of a co-spend proposal are public, constraining things like the range of acceptable feerates or whether or not non-SegWit inputs can be used. Proposals can also include fees, which are kept confidential. A proposal involving three or more parties additionally hides which of them made the proposal. Once accepted by all parties, co-spend proposals are publicly broadcast.

While it is beneficial for privacy, interacting with strangers in a permissionless protocol invites disruption or attempts at surveillance. For honest peers to be able to reliably transact, especially with the unanimity requirement, demands a byzantine fault tolerance from the transaction construction protocol.
Any participant can collect fully accepted co-spend proposals from multiple parties, and then make a *coalition formation proposal* which aggregates them together, so long as the terms are compatible. If everyone accepts the coalition formation proposal, then a coalition has been formed, and the parties can proceed with collaborative transaction construction. Privacy is brittle in small transactions. A critical mass of participants is therefore necessary for privacy to be robust and cost-effective, and coalition formation is designed to facilitate such a critical mass forming.

In the course of a BFT interaction with untrusted peers, a participant may still use the other variants of the protocol to coordinate activity with the subset of peers they do know or trust, for instance by performing net-settlement within a larger BFT CoinJoin.
During collaborative transaction construction, participants add their intended inputs and outputs anonymously. This preserves the unlinkability of inputs and outputs so long as it isn't undermined in some other way (for example by reusing an output address). Interactive payments additionally provide privacy against the payment counterparty. In addition to hiding the payment amount from third parties, the sender and receiver do not learn about each other's coins. Since many interactive payments can be settled in one collaborative transaction, this creates the opportunity for blockspace savings.

## Motivation
In order to ensure funds are secure throughout, every participant retains control at all times and therefore signs their own inputs. Unfortunately, this means that as the number of participants grows, reliability will necessarily degrade. Even if the probability of any one participant causing a fault is low, that compounds when considering the probability that none of them will. Transaction construction is designed to be fault-tolerant, so that even when a valid transaction has not successfully been produced, participants have a basis to refine the coalition structure. Fault tolerance includes malicious disruption.

Wallet clustering is the broad term for a variety of techniques that can be used to deanonymize on chain activity. Clustering involves linking particular coins to one another, attributing their ownership to the same entity. Over the course of Bitcoin's existence many papers have been published on this subject, also about related privacy issues issues, such as Lightning network, or the transport layer.
## Compared to JoinMarket

This body of work has, time and again, shown that misconceptions about privacy invariably err on the side of optimism. Moreover, depending on individual users' threat models, their adversary may have access to comprehensive auxiliary information. Such auxiliary information doesn't even need to be about the target user, information about other users may be useful for deanonymization of the target user. In cryptography, rotating long lived keys or favoring ephemeral ones can limit the scope of a compromise, but an individual's identity is who they are.
Fungi could be described as a spiritual successor to JoinMarket (as implemented by [JoinMarket](https://github.com/JoinMarket-Org/joinmarket-clientserver) and [JoinMarket-NG](https://github.com/joinmarket-ng/joinmarket-ng)). Like JoinMarket, it is peer-to-peer and market-based, with no centralized coordinator, but differs substantially in how it approaches the order book mechanism as well as transaction construction.

The fungi protocol suite is designed to make arbitrary transactions possible between mutually consenting but distrusting peers. This means safety, liveness, and unlinkability of protocol messages. Within such a protocol peers are free to pursue whatever outcomes they desire.
JoinMarket distinguishes two roles: in each transaction, a user in the role of the taker selects several makers from the order book. Makers advertise their willingness to participate in CoinJoin transactions. Generally, the taker will pay the on-chain fees as well as any maker fees. The taker learns which coins belong to the makers it pays, so makers get no privacy from the taker. And because there is a single taker, a transaction can only include as many participants as one taker is willing to pay for.

Privacy does not simply emerge naturally from collaborative transactions. Peers must actively optimize for that. Optimization involves the use of a cost function, an extension or generalization of the kind used for coin selection. This repository makes specific recommendations with which the protocols are designed to be compatible. The recommended privacy related terms address Sybil resistance, the transaction structure (for example how output values are chosen in relation to others' input values) and the graph structure.
Fungi makes no distinction between the taker and maker roles. Instead, a participant can both send and accept proposals, and proposals involving multiple users can be aggregated, allowing a single transaction to be negotiated more as a group buy of blockspace. As the number of participants grows, the privacy benefits compound for everyone, which means each individual user needs to pay for proportionately less blockspace in order to meet their individual privacy requirements. Finally, the proposed transaction structure aims to address several deanonymization attacks that were not yet understood at the time JoinMarket's design was put in place.
10 changes: 9 additions & 1 deletion book.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ src = "src"
[build]
create-missing = false

[preprocessor.jupyter]
command = "python3 contrib/jupyter_nb.py"
after = ["links"]
before = ["graphviz", "mermaid", "katex"]

[preprocessor.graphviz]
command = "mdbook-graphviz"
after = ["links"]
Expand All @@ -17,8 +22,11 @@ after = ["links", "graphviz"]
[preprocessor.katex]
command = "mdbook-katex"
after = ["links"]
# mdbook-graphviz re-serializes the markdown, escaping the TeX source.
before = ["graphviz"]

[output.html]
additional-css = ["theme/diagrams.css"]
git-repository-url = "https://github.com/fungi-protocol/docs"
site-url = "/docs/"
site-url = "/docs-draft/"
additional-js = ["theme/nb-table.js"]
69 changes: 57 additions & 12 deletions contrib/book.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@
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.
relative to docs/, and every chapter must have an entry. An entry
indented two spaces more than the entry above it is a sub-chapter of
that 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. Chapters are markdown pages
and Jupyter notebooks, whose frontmatter is a raw cell that opens them.
The bundle's other files are copied as they are, except for Jupyter's
autosaves.

python3 book.py ROOT
"""
import argparse
import json
import posixpath
import re
import shutil
Expand All @@ -21,20 +26,43 @@
import yaml

FRONTMATTER = re.compile(r"\A---\n(.*?)\n---[ \t]*(?:\n+|\Z)", re.DOTALL)
ENTRY = re.compile(r"\* \[([^\]]+)\]\(([^)\s]+)\)(?: - .*)?")
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")
NOTEBOOK = ".ipynb"
CHECKPOINTS = ".ipynb_checkpoints"
# A sub-chapter's entry is indented this much more than its chapter's.
INDENT = " "


def strip_frontmatter(text):
return FRONTMATTER.sub("", text, count=1)


def notebook_frontmatter(text):
"""A notebook's frontmatter, as a markdown page would have it, if a
raw cell holding nothing else opens it, and the notebook without it."""
nb = json.loads(text)
cells = nb.get("cells", [])
if cells and cells[0].get("cell_type") == "raw":
head = "".join(cells[0].get("source", []))
if FRONTMATTER.fullmatch(head):
nb["cells"] = cells[1:]
return head, json.dumps(nb, indent=1, ensure_ascii=False) + "\n"
return "", text


def body(name, text):
if name.endswith(NOTEBOOK):
return notebook_frontmatter(text)[1]
return strip_frontmatter(text)


def frontmatter(text):
m = FRONTMATTER.match(text)
fields = yaml.safe_load(m.group(1)) if m else None
Expand All @@ -47,6 +75,8 @@ def check(name, text):
has passed is not checked, so that a build does not depend on when it
runs."""
try:
if name.endswith(NOTEBOOK):
text = notebook_frontmatter(text)[0]
fields = frontmatter(text)
except (yaml.YAMLError, ValueError) as e:
raise ValueError(f"{name}: {e}") from e
Expand All @@ -71,20 +101,35 @@ def check(name, text):
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."""
the paths in docs/ of the pages the book is given. Indented entries
are nested under the entry above them."""
out = ["# Summary", "", "[Introduction](README.md)", f"[Contents]({CONTENTS})"]
listed = set()
# The depth of the entry above, or -1 at the start of a part, where
# there is none to nest under.
above = -1
for line in strip_frontmatter(index).splitlines():
if line.startswith("# "):
out += ["", line, ""]
above = -1
elif m := ENTRY.fullmatch(line):
chapter = posixpath.normpath(m.group(2))
depth, odd = divmod(len(m.group(1)), len(INDENT))
if odd:
raise ValueError(f"index entry has odd indentation: {line!r}")
if depth > above + 1:
if above < 0:
raise ValueError(f"index entry has no entry above: {line!r}")
raise ValueError(
f"index entry is nested more than one level deeper: {line!r}"
)
above = depth
chapter = posixpath.normpath(m.group(3))
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})")
out.append(f"{INDENT * depth}- [{m.group(2)}]({chapter})")
elif line.strip():
raise ValueError(f"unexpected line in index: {line!r}")
if unlisted := sorted(set(pages) - listed):
Expand All @@ -103,8 +148,8 @@ 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:
for page in sorted([*docs.rglob("*.md"), *docs.rglob("*" + NOTEBOOK)]):
if page.name not in RESERVED and CHECKPOINTS not in page.parts:
name = page.relative_to(docs).as_posix()
pages[name] = page.read_text()
check(name, pages[name])
Expand Down Expand Up @@ -136,14 +181,14 @@ def assemble(root):
shutil.copytree(
docs,
out,
ignore=shutil.ignore_patterns("*.md", ".gitignore"),
ignore=shutil.ignore_patterns("*.md", "*" + NOTEBOOK, CHECKPOINTS, ".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))
(out / name).write_text(body(name, text))


def main(argv=None):
Expand Down
Loading
Loading