Files
marketplace/skills/page/scripts/page.py
T
naudachu 1815d91cdf feat: discussion artifacts as wiki pages, in two new layers
A discussion leaves behind a directory of markdown somewhere outside this
repo, and the only durable home for it is the Gitea wiki. Getting it
there by hand means re-deriving the same three things every time: what
each file should be called, where it goes, and whether the page already
exists. Two skills wrap that, along the split the repo already uses.

`skills/page` is domain, offline, stdlib-only, and knows nothing about
Gitea. It imports a directory into a space under `tmp/wiki/`, titles
every file, records the result in `.pages.json`, and writes the index.
`skills/wiki` is the bridge — `wikimap.py` translates, and the transport
is `_gitea.py`, the same one the issue side uses. There is no second
transport, and `tea` has no wiki subcommand to offer one.

The Gitea wiki is flat, and that fact shapes everything

There are no directories. A title of `a/b` is stored as one file named
`a%2Fb.md`, and Gitea escapes it by rules of its own: space becomes `-`,
`/` becomes `%2F`, and a literal `-` forces a trailing `.-` marker so the
two stay distinct. `Chain decisions — DC` under two levels of prefix
comes back as `Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC`.

So `sub_url` is the identity, it is read back from whatever the API
returned, and it is never constructed. One built by hand that is almost
right does not fail — it creates a second page and abandons the first.

And a real subdirectory committed into a wiki's git repository is a ghost:
the file exists, the API and the web UI do not see it. `folder/page.md`
in this repo's own wiki is one. Nothing here clones a wiki repo.

A title is a decision, not a derivation

Titles come from the first heading, because 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`. But they are derived
exactly once. A re-import replaces bodies and keeps titles, so editing a
heading cannot rename a published page — which would not rename it, it
would publish a second one.

`--retitle` opts in. It finds the prior entry by `source` rather than by
path, because the path is derived from the title and a retitle moves it;
looked up by path the page would read as new and the next push would
duplicate it. The old file goes, `sub_url` comes along, and `pushed` is
cleared — a rename can leave the body byte-identical, and push decides by
body hash alone, so a stale hash would skip the rename forever.

Ordering is a `NN-` file-name prefix and never reaches the title. `00-`
means "this is the directory's own page", and that page is named for the
directory, not for its own heading: a child's title has to extend its
parent's exactly, and `ideas/00-intro.md` opens with "Ideas for chain
business requirements".

Path collisions are reported and never resolved. Picking a winner is how
a discussion loses a document.

The index is navigation, not decoration

Nothing draws a tree from flat titles. `page_index.py` writes one as an
ordinary page, nested by title depth rather than by manifest path order —
those disagree, since on disk `Top/System.md` sorts before
`Top/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 use `sub_url` when there is one and Gitea's `[[Title|label]]`
syntax when there is not, so the order is push, rebuild, push.

The same stances as the issue store, for the same reasons

Pull overwrites, push is additive and never deletes, change detection is
one hash and there is no drift model. A page with no `sub_url` has never
been published, and that is a durable state.

Issues gain a `wiki:` field holding page titles — titles, not URLs, so
the reference stays in the domain. It already round-trips as a foreign
key; this documents it.

Verified against a live Gitea 1.26.1: create, update with a message,
unchanged-skip, prefix-filtered pull, byte-identical round trip, and the
per-page revision history carrying the operator's own words. The probe
pages were deleted afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 16:29:55 +05:00

524 lines
20 KiB
Python

#!/usr/bin/env python3
r"""
page.py — what a PAGE TREE is. The domain layer for wiki artifacts.
Not a command; the module the other page scripts build on. It knows how a
directory of markdown becomes a named, ordered tree of pages, and it knows
NOTHING about any wiki: no Gitea, no `tea`, no logins, no HTTP, no `sub_url`.
The layering rule is mechanically checkable — every import in this directory is
stdlib, and `subprocess` is not among them:
grep -rh '^import \|^from ' skills/page/scripts/ | sort -u
Delete skills/wiki/ entirely and this layer keeps working: a discussion's
artifacts organized into a tree on this machine are a finished thing, not a
draft waiting for an upload.
tmp/wiki/claude-skills/tea/ <- a SPACE
.pages.json <- the manifest
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. The space's name
("claude-skills/tea") is an opaque relative path to this module — it happens to
be an owner/repo pair, and this layer never learns that.
Why a manifest at all
---------------------
Because the wiki's own page identity is not derivable from a file path, and
guessing at it is how you get duplicate pages. The manifest is the record of
what each local file IS, written once at import or pull and never re-derived.
Domain keys in a manifest entry are `title` and `order`. Everything else —
`sub_url`, `sha`, `synced`, `pushed` — is written by the wiki layer, carried
through load/save verbatim, and never read here. That passthrough is what lets
one manifest describe both a local-only tree and a published one without the
domain learning a second vocabulary.
Titles
------
The title is the identity that matters, and `/` inside it is the ONLY
hierarchy there is — the wiki this feeds has no directories. A local path is
derived from the title, never the reverse:
title "Simple Chains/Ideas/Chain core"
path "Simple-Chains/Ideas/Chain-core.md"
That direction is deliberate. Deriving a title back from a path would have to
undo `-`-for-space, and `02-chain-core` proves it cannot: the dashes there are
real. So a title is chosen ONCE, at import or at pull, and then it is a fact in
the manifest. Renaming is an explicit act, not a side effect of editing a
heading.
Ordering
--------
A leading `NN-` on a file name is sort order and nothing else — it never
reaches the title. `order 0` is special: it is the directory's own page, so
`ideas/00-intro.md` becomes the page "…/Ideas" rather than a child of it.
"""
import hashlib
import json
import os
import re
# --------------------------------------------------------------------------
# where the cache lives
# --------------------------------------------------------------------------
# `<repo root>/tmp/wiki`, absolute, resolved once at import — the same anchoring
# rule the issue store uses, and for the same reason: a script's own location is
# a fact about the installation, cwd is a fact about the last `cd`. Walking up
# from __file__ hands every script in both layers one answer no matter where it
# is invoked from.
#
# The twenty lines below are duplicated from the issue domain rather than
# imported from it. Two domains that do not know about each other is worth more
# than the duplication is worth saving: skills/page must keep working with
# skills/issue deleted, exactly as skills/issue keeps working with skills/sync
# deleted.
STORE_PARTS = ("tmp", "wiki")
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
# git; the agents-sync hook only ever puts one at a repository root.
REPO_MARKERS = (".git", "AGENTS.md")
_HERE = os.path.dirname(os.path.abspath(__file__))
MANIFEST = ".pages.json"
# Written here; read here. Everything else in an entry belongs to the wiki
# layer and is passed through untouched.
DOMAIN_KEYS = ("title", "order", "source")
def repo_root(start):
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None."""
d = os.path.abspath(start)
while True:
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
return d
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
def store_root(start=None):
"""Absolute path of the wiki cache root.
`start` overrides the anchor so the resolution can be exercised against a
scratch tree. Outside a repository, cwd gets a turn, then the historical
cwd-relative location stands — made absolute so an error can name the
directory it really looked in."""
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
root = repo_root(anchor)
if root:
return os.path.join(root, *STORE_PARTS)
return os.path.abspath(os.path.join(*STORE_PARTS))
WIKI_ROOT = store_root()
def space_root(space, root=None):
"""Directory of one space. `space` is an opaque relative path — it may
contain `/` (it usually does) and is used as typed."""
return os.path.join(root or WIKI_ROOT, *space.split("/"))
# --------------------------------------------------------------------------
# names, titles, order
# --------------------------------------------------------------------------
# Characters a title may not carry into a path. `/` is absent on purpose: it is
# the hierarchy separator and is split on before this ever applies.
_UNSAFE = re.compile(r'[\\:*?"<>|\x00-\x1f]+')
# Inline code in a heading is markup, not a name: `Inventory — \`P-NN\`` is a
# page called "Inventory — P-NN", and a page list does not render markdown.
_MARKUP = re.compile(r"[`*_]+")
# Dropped from a PATH but kept in a title. An apostrophe in "Don't send to this
# one" belongs in the name and does not belong in something a shell has to
# quote.
_PATH_NOISE = re.compile(r"['‘’\"“”,]+")
_DASHES = re.compile(r"-{2,}")
_ORDER = re.compile(r"^(\d+)[-_. ]+(.*)$")
_HEADING = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$")
def order_of(name):
"""The `NN-` sort key on a file or directory name, or None.
`00-intro.md` -> 0, `02-chain-core.md` -> 2, `handoff.md` -> None. Zero is
a real answer and not None; callers distinguish them."""
m = _ORDER.match(strip_ext(name))
return int(m.group(1)) if m else None
def strip_ext(name):
stem, ext = os.path.splitext(name)
return stem if ext.lower() in (".md", ".markdown") else name
def strip_order(name):
"""`02-chain-core` -> `chain-core`; a name that is only digits is left
alone, because stripping it would leave nothing to call the page."""
m = _ORDER.match(strip_ext(name))
return m.group(2) if m and m.group(2) else strip_ext(name)
def title_from_name(name):
"""Fallback title: the file or directory name made readable.
`02-chain-core.md` -> `Chain core`. Only the first letter is raised —
title-casing would wreck `Q-01`, `sqlc`, `APNs`, and every other name that
already knows how it is spelled."""
t = strip_order(name).replace("_", " ").replace("-", " ").strip()
t = re.sub(r"\s+", " ", t)
return t[:1].upper() + t[1:] if t else t
def title_from_body(text):
"""The document's first markdown heading, or None.
Preferred over the file name because it is what a human wrote for a human:
`03-q-01-do-we-know-the-chain-participant-by-name.md` opens with
`## Q-01. Do We Know the Chain Participant by Name`, and there is no
mechanical route from the first string to the second. Only the first
heading is consulted, and only before any prose — a heading further down is
a section, not a name."""
for line in text.splitlines():
if not line.strip():
continue
m = _HEADING.match(line)
return m.group(1).strip() if m else None
return None
def sanitize_title(title):
"""Make a string safe to be one title SEGMENT.
`/` becomes `-`: a slash inside a heading would silently invent a level of
hierarchy that the author did not ask for, and inventing structure is worse
than losing a slash."""
t = _MARKUP.sub("", _UNSAFE.sub("", title.replace("/", "-")))
return re.sub(r"\s+", " ", t).strip(" .-") or "untitled"
def join_title(*parts):
"""Join title segments with the hierarchy separator, dropping empties."""
return "/".join(p for p in parts if p)
def path_segment(segment):
"""One title segment as one path component."""
s = _PATH_NOISE.sub("", _MARKUP.sub("", _UNSAFE.sub("", segment)))
s = re.sub(r"\s+", "-", s.replace("/", "-").strip())
return _DASHES.sub("-", s).strip("-.") or "untitled"
def path_for_title(title):
"""Relative path, inside a space, for a title. Always ends in `.md`."""
parts = [path_segment(p) for p in title.split("/") if p.strip()]
if not parts:
parts = ["untitled"]
return os.path.join(*parts) + ".md"
# --------------------------------------------------------------------------
# the manifest
# --------------------------------------------------------------------------
def blank_manifest(space):
return {"space": space, "pages": {}}
def manifest_path(space, root=None):
return os.path.join(space_root(space, root), MANIFEST)
def load_manifest(space, root=None):
"""The space's manifest, or a blank one.
A missing manifest and an empty one are the same thing to every caller here
— but they are NOT the same thing to a caller deciding whether to print
"no such space". That distinction is `os.path.isdir(space_root(...))`, and
the commands make it themselves rather than reading it out of a dict."""
p = manifest_path(space, root)
if not os.path.isfile(p):
return blank_manifest(space)
with open(p, encoding="utf-8") as f:
m = json.load(f)
m.setdefault("space", space)
m.setdefault("pages", {})
return m
def save_manifest(manifest, root=None):
"""Write the manifest, keys sorted, one page per line-block.
Sorted and indented because this file lands in a diff every time anything
syncs, and a diff nobody can read is a diff nobody checks."""
p = manifest_path(manifest["space"], root)
os.makedirs(os.path.dirname(p), exist_ok=True)
ordered = {"space": manifest["space"], "pages": {}}
for path, e in sorted(manifest.get("pages", {}).items()):
ordered["pages"][path] = {k: e[k] for k in DOMAIN_KEYS if k in e}
ordered["pages"][path].update(
{k: v for k, v in sorted(e.items()) if k not in DOMAIN_KEYS})
with open(p, "w", encoding="utf-8") as f:
json.dump(ordered, f, ensure_ascii=False, indent=2, sort_keys=False)
f.write("\n")
return p
def entry(title, order=None, source=None, **extra):
"""A manifest entry. Domain keys first, passthrough after — the same
render order the issue layer uses, for the same reason: it makes a diff of
the file readable."""
e = {"title": title}
if order is not None:
e["order"] = order
if source is not None:
e["source"] = source
e.update({k: v for k, v in extra.items() if v is not None})
return e
def find_by_source(manifest, source, prefix=""):
"""(relpath, entry) for the page imported from this source file, or
(None, None).
The path is derived from the title, so a retitle moves it — and looking a
page up by its new path would find nothing, treat it as new, and publish a
duplicate beside the page it was meant to rename. Source is the one link
that survives a rename, which is why it is recorded at all.
Scoped by title prefix, so importing the same directory twice under two
prefixes gives two independent trees rather than one fighting over itself.
"""
for path, e in manifest.get("pages", {}).items():
if e.get("source") != source:
continue
if prefix and not (e.get("title", "") == prefix
or e.get("title", "").startswith(prefix + "/")):
continue
return path, e
return None, None
def sort_key(relpath, e):
"""Order a tree for display and for an index.
Directory by directory, `order` first and unnumbered pages after — an
explicit `NN-` is a decision, its absence is not. Ties break on title so
the output is stable."""
d = os.path.dirname(relpath)
o = e.get("order")
return (d, 0 if o is not None else 1, o if o is not None else 0,
e.get("title", relpath))
def sorted_pages(manifest):
"""[(relpath, entry)] in tree order."""
return sorted(manifest.get("pages", {}).items(),
key=lambda kv: sort_key(kv[0], kv[1]))
def by_title(manifest):
return {e["title"]: (p, e) for p, e in manifest.get("pages", {}).items()
if e.get("title")}
def children_of(manifest, prefix):
"""Every page at or under a title prefix.
The wiki this feeds is flat, so "children" is a prefix test on the title
and nothing more — there is no tree to walk, only a naming convention to
trust."""
out = []
for p, e in sorted_pages(manifest):
t = e.get("title", "")
if t == prefix or t.startswith(prefix + "/"):
out.append((p, e))
return out
def body_hash(text):
"""sha1 of the exact bytes a page would be published as.
This is the whole of change detection: a page is worth pushing when what is
on disk hashes differently from what was pushed last. No timestamps, no
drift model — the same stance the issue store takes."""
if isinstance(text, str):
text = text.encode("utf-8")
return hashlib.sha1(text).hexdigest()
# --------------------------------------------------------------------------
# importing a directory of markdown
# --------------------------------------------------------------------------
SKIP_DIRS = {".git", ".svn", "__pycache__", "node_modules"}
MD_EXT = (".md", ".markdown")
def walk_markdown(src):
"""Every markdown file under `src`, as paths relative to it, depth first
and sorted so an import is reproducible."""
out = []
for dirpath, dirnames, filenames in os.walk(src):
dirnames[:] = sorted(d for d in dirnames
if d not in SKIP_DIRS and not d.startswith("."))
rel = os.path.relpath(dirpath, src)
rel = "" if rel == "." else rel
for f in sorted(filenames):
if f.lower().endswith(MD_EXT) and not f.startswith("."):
out.append(os.path.join(rel, f) if rel else f)
return out
def title_for_source(relpath, text, prefix=""):
"""The title a source file gets on import.
Three rules, in this order, and the reference doc spells out why:
1. `order 0` (`00-intro.md`, or a literal `index`/`readme`) is the page for
the directory it sits in. Its title comes from the DIRECTORY name, not
from its own heading — a child's title must extend its parent's exactly,
and `ideas/00-intro.md` opens with "Ideas for chain business
requirements", which no child would ever be prefixed by.
2. Any other file takes its first heading, sanitized.
3. No heading: the file name, made readable.
"""
parts = relpath.replace(os.sep, "/").split("/")
name = parts[-1]
dirs = [sanitize_title(title_from_name(d)) for d in parts[:-1]]
stem = strip_ext(name).lower()
if order_of(name) == 0 or stem in ("index", "readme"):
# The directory's own page. At the root of the import that is the
# prefix itself.
return join_title(prefix, *dirs)
own = title_from_body(text)
own = sanitize_title(own) if own else sanitize_title(title_from_name(name))
return join_title(prefix, *dirs, own)
def plan_import(src, prefix="", read=None):
"""Work out what an import would produce, without writing anything.
Returns (pages, collisions):
pages [{"source", "path", "title", "order", "text"}] in tree order
collisions [(path, [title, title, ...])] — two sources landing on one
file. Reported, never resolved: the wiki would end up with
two pages fighting over one local copy, and picking a winner
for the operator is how a discussion loses a document."""
def default_read(p):
with open(p, encoding="utf-8") as f:
return f.read()
read = read or default_read
pages, seen = [], {}
for rel in walk_markdown(src):
source = os.path.join(src, rel)
text = read(source)
title = title_for_source(rel, text, prefix)
path = path_for_title(title)
seen.setdefault(path, []).append(title)
# `source` is kept relative to the import root, not absolute: it is the
# only durable link between a file on the far side and the page it
# became, and it has to survive the artifacts directory being moved.
pages.append({"source": source, "rel": rel.replace(os.sep, "/"),
"path": path, "title": title,
"order": order_of(os.path.basename(rel)), "text": text})
pages.sort(key=lambda p: sort_key(p["path"], p))
collisions = [(p, t) for p, t in sorted(seen.items()) if len(t) > 1]
return pages, collisions
# --------------------------------------------------------------------------
# rendering
# --------------------------------------------------------------------------
def title_tree(manifest, prefix=""):
"""Group pages into a parent -> children map keyed by title.
Built from the titles, not from the manifest's path order. Those two
disagree: on disk `Simple-Chains/System.md` sorts before
`Simple-Chains/Ideas/Scale.md`, while in the hierarchy Scale is a
grandchild of Simple Chains and System is a child. Nesting has to follow
the titles, because the titles are the only hierarchy there is.
A parent with no page of its own still gets a node: `Simple Chains/Parked`
can have children while nothing is published at that title, and dropping
its children because it is missing would hide them entirely."""
kids, entries = {}, {}
for _, e in manifest.get("pages", {}).items():
title = e.get("title")
if not title:
continue
if prefix and not (title == prefix or title.startswith(prefix + "/")):
continue
entries[title] = e
parts = title.split("/")
# Every ancestor gets a node, so a gap in the chain does not orphan a
# subtree.
for i in range(len(parts), 0, -1):
kids.setdefault("/".join(parts[:i - 1]), set()).add("/".join(parts[:i]))
return kids, entries
def render_index(manifest, prefix="", heading=None):
"""A table-of-contents page for a space or a subtree.
Nested markdown list, indented by title depth. The wiki is flat and will
not draw this for you, so the index IS the navigation.
Links: a published page is linked by its `sub_url`, which is the only
address Gitea guarantees. A page that has never been pushed has no sub_url
yet, so it gets Gitea's own `[[Title]]` wiki-link syntax — which resolves
the escaping itself, at render time, on the server. Rebuilding the index
after a push upgrades those links to exact ones."""
kids, entries = title_tree(manifest, prefix)
lines = ["# %s" % (heading or prefix or "Contents"), ""]
def order_key(title):
e = entries.get(title) or {}
o = e.get("order")
return (0 if o is not None else 1, o if o is not None else 0, title)
def walk(node, depth):
for child in sorted(kids.get(node, ()), key=order_key):
e = entries.get(child) or {}
label = child.split("/")[-1]
sub = e.get("sub_url")
link = "[%s](%s)" % (label, sub) if sub else "[[%s|%s]]" % (child, label)
lines.append("%s- %s" % (" " * depth, link))
walk(child, depth + 1)
walk(prefix, 0)
lines.append("")
return "\n".join(lines)
def tree_lines(manifest, mark=None):
"""The space as an ascii tree, for a terminal.
`mark(relpath, entry)` returns a short state tag shown after the title —
the wiki layer passes sync state through it, and this module stays unaware
of what the tags mean."""
out, last_dir = [], None
for path, e in sorted_pages(manifest):
d = os.path.dirname(path)
if d != last_dir:
out.append("%s/" % d if d else ".")
last_dir = d
tag = mark(path, e) if mark else ""
out.append(" %-40s %s%s" % (os.path.basename(path),
e.get("title", ""),
(" " + tag) if tag else ""))
return out