"""Quill: a read-only wiki whose spaces are Git repositories. Each space is a Git repository on a Gitea server. Quill only reads and renders; people and agents change a space by pushing to its repo (branch -> PR -> merge). Adding a space = one entry in "spaces" in the config file (see configure()). Posts are: * Markdown files (*.md, *.markdown) anywhere in the repo except README/LICENSE-style files. Optional front matter (--- title/date/author ---); otherwise the first "# " heading is the title and a YYYY-MM-DD filename prefix is the date. *
blocks inside index.html (a static site published from the repo). """ from __future__ import annotations import html import json import re import threading import time import urllib.error import urllib.parse from html.parser import HTMLParser WIKI_NAME = "Quill" WIKI_PATHS = ("/quill", "/wiki") # exact paths and anything below them serve the Quill page # Space registry, set by configure() from the config file. key: URL slug (/quill/). # repo: owner/name on the Gitea. branch: None = the repo's default branch. # public_url: optional published site for the space. SPACES: list[dict] = [] # Where the Git web UI for the spaces lives (links to sources and history). "/gitea" when Quill is # served next to a Gitea proxy; a full URL such as "https://gitea.example.com" otherwise. GIT_WEB_BASE = "/gitea" KEY_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,40}$") SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,160}$") SKIP_MD = {"readme", "license", "licence", "contributing", "changelog", "code_of_conduct", "security"} MAX_FILES = 300 CACHE_SECONDS = 15 _CACHE: dict[str, tuple[float, str, dict]] = {} _CACHE_LOCK = threading.Lock() def configure(spaces: list[dict], git_web_base: str = "/gitea") -> None: """Replace the space registry (validated) and the Git web base.""" global GIT_WEB_BASE clean = [] for item in spaces: if not isinstance(item, dict): raise ValueError("space must be an object") key, repo = str(item.get("key") or ""), str(item.get("repo") or "") if not KEY_RE.fullmatch(key) or not re.fullmatch(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+", repo): raise ValueError(f"bad space key/repo: {key!r} {repo!r}") clean.append({ "key": key, "name": str(item.get("name") or key), "repo": repo, "branch": item.get("branch") or None, "description": str(item.get("description") or ""), "public_url": str(item.get("public_url") or ""), }) SPACES[:] = clean GIT_WEB_BASE = (git_web_base or "/gitea").rstrip("/") with _CACHE_LOCK: _CACHE.clear() def space_by_key(key: str) -> dict | None: for space in SPACES: if space["key"] == key: return space return None def public_space(space: dict) -> dict: return { "key": space["key"], "name": space["name"], "repo": space["repo"], "description": space.get("description") or "", "public_url": space.get("public_url") or "", "git_url": GIT_WEB_BASE + "/" + space["repo"], } def is_wiki_path(path: str) -> bool: return any(path == p or path.startswith(p + "/") for p in WIKI_PATHS) # ---------------------------------------------------------------- helpers def _slugify(text: str, limit: int = 80) -> str: s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") return s[:limit].strip("-") or "post" def _safe_href(href: str | None, space: dict, branch: str, base_dir: str = "") -> str | None: if not href: return None href = href.strip() if re.match(r"^(https?://|mailto:)", href, re.I): return href if not re.search(r"[\s<>\"']", href) else None if href.startswith("#"): return href if re.fullmatch(r"#[A-Za-z0-9_-]{1,80}", href) else None if re.match(r"^[a-z][a-z0-9+.-]*:", href, re.I) or href.startswith("//"): return None # javascript:, data:, protocol-relative # repo-relative link -> the file in Git (through the signed-in Gitea proxy) target = urllib.parse.urljoin("/" + base_dir.strip("/") + "/" if base_dir else "/", href) target = target.lstrip("/") if ".." in target: return None return GIT_WEB_BASE + "/" + space["repo"] + "/src/branch/" + urllib.parse.quote(branch) + "/" + urllib.parse.quote(target, safe="/#") # ---------------------------------------------------------------- markdown (small, escape-first) _INLINE_CODE = re.compile(r"`([^`]+)`") _LINK = re.compile(r"\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^)]*")?\)") _BOLD = re.compile(r"(\*\*|__)(?=\S)(.+?)(?<=\S)\1") _ITAL = re.compile(r"(?])(https?://[^\s<]+[^\s<.,;:!?)\]])") def _inline(text: str, space: dict, branch: str, base_dir: str) -> str: text = html.escape(text, quote=True) codes: list[str] = [] def keep_code(m): codes.append("" + m.group(1) + "") return f"\x00{len(codes) - 1}\x00" text = _INLINE_CODE.sub(keep_code, text) links: list[str] = [] def keep_link(m): label, raw = m.group(1), html.unescape(m.group(2)) href = _safe_href(raw, space, branch, base_dir) if not href: return label ext = ' rel="noopener" target="_blank"' if href.startswith("http") else "" links.append(f'{label}') return f"\x01{len(links) - 1}\x01" text = text.replace("!" + "[", "[") # images render as links text = _LINK.sub(keep_link, text) def auto(m): url = html.unescape(m.group(1)) links.append(f'{m.group(1)}') return f"\x01{len(links) - 1}\x01" text = _AUTOLINK.sub(auto, text) text = _BOLD.sub(r"\2", text) text = _ITAL.sub(r"\2", text) text = re.sub(r"\x01(\d+)\x01", lambda m: links[int(m.group(1))], text) text = re.sub(r"\x00(\d+)\x00", lambda m: codes[int(m.group(1))], text) return text def render_markdown(src: str, space: dict, branch: str, base_dir: str = "") -> str: lines = src.replace("\r\n", "\n").replace("\r", "\n").split("\n") out: list[str] = [] para: list[str] = [] i = 0 def flush(): if para: out.append("

" + _inline(" ".join(s.strip() for s in para), space, branch, base_dir) + "

") para.clear() while i < len(lines): line = lines[i] stripped = line.strip() fence = re.match(r"^(```|~~~)", stripped) if fence: flush() mark = fence.group(1) i += 1 code = [] while i < len(lines) and not lines[i].strip().startswith(mark): code.append(lines[i]) i += 1 out.append("
" + html.escape("\n".join(code)) + "
") i += 1 continue if not stripped: flush() i += 1 continue h = re.match(r"^(#{1,6})\s+(.*?)\s*#*\s*$", stripped) if h: flush() level = min(len(h.group(1)) + 1, 6) # the post title is the page h1 out.append(f"" + _inline(h.group(2), space, branch, base_dir) + f"") i += 1 continue if re.fullmatch(r"(\*\s*){3,}|(-\s*){3,}|(_\s*){3,}", stripped): flush() out.append("
") i += 1 continue if stripped.startswith(">"): flush() quote = [] while i < len(lines) and lines[i].strip().startswith(">"): quote.append(re.sub(r"^\s*>\s?", "", lines[i])) i += 1 out.append("
" + render_markdown("\n".join(quote), space, branch, base_dir) + "
") continue lm = re.match(r"^\s*([-*+]|\d+[.)])\s+", line) if lm: flush() ordered = lm.group(1)[0].isdigit() tag = "ol" if ordered else "ul" items: list[str] = [] while i < len(lines): m = re.match(r"^\s*([-*+]|\d+[.)])\s+(.*)$", lines[i]) if m and (m.group(1)[0].isdigit()) == ordered: items.append(m.group(2)) elif items and lines[i].strip() and lines[i].startswith((" ", "\t")): items[-1] += " " + lines[i].strip() else: break i += 1 out.append(f"<{tag}>" + "".join("
  • " + _inline(it, space, branch, base_dir) + "
  • " for it in items) + f"") continue para.append(line) i += 1 flush() return "\n".join(out) def _front_matter(src: str) -> tuple[dict, str]: meta: dict = {} text = src.lstrip("\ufeff") if text.startswith("---\n") or text.startswith("---\r\n"): end = re.search(r"\n---\s*(\n|$)", text[3:]) if end: block = text[4:3 + end.start()] for row in block.splitlines(): if ":" in row: k, v = row.split(":", 1) v = v.strip() if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": v = v[1:-1] meta[k.strip().lower()] = v text = text[3 + end.end():] return meta, text def _strip_md(text: str) -> str: text = re.sub(r"`([^`]*)`", r"\1", text) text = re.sub(r"!?\[([^\]]*)\]\([^)]*\)", r"\1", text) text = re.sub(r"[*_#>]+", "", text) return re.sub(r"\s+", " ", text).strip() def _summary(text: str, limit: int = 220) -> str: text = re.sub(r"\s+", " ", text).strip() return text if len(text) <= limit else text[: limit - 1].rsplit(" ", 1)[0] + "\u2026" def parse_markdown_post(path: str, src: str, space: dict, branch: str) -> dict: meta, body = _front_matter(src) name = path.rsplit("/", 1)[-1] stem = re.sub(r"\.(md|markdown)$", "", name, flags=re.I) title = meta.get("title") m = re.search(r"^\s*#\s+(.+?)\s*#*\s*$", body, re.M) if not title and m: title = _strip_md(m.group(1)) if m and (meta.get("title") is None or _strip_md(m.group(1)) == title): body = body[: m.start()] + body[m.end():] # do not repeat the title in the body date = meta.get("date") or "" dm = re.match(r"^(\d{4}-\d{2}-\d{2})[-_]?(.*)$", stem) if not date and dm: date = dm.group(1) if not title: title = (dm.group(2) if dm and dm.group(2) else stem).replace("-", " ").replace("_", " ").strip().capitalize() or stem base_dir = path.rsplit("/", 1)[0] if "/" in path else "" first_para = next((p for p in re.split(r"\n\s*\n", body) if p.strip() and not p.strip().startswith(("#", "```", "---", ">"))), "") slug = re.sub(r"[^A-Za-z0-9._-]+", "-", re.sub(r"\.(md|markdown)$", "", path, flags=re.I).replace("/", "--")).strip("-") return { "slug": slug or _slugify(title), "title": title[:200], "date": date[:40], "author": (meta.get("author") or "")[:80], "summary": _summary(_strip_md(meta.get("summary") or first_para)), "source": path, "kind": "markdown", "html": render_markdown(body, space, branch, base_dir), } # ---------------------------------------------------------------- html articles (whitelist sanitizer) _ALLOWED = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "a", "em", "strong", "b", "i", "code", "pre", "blockquote", "br", "hr", "figure", "figcaption", "time", "small"} _VOID = {"br", "hr"} _DROP = {"script", "style", "template", "iframe", "object", "embed", "noscript", "svg", "math", "head", "title"} _DATE_RE = re.compile(r"\b(\d{1,2}\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{4}|\d{4}-\d{2}-\d{2})\b") class _ArticleParser(HTMLParser): """Collects
    blocks as sanitized HTML, plus intro paragraphs outside articles.""" def __init__(self, space: dict, branch: str): super().__init__(convert_charrefs=True) self.space, self.branch = space, branch self.articles: list[list[str]] = [] self.intro: list[str] = [] self.site_title = "" self._in_article = 0 self._drop = 0 self._buf: list[str] | None = None self._intro_p: list[str] | None = None self._h1: list[str] | None = None self._ended = False def handle_starttag(self, tag, attrs): if tag in _DROP: self._drop += 1 return if self._drop: return if tag == "article" and not self._ended: self._in_article += 1 if self._in_article == 1: self._buf = [] self.articles.append(self._buf) return if self._in_article and self._buf is not None and tag in _ALLOWED: if tag == "a": href = _safe_href(dict(attrs).get("href"), self.space, self.branch) if href: ext = ' rel="noopener" target="_blank"' if href.startswith("http") else "" self._buf.append(f'') else: self._buf.append("") elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"): self._buf.append(f"<{tag}>") else: self._buf.append(f"<{tag}>") return if not self._in_article and not self._ended: if tag == "p": self._intro_p = [] elif tag == "h1" and not self.site_title: self._h1 = [] def handle_endtag(self, tag): if tag in _DROP: self._drop = max(0, self._drop - 1) return if self._drop: return if tag == "html": self._ended = True # stray text after is not part of the page if tag == "article" and self._in_article: self._in_article -= 1 if not self._in_article: self._buf = None return if self._in_article and self._buf is not None and tag in _ALLOWED and tag not in _VOID: self._buf.append(f"") return if tag == "p" and self._intro_p is not None: text = re.sub(r"\s+", " ", "".join(self._intro_p)).strip() if text: self.intro.append(text) self._intro_p = None if tag == "h1" and self._h1 is not None: self.site_title = re.sub(r"\s+", " ", "".join(self._h1)).strip() self._h1 = None def handle_data(self, data): if self._drop or self._ended: return if self._in_article and self._buf is not None: self._buf.append(html.escape(data, quote=False)) elif self._intro_p is not None: self._intro_p.append(data) elif self._h1 is not None: self._h1.append(data) def _text_of(fragment: str) -> str: return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip() def parse_html_articles(path: str, src: str, space: dict, branch: str) -> tuple[list[dict], list[str]]: parser = _ArticleParser(space, branch) try: parser.feed(src) parser.close() except Exception: return [], [] posts = [] seen: set[str] = set() for idx, parts in enumerate(parser.articles): body = "".join(parts).strip() hm = re.search(r"<(h[1-6])>(.*?)", body, re.S) title = _text_of(hm.group(2)) if hm else "" if hm: body = body[: hm.start()] + body[hm.end():] date, author = "", "" pm = re.search(r"

    (.*?)

    ", body, re.S) if pm: first = _text_of(pm.group(1)) dm = _DATE_RE.search(first) if dm and len(first) < 160: date = dm.group(1) am = re.search(r"(?:written|posted)\s+by\s+([^.]+)", first, re.I) author = am.group(1).strip() if am else "" body = body[: pm.start()] + body[pm.end():] title = title or f"Untitled {idx + 1}" slug = _slugify(title) while slug in seen: slug += "-" + str(idx + 1) seen.add(slug) paras = re.findall(r"

    (.*?)

    ", body, re.S) posts.append({ "slug": slug, "title": title[:200], "date": date, "author": author[:80], "summary": _summary(_text_of(paras[0]) if paras else _text_of(body)), "source": path, "kind": "html", "html": body.strip(), }) return posts, parser.intro # ---------------------------------------------------------------- loading a space from Gitea def _date_key(post: dict) -> str: d = post.get("date") or "" m = re.match(r"(\d{4})-(\d{2})-(\d{2})", d) if m: return d[:10] m = _DATE_RE.search(d) if m and not m.group(1)[0:4].isdigit(): try: return time.strftime("%Y-%m-%d", time.strptime(m.group(1), "%d %B %Y")) except ValueError: return "" return "" def load_space(space: dict, gitea_get) -> dict: """gitea_get(path, raw=False) -> parsed JSON or text from the Gitea API (read only).""" repo = space["repo"] meta = gitea_get(f"/repos/{repo}") branch = space.get("branch") or meta.get("default_branch") or "main" qb = urllib.parse.quote(branch, safe="") head = gitea_get(f"/repos/{repo}/branches/{qb}") commit = head.get("commit") or {} sha = commit.get("id") or "" with _CACHE_LOCK: hit = _CACHE.get(space["key"]) if hit and hit[1] == sha and time.time() - hit[0] < 3600: return hit[2] tree = gitea_get(f"/repos/{repo}/git/trees/{urllib.parse.quote(sha or branch, safe='')}?recursive=true&per_page=1000") files = [t.get("path") or "" for t in tree.get("tree") or [] if t.get("type") == "blob"] posts: list[dict] = [] intro: list[str] = [] readme = "" for path in sorted(files)[:MAX_FILES]: low = path.lower() name = low.rsplit("/", 1)[-1] stem = name.rsplit(".", 1)[0] ref = urllib.parse.quote(sha or branch, safe="") raw_path = urllib.parse.quote(path) if low.endswith((".md", ".markdown")): try: src = gitea_get(f"/repos/{repo}/raw/{raw_path}?ref={ref}", raw=True) except Exception: continue if stem in SKIP_MD: if path.count("/") == 0 and stem == "readme": readme = src continue posts.append(parse_markdown_post(path, src, space, branch)) elif name == "index.html" and path.count("/") <= 1: try: src = gitea_get(f"/repos/{repo}/raw/{raw_path}?ref={ref}", raw=True) except Exception: continue found, paras = parse_html_articles(path, src, space, branch) posts.extend(found) if path == "index.html": intro = paras # unique slugs across sources seen: set[str] = set() for p in posts: base, n = p["slug"], 2 while p["slug"] in seen: p["slug"] = f"{base}-{n}" n += 1 seen.add(p["slug"]) # newest first when dated; undated keep repo order after dated ones dated = [p for p in posts if _date_key(p)] undated = [p for p in posts if not _date_key(p)] dated.sort(key=_date_key, reverse=True) posts = dated + undated about_html = "" if intro: about_html = "".join("

    " + html.escape(t) + "

    " for t in intro[:6]) elif readme: _, body = _front_matter(readme) body = re.sub(r"^\s*#\s+.*$", "", body, count=1, flags=re.M) about_html = render_markdown(body, space, branch) cauthor = (commit.get("author") or {}) if isinstance(commit.get("author"), dict) else {} data = { "space": public_space(space), "branch": branch, "about_html": about_html, "last_change": { "sha": sha[:10], "message": (commit.get("message") or "").strip().split("\n")[0][:200], "author": cauthor.get("name") or cauthor.get("username") or "", "at": commit.get("timestamp") or "", }, "posts": posts, } with _CACHE_LOCK: _CACHE[space["key"]] = (time.time(), sha, data) return data def space_listing(data: dict) -> dict: out = dict(data) out["posts"] = [{k: v for k, v in p.items() if k != "html"} for p in data["posts"]] return out def api(path: str, gitea_get) -> tuple[int, dict]: """GET /v1/quill/... (the caller has already checked read access).""" rest = path[len("/v1/quill"):].strip("/") parts = [urllib.parse.unquote(p) for p in rest.split("/")] if rest else [] if not parts or parts == ["spaces"]: return 200, {"name": WIKI_NAME, "spaces": [public_space(s) for s in SPACES]} if parts[0] != "spaces" or len(parts) not in (2, 4) or not KEY_RE.fullmatch(parts[1]): return 404, {"error": "not_found"} space = space_by_key(parts[1]) if space is None: return 404, {"error": "space_not_found"} try: data = load_space(space, gitea_get) except urllib.error.HTTPError: return 502, {"error": "space_repo_unavailable"} except Exception: return 502, {"error": "gitea_unavailable"} if len(parts) == 2: return 200, space_listing(data) if parts[2] != "posts" or not SLUG_RE.fullmatch(parts[3]): return 404, {"error": "not_found"} for post in data["posts"]: if post["slug"] == parts[3]: out = dict(post) out["git_url"] = GIT_WEB_BASE + "/" + space["repo"] + "/src/branch/" + urllib.parse.quote(data["branch"]) + "/" + urllib.parse.quote(post["source"]) out["history_url"] = GIT_WEB_BASE + "/" + space["repo"] + "/commits/branch/" + urllib.parse.quote(data["branch"]) + "/" + urllib.parse.quote(post["source"]) return 200, {"space": data["space"], "branch": data["branch"], "post": out} return 404, {"error": "post_not_found"}