#!/usr/bin/env python3 # Copyright 2026 Michał Kopeć # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Export wiki/ as an Open Knowledge Format (OKF) v0.1 bundle at outputs/okf/. Usage: python3 export_okf.py [--check] [--out DIR] Run from the repository root (the directory containing wiki/ and outputs/). --check validate only: build the bundle in a temporary directory, run the conformance checks, print the report, and write nothing to outputs/. --out override the output directory (default: outputs/okf). The transform is fully deterministic: two runs over an unchanged wiki/ produce byte-identical output. Every directory walk and generated list is sorted. """ import argparse import re import shutil import sys import tempfile from pathlib import Path REPO_ROOT = Path.cwd() WIKI = REPO_ROOT / "wiki" OKF_VERSION = "0.1" RESERVED = {"index.md", "log.md"} # wiki/ frontmatter keys that ride along unchanged as OKF extension fields. PASSTHROUGH_EXT = [ "confidence", "quality", "retention", "supersedes", "superseded_by", "freshness_window_days", ] # Keys with no valid home in an OKF bundle. DROPPED = {"kb_schema_version"} VERB_MAP = { "CREATE": "Creation", "UPDATE": "Update", "DELETE": "Deprecation", "RESTRUCTURE": "Update", } # -------------------------------------------------------------------------- # frontmatter # -------------------------------------------------------------------------- def parse_frontmatter(text): """Return (dict, body). Flat `key: value` YAML only — that is all the schema uses. Unparseable or absent frontmatter yields ({}, text).""" if not text.startswith("---\n"): return {}, text end = text.find("\n---\n", 4) if end == -1: return {}, text raw = text[4:end] body = text[end + 5 :] fm = {} for line in raw.split("\n"): line = line.rstrip() if not line or line.lstrip().startswith("#"): continue if ":" not in line: continue key, _, value = line.partition(":") fm[key.strip()] = unquote(value.strip()) return fm, body def unquote(value): if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): return value[1:-1] return value def yaml_scalar(value): """Emit a value that round-trips through the parser above.""" s = str(value) if s == "": return '""' if s[0] in "\"'&*!|>%@`[]{},#" or s[-1] == ":" or ": " in s or s.strip() != s: return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' return s def render_frontmatter(pairs): lines = ["---"] for key, value in pairs: lines.append(f"{key}: {yaml_scalar(value)}") lines.append("---") return "\n".join(lines) + "\n" # -------------------------------------------------------------------------- # body text # -------------------------------------------------------------------------- def first_h1(body): for line in body.split("\n"): if line.startswith("# "): return line[2:].strip() return None def slug_title(filename): stem = Path(filename).stem return " ".join(w.capitalize() for w in re.split(r"[-_]+", stem) if w) def strip_wikilinks(body, counters): """Drop the [[...]] half of every dual-link, keeping the markdown half.""" def drop_pair(m): counters["wikilinks"] += 1 return m.group("keep") # [[X]] / [text](path) and [text](path) / [[X]] body = re.sub( r"\[\[[^\]]*\]\]\s*/\s*(?P\[[^\]]*\]\([^)]*\))", drop_pair, body ) body = re.sub( r"(?P\[[^\]]*\]\([^)]*\))\s*/\s*\[\[[^\]]*\]\]", drop_pair, body ) # Any remaining bare wikilink degrades to its plain label. def bare(m): counters["wikilinks"] += 1 label = m.group(1) return label.split("|", 1)[-1].strip() return re.sub(r"\[\[([^\]]*)\]\]", bare, body) def rewrite_links(body, counters): """Strip the /wiki prefix from repo-root-absolute intra-wiki links and count (but never touch) cross-cascade linked//libs/ references.""" def repl(m): target = m.group(2) if re.match(r"^\.{0,2}/?(linked|libs)/", target): counters["cascade_refs"] += 1 return m.group(0) if target.startswith("/wiki/"): counters["rewritten"] += 1 target = target[5:] elif target == "/wiki" or target == "/wiki/": counters["rewritten"] += 1 target = "/" return f"[{m.group(1)}]({target})" return re.sub(r"\[([^\]]*)\]\(([^)]*)\)", repl, body) def transform_body(body, counters): return rewrite_links(strip_wikilinks(body, counters), counters) # -------------------------------------------------------------------------- # source tree # -------------------------------------------------------------------------- class Page: def __init__(self, relpath, text): self.relpath = relpath # PosixPath relative to wiki/ self.fm, self.body = parse_frontmatter(text) self.title = first_h1(self.body) or slug_title(relpath.name) self.description = self.fm.get("tldr", "") def read_tree(wiki): pages, logs, indexes, assets = {}, {}, {}, [] for path in sorted(wiki.rglob("*")): if not path.is_file() or path.name.startswith("."): continue rel = path.relative_to(wiki) if path.suffix != ".md": assets.append(rel) continue text = path.read_text(encoding="utf-8") if path.name == "index.md": indexes[rel] = text elif path.name == "log.md": logs[rel] = text else: pages[rel] = Page(rel, text) return pages, logs, indexes, assets # -------------------------------------------------------------------------- # emitters # -------------------------------------------------------------------------- def emit_concept(page, counters, issues): ptype = page.fm.get("type", "").strip() if not ptype: ptype = "unknown" issues.append(f"{page.relpath}: no `type` in source frontmatter (exported as `unknown`)") pairs = [("type", ptype), ("title", page.title)] if page.description: pairs.append(("description", page.description)) if page.fm.get("resource"): pairs.append(("resource", page.fm["resource"])) if page.fm.get("last_updated"): pairs.append(("timestamp", page.fm["last_updated"])) for key in PASSTHROUGH_EXT: if key in page.fm: pairs.append((key, page.fm[key])) for key in sorted(page.fm): if key in DROPPED or key in PASSTHROUGH_EXT: continue if key in ("type", "resource", "tldr", "last_updated"): continue pairs.append((key, page.fm[key])) body = transform_body(page.body, counters).lstrip("\n") return render_frontmatter(pairs) + "\n" + body.rstrip("\n") + "\n" def index_entries(directory, pages, logs, indexes, assets): """Every direct child of `directory` (a PosixPath relative to wiki/, or Path('.') for the root), as (title, href, description) — sorted, so the generated list is stable across runs.""" entries = [] for rel, page in pages.items(): if rel.parent == directory: entries.append((page.title, rel.name, page.description)) for rel in logs: if rel.parent == directory: entries.append(("Change Log", rel.name, "Chronological record of changes in this directory.")) for rel in assets: if rel.parent == directory: entries.append((rel.name, rel.name, "")) subdirs = set() for rel in list(pages) + list(logs) + list(indexes) + list(assets): parent = rel.parent while parent != Path("."): if parent.parent == directory: subdirs.add(parent) parent = parent.parent for sub in subdirs: sub_index = sub / "index.md" title = slug_title(sub.name) if sub_index in indexes: title = first_h1(parse_frontmatter(indexes[sub_index])[1]) or title entries.append((title, f"{sub.name}/index.md", "")) return sorted(entries, key=lambda e: e[1]) def emit_index(directory, pages, logs, indexes, assets, is_root): lines = [] if is_root: lines.append(render_frontmatter([("okf_version", OKF_VERSION)]).rstrip("\n")) lines.append("") src = indexes.get(directory / "index.md" if directory != Path(".") else Path("index.md"), "") heading = first_h1(parse_frontmatter(src)[1]) if src else None lines.append(f"# {heading or slug_title(directory.name) or 'Index'}") lines.append("") for title, href, description in index_entries(directory, pages, logs, indexes, assets): if description: lines.append(f"* [{title}]({href}) - {description}") else: lines.append(f"* [{title}]({href})") return "\n".join(lines).rstrip("\n") + "\n" LOG_HEADER_RE = re.compile( r"^##\s*\[?(?P\d{4}-\d{2}-\d{2})(?:[ T](?P