From 2d36400ba0391233da059934ca4d8c89cc81eda5 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 7 Apr 2026 09:55:41 -0700 Subject: [PATCH] Set up literate programming structure --- .gitignore | 11 ++ Cargo.lock | 321 +++++++++++++++++++++++++++++++++++++ Cargo.toml | 10 ++ README.md | 77 +++++++++ book/01-introduction.md | 49 ++++++ book/02-events.md | 202 +++++++++++++++++++++++ book/SUMMARY.md | 4 + book/book.toml | 8 + coracle-content/Cargo.toml | 8 + coracle-lib/Cargo.toml | 12 ++ coracle-net/Cargo.toml | 8 + coracle-signer/Cargo.toml | 8 + coracle-storage/Cargo.toml | 8 + coracle-tangle/Cargo.toml | 12 ++ coracle-tangle/src/main.rs | 177 ++++++++++++++++++++ justfile | 18 +++ 16 files changed, 933 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 book/01-introduction.md create mode 100644 book/02-events.md create mode 100644 book/SUMMARY.md create mode 100644 book/book.toml create mode 100644 coracle-content/Cargo.toml create mode 100644 coracle-lib/Cargo.toml create mode 100644 coracle-net/Cargo.toml create mode 100644 coracle-signer/Cargo.toml create mode 100644 coracle-storage/Cargo.toml create mode 100644 coracle-tangle/Cargo.toml create mode 100644 coracle-tangle/src/main.rs create mode 100644 justfile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..035db3f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5c63a3d --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..723f87d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[workspace] +resolver = "2" +members = [ + "coracle-tangle", + "coracle-lib", + "coracle-net", + "coracle-signer", + "coracle-content", + "coracle-storage", +] diff --git a/README.md b/README.md new file mode 100644 index 0000000..95ba5a4 --- /dev/null +++ b/README.md @@ -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/) +``` diff --git a/book/01-introduction.md b/book/01-introduction.md new file mode 100644 index 0000000..63e8e14 --- /dev/null +++ b/book/01-introduction.md @@ -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. diff --git a/book/02-events.md b/book/02-events.md new file mode 100644 index 0000000..16617a0 --- /dev/null +++ b/book/02-events.md @@ -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", ], ["p", ]. + pub tags: Vec>, + /// 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, , , , , ] +``` + +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>, + 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. diff --git a/book/SUMMARY.md b/book/SUMMARY.md new file mode 100644 index 0000000..f37be86 --- /dev/null +++ b/book/SUMMARY.md @@ -0,0 +1,4 @@ +# Summary + +- [Introduction](01-introduction.md) +- [Events](02-events.md) diff --git a/book/book.toml b/book/book.toml new file mode 100644 index 0000000..f1895c8 --- /dev/null +++ b/book/book.toml @@ -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" diff --git a/coracle-content/Cargo.toml b/coracle-content/Cargo.toml new file mode 100644 index 0000000..1936a41 --- /dev/null +++ b/coracle-content/Cargo.toml @@ -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" } diff --git a/coracle-lib/Cargo.toml b/coracle-lib/Cargo.toml new file mode 100644 index 0000000..5ec95e1 --- /dev/null +++ b/coracle-lib/Cargo.toml @@ -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" diff --git a/coracle-net/Cargo.toml b/coracle-net/Cargo.toml new file mode 100644 index 0000000..5cc985d --- /dev/null +++ b/coracle-net/Cargo.toml @@ -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" } diff --git a/coracle-signer/Cargo.toml b/coracle-signer/Cargo.toml new file mode 100644 index 0000000..fdcbfae --- /dev/null +++ b/coracle-signer/Cargo.toml @@ -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" } diff --git a/coracle-storage/Cargo.toml b/coracle-storage/Cargo.toml new file mode 100644 index 0000000..09d637f --- /dev/null +++ b/coracle-storage/Cargo.toml @@ -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" } diff --git a/coracle-tangle/Cargo.toml b/coracle-tangle/Cargo.toml new file mode 100644 index 0000000..ba658cc --- /dev/null +++ b/coracle-tangle/Cargo.toml @@ -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" diff --git a/coracle-tangle/src/main.rs b/coracle-tangle/src/main.rs new file mode 100644 index 0000000..6604739 --- /dev/null +++ b/coracle-tangle/src/main.rs @@ -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 = BTreeMap::new(); + + // Collect all .md files sorted by name + let mut md_files: Vec = 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 = 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 = 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); + } + } +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..eba5d63 --- /dev/null +++ b/justfile @@ -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