Set up literate programming structure

This commit is contained in:
Jon Staab
2026-04-07 09:55:41 -07:00
commit 2d36400ba0
16 changed files with 933 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
ref
# Generated source (tangled from book/)
coracle-lib/src/
coracle-net/src/
coracle-signer/src/
coracle-content/src/
coracle-storage/src/
# Build artifacts
target/
Generated
+321
View File
@@ -0,0 +1,321 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cc"
version = "1.2.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "coracle-content"
version = "0.1.0"
dependencies = [
"coracle-lib",
]
[[package]]
name = "coracle-lib"
version = "0.1.0"
dependencies = [
"hex",
"secp256k1",
"serde",
"serde_json",
"sha2",
]
[[package]]
name = "coracle-net"
version = "0.1.0"
dependencies = [
"coracle-lib",
]
[[package]]
name = "coracle-signer"
version = "0.1.0"
dependencies = [
"coracle-lib",
]
[[package]]
name = "coracle-storage"
version = "0.1.0"
dependencies = [
"coracle-lib",
]
[[package]]
name = "coracle-tangle"
version = "0.1.0"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "pulldown-cmark"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
dependencies = [
"bitflags",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "secp256k1"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
"secp256k1-sys",
"serde",
]
[[package]]
name = "secp256k1-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
dependencies = [
"cc",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+10
View File
@@ -0,0 +1,10 @@
[workspace]
resolver = "2"
members = [
"coracle-tangle",
"coracle-lib",
"coracle-net",
"coracle-signer",
"coracle-content",
"coracle-storage",
]
+77
View File
@@ -0,0 +1,77 @@
This is a monorepo aimed at developers building nostr applications in rust. All crates are prefixed by the namespace `coracle`.
It has the following crates:
- `coracle-lib` - Struct definitions, stateless utilities related to nostr.
- `coracle-net` - Networking utilities for working with relays
- `coracle-signer` - Signer client/server utilities
- `coracle-content` - Text parsing and rendering utilities
- `coracle-storage` - Storage adapters for different platforms
All code is written in a [literate programming](https://en.wikipedia.org/wiki/Literate_programming) style and compiled to both html documentation and rust source code. The goal of this repository is threefold:
- To create a complete resource for learning how to work with the nostr protocol for humans
- To create a production-ready nostr utility library for inclusion in rust, KMP, and web projects.
- To experiment with using literate programming to serve both as a library and context file for LLMs.
## Prerequisites
- [Rust](https://rustup.rs/)
- [just](https://github.com/casey/just)
- [mdbook](https://rust-lang.github.io/mdBook/) (`cargo install mdbook`)
## How it works
The source of truth is the `book/` directory, which contains markdown files written in a literate programming style. Code blocks annotated with a file path are extracted ("tangled") into Rust source files by the `coracle-tangle` tool.
For example, a code block like this in a markdown file:
```text
```rust {file=coracle-lib/src/event.rs}
pub struct Event { ... }
```
```
will be extracted to `coracle-lib/src/event.rs`. Multiple blocks targeting the same file are concatenated in document order, so you can introduce a struct in one section and add methods to it later in the narrative.
Code blocks without a `{file=...}` annotation are illustrative only and are not tangled.
The `src/` directories of the library crates are generated artifacts and should not be edited directly. Edit the markdown in `book/` instead.
## Usage
```sh
# Tangle: extract code from markdown into .rs files
just tangle
# Build: tangle + compile all library crates
just build
# Check: tangle + type-check without full compilation
just check
# Weave: tangle + generate the HTML book (output in target/book/)
just weave
# Clean: remove all generated source and book output
just clean
# All: build + weave
just all
```
## Project structure
```
book/ # Literate source (the single source of truth)
book.toml # mdBook configuration
SUMMARY.md # Table of contents
01-introduction.md
02-events.md # etc.
coracle-tangle/ # The tangle/weave tool (Rust binary)
coracle-lib/ # Core nostr types and utilities (generated src/)
coracle-net/ # Relay networking (generated src/)
coracle-signer/ # Signing abstractions (generated src/)
coracle-content/ # Content parsing (generated src/)
coracle-storage/ # Storage adapters (generated src/)
```
+49
View File
@@ -0,0 +1,49 @@
# Introduction
Nostr is a simple, open protocol for decentralized social networking. Unlike traditional
platforms, nostr has no central server. Instead, users publish signed messages called
**events** to a network of **relays** — simple WebSocket servers that store and forward
events.
The protocol's power comes from its simplicity. At its core, nostr defines just one data
structure (the event) and a handful of message types for communicating with relays. Everything
else — social graphs, encrypted messaging, long-form content, marketplace listings — is built
on top of this foundation through a system of **NIPs** (Nostr Implementation Possibilities),
which are community-authored specifications.
## What this book covers
This book is both a tutorial and the source code for the `coracle` family of Rust crates:
- **coracle-lib** — Core types and stateless utilities: events, keys, tags, filters, and
serialization. Everything you need to understand and manipulate nostr data.
- **coracle-net** — Networking: connecting to relays, managing subscriptions, publishing
events, and relay discovery.
- **coracle-signer** — Signing abstractions: local key signing, NIP-46 remote signing,
and browser extension integration.
- **coracle-content** — Content handling: parsing note text, rendering mentions, handling
media links, and working with NIP-27 references.
- **coracle-storage** — Persistence: storing and querying events locally across different
platforms and backends.
The chapters are ordered so that each concept builds on what came before. Code blocks marked
with a file path are **tangled** — extracted and assembled into Rust source files that form the
actual library. What you're reading *is* the source code.
## How literate programming works here
Throughout this book, you'll see code blocks annotated with an output file path like this:
```text
```rust {file=coracle-lib/src/lib.rs}
pub mod event;
```
```
These blocks are the real source code. The `coracle-tangle` tool extracts them in document
order and writes them to the indicated file paths. Multiple blocks targeting the same file are
concatenated, so a struct can be introduced in one section and have methods added later in the
narrative.
Code blocks *without* a file annotation are illustrative — they show examples, intermediate
states, or protocol concepts without contributing to the compiled output.
+202
View File
@@ -0,0 +1,202 @@
# Events
Everything in nostr is an **event**. A short note, a profile update, a reaction, a relay list —
they're all events. An event is a JSON object signed by its author's cryptographic key. Once
signed, it cannot be altered without invalidating the signature, which means any relay or client
can verify that an event is authentic without trusting anyone.
## The event structure
Here's what a nostr event looks like as JSON:
```json
{
"id": "4376c65d2f232afbe9b882a35baa4f6fe8bce184396e157b1a35b3f01b3e285c",
"pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
"created_at": 1673347337,
"kind": 1,
"tags": [
["e", "3da979448d9ba263864c4d6f14984c423a3838364ec255f03c7904b1ae77f206"],
["p", "bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce"]
],
"content": "Hello, nostr!",
"sig": "908a15e46fb4d8675bab026fc230a0e3542bfade63da02d542fb78b2a8513fcd..."
}
```
Let's define this in Rust. We'll start with the module declaration:
```rust {file=coracle-lib/src/lib.rs}
pub mod event;
```
And now the `Event` struct itself. Each field maps directly to the JSON above:
```rust {file=coracle-lib/src/event.rs}
use serde::{Deserialize, Serialize};
use sha2::{Sha256, Digest};
/// A nostr event — the fundamental data type of the protocol.
///
/// Every interaction on nostr is represented as a signed event. The `id` is a
/// SHA-256 hash of the canonical serialization, and the `sig` is a Schnorr
/// signature over that hash using the author's private key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
/// The event ID: a 32-byte hex-encoded SHA-256 hash of the canonical serialization.
pub id: String,
/// The author's public key: a 32-byte hex-encoded secp256k1 public key (x-only).
pub pubkey: String,
/// Unix timestamp in seconds when the event was created.
pub created_at: u64,
/// The event kind determines how the event should be interpreted.
/// Kind 0 = metadata, kind 1 = short text note, kind 3 = contacts, etc.
pub kind: u16,
/// A list of tags. Each tag is an array of strings where the first element
/// identifies the tag type. Common tags: ["e", <event-id>], ["p", <pubkey>].
pub tags: Vec<Vec<String>>,
/// The event content. Interpretation depends on the kind.
pub content: String,
/// A 64-byte hex-encoded Schnorr signature of the event ID.
pub sig: String,
}
```
## Computing the event ID
The event ID is not chosen by the author — it's *derived* from the event's content via a
deterministic process. This is what makes events tamper-proof: change any field and the ID
changes, which invalidates the signature.
The ID is the SHA-256 hash of a specific JSON serialization called the **canonical form**:
```json
[0, <pubkey>, <created_at>, <kind>, <tags>, <content>]
```
That leading `0` is a version number reserved for future use. The array is serialized as
compact JSON (no whitespace) and UTF-8 encoded before hashing.
```rust {file=coracle-lib/src/event.rs}
impl Event {
/// Serialize this event into the canonical form used for ID computation.
/// The result is: `[0, pubkey, created_at, kind, tags, content]`
pub fn serialize(&self) -> String {
serde_json::to_string(
&serde_json::json!([0, self.pubkey, self.created_at, self.kind, self.tags, self.content])
).expect("event serialization should never fail")
}
/// Compute the event ID by SHA-256 hashing the canonical serialization.
pub fn compute_id(&self) -> String {
let serialized = self.serialize();
let hash = Sha256::digest(serialized.as_bytes());
hex::encode(hash)
}
/// Check whether the event's `id` field matches the computed hash.
pub fn id_is_valid(&self) -> bool {
self.id == self.compute_id()
}
}
```
## Verifying signatures
Nostr uses **Schnorr signatures** over the secp256k1 curve — the same curve used by Bitcoin.
The signature is computed over the event ID (which is already a hash), and can be verified
using the author's public key.
This means anyone can verify an event's authenticity without contacting the author or any
trusted authority. The math guarantees it.
```rust {file=coracle-lib/src/event.rs}
impl Event {
/// Verify the event's Schnorr signature against its public key and ID.
/// Returns `true` if the signature is valid and the ID matches the content.
pub fn verify(&self) -> bool {
if !self.id_is_valid() {
return false;
}
let id_bytes = match hex::decode(&self.id) {
Ok(b) => b,
Err(_) => return false,
};
let sig_bytes = match hex::decode(&self.sig) {
Ok(b) => b,
Err(_) => return false,
};
let pubkey_bytes = match hex::decode(&self.pubkey) {
Ok(b) => b,
Err(_) => return false,
};
let msg = match secp256k1::Message::from_digest_slice(&id_bytes) {
Ok(m) => m,
Err(_) => return false,
};
let sig = match secp256k1::schnorr::Signature::from_slice(&sig_bytes) {
Ok(s) => s,
Err(_) => return false,
};
let pubkey = match secp256k1::XOnlyPublicKey::from_slice(&pubkey_bytes) {
Ok(k) => k,
Err(_) => return false,
};
sig.verify(&msg, &pubkey).is_ok()
}
}
```
## Creating events
To create an event, you provide the content fields and sign with a secret key. The process is:
1. Fill in `pubkey`, `created_at`, `kind`, `tags`, and `content`
2. Compute the `id` from the canonical serialization
3. Sign the `id` with the secret key to produce `sig`
```rust {file=coracle-lib/src/event.rs}
impl Event {
/// Create a new signed event from the given fields and secret key.
///
/// The `pubkey`, `id`, and `sig` fields are computed automatically.
pub fn new(
kind: u16,
content: &str,
tags: Vec<Vec<String>>,
created_at: u64,
secret_key: &secp256k1::SecretKey,
) -> Self {
let secp = secp256k1::Secp256k1::new();
let keypair = secp256k1::Keypair::from_secret_key(&secp, secret_key);
let (pubkey, _parity) = keypair.x_only_public_key();
let mut event = Event {
id: String::new(),
pubkey: hex::encode(pubkey.serialize()),
created_at,
kind,
tags,
content: content.to_string(),
sig: String::new(),
};
event.id = event.compute_id();
let id_bytes = hex::decode(&event.id).expect("just computed, must be valid hex");
let msg = secp256k1::Message::from_digest_slice(&id_bytes)
.expect("SHA-256 output is always 32 bytes");
let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair);
event.sig = hex::encode(sig.serialize());
event
}
}
```
With this, you can create and verify events — the atomic unit of all nostr communication. In
the next chapter, we'll look at how keys work in more detail and introduce signing abstractions
that go beyond a raw secret key.
+4
View File
@@ -0,0 +1,4 @@
# Summary
- [Introduction](01-introduction.md)
- [Events](02-events.md)
+8
View File
@@ -0,0 +1,8 @@
[book]
title = "Coracle: A Nostr Library"
description = "A literate programming approach to building nostr applications in Rust"
authors = ["Jonathan Staab"]
language = "en"
[build]
build-dir = "../../target/book"
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "coracle-content"
version = "0.1.0"
edition = "2021"
description = "Text parsing and rendering utilities for nostr"
[dependencies]
coracle-lib = { path = "../coracle-lib" }
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "coracle-lib"
version = "0.1.0"
edition = "2021"
description = "Struct definitions and stateless utilities related to nostr"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
secp256k1 = { version = "0.29", features = ["global-context", "serde"] }
hex = "0.4"
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "coracle-net"
version = "0.1.0"
edition = "2021"
description = "Networking utilities for working with nostr relays"
[dependencies]
coracle-lib = { path = "../coracle-lib" }
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "coracle-signer"
version = "0.1.0"
edition = "2021"
description = "Signer client/server utilities for nostr"
[dependencies]
coracle-lib = { path = "../coracle-lib" }
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "coracle-storage"
version = "0.1.0"
edition = "2021"
description = "Storage adapters for nostr on different platforms"
[dependencies]
coracle-lib = { path = "../coracle-lib" }
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "coracle-tangle"
version = "0.1.0"
edition = "2021"
description = "Literate programming tangle/weave tool for the coracle monorepo"
[[bin]]
name = "coracle-tangle"
path = "src/main.rs"
[dependencies]
pulldown-cmark = "0.12"
+177
View File
@@ -0,0 +1,177 @@
use pulldown_cmark::{Event, Parser, Tag, TagEnd, CodeBlockKind};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
fn extract_file_attr(info_string: &str) -> Option<&str> {
// Parse info strings like "rust {file=coracle-lib/src/event.rs}"
let brace_start = info_string.find('{')?;
let brace_end = info_string.find('}')?;
let attrs = &info_string[brace_start + 1..brace_end];
for attr in attrs.split(',') {
let attr = attr.trim();
if let Some(path) = attr.strip_prefix("file=") {
return Some(path.trim());
}
}
None
}
fn tangle(book_dir: &Path, output_base: &Path) {
let mut files: BTreeMap<PathBuf, String> = BTreeMap::new();
// Collect all .md files sorted by name
let mut md_files: Vec<PathBuf> = fs::read_dir(book_dir)
.expect("Failed to read book directory")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "md"))
.collect();
md_files.sort();
for md_path in &md_files {
let content = fs::read_to_string(md_path)
.unwrap_or_else(|_| panic!("Failed to read {}", md_path.display()));
let parser = Parser::new(&content);
let mut current_file: Option<PathBuf> = None;
let mut in_code_block = false;
for event in parser {
match event {
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
let info_str = info.as_ref();
if let Some(file_path) = extract_file_attr(info_str) {
current_file = Some(output_base.join(file_path));
in_code_block = true;
}
}
Event::Text(text) if in_code_block => {
if let Some(ref path) = current_file {
files.entry(path.clone())
.or_default()
.push_str(text.as_ref());
}
}
Event::End(TagEnd::CodeBlock) => {
if in_code_block {
// Add a newline between concatenated blocks
if let Some(ref path) = current_file {
files.entry(path.clone())
.or_default()
.push('\n');
}
current_file = None;
in_code_block = false;
}
}
_ => {}
}
}
}
if files.is_empty() {
println!("No tangled files found.");
return;
}
for (path, content) in &files {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.unwrap_or_else(|_| panic!("Failed to create directory {}", parent.display()));
}
fs::write(path, content)
.unwrap_or_else(|_| panic!("Failed to write {}", path.display()));
println!(" tangled: {}", path.display());
}
println!("\n{} files tangled.", files.len());
}
fn weave(book_dir: &Path, output_dir: &Path) {
// Preprocess markdown files: transform {file=...} annotations into
// readable captions for mdBook, writing to a temp directory.
let staging = output_dir.join("book-staging");
if staging.exists() {
fs::remove_dir_all(&staging).expect("Failed to clean staging directory");
}
let staging_src = staging.join("src");
fs::create_dir_all(&staging_src).expect("Failed to create staging src directory");
for entry in fs::read_dir(book_dir)
.expect("Failed to read book directory")
.filter_map(|e| e.ok())
{
let path = entry.path();
let filename = path.file_name().unwrap();
if path.file_name().is_some_and(|n| n == "book.toml") {
// book.toml goes at the staging root
let content = fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("Failed to read {}", path.display()));
let dest = staging.join(filename);
fs::write(&dest, content)
.unwrap_or_else(|_| panic!("Failed to write {}", dest.display()));
} else if path.extension().is_some_and(|ext| ext == "md") {
// Markdown files go into staging/src/
let content = fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("Failed to read {}", path.display()));
let transformed = transform_for_weave(&content);
let dest = staging_src.join(filename);
fs::write(&dest, transformed)
.unwrap_or_else(|_| panic!("Failed to write {}", dest.display()));
}
}
println!("Preprocessed markdown written to {}", staging.display());
println!("Run: mdbook build {}", staging.display());
}
fn transform_for_weave(content: &str) -> String {
let mut result = String::with_capacity(content.len());
for line in content.lines() {
if line.starts_with("```") && line.contains("{file=") {
// Extract language and file path
let lang = line.trim_start_matches('`')
.split_whitespace()
.next()
.unwrap_or("rust");
if let Some(file_path) = extract_file_attr(line) {
result.push_str(&format!("```{lang}\n// -> {file_path}\n"));
} else {
result.push_str(line);
result.push('\n');
}
} else {
result.push_str(line);
result.push('\n');
}
}
result
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let (command, book_dir) = match args.len() {
1 => ("tangle", "book"),
2 => (args[1].as_str(), "book"),
3 => (args[1].as_str(), args[2].as_str()),
_ => {
eprintln!("Usage: coracle-tangle [tangle|weave] [book-dir]");
std::process::exit(1);
}
};
let book_path = Path::new(book_dir);
let output_base = Path::new(".");
match command {
"tangle" => tangle(book_path, output_base),
"weave" => weave(book_path, Path::new("target")),
_ => {
eprintln!("Unknown command: {command}. Use 'tangle' or 'weave'.");
std::process::exit(1);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
tangle:
cargo run -p coracle-tangle -- tangle book/
build: tangle
cargo build --workspace --exclude coracle-tangle
weave: tangle
cargo run -p coracle-tangle -- weave book/
mdbook build target/book-staging/
check: tangle
cargo check --workspace --exclude coracle-tangle
clean:
rm -rf coracle-lib/src/ coracle-net/src/ coracle-signer/src/ coracle-content/src/ coracle-storage/src/
rm -rf target/book/ target/book-staging/
all: build weave