Files
2026-10-09 03:28:20 +02:00

433 lines
25 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml>=6"]
# ///
"""Render an Open Knowledge Format (OKF) bundle as a single self-contained,
interactive HTML graph (`viz.html`). No backend, no install on the viewing side,
no data leaves the page — concepts become nodes (coloured by `type`, sized by
body length), markdown links and bundle-internal `sources` become edges, and
clicking a node opens a wiki-style panel with its rendered markdown, OKF v0.2
provenance/trust/lifecycle metadata, outgoing links, and "Cited by" backlinks.
Features: force/concentric/breadth-first/circle/grid layouts, per-type filter,
free-text search, neighbour highlight, clickable cross-links and backlinks.
The default layout is force (cose) up to AUTO_COSE_MAX concepts, then the linear
concentric layout — force-directed cost grows roughly quadratically with node
count and freezes the page on large bundles. An explicit --layout always wins.
Run: uv run okf_visualize.py <bundle-dir> [-o viz.html]
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
import yaml
RESERVED = {"index.md", "log.md"}
# Force (cose) layout froze the page for ~32 s at ~2k concepts (measured in
# Chrome); the linear layouts load the same bundle in under 2 s. Past this size
# the default switches to concentric, and the in-page layout picker asks before
# running force. An explicit --layout (or ?layout=) still wins.
AUTO_COSE_MAX = 1000
# Above this the page is slow on any layout (23k concepts measured: ~27 s load,
# ~650 MB heap) and reads as a hairball — warn and suggest rendering a subtree.
SCALE_WARN = 5000
FENCE = re.compile(r"^(```|~~~)")
LINK = re.compile(r"(?<!\!)\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
def json_for_script(obj) -> str:
"""JSON-encode ``obj`` for safe embedding inside an inline ``<script>``.
The HTML tokenizer knows nothing about JS string context: a literal
``</script>`` in a concept body ends the inline script early (truncating the
embedded ``NODES``/``EDGES`` and breaking the whole page), and ``<!--`` can
shift it into the escaped script-data states where even the template's real
closing tag stops working. Escaping every ``<`` as ``\\u003c`` neutralizes
``</script>``, ``<!--`` and ``<script`` in one stroke, and the result stays
valid JSON *and* JavaScript (``json.dumps`` keeps everything else ASCII via
the default ``ensure_ascii=True``).
"""
return json.dumps(obj, default=str).replace("<", "\\u003c")
def split_frontmatter(text: str):
if not text.startswith("---"):
return {}, text
lines = text.splitlines(keepends=True)
if lines[0].strip() != "---":
return {}, text
for i in range(1, len(lines)):
if lines[i].strip() == "---":
try:
meta = yaml.safe_load("".join(lines[1:i])) or {}
except yaml.YAMLError:
meta = {}
return (meta if isinstance(meta, dict) else {}), "".join(lines[i + 1:])
return {}, text
def link_targets(text: str):
out, in_fence = [], False
for line in text.splitlines():
if FENCE.match(line.strip()):
in_fence = not in_fence
continue
if not in_fence:
out.extend(LINK.findall(line))
return out
def resolve(target: str, path: Path, bundle: Path):
"""Resolve a link/`sources[].resource` to a concept id, or None if it is not
one (an external URL, an asset, a scope descriptor, an escape from the tree)."""
t = str(target).split("#", 1)[0]
if not t.endswith(".md"):
return None
if t.startswith("/"):
return t.lstrip("/")[:-3]
cand = (path.parent / t).resolve()
return cand.relative_to(bundle.resolve()).as_posix()[:-3] \
if cand.is_relative_to(bundle.resolve()) else None
def read_sources(meta: dict, path: Path, bundle: Path):
"""§5.1 `sources`, flattened for display. `cid` is set when the source is
itself a concept in this bundle — that derivation is a real graph edge."""
raw = meta.get("sources")
out = []
if not isinstance(raw, list):
return out
for src in raw:
if not isinstance(src, dict):
continue
resource = str(src.get("resource", "")).strip()
# §5.1 — an entry's own `usage_window` overrides the one written once as a
# sibling of `sources`. A count without its window has no units.
window = src.get("usage_window", meta.get("usage_window"))
out.append({
"title": str(src.get("title") or src.get("id") or resource),
"resource": resource,
"cid": resolve(resource, path, bundle) if resource else None,
"author": str(src.get("author", "")),
"usage_count": src.get("usage_count"),
"usage_window": (f"{window.get('from', '?')}→{window.get('to', '?')}"
if isinstance(window, dict) else ""),
"last_modified": str(src.get("last_modified") or ""),
})
return out
def read_trust(meta: dict):
"""§5.2 `generated` / `verified`, falling back to a v0.1 `timestamp` (§13.1)."""
gen = meta.get("generated")
if isinstance(gen, dict):
generated = {"by": str(gen.get("by", "")), "at": str(gen.get("at", ""))}
elif meta.get("timestamp"):
generated = {"by": "", "at": str(meta["timestamp"])}
else:
generated = None
ver = meta.get("verified")
# a bare mapping is one verification event (§5.2)
entries = [ver] if isinstance(ver, dict) else (ver if isinstance(ver, list) else [])
verified = [{"by": str(e.get("by", "")), "at": str(e.get("at", ""))}
for e in entries if isinstance(e, dict)]
return generated, verified
def build(bundle: Path):
nodes, edges, seen = [], [], set()
files = sorted(p for p in bundle.rglob("*.md") if p.is_file() and p.name not in RESERVED)
ids = {p.relative_to(bundle).with_suffix("").as_posix() for p in files}
for p in files:
cid = p.relative_to(bundle).with_suffix("").as_posix()
try:
raw = p.read_text(encoding="utf-8").lstrip("")
except (UnicodeDecodeError, OSError) as exc:
print(f"warning: skipping {p.relative_to(bundle)}: cannot read file: {exc}", file=sys.stderr)
ids.discard(cid)
continue
meta, body = split_frontmatter(raw)
body = body.strip()
generated, verified = read_trust(meta)
sources = read_sources(meta, p, bundle)
nodes.append({
"id": cid,
"type": str(meta.get("type", "Untyped")),
"title": str(meta.get("title", p.stem)),
"description": str(meta.get("description", "")),
"tags": meta.get("tags", []) if isinstance(meta.get("tags"), list) else [],
"group": cid.split("/")[0] if "/" in cid else "(root)",
"sz": max(24, min(70, 24 + len(body) // 200)),
"status": str(meta.get("status", "")),
"stale_after": str(meta.get("stale_after") or ""),
"generated": generated,
"verified": verified,
"sources": sources,
"body": body[:8000],
})
targets = link_targets(body) + [s["resource"] for s in sources if s["cid"]]
for t in targets:
tgt = resolve(t, p, bundle)
if tgt and tgt in ids and tgt != cid and (cid, tgt) not in seen:
seen.add((cid, tgt))
edges.append({"source": cid, "target": tgt})
return nodes, edges
HTML = r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>OKF — __NAME__</title>
<meta property="og:title" content="__OGTITLE__">
<meta property="og:description" content="__OGDESC__">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
__OGIMAGE__
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@14/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.12/dist/purify.min.js" integrity="sha384-piCcpDdJ7qVeK4Tv8Z6Hpcr3ZBIgP16TxQTPVfsLFdZ5uDgwc3Y8Ho7oUnqf12qu" crossorigin="anonymous"></script>
<style>
:root{--bg:#0e0f13;--panel:#16181f;--line:#262a35;--fg:#e6e8ee;--mut:#9aa3b2;--accent:#8ab4ff}
*{box-sizing:border-box} html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);
font:14px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
/* The row must be stated. Left implicit it sizes to `auto`, i.e. to the tallest
item — a long concept body then stretched #side past the viewport and the page
grew its own scrollbar on top of the panel's, which in turn forced a spurious
horizontal one. Pinning the row to 100% keeps the panel scrolling inside. */
#app{display:grid;grid-template-columns:1fr 400px;grid-template-rows:100%;height:100vh}
#cy{width:100%;height:100%}
#side{border-left:1px solid var(--line);background:var(--panel);overflow:auto;padding:18px}
header{position:absolute;top:0;left:0;padding:14px 18px;z-index:5;pointer-events:none}
h1{font-size:15px;margin:0;font-weight:650} .sub{color:var(--mut);font-size:12px;margin-top:2px}
#bar{position:absolute;top:12px;left:50%;transform:translateX(-50%);z-index:5;display:flex;gap:8px}
#bar input,#bar select{background:var(--panel);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:8px 10px;outline:none;font-size:13px}
#search{width:min(300px,32vw)}
#legend{position:absolute;bottom:14px;left:18px;z-index:5;display:flex;flex-wrap:wrap;gap:6px;max-width:60vw}
.chip{display:flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);
border-radius:20px;padding:3px 10px;font-size:12px;color:var(--mut);cursor:pointer;user-select:none}
.chip.off{opacity:.4} .dot{width:10px;height:10px;border-radius:50%}
#side h2{font-size:17px;margin:.2em 0} .type{display:inline-block;border-radius:6px;padding:2px 8px;
font-size:11px;font-weight:600;color:#0e0f13} .desc{color:var(--mut);margin:8px 0 12px}
.tags{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}
.tag{background:#1d2230;border:1px solid var(--line);border-radius:6px;padding:1px 8px;font-size:11px;color:var(--mut)}
.meta{margin:10px 0;font-size:12px;color:var(--mut)} .meta div{padding:1px 0} .meta b{color:var(--fg);font-weight:600}
.sig{color:var(--mut)}
.badges{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}
.badge{border-radius:6px;padding:2px 8px;font-size:11px;font-weight:600;border:1px solid}
.t-unverified{color:#9aa3b2;border-color:#3a4150;background:#1d2230}
.t-machine{color:#8ab4ff;border-color:#2b4570;background:#141c2b}
.t-human{color:#4ade80;border-color:#276b45;background:#12211a}
.b-stale{color:#fca5a5;border-color:#7f2b2b;background:#241416}
.b-deprecated{color:#fbbf24;border-color:#7a5312;background:#241d10}
.rel{margin:10px 0} .rel h4{margin:0 0 4px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)}
.rel a{display:block;color:var(--accent);cursor:pointer;font-size:13px;padding:1px 0;text-decoration:none}
.rel a:hover{text-decoration:underline}
.body{border-top:1px solid var(--line);padding-top:12px;margin-top:12px} .body table{border-collapse:collapse;display:block;overflow:auto}
.body td,.body th{border:1px solid var(--line);padding:4px 8px} .body code{background:#1d2230;padding:1px 5px;border-radius:4px}
.body pre{background:#1d2230;padding:10px;border-radius:8px;overflow:auto} .body img{max-width:100%}
.empty{color:var(--mut)} a{color:var(--accent)}
.src{pointer-events:auto;color:var(--accent);margin-left:10px;text-decoration:none} .src:hover{text-decoration:underline}
</style></head><body>
<div id="app"><div id="cy"></div><div id="side"><p class="empty">Click a concept to inspect it.</p></div></div>
<header><h1>__NAME__</h1><div class="sub">__N__ concepts · __E__ links · OKF v0.2__LINK__</div></header>
<div id="bar">
<input id="search" placeholder="search concepts…">
<select id="type"><option value="">all types</option></select>
<select id="layout">
<option value="cose">force</option><option value="concentric">concentric</option>
<option value="breadthfirst">breadth-first</option><option value="circle">circle</option><option value="grid">grid</option>
</select>
</div>
<div id="legend"></div>
<script>
const NODES=__NODES__, EDGES=__EDGES__;
const PALETTE=["#6E56CF","#D97757","#22C55E","#3B82F6","#EAB308","#EC4899","#14B8A6","#F97316","#A855F7","#0EA5E9","#84CC16","#EF4444","#64748B"];
const byId=Object.fromEntries(NODES.map(n=>[n.id,n]));
const outL={}, inL={};
NODES.forEach(n=>{outL[n.id]=[];inL[n.id]=[];});
EDGES.forEach(e=>{outL[e.source].push(e.target);inL[e.target].push(e.source);});
const types=[...new Set(NODES.map(n=>n.type))].sort();
const color=Object.fromEntries(types.map((t,i)=>[t,PALETTE[i%PALETTE.length]]));
const off=new Set();
const cy=cytoscape({container:document.getElementById('cy'),minZoom:.2,maxZoom:1.6,wheelSensitivity:.2,
elements:[...NODES.map(n=>({data:{...n,c:color[n.type]}})),...EDGES.map(e=>({data:e}))],
style:[
{selector:'node',style:{'background-color':'data(c)','label':'data(title)','color':'#e6e8ee',
'font-size':10,'text-wrap':'wrap','text-max-width':120,'text-valign':'bottom','text-margin-y':4,
'text-outline-width':2,'text-outline-color':'#0e0f13','min-zoomed-font-size':6,
'width':'data(sz)','height':'data(sz)'}},
{selector:'edge',style:{'width':1.2,'line-color':'#3a4150','target-arrow-color':'#3a4150',
'target-arrow-shape':'triangle','arrow-scale':.8,'curve-style':'bezier','opacity':.7}},
{selector:'.dim',style:{'opacity':.10}},{selector:'.hl',style:{'border-width':3,'border-color':'#fff'}}
],
layout:{name:'__LAYOUT__',animate:false,nodeRepulsion:9000,idealEdgeLength:90,padding:40}});
const side=document.getElementById('side');
const esc=s=>(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function relList(title,arr){if(!arr.length)return'';
return `<div class="rel"><h4>${title}</h4>${arr.map(id=>`<a data-go="${esc(id)}">${esc((byId[id]||{}).title||id)}</a>`).join('')}</div>`;}
// §5.3 — the trust tier is derived, never stored: no `verified` is unverified,
// `verified` by non-`human:` actors only is machine-confirmed, and any `human:`
// actor makes it human-reviewed. The exact lowercase prefix is the whole key.
// §5.5 — a concept is stale when now >= stale_after. PyYAML renders an unquoted
// datetime with a space separator, which Date.parse rejects in some engines.
// Advisory signals, not access control.
function trustTier(n){const v=n.verified||[];
if(!v.length)return['t-unverified','unverified'];
return v.some(e=>(e.by||'').startsWith('human:'))?['t-human','human-reviewed']
:['t-machine','machine-confirmed'];}
function badges(n){const [cls,label]=trustTier(n),out=[`<span class="badge ${cls}">${label}</span>`];
if(n.stale_after&&Date.parse(n.stale_after.replace(' ','T'))<=Date.now())out.push(`<span class="badge b-stale">stale since ${esc(n.stale_after)}</span>`);
if(n.status==='deprecated')out.push('<span class="badge b-deprecated">deprecated</span>');
return `<div class="badges">${out.join('')}</div>`;}
// OKF v0.2 trust (§5.2) + lifecycle (§5.4/§5.5). A v0.1 `timestamp` arrives here
// as generated.at with an empty `by`, so legacy bundles still show a date.
function metaBlock(n){const g=n.generated||{},rows=[];
if(n.status)rows.push(`<div>status <b>${esc(n.status)}</b></div>`);
if(g.at||g.by)rows.push(`<div>generated${g.at?` <b>${esc(g.at)}</b>`:''}${g.by?` by ${esc(g.by)}`:''}</div>`);
(n.verified||[]).forEach(v=>rows.push(`<div>verified${v.at?` <b>${esc(v.at)}</b>`:''}${v.by?` by ${esc(v.by)}`:''}</div>`));
if(n.stale_after)rows.push(`<div>stale after <b>${esc(n.stale_after)}</b></div>`);
return rows.length?`<div class="meta">${rows.join('')}</div>`:'';}
// Provenance (§5.1). A source may be another concept (graph link), an external
// URL, or a scope descriptor that is not followable at all.
function srcList(n){const s=n.sources||[];if(!s.length)return'';
return `<div class="rel"><h4>Sources</h4>${s.map(x=>{
const used=x.usage_count!=null?`used ${x.usage_count}×${x.usage_window?` (${x.usage_window})`:''}`:'';
const sig=[x.author,used,x.last_modified].filter(Boolean).join(' · ');
const label=esc(x.title||x.resource)+(sig?` <span class="sig">(${esc(sig)})</span>`:'');
if(x.cid&&byId[x.cid])return `<a data-go="${esc(x.cid)}">${label}</a>`;
if(/^https?:\/\//i.test(x.resource))return `<a href="${esc(x.resource)}" target="_blank" rel="noopener">${label}</a>`;
return `<span class="empty">${label}</span>`;}).join('')}</div>`;}
// Resolve an in-body markdown link href to a concept id, mirroring build()'s
// resolution: strip #anchor, require .md, absolute strips leading /, relative
// resolves against the current concept's dir. Returns null if it's not a concept.
function resolveHref(cid,href){let t=(href||'').split('#')[0];if(!t.endsWith('.md'))return null;
let tgt;if(t[0]==='/'){tgt=t.replace(/^\/+/,'').slice(0,-3);}
else{const base=cid.split('/').slice(0,-1);
for(const seg of t.slice(0,-3).split('/')){if(seg===''||seg==='.')continue;
if(seg==='..'){if(!base.length)return null;base.pop();}else base.push(seg);}
tgt=base.join('/');}
return byId[tgt]?tgt:null;}
function show(id){const n=byId[id];if(!n)return;const c=color[n.type];
side.innerHTML=`<span class="type" style="background:${c}">${esc(n.type)}</span>
<h2>${esc(n.title)}</h2><div class="desc">${esc(n.description)||'<span class=empty>no description</span>'}</div>
<div class="tags">${(n.tags||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>
${badges(n)}${metaBlock(n)}${srcList(n)}${relList('Links to',outL[id])}${relList('Cited by',inL[id])}
<div class="body">${n.body?DOMPurify.sanitize(marked.parse(n.body)):'<span class=empty>empty body</span>'}</div>`;
side.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
side.querySelectorAll('.body a[href]').forEach(a=>{const tgt=resolveHref(id,a.getAttribute('href'));
if(tgt)a.onclick=e=>{e.preventDefault();select(tgt);};});}
function select(id){const ele=cy.getElementById(id);if(!ele.length)return;show(id);
cy.elements().removeClass('hl').addClass('dim');const nb=ele.closedNeighborhood();nb.removeClass('dim');ele.addClass('hl');
cy.animate({center:{eles:ele},duration:250});
try{if(decodeURIComponent((location.hash||'').slice(1))!==id)location.hash=encodeURIComponent(id);}catch(e){}}
cy.on('tap','node',e=>select(e.target.id()));
cy.on('tap',e=>{if(e.target===cy)cy.elements().removeClass('dim hl');});
function applyFilter(){const q=document.getElementById('search').value.toLowerCase();const ty=document.getElementById('type').value;
cy.batch(()=>cy.nodes().forEach(n=>{const d=n.data();
const m=(!q||(d.title+' '+d.type+' '+d.description+' '+(d.tags||[]).join(' ')).toLowerCase().includes(q))
&&(!ty||d.type===ty)&&!off.has(d.type);
n.style('display',m?'element':'none');}));}
let debounce;
document.getElementById('search').oninput=()=>{clearTimeout(debounce);debounce=setTimeout(applyFilter,150);};
document.getElementById('type').oninput=applyFilter;
const tysel=document.getElementById('type');types.forEach(t=>{const o=document.createElement('option');o.value=t;o.textContent=t;tysel.appendChild(o);});
let curLayout='__LAYOUT__';
document.getElementById('layout').onchange=e=>{const v=e.target.value;
if(v==='cose'&&NODES.length>__COSEMAX__&&!confirm(`force layout on ${NODES.length} concepts can freeze this tab — run anyway?`)){e.target.value=curLayout;return;}
curLayout=v;cy.layout({name:v,animate:true,padding:40,nodeRepulsion:9000,idealEdgeLength:90}).run();};
document.getElementById('legend').innerHTML=types.map(t=>`<span class="chip" data-t="${esc(t)}"><span class="dot" style="background:${color[t]}"></span>${esc(t)} (${NODES.filter(n=>n.type===t).length})</span>`).join('');
document.querySelectorAll('#legend .chip').forEach(ch=>ch.onclick=()=>{const t=ch.getAttribute('data-t');
if(off.has(t)){off.delete(t);ch.classList.remove('off');}else{off.add(t);ch.classList.add('off');}applyFilter();});
document.getElementById('layout').value='__LAYOUT__';
const Q=new URLSearchParams(location.search),QL=Q.get('layout'),QS=Q.get('select');
if(QL&&[...document.querySelectorAll('#layout option')].some(o=>o.value===QL)){document.getElementById('layout').value=QL;curLayout=QL;cy.layout({name:QL,animate:false,padding:40,nodeRepulsion:9000,idealEdgeLength:90}).run();}
function fromHash(){try{const h=decodeURIComponent((location.hash||'').slice(1));if(h&&byId[h])select(h);}catch(e){}}
addEventListener('hashchange',fromHash);
if(QS&&byId[QS])select(QS);else fromHash();
</script></body></html>"""
def render(bundle: Path, out: Path, title: str | None = None, link: str | None = None,
layout: str | None = None, og_image: str | None = None,
max_nodes: int | None = None):
nodes, edges = build(bundle)
if max_nodes is not None and len(nodes) > max_nodes:
sys.exit(f"error: {len(nodes)} concepts exceeds --max-nodes {max_nodes}")
if layout is None:
layout = "cose" if len(nodes) <= AUTO_COSE_MAX else "concentric"
if layout != "cose":
print(f"note: {len(nodes)} concepts > {AUTO_COSE_MAX} — using the linear 'concentric' "
"layout (force freezes the page at this size; pass --layout cose to override)",
file=sys.stderr)
if len(nodes) > SCALE_WARN:
print(f"warning: {len(nodes)} concepts — the page will load slowly and read as a hairball; "
f"consider rendering a subtree, e.g. okf_visualize.py {bundle}/<subdir>",
file=sys.stderr)
name = title or f"{bundle.resolve().parent.name}/{bundle.name}"
src = f' <a class="src" href="{link}" target="_blank" rel="noopener">source ↗</a>' if link else ""
aesc = lambda s: (s or "").replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
og_title = aesc(f"OKF — {name}")
og_desc = aesc(f"{len(nodes)} concepts · interactive Open Knowledge Format knowledge graph")
og_img = (f'<meta property="og:image" content="{aesc(og_image)}">\n'
f'<meta name="twitter:image" content="{aesc(og_image)}">') if og_image else ""
subs = {"__NAME__": name, "__LINK__": src, "__LAYOUT__": layout,
"__COSEMAX__": str(AUTO_COSE_MAX),
"__OGTITLE__": og_title, "__OGDESC__": og_desc, "__OGIMAGE__": og_img,
"__N__": str(len(nodes)), "__E__": str(len(edges)),
"__NODES__": json_for_script(nodes), "__EDGES__": json_for_script(edges)}
# One pass, longest marker first: substituted content (e.g. a concept body that
# mentions "__EDGES__") must never itself be rescanned for other markers.
marker = re.compile("|".join(sorted(map(re.escape, subs), key=len, reverse=True)))
html = marker.sub(lambda m: subs[m.group(0)], HTML)
out.write_text(html, encoding="utf-8")
return len(nodes), len(edges)
def _force_utf8_stdio() -> None:
"""Reconfigure stdout/stderr to UTF-8 so status lines never crash on a
cp1252 (default Windows) console. `errors="replace"` is a fallback."""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8", errors="replace")
except (ValueError, OSError):
pass
def main() -> int:
_force_utf8_stdio()
ap = argparse.ArgumentParser(description="Render an OKF bundle as a self-contained HTML graph.")
ap.add_argument("bundle", type=Path)
ap.add_argument("-o", "--out", type=Path, default=None)
ap.add_argument("-t", "--title", default=None, help="graph title (default: parent/bundle dir name)")
ap.add_argument("-l", "--link", default=None, help="optional source URL shown in the header")
ap.add_argument("--layout", default=None,
choices=["cose", "concentric", "breadthfirst", "circle", "grid"],
help=f"initial graph layout (default: cose, or concentric above {AUTO_COSE_MAX} "
"concepts — force layout freezes the page on large bundles)")
ap.add_argument("--max-nodes", type=int, default=None,
help="refuse to render bundles with more concepts than this (useful in CI)")
ap.add_argument("--og-image", default=None,
help="absolute URL for the social-preview image (og:image / twitter:image)")
args = ap.parse_args()
if not args.bundle.is_dir():
print(f"error: {args.bundle} is not a directory", file=sys.stderr)
return 2
out = args.out or (args.bundle / "viz.html")
n, e = render(args.bundle, out, title=args.title, link=args.link, layout=args.layout,
og_image=args.og_image, max_nodes=args.max_nodes)
print(f"rendered {n} concepts, {e} links -> {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())