The renderer and its stylesheet moved in from the-custodian with a make build target, so T02 generalises something that works rather than starting from scratch. Publication tooling belongs to the repo that owns publication. Disclosure is resolved for now: full public is fine in build mode, where there are no users to expose and no attacker with anything to gain. Recorded as deferred rather than closed, because it stops being true at production - the same blast-radius disclosure that a consumer must read becomes a map once real tenant data exists. Controlled disclosure is deliberately not this repo's job. Publication is about permanence and currency; embargo is about severity, remediation and timing, and building it here would put risk judgement in the repo least qualified to make it. It likely wants a service of its own - a risk-nexus - with this repo as its publication surface rather than its brain. The only cost today is one line in T01: the addressing scheme must not assume every document is public from birth, so that adding an embargo state later is a new status rather than a URL migration. First publication retargeted - the framework relocated to NetKingdom canon and is now tenancy-posture_v0.1, five axes rather than five planes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
377 lines
14 KiB
Python
377 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Render a canon markdown document into a styled, self-contained artifact page.
|
|
|
|
Single source of truth: the markdown. The page is generated, never hand-edited,
|
|
so the two cannot diverge.
|
|
|
|
Design devices are recognised from conventions already present in the markdown
|
|
rather than from extra markup, so the source stays a readable document:
|
|
|
|
* a table whose first column is `**X0**`/`**X1**`... renders as a level ladder
|
|
* a table whose first header cell is `Threat` renders as a threat matrix
|
|
* a table with a `Kind` column renders with mechanical/adversarial chips
|
|
* a table whose first header cell is `E \\ P` renders as the E x P matrix
|
|
* a blockquote renders as a pull quote
|
|
* `**Decision N.N...**` at the start of a paragraph renders as a decision
|
|
* `## N. Title` headings build the section rail
|
|
|
|
Stdlib only, per the estate's structure-not-tooling stance: a publishing step
|
|
that needs its own toolchain is a publishing step that stops being run.
|
|
|
|
Usage:
|
|
python3 tools/render-artifact.py <source.md> --output <page.html> \\
|
|
[--title "Name"] [--subtitle "..."]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import html
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
STYLE = pathlib.Path(__file__).parent / "style.css"
|
|
|
|
INLINE_CODE = re.compile(r"`([^`]+)`")
|
|
BOLD = re.compile(r"\*\*([^*]+)\*\*")
|
|
EM = re.compile(r"(?<![*\w])\*([^*]+)\*(?!\*)")
|
|
LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
HEADING = re.compile(r"^(#{1,4})\s+(.*)$")
|
|
SECTION_NO = re.compile(r"^(\d+)\.\s+(.*)$")
|
|
LEVEL_CELL = re.compile(r"^\*\*([IAEPR])(\d)\*\*$")
|
|
DECISION = re.compile(r"^\*\*(Decision [\d.]+[^*]*)\*\*(.*)$", re.S)
|
|
|
|
|
|
def inline(text: str) -> str:
|
|
"""Escape, then apply inline markdown. Order matters: code first."""
|
|
slots: list[str] = []
|
|
|
|
def stash(rendered: str) -> str:
|
|
slots.append(rendered)
|
|
return f"\x00{len(slots) - 1}\x00"
|
|
|
|
text = INLINE_CODE.sub(lambda m: stash(f"<code>{html.escape(m.group(1))}</code>"), text)
|
|
text = html.escape(text, quote=False)
|
|
text = LINK.sub(
|
|
lambda m: f'<a href="{html.escape(m.group(2), quote=True)}">{m.group(1)}</a>', text
|
|
)
|
|
text = BOLD.sub(r"<strong>\1</strong>", text)
|
|
text = EM.sub(r"<em>\1</em>", text)
|
|
for i, rendered in enumerate(slots):
|
|
text = text.replace(f"\x00{i}\x00", rendered)
|
|
return text
|
|
|
|
|
|
def split_frontmatter(source: str) -> tuple[dict, str]:
|
|
if not source.startswith("---\n"):
|
|
return {}, source
|
|
end = source.index("\n---\n", 4)
|
|
meta = {}
|
|
for line in source[4:end].splitlines():
|
|
if ":" in line and not line.startswith((" ", "-")):
|
|
key, _, value = line.partition(":")
|
|
meta[key.strip()] = value.strip().strip('"')
|
|
return meta, source[end + 5 :]
|
|
|
|
|
|
def parse_table(lines: list[str], start: int) -> tuple[list[list[str]], int]:
|
|
rows, i = [], start
|
|
while i < len(lines) and lines[i].lstrip().startswith("|"):
|
|
cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]
|
|
if not all(set(c) <= set("-: ") for c in cells):
|
|
rows.append(cells)
|
|
i += 1
|
|
return rows, i
|
|
|
|
|
|
# --- table renderers -------------------------------------------------------
|
|
|
|
|
|
def render_ladder(rows: list[list[str]]) -> str:
|
|
"""A level table becomes a stepped scale. Colour depth encodes strength."""
|
|
body = rows[1:]
|
|
plane = LEVEL_CELL.match(body[0][0]).group(1)
|
|
names = {
|
|
"I": "Identity", "A": "Authorization", "E": "Enforcement",
|
|
"P": "Placement", "R": "Retention",
|
|
}
|
|
rungs = []
|
|
for cells in body:
|
|
match = LEVEL_CELL.match(cells[0])
|
|
if not match:
|
|
continue
|
|
n = int(match.group(2))
|
|
text = cells[1] if len(cells) > 1 else ""
|
|
rungs.append(
|
|
f'<div class="rung r{n}"><span class="code">{plane}{n}</span>'
|
|
f'<span class="txt">{inline(text)}</span></div>'
|
|
)
|
|
while len(rungs) < 5:
|
|
rungs.append('<div class="rung na"><span class="code">—</span>'
|
|
f'<span class="txt">Ladder ends at {plane}{len(rungs) - 1}.</span></div>')
|
|
return (
|
|
'<div class="ladder"><div class="pname">'
|
|
f'{names.get(plane, plane)}<span>plane {plane}</span></div>'
|
|
f'<div class="rungs">{"".join(rungs)}</div></div>'
|
|
)
|
|
|
|
|
|
def render_threat(rows: list[list[str]]) -> str:
|
|
head = "".join(f"<th>{inline(c)}</th>" for c in rows[0])
|
|
body = []
|
|
for cells in rows[1:]:
|
|
tds = [f"<td>{inline(cells[0])}</td>"]
|
|
for c in cells[1:]:
|
|
cls = "yes" if "✓" in c else "no" if "✗" in c else ""
|
|
tds.append(f'<td class="{cls}">{inline(c)}</td>')
|
|
body.append(f"<tr>{''.join(tds)}</tr>")
|
|
return (f'<div class="scroll"><table class="tm"><thead><tr>{head}</tr></thead>'
|
|
f'<tbody>{"".join(body)}</tbody></table></div>')
|
|
|
|
|
|
def render_matrix(rows: list[list[str]]) -> str:
|
|
"""`E \\ P` table becomes the two-axis grid. Cells hold pins or markers."""
|
|
cols = rows[0][1:]
|
|
cells = ['<div class="mgrid">']
|
|
for cells_row in rows[1:]:
|
|
e = cells_row[0].strip("*")
|
|
level = int(e[1]) if len(e) > 1 and e[1].isdigit() else 0
|
|
cells.append(f'<div class="rlab">{html.escape(e)}</div>')
|
|
for value in cells_row[1:]:
|
|
v = value.strip()
|
|
if v == "—":
|
|
cells.append('<div class="mcell void"></div>')
|
|
continue
|
|
tint = f" tint{level}" if level else ""
|
|
pins = ""
|
|
for entry in (p.strip() for p in v.split("<br>") if p.strip()):
|
|
ghost = " ghost" if entry.startswith("(") else ""
|
|
pins += f'<span class="pin{ghost}">{inline(entry.strip("()"))}</span>'
|
|
cells.append(f'<div class="mcell{tint}">{pins}</div>')
|
|
cells.append('<div class="rlab"></div>')
|
|
cells.extend(f'<div class="clab">{html.escape(c)}</div>' for c in cols)
|
|
cells.append("</div>")
|
|
return (
|
|
'<div class="matrix-shell"><div class="ylab">Enforcement →</div>'
|
|
+ "".join(cells)
|
|
+ "</div>"
|
|
'<div class="mnote">'
|
|
'<span class="k"><span class="sw"></span>Where a service sits today</span>'
|
|
'<span class="k"><span class="sw g"></span>Target or default</span>'
|
|
'<span class="k"><span class="sw v"></span>Unreachable at this placement</span>'
|
|
"</div>"
|
|
)
|
|
|
|
|
|
def render_table(rows: list[list[str]]) -> str:
|
|
if not rows:
|
|
return ""
|
|
header = [c.strip() for c in rows[0]]
|
|
first = header[0].lower()
|
|
if first.replace(" ", "") in {"e\\p", "e\\p"}:
|
|
return render_matrix(rows)
|
|
if first == "threat":
|
|
return render_threat(rows)
|
|
kind_col = header.index("Kind") if "Kind" in header else None
|
|
# A level table is a ladder. The evidence table also leads with `Level` but
|
|
# carries a `Kind` column, and is a table of artifacts, not of rungs.
|
|
if first == "level" and kind_col is None and len(rows) > 1 and LEVEL_CELL.match(rows[1][0]):
|
|
return render_ladder(rows)
|
|
head = "".join(f"<th>{inline(c)}</th>" for c in header)
|
|
body = []
|
|
for cells in rows[1:]:
|
|
tds = []
|
|
for i, c in enumerate(cells):
|
|
if i == kind_col:
|
|
adv = "adv" if "adversarial" in c.lower() else ""
|
|
label = re.sub(r"[*_]", "", c).strip()
|
|
tds.append(f'<td><span class="kind {adv}">{html.escape(label)}</span></td>')
|
|
else:
|
|
tds.append(f"<td>{inline(c)}</td>")
|
|
body.append(f"<tr>{''.join(tds)}</tr>")
|
|
return (f'<div class="scroll"><table><thead><tr>{head}</tr></thead>'
|
|
f'<tbody>{"".join(body)}</tbody></table></div>')
|
|
|
|
|
|
# --- document ---------------------------------------------------------------
|
|
|
|
|
|
def render_body(markdown: str) -> tuple[str, list[tuple[str, str, str]]]:
|
|
lines = markdown.splitlines()
|
|
out: list[str] = []
|
|
rail: list[tuple[str, str, str]] = []
|
|
open_section = False
|
|
ladders_open = False
|
|
i = 0
|
|
|
|
def close_ladders() -> None:
|
|
nonlocal ladders_open
|
|
if ladders_open:
|
|
out.append("</div></div>")
|
|
ladders_open = False
|
|
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
stripped = line.strip()
|
|
|
|
if not stripped:
|
|
i += 1
|
|
continue
|
|
|
|
heading = HEADING.match(stripped)
|
|
if heading:
|
|
level, text = len(heading.group(1)), heading.group(2)
|
|
if level == 1:
|
|
i += 1
|
|
continue
|
|
if level == 2:
|
|
close_ladders()
|
|
if open_section:
|
|
out.append("</section>")
|
|
match = SECTION_NO.match(text)
|
|
if match:
|
|
num, title = match.group(1), match.group(2)
|
|
anchor = f"s{num}"
|
|
rail.append((anchor, num, title))
|
|
out.append(
|
|
f'<section id="{anchor}"><h2>'
|
|
f'<span class="sn">{int(num):02d}</span>{inline(title)}</h2>'
|
|
)
|
|
else:
|
|
anchor = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
rail.append((anchor, "·", text))
|
|
out.append(f'<section id="{anchor}"><h2>{inline(text)}</h2>')
|
|
open_section = True
|
|
else:
|
|
close_ladders()
|
|
out.append(f"<h3>{inline(text)}</h3>")
|
|
i += 1
|
|
continue
|
|
|
|
if stripped.startswith("|"):
|
|
rows, i = parse_table(lines, i)
|
|
rendered = render_table(rows)
|
|
if 'class="ladder"' in rendered:
|
|
if not ladders_open:
|
|
out.append('<div class="breakout"><div class="ladders">')
|
|
ladders_open = True
|
|
out.append(rendered)
|
|
else:
|
|
close_ladders()
|
|
out.append(rendered)
|
|
continue
|
|
|
|
close_ladders()
|
|
|
|
if stripped.startswith("```"):
|
|
block = []
|
|
i += 1
|
|
while i < len(lines) and not lines[i].strip().startswith("```"):
|
|
block.append(lines[i])
|
|
i += 1
|
|
out.append(f"<pre>{html.escape(chr(10).join(block))}</pre>")
|
|
i += 1
|
|
continue
|
|
|
|
if stripped.startswith(">"):
|
|
quote = []
|
|
while i < len(lines) and lines[i].strip().startswith(">"):
|
|
quote.append(lines[i].strip().lstrip(">").strip())
|
|
i += 1
|
|
out.append(f'<div class="rule-quote"><p>{inline(" ".join(quote))}</p></div>')
|
|
continue
|
|
|
|
if re.match(r"^[-*]\s+", stripped) or re.match(r"^\d+\.\s+", stripped):
|
|
ordered = bool(re.match(r"^\d+\.\s+", stripped))
|
|
items = []
|
|
while i < len(lines):
|
|
s = lines[i].strip()
|
|
if re.match(r"^[-*]\s+", s) or re.match(r"^\d+\.\s+", s):
|
|
items.append(re.sub(r"^([-*]|\d+\.)\s+", "", s))
|
|
elif s and lines[i].startswith((" ", "\t")) and items:
|
|
items[-1] += " " + s
|
|
else:
|
|
break
|
|
i += 1
|
|
tag = "ol" if ordered else "ul"
|
|
body = "".join(f"<li>{inline(t)}</li>" for t in items)
|
|
out.append(f"<{tag}>{body}</{tag}>")
|
|
continue
|
|
|
|
if set(stripped) <= set("-") and len(stripped) >= 3:
|
|
i += 1
|
|
continue
|
|
|
|
para = [stripped]
|
|
i += 1
|
|
while i < len(lines) and lines[i].strip() and not re.match(
|
|
r"^(\||>|```|#{1,4}\s|[-*]\s|\d+\.\s|---)", lines[i].strip()
|
|
):
|
|
para.append(lines[i].strip())
|
|
i += 1
|
|
text = " ".join(para)
|
|
decision = DECISION.match(text)
|
|
if decision:
|
|
out.append(
|
|
f'<p><span class="dec">{inline(decision.group(1))}</span>'
|
|
f"{inline(decision.group(2))}</p>"
|
|
)
|
|
else:
|
|
out.append(f"<p>{inline(text)}</p>")
|
|
|
|
close_ladders()
|
|
if open_section:
|
|
out.append("</section>")
|
|
return "\n".join(out), rail
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("source", type=pathlib.Path)
|
|
ap.add_argument("--output", required=True, type=pathlib.Path)
|
|
ap.add_argument("--title", default=None)
|
|
ap.add_argument("--subtitle", default="")
|
|
args = ap.parse_args()
|
|
|
|
meta, markdown = split_frontmatter(args.source.read_text())
|
|
body, rail = render_body(markdown)
|
|
|
|
title = args.title or meta.get("title", args.source.stem)
|
|
display = title.split(":")[0].strip()
|
|
eyebrow = " ".join(
|
|
f"<span{' class=\"stat\"' if k == 'status' else ''}>{html.escape(v)}</span>"
|
|
for k, v in (
|
|
("id", meta.get("id", "")),
|
|
("status", f"{meta.get('status', '')} · {meta.get('revision', '')}".strip(" ·")),
|
|
("date", meta.get("date", "")),
|
|
)
|
|
if v
|
|
)
|
|
rail_html = "".join(
|
|
f'<li><a href="#{a}"><span class="n">{n}</span>{html.escape(t)}</a></li>'
|
|
for a, n, t in rail
|
|
)
|
|
|
|
page = (
|
|
f"<title>{html.escape(display)}</title>\n"
|
|
f"<style>\n{STYLE.read_text()}\n</style>\n"
|
|
'<div class="wrap"><header>'
|
|
f'<div class="eyebrow">{eyebrow}<span>generated from canon — do not edit</span></div>'
|
|
f"<h1>{html.escape(display)}</h1>"
|
|
+ (f'<p class="sub">{html.escape(args.subtitle)}</p>' if args.subtitle else "")
|
|
+ '</header><div class="layout">'
|
|
f'<nav class="rail" aria-label="Sections"><ol>{rail_html}</ol></nav>'
|
|
f"<main>{body}"
|
|
f'<footer><span>{html.escape(meta.get("id", ""))} · '
|
|
f'{html.escape(meta.get("revision", ""))} · {html.escape(meta.get("status", ""))}</span>'
|
|
"<span>generated from the-custodian/canon</span></footer>"
|
|
"</main></div></div>\n"
|
|
)
|
|
args.output.write_text(page)
|
|
print(f"{args.output}: {len(rail)} sections, {len(page)} bytes")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|