Build immutable policy publication artifact
This commit is contained in:
parent
cac0301866
commit
e8035f3887
22 changed files with 2375 additions and 452 deletions
139
tools/render.py
139
tools/render.py
|
|
@ -19,7 +19,7 @@ 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> \\
|
||||
python3 tools/render.py <source.md> --output <page.html> \\
|
||||
[--title "Name"] [--subtitle "..."]
|
||||
"""
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ 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)\*\*$")
|
||||
LEVEL_CELL = re.compile(r"^\*\*([IAEPRV])(\d)\*\*$")
|
||||
DECISION = re.compile(r"^\*\*(Decision [\d.]+[^*]*)\*\*(.*)$", re.S)
|
||||
|
||||
|
||||
|
|
@ -91,10 +91,10 @@ def parse_table(lines: list[str], start: int) -> tuple[list[list[str]], int]:
|
|||
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)
|
||||
axis = LEVEL_CELL.match(body[0][0]).group(1)
|
||||
names = {
|
||||
"I": "Identity", "A": "Authorization", "E": "Enforcement",
|
||||
"P": "Placement", "R": "Retention",
|
||||
"P": "Placement", "R": "Retention", "V": "Availability",
|
||||
}
|
||||
rungs = []
|
||||
for cells in body:
|
||||
|
|
@ -104,15 +104,15 @@ def render_ladder(rows: list[list[str]]) -> str:
|
|||
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'<div class="rung r{n}"><span class="code">{axis}{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>')
|
||||
f'<span class="txt">Ladder ends at {axis}{len(rungs) - 1}.</span></div>')
|
||||
return (
|
||||
'<div class="ladder"><div class="pname">'
|
||||
f'{names.get(plane, plane)}<span>plane {plane}</span></div>'
|
||||
f'{names.get(axis, axis)}<span>axis {axis}</span></div>'
|
||||
f'<div class="rungs">{"".join(rungs)}</div></div>'
|
||||
)
|
||||
|
||||
|
|
@ -326,25 +326,30 @@ def render_body(markdown: str) -> tuple[str, list[tuple[str, str, str]]]:
|
|||
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())
|
||||
def render_page(
|
||||
source: pathlib.Path,
|
||||
*,
|
||||
title: str | None = None,
|
||||
subtitle: str = "",
|
||||
publication: dict[str, str] | None = None,
|
||||
) -> tuple[str, dict, int]:
|
||||
meta, markdown = split_frontmatter(source.read_text(encoding="utf-8"))
|
||||
body, rail = render_body(markdown)
|
||||
|
||||
title = args.title or meta.get("title", args.source.stem)
|
||||
display = title.split(":")[0].strip()
|
||||
resolved_title = title or meta.get("title", source.stem)
|
||||
display = resolved_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", "")),
|
||||
("owner", meta.get("owner", "")),
|
||||
(
|
||||
"review",
|
||||
f"reviewed {meta.get('last_reviewed', '')}"
|
||||
if meta.get("last_reviewed")
|
||||
else "",
|
||||
),
|
||||
)
|
||||
if v
|
||||
)
|
||||
|
|
@ -353,23 +358,103 @@ def main() -> int:
|
|||
for a, n, t in rail
|
||||
)
|
||||
|
||||
publication = publication or {}
|
||||
source_note = " · ".join(
|
||||
item
|
||||
for item in (
|
||||
publication.get("source_repo", ""),
|
||||
publication.get("source_path", ""),
|
||||
publication.get("source_revision", ""),
|
||||
)
|
||||
if item
|
||||
)
|
||||
source_line = (
|
||||
f'<p class="sub">Source: <code>{html.escape(source_note)}</code></p>'
|
||||
if source_note
|
||||
else ""
|
||||
)
|
||||
review_due = publication.get("review_due", "")
|
||||
review_line = (
|
||||
f'<p class="sub">Review due: {html.escape(review_due)}</p>'
|
||||
if review_due
|
||||
else ""
|
||||
)
|
||||
lifecycle = publication.get("lifecycle", "active")
|
||||
successor = publication.get("successor", "")
|
||||
lifecycle_notice = ""
|
||||
if lifecycle == "superseded":
|
||||
successor_link = (
|
||||
f' <a href="{html.escape(successor, quote=True)}">Read its successor.</a>'
|
||||
if successor
|
||||
else ""
|
||||
)
|
||||
lifecycle_notice = (
|
||||
'<div class="rule-quote"><p><strong>Superseded.</strong>'
|
||||
f" This address is retained as part of the policy record.{successor_link}</p></div>"
|
||||
)
|
||||
elif lifecycle == "withdrawn":
|
||||
lifecycle_notice = (
|
||||
'<div class="rule-quote"><p><strong>Withdrawn.</strong> '
|
||||
"This document is retained for historical reference and is not current policy."
|
||||
"</p></div>"
|
||||
)
|
||||
currency_notice = (
|
||||
'<div class="rule-quote"><p><strong>Review overdue.</strong> '
|
||||
f"This document was due for review on {html.escape(review_due)}.</p></div>"
|
||||
if review_due and publication.get("stale") == "true"
|
||||
else ""
|
||||
)
|
||||
source_revision_meta = (
|
||||
f'<meta name="policy-source-revision" content="'
|
||||
f'{html.escape(publication.get("source_revision", ""), quote=True)}">\n'
|
||||
if publication.get("source_revision")
|
||||
else ""
|
||||
)
|
||||
source_digest_meta = (
|
||||
f'<meta name="policy-source-digest" content="'
|
||||
f'{html.escape(publication.get("source_digest", ""), quote=True)}">\n'
|
||||
if publication.get("source_digest")
|
||||
else ""
|
||||
)
|
||||
page = (
|
||||
f"<title>{html.escape(display)}</title>\n"
|
||||
"<!doctype html>\n<html lang=\"en\"><meta charset=\"utf-8\">\n"
|
||||
+ source_revision_meta
|
||||
+ source_digest_meta
|
||||
+ 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'<div class="eyebrow">{eyebrow}<span>generated from canonical source — do not edit</span></div>'
|
||||
f"<h1>{html.escape(display)}</h1>"
|
||||
+ (f'<p class="sub">{html.escape(args.subtitle)}</p>' if args.subtitle else "")
|
||||
+ (f'<p class="sub">{html.escape(subtitle)}</p>' if subtitle else "")
|
||||
+ source_line
|
||||
+ review_line
|
||||
+ '</header><div class="layout">'
|
||||
f'<nav class="rail" aria-label="Sections"><ol>{rail_html}</ol></nav>'
|
||||
f"<main>{body}"
|
||||
f"<main>{lifecycle_notice}{currency_notice}{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"
|
||||
f"<span>{html.escape(source_note or 'generated from canonical source')}</span></footer>"
|
||||
"</main></div></div></html>\n"
|
||||
)
|
||||
args.output.write_text(page)
|
||||
print(f"{args.output}: {len(rail)} sections, {len(page)} bytes")
|
||||
return page, meta, len(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()
|
||||
|
||||
page, _meta, section_count = render_page(
|
||||
args.source,
|
||||
title=args.title,
|
||||
subtitle=args.subtitle,
|
||||
)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(page, encoding="utf-8")
|
||||
print(f"{args.output}: {section_count} sections, {len(page)} bytes")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue