# The page-tree format Canonical. Everything about how a discussion's artifacts become named, ordered, navigable pages lives here. The scripts implement this document; when they disagree, this document is right. ## The one fact that shapes everything: the wiki is flat Gitea's wiki has no directories. It has a list of pages, each stored as one file whose name Gitea escapes from the title: | title | file Gitea writes | `sub_url` | |---|---|---| | `Abstract Issue` | `Abstract-Issue.md` | `Abstract-Issue` | | `zz-probe/child` | `zz-probe%2Fchild.-.md` | `zz-probe%2Fchild.-` | | `Simple Chains/Parked/Chain decisions — DC` | `Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC.md` | same, minus `.md` | Three rules are visible in that table, and all three are Gitea's to change: space becomes `-`; `/` becomes `%2F`; a **literal** `-` in the title forces a trailing `.-` marker so it stays distinguishable from a space. Two consequences run through the whole design. **Hierarchy lives in the title and nowhere else.** `/` inside a title is the only nesting there is. A real subdirectory committed into the wiki's git repository — `folder/page.md` — is invisible to the API and to the web UI. It is a ghost file. Never create one. **`sub_url` is identity and is never constructed.** It is read back from whatever the API returned and stored in the manifest. A hand-built one that is almost right does not fail loudly; it creates a second page and abandons the first. ## The space ``` tmp/wiki/claude-skills/tea/ a SPACE .pages.json the manifest Simple-Chains.md title: Simple Chains (the index) Simple-Chains/ Ideas.md title: Simple Chains/Ideas Ideas/ Chain-core.md title: Simple Chains/Ideas/Chain core ``` A space is a directory holding a page tree and one manifest. Its name is normally the `owner/repo` it syncs with, and to the domain layer that is an opaque relative path — `--space docs` and `--space a/b/c` are equally valid. The path is `/tmp/wiki`, resolved from `page.py`'s own location and not from the working directory. `--out` overrides it and is used exactly as typed. Nothing creates a space as a side effect of a write: the scripts say so on stderr when they make one. ## The manifest `.pages.json`, one entry per page, keyed by the file's path inside the space. ```json { "space": "claude-skills/tea", "pages": { "Simple-Chains/Ideas/Chain-core.md": { "title": "Simple Chains/Ideas/Chain core", "order": 2, "pushed": "9a1ab2e3bfd45f7c7ba323d9d8cd59642d6f0540", "remote-updated": "2026-08-10T11:15:39Z", "sha": "fc8ec1779d910850f49bfef60dd5a0e737bbdc8a", "sub_url": "Simple-Chains%2FIdeas%2FChain-core", "synced": "2026-08-10T11:15:39Z", "url": "https://git.noodles.cam/…/wiki/Simple-Chains%2FIdeas%2FChain-core" } } } ``` | key | owner | meaning | |---|---|---| | `title` | domain | the page's name; `/` is hierarchy | | `order` | domain | sort key from a `NN-` file-name prefix; absent when there was none | | `sub_url` | wiki | Gitea's address for the page — **the identity** | | `pushed` | wiki | sha1 of the bytes last published; the whole of change detection | | `sha` | wiki | the wiki commit the local copy came from | | `synced` | wiki | when this copy was fetched or pushed | | `url` | wiki | browser link | | `remote-updated` | wiki | the page's last commit date in the wiki | The domain layer writes `title` and `order`, carries everything else through load and save verbatim, and never reads it. A page with no `sub_url` has never been published — a complete state, not a pending one, exactly as `origin: local` is for an issue. ## How a source file gets its title Applied at import, once. Three rules, in order: 1. **`order 0`, or a file literally named `index` / `readme`, is the page for the directory it sits in.** `ideas/00-intro.md` becomes `…/Ideas`, not a child of it. Its title comes from the **directory name**, never from its own heading — a child's title has to extend its parent's exactly, and that file opens with "Ideas for chain business requirements", which no child would ever be prefixed by. 2. **Otherwise the file's first markdown heading**, sanitized. It is what a human wrote for a human: there is no mechanical route from `03-q-01-do-we-know-the-chain-participant-by-name.md` to `Q-01. Do We Know the Chain Participant by Name`. 3. **No heading: the file name**, made readable — `NN-` stripped, `-` and `_` to spaces, first letter raised. Only the first letter: title-casing would wreck `Q-01`, `sqlc`, and `APNs`. Sanitizing a title drops markdown markup (`` ` ``, `*`, `_` — a page list does not render markdown) and turns `/` into `-`, because a slash inside a heading would silently invent a level of hierarchy the author did not ask for. ### A title is a decision, not a derivation Once a page is in the manifest its title stays put. Re-importing replaces the body and leaves the title alone, so editing a heading cannot rename a page — which matters because renaming a **published** page does not move it, it creates a second one and orphans the first. `--retitle` opts into that explicitly. The reverse direction does not exist. A path is derived from a title; a title is never derived from a path. `02-chain-core` proves why: those dashes are real, and undoing "space became dash" would eat them. ## Ordering A leading `NN-` on a file name is sort order and nothing else — it never reaches the title. `00` is special and means "this is the directory's own page". Pages with an order sort before pages without one: an explicit `NN-` is a decision, its absence is not. The wiki cannot hold ordering, so `order` is local-only and survives a pull. ## Paths A path is one component per title segment, spaces to `-`, with characters a shell has to quote dropped — apostrophes and quotes and commas. `Don't send to this one` keeps its apostrophe in the title and loses it in `Dont-send-to-this-one.md`. Two titles can land on one path. That is reported and never resolved automatically: picking a winner is how a discussion loses a document. Rename a source, or rename the page in the wiki, and run it again. ## The index page The wiki will not draw a tree from titles, so an index page is the navigation, not a nicety. `page_index.py` writes one as an ordinary page in the space — it is pushed by the same command as everything else. Nesting follows the **titles**, not the manifest's path order; those two disagree, because on disk `Simple-Chains/System.md` sorts before `Simple-Chains/Ideas/Scale.md` while in the hierarchy System is a child and Scale a grandchild. A parent with no page of its own still gets a node, so its children are not hidden. Links: a published page is linked by its `sub_url`, the only address Gitea guarantees. A page that has never been pushed gets Gitea's `[[Title|label]]` wiki-link syntax, which resolves the escaping on the server at render time. Rebuilding the index after a push upgrades those links to exact ones — so the order is **push, rebuild the index, push again**. ## What the sync does not do - **No merge.** A pull overwrites the local body. `synced` tells you how old your copy is; re-pull when it matters. - **No drift tracking.** `pushed` answers one question — is the local file different from what was published — and answers it with a hash. - **No deletes.** Pushing is additive. A page removed locally stays in the wiki; removing a published page is an explicit act, done in the web UI or with a `DELETE` through `/tea:use`.