aboutsummaryrefslogtreecommitdiffstats
path: root/spec/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'spec/scripts')
-rw-r--r--spec/scripts/gen_abnf.py265
-rw-r--r--spec/scripts/gen_ebnf.py215
-rw-r--r--spec/scripts/gen_spec.py1053
3 files changed, 1533 insertions, 0 deletions
diff --git a/spec/scripts/gen_abnf.py b/spec/scripts/gen_abnf.py
new file mode 100644
index 0000000..9569195
--- /dev/null
+++ b/spec/scripts/gen_abnf.py
@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+"""Generate UGI_GRAMMAR_v0.abnf from ugi_registry_v0.json."""
+
+import json
+import os
+import sys
+
+
+def load_registry(path):
+ with open(path, "r") as f:
+ return json.load(f)
+
+
+def hex_char(c):
+ """Return ABNF hex notation for a character."""
+ return f"%x{ord(c):02X}"
+
+
+def hex_range(start, end):
+ """Return ABNF hex range."""
+ return f"%x{ord(start):02X}-{ord(end):02X}"
+
+
+def collect_sub_ids(f):
+ """Collect all sub-ID codes from a field."""
+ ids = []
+ if "sub_ids" in f:
+ ids.extend(f["sub_ids"].keys())
+ if "sub_id_groups" in f:
+ for group in f["sub_id_groups"].values():
+ if "sub_ids" in group:
+ ids.extend(group["sub_ids"].keys())
+ return ids
+
+
+def gen_sub_id_rule(name, sub_ids):
+ """Generate ABNF rule matching any of the given sub-ID strings."""
+ # Quote each as case-insensitive string
+ parts = []
+ for sid in sorted(set(sub_ids)):
+ # ABNF quoted strings are case-insensitive by default
+ parts.append(f'"{sid}"')
+ return f"{name} = " + " / ".join(parts)
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(f"Usage: {sys.argv[0]} <registry.json> <output.abnf>", file=sys.stderr)
+ sys.exit(1)
+
+ registry_path, output_path = sys.argv[1], sys.argv[2]
+ reg = load_registry(registry_path)
+ fields = reg["fields"]
+
+ lines = []
+ lines.append("; UGI Grammar v0 — RFC 5234 ABNF")
+ lines.append("; Generated from ugi_registry_v0.json")
+ lines.append(";")
+ lines.append("; Compatible with abnf.dev/abnf2svg")
+ lines.append("")
+
+ # ── Top-level ──
+ lines.append("; === Top-level ===")
+ lines.append("")
+ lines.append('ugi-string = "ugi:" version "@" handle ":" field-list')
+ lines.append("")
+ lines.append('version = 1*DIGIT ["/" 1*DIGIT]')
+ lines.append("")
+ lines.append("; handle: alphanumeric, hyphens, underscores, dots")
+ lines.append("handle = 1*(ALPHA / DIGIT / %x2D / %x5F / %x2E)")
+ lines.append("")
+ lines.append('field-list = field *("," field)')
+ lines.append("")
+
+ # ── Field dispatch ──
+ lines.append("; === Field dispatch ===")
+ lines.append("")
+
+ # Build field alternatives
+ field_rules = []
+ for code in sorted(fields.keys()):
+ field_rules.append(f"field-{code}")
+
+ lines.append("field = " + " / ".join(field_rules))
+ lines.append("")
+
+ # ── Common rules ──
+ lines.append("; === Common rules ===")
+ lines.append("")
+ lines.append("ODIGIT = %x30-37")
+ lines.append("")
+ lines.append('paid = "$"')
+ lines.append("")
+ lines.append('aspire = "+" ODIGIT')
+ lines.append("")
+ lines.append("paid-aspire = paid [aspire]")
+ lines.append("")
+ lines.append("modifier = paid-aspire / aspire")
+ lines.append("")
+ lines.append('alternative = "/" ODIGIT')
+ lines.append("")
+ lines.append('; custom sub-ID: ~ followed by 2+ alpha chars')
+ lines.append('custom-sub = "~" 2*ALPHA')
+ lines.append("")
+ lines.append("; sub-ID: 1-4 alpha chars")
+ lines.append("sub-id = 1*4ALPHA")
+ lines.append("")
+
+ # ── Per-field rules ──
+ lines.append("; === Per-field rules ===")
+ lines.append("")
+
+ for code in sorted(fields.keys()):
+ f = fields[code]
+ ftype = f["type"]
+ mods = f.get("modifiers", {})
+ has_paid = mods.get("paid", False)
+ has_aspire = mods.get("aspire", False)
+ has_alt = mods.get("alternative", False)
+
+ hex_code = hex_char(code)
+ lines.append(f"; {code} — {f['name']} ({ftype})")
+
+ if ftype == "direct":
+ # Build modifier part
+ mod_parts = []
+ if has_alt:
+ mod_parts.append("alternative")
+ if has_paid and has_aspire:
+ mod_parts.append("modifier")
+ elif has_paid:
+ mod_parts.append("paid")
+ elif has_aspire:
+ mod_parts.append("aspire")
+
+ if mod_parts:
+ mod_str = " [" + " / ".join(mod_parts) + "]"
+ else:
+ mod_str = ""
+
+ lines.append(f"field-{code} = {hex_code} ODIGIT{mod_str}")
+
+ elif ftype == "multi":
+ # Entry: sub-id + digit + optional modifier
+ mod_parts = []
+ if has_paid and has_aspire:
+ mod_parts.append("modifier")
+ elif has_paid:
+ mod_parts.append("paid")
+ elif has_aspire:
+ mod_parts.append("aspire")
+
+ if mod_parts:
+ mod_str = " [" + " / ".join(mod_parts) + "]"
+ else:
+ mod_str = ""
+
+ entry_rule = f"field-{code}-entry"
+ if code == "l":
+ # l uses 2-char ISO 639-1 codes instead of generic sub-ids
+ lines.append(
+ f"{entry_rule} = 2ALPHA ODIGIT{mod_str}"
+ )
+ else:
+ lines.append(
+ f"{entry_rule} = (sub-id / custom-sub) ODIGIT{mod_str}"
+ )
+ lines.append(
+ f"field-{code} = {hex_code} 1*{entry_rule}"
+ )
+
+ elif ftype == "single":
+ mod_parts = []
+ if has_paid and has_aspire:
+ mod_parts.append("modifier")
+ elif has_paid:
+ mod_parts.append("paid")
+ elif has_aspire:
+ mod_parts.append("aspire")
+
+ if mod_parts:
+ mod_str = " [" + " / ".join(mod_parts) + "]"
+ else:
+ mod_str = ""
+
+ lines.append(
+ f"field-{code} = {hex_code} (sub-id / custom-sub) ODIGIT{mod_str}"
+ )
+
+ elif ftype == "special":
+ if code == "g":
+ # g: domains separated by /, $ after domain, ~ for custom, no digits
+ lines.append(f'g-domain = 2*4ALPHA ["$"]')
+ lines.append(f'g-custom = "~" 2*ALPHA')
+ lines.append(
+ f'field-g = {hex_code} (g-domain / g-custom) *("/" (g-domain / g-custom))'
+ )
+ elif code == "b":
+ # b: digit/digit, optional aspire on each
+ aspire_str = " [aspire]" if has_aspire else ""
+ lines.append(
+ f'field-{code} = {hex_code} ODIGIT{aspire_str} "/" ODIGIT{aspire_str}'
+ )
+ elif code == "v":
+ lines.append(
+ f'field-{code} = {hex_code} ODIGIT "/" ODIGIT'
+ )
+ else:
+ # Fallback
+ lines.append(f"field-{code} = {hex_code} 1*(ALPHA / DIGIT / %x24 / %x2B / %x2F / %x7E)")
+
+ lines.append("")
+
+ # ── Block format ──
+ lines.append("; === Block format ===")
+ lines.append("")
+ lines.append(
+ 'ugi-block = block-header CRLF block-first-line CRLF '
+ '1*(block-field-line CRLF) block-footer'
+ )
+ lines.append("")
+ lines.append('block-header = "------- BEGIN UGI BLOCK -------"')
+ lines.append('block-footer = "-------- END UGI BLOCK --------"')
+ lines.append("")
+ lines.append(
+ 'block-first-line = "v:" version SP "@" handle [SP block-geek-field]'
+ )
+ lines.append("")
+ lines.append('; In block format, G field uses uppercase and appears on first line')
+ lines.append(
+ 'block-geek-field = "G" (g-domain / g-custom) *("/" (g-domain / g-custom))'
+ )
+ lines.append("")
+ lines.append(
+ 'block-field-line = block-field *(SP block-field)'
+ )
+ lines.append("")
+ lines.append(
+ "; Block fields use uppercase code prefix repeated per sub-ID"
+ )
+ lines.append(
+ 'block-field = ALPHA 1*(ALPHA / DIGIT / %x24 / %x2B / %x2F / %x7E)'
+ )
+ lines.append("")
+
+ # ── Core rules ──
+ lines.append("; === Core rules (RFC 5234) ===")
+ lines.append("")
+ lines.append("ALPHA = %x41-5A / %x61-7A")
+ lines.append("DIGIT = %x30-39")
+ lines.append("SP = %x20")
+ lines.append("CRLF = %x0D.0A / %x0A")
+ lines.append("")
+
+ output = "\n".join(lines)
+
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
+ with open(output_path, "w") as f:
+ f.write(output)
+
+ print(f"Generated {output_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/spec/scripts/gen_ebnf.py b/spec/scripts/gen_ebnf.py
new file mode 100644
index 0000000..65824bd
--- /dev/null
+++ b/spec/scripts/gen_ebnf.py
@@ -0,0 +1,215 @@
+#!/usr/bin/env python3
+"""Generate UGI_GRAMMAR_v0.ebnf from ugi_registry_v0.json."""
+
+import json
+import os
+import sys
+
+
+def load_registry(path):
+ with open(path, "r") as f:
+ return json.load(f)
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(f"Usage: {sys.argv[0]} <registry.json> <output.ebnf>", file=sys.stderr)
+ sys.exit(1)
+
+ registry_path, output_path = sys.argv[1], sys.argv[2]
+ reg = load_registry(registry_path)
+ fields = reg["fields"]
+
+ lines = []
+ lines.append("/* UGI Grammar v0 — W3C EBNF */")
+ lines.append("/* Generated from ugi_registry_v0.json */")
+ lines.append("/* Compatible with bottlecaps.de/rr/ui */")
+ lines.append("")
+
+ # ── Top-level ──
+ lines.append("/* === Top-level === */")
+ lines.append("")
+ lines.append("ugi_string ::= 'ugi:' version '@' handle ':' field_list")
+ lines.append("")
+ lines.append("version ::= digit+ ( '/' digit+ )?")
+ lines.append("")
+ lines.append("/* handle: alphanumeric, hyphens, underscores, dots */")
+ lines.append("handle ::= ( alpha | digit | '-' | '_' | '.' )+")
+ lines.append("")
+ lines.append("field_list ::= field ( ',' field )*")
+ lines.append("")
+
+ # ── Field dispatch ──
+ lines.append("/* === Field dispatch === */")
+ lines.append("")
+
+ field_alts = [f"field_{code}" for code in sorted(fields.keys())]
+ # Break into multiple lines for readability
+ lines.append("field ::= " + "\n | ".join(field_alts))
+ lines.append("")
+
+ # ── Common rules ──
+ lines.append("/* === Common rules === */")
+ lines.append("")
+ lines.append("odigit ::= [0-7]")
+ lines.append("")
+ lines.append("paid ::= '$'")
+ lines.append("")
+ lines.append("aspire ::= '+' odigit")
+ lines.append("")
+ lines.append("paid_aspire ::= paid aspire?")
+ lines.append("")
+ lines.append("modifier ::= paid_aspire | aspire")
+ lines.append("")
+ lines.append("alternative ::= '/' odigit")
+ lines.append("")
+ lines.append("/* custom sub-ID: ~ followed by 2+ alpha chars */")
+ lines.append("custom_sub ::= '~' alpha alpha alpha*")
+ lines.append("")
+ lines.append("/* sub-ID: 1-4 alpha chars */")
+ lines.append("sub_id ::= alpha alpha? alpha? alpha?")
+ lines.append("")
+
+ # ── Per-field rules ──
+ lines.append("/* === Per-field rules === */")
+ lines.append("")
+
+ for code in sorted(fields.keys()):
+ f = fields[code]
+ ftype = f["type"]
+ mods = f.get("modifiers", {})
+ has_paid = mods.get("paid", False)
+ has_aspire = mods.get("aspire", False)
+ has_alt = mods.get("alternative", False)
+
+ lines.append(f"/* {code} — {f['name']} ({ftype}) */")
+
+ if ftype == "direct":
+ mod_parts = []
+ if has_alt:
+ mod_parts.append("alternative")
+ if has_paid and has_aspire:
+ mod_parts.append("modifier")
+ elif has_paid:
+ mod_parts.append("paid")
+ elif has_aspire:
+ mod_parts.append("aspire")
+
+ if mod_parts:
+ mod_str = " ( " + " | ".join(mod_parts) + " )?"
+ else:
+ mod_str = ""
+
+ lines.append(f"field_{code} ::= '{code}' odigit{mod_str}")
+
+ elif ftype == "multi":
+ mod_parts = []
+ if has_paid and has_aspire:
+ mod_parts.append("modifier")
+ elif has_paid:
+ mod_parts.append("paid")
+ elif has_aspire:
+ mod_parts.append("aspire")
+
+ if mod_parts:
+ mod_str = " ( " + " | ".join(mod_parts) + " )?"
+ else:
+ mod_str = ""
+
+ if code == "l":
+ # l uses 2-char ISO 639-1 codes instead of generic sub-ids
+ lines.append(
+ f"field_{code}_entry ::= alpha alpha odigit{mod_str}"
+ )
+ else:
+ lines.append(
+ f"field_{code}_entry ::= ( sub_id | custom_sub ) odigit{mod_str}"
+ )
+ lines.append(
+ f"field_{code} ::= '{code}' field_{code}_entry+"
+ )
+
+ elif ftype == "single":
+ mod_parts = []
+ if has_paid and has_aspire:
+ mod_parts.append("modifier")
+ elif has_paid:
+ mod_parts.append("paid")
+ elif has_aspire:
+ mod_parts.append("aspire")
+
+ if mod_parts:
+ mod_str = " ( " + " | ".join(mod_parts) + " )?"
+ else:
+ mod_str = ""
+
+ lines.append(
+ f"field_{code} ::= '{code}' ( sub_id | custom_sub ) odigit{mod_str}"
+ )
+
+ elif ftype == "special":
+ if code == "g":
+ lines.append("g_domain ::= alpha alpha alpha? alpha? paid?")
+ lines.append("g_custom ::= '~' alpha alpha alpha*")
+ lines.append(
+ "field_g ::= 'g' ( g_domain | g_custom ) ( '/' ( g_domain | g_custom ) )*"
+ )
+ elif code == "b":
+ aspire_str = " aspire?" if has_aspire else ""
+ lines.append(
+ f"field_b ::= 'b' odigit{aspire_str} '/' odigit{aspire_str}"
+ )
+ elif code == "v":
+ lines.append("field_v ::= 'v' odigit '/' odigit")
+ else:
+ lines.append(
+ f"field_{code} ::= '{code}' ( alpha | digit | '$' | '+' | '/' | '~' )+"
+ )
+
+ lines.append("")
+
+ # ── Block format ──
+ lines.append("/* === Block format === */")
+ lines.append("")
+ lines.append(
+ "ugi_block ::= block_header newline block_first_line newline "
+ "block_field_line+ block_footer"
+ )
+ lines.append("")
+ lines.append("block_header ::= '------- BEGIN UGI BLOCK -------'")
+ lines.append("block_footer ::= '-------- END UGI BLOCK --------'")
+ lines.append("")
+ lines.append(
+ "block_first_line ::= 'v:' version ' @' handle ( ' ' block_geek_field )?"
+ )
+ lines.append("")
+ lines.append(
+ "block_geek_field ::= 'G' ( g_domain | g_custom ) ( '/' ( g_domain | g_custom ) )*"
+ )
+ lines.append("")
+ lines.append("block_field_line ::= block_field ( ' ' block_field )* newline")
+ lines.append("")
+ lines.append(
+ "block_field ::= alpha ( alpha | digit | '$' | '+' | '/' | '~' )+"
+ )
+ lines.append("")
+
+ # ── Terminals ──
+ lines.append("/* === Terminals === */")
+ lines.append("")
+ lines.append("alpha ::= [a-zA-Z]")
+ lines.append("digit ::= [0-9]")
+ lines.append("newline ::= #xD #xA | #xA")
+ lines.append("")
+
+ output = "\n".join(lines)
+
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
+ with open(output_path, "w") as f:
+ f.write(output)
+
+ print(f"Generated {output_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/spec/scripts/gen_spec.py b/spec/scripts/gen_spec.py
new file mode 100644
index 0000000..4ab8a20
--- /dev/null
+++ b/spec/scripts/gen_spec.py
@@ -0,0 +1,1053 @@
+#!/usr/bin/env python3
+"""Generate UGI_SPEC_v0.md from ugi_registry_v0.json."""
+
+import json
+import os
+import sys
+
+
+def load_registry(path):
+ with open(path, "r") as f:
+ return json.load(f)
+
+
+def gen_title(spec):
+ lines = []
+ lines.append(f"# {spec['name']} — {spec['full_name']}")
+ lines.append("")
+ lines.append(
+ f"**Version: {spec['version']} ({spec['status'].title()}) · "
+ f"{spec['date']} · {spec['license']} License**"
+ )
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_overview(spec):
+ lines = []
+ lines.append("## Overview")
+ lines.append("")
+ lines.append(spec["description"])
+ lines.append("")
+ lines.append("It descends from two predecessors:")
+ lines.append("")
+ for p in spec["predecessors"]:
+ line = f"- [{p['name']}]({p['url']}) by {p['author']} ({p['years']})"
+ if "continuation" in p:
+ c = p["continuation"]
+ line += f", with its [{c['name']}]({c['url']}) by {c['author']} ({c['years']})"
+ lines.append(line)
+ lines.append("")
+ lines.append(
+ "UGI takes the cultural breadth of the Geek Code, the numeric compactness "
+ "of the Hacker Key, and adds URI safety, modern fields, and extensibility."
+ )
+ lines.append("")
+ lines.append("### Design goals")
+ lines.append("")
+ lines.append("1. Single-letter field codes — 24 active, 2 reserved")
+ lines.append("2. Octal (0–7) rating scale")
+ lines.append("3. URI-safe characters only — no percent-encoding needed")
+ lines.append("4. Dual format — URI for machines, block for humans")
+ lines.append("5. Case-insensitive")
+ lines.append("6. Extensible via `~` custom sub-IDs")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_formats(fmt):
+ lines = []
+ lines.append("## Formats")
+ lines.append("")
+ lines.append("### URI")
+ lines.append("")
+ lines.append("```")
+ lines.append(fmt["uri"]["template"])
+ lines.append("```")
+ lines.append("")
+ lines.append("### Block")
+ lines.append("")
+ lines.append("```")
+ lines.append(fmt["block"]["header"])
+ lines.append(fmt["block"]["first_line"])
+ lines.append("<field> <field> ...")
+ lines.append(fmt["block"]["footer"])
+ lines.append("```")
+ lines.append("")
+ lines.append(
+ "In block format, field codes are repeated per sub-ID for readability: "
+ "`Ppy6 Prs5` instead of `ppy6rs5`. Long-form sub-ID aliases (noted in "
+ "registries) are permitted in block format."
+ )
+ lines.append("")
+ lines.append("### Conversion")
+ lines.append("")
+ lines.append(
+ "Block → URI: remove header/footer, merge repeated field codes, "
+ "replace spaces with commas, prepend `ugi:`."
+ )
+ lines.append("")
+ lines.append(
+ "URI → Block: split on commas, expand merged sub-IDs by repeating "
+ "field codes, add header/footer."
+ )
+ lines.append("")
+ lines.append(
+ "No modifier or character changes are needed — both formats use identical symbols."
+ )
+ lines.append("")
+ lines.append("### Mandatory fields")
+ lines.append("")
+ mand = ", ".join(f"`{f}`" for f in fmt["mandatory_fields"])
+ lines.append(
+ f"`@handle` and {mand} (geek type) are required. All others are optional."
+ )
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_grammar(fields):
+ lines = []
+ lines.append("## Grammar")
+ lines.append("")
+ lines.append("```")
+ lines.append("<field_code><sub_id><digit>[<modifier>]...")
+ lines.append("```")
+ lines.append("")
+ lines.append("- **Field codes** — single letter (a–z), case-insensitive")
+ lines.append("- **Sub-IDs** — 1–4 lowercase alpha chars")
+ lines.append("- **Ratings** — single octal digit (0–7), required on every sub-ID")
+ lines.append("- **Custom sub-IDs** — `~` prefix: `~fermenting5`")
+ lines.append("- **Modifiers** — after the digit:")
+ lines.append(" - `$` — paid")
+ lines.append(" - `+` followed by digit — aspiring toward that level")
+ lines.append(" - `$+` combined — paid and aspiring")
+ lines.append(
+ '- **Alternatives** — `/` between two digits on direct-value fields: `c3/6`'
+ )
+ lines.append("")
+ lines.append("### Special formats")
+ lines.append("")
+ lines.append("| Field | Format | Example |")
+ lines.append("|---|---|---|")
+ lines.append(
+ "| `g` geek type | `/` separates domains, `~` for custom, `$` for paid, no ratings | `gcs$/ai/wr~fermenting` |"
+ )
+ lines.append("| `b` build | `<height>/<width>` | `b6/3` |")
+ lines.append("| `l` languages | ISO 639-1 code + rating | `lar7en6` |")
+ lines.append("| `v` politics | `<social>/<economic>` | `v5/3` |")
+ lines.append("")
+ lines.append("### Parser algorithm")
+ lines.append("")
+ lines.append(
+ "After each comma or space, read a single letter (field code). Then:"
+ )
+ lines.append("")
+ lines.append(
+ "- `g` → read alpha tokens separated by `/` and `~`-prefixed tokens until delimiter"
+ )
+ lines.append("- `b` → digit, `/`, digit")
+ lines.append("- `v` → digit, `/`, digit")
+ lines.append("- `l` → repeat: 2 alpha + digit + optional modifier")
+ lines.append("- `r` → one sub-id + digit (single value only)")
+ lines.append("- direct fields → digit + optional modifier")
+ lines.append("- direct-alt fields → digit + optional `/` + digit + optional modifier")
+ lines.append("- multi fields → repeat: alpha (sub-id) + digit + optional modifier")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_scale(scale):
+ lines = []
+ lines.append("## Rating scale (0–7)")
+ lines.append("")
+ lines.append("| Value | General | Proficiency | Enthusiasm | Stance |")
+ lines.append("|---|---|---|---|---|")
+ for v in scale["values"]:
+ lines.append(
+ f"| {v['value']} | {v['general']} | {v['proficiency']} | "
+ f"{v['enthusiasm']} | {v['stance']} |"
+ )
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_modifier_permissions(fields, modifiers):
+ lines = []
+ lines.append("### Modifier permissions")
+ lines.append("")
+ lines.append("| Modifiers | Fields |")
+ lines.append("|---|---|")
+
+ both = []
+ paid_only = []
+ aspire_only = []
+ neither = []
+
+ for code in sorted(fields.keys()):
+ f = fields[code]
+ mods = f.get("modifiers", {})
+ has_paid = mods.get("paid", False)
+ has_aspire = mods.get("aspire", False)
+ if has_paid and has_aspire:
+ both.append(code)
+ elif has_paid:
+ paid_only.append(code)
+ elif has_aspire:
+ aspire_only.append(code)
+ else:
+ neither.append(code)
+
+ if both:
+ lines.append(
+ f"| `$` and `+` | {' '.join(f'`{c}`' for c in both)} |"
+ )
+ if paid_only:
+ lines.append(
+ f"| `$` only | {' '.join(f'`{c}`' for c in paid_only)} |"
+ )
+ if aspire_only:
+ lines.append(
+ f"| `+` only | {' '.join(f'`{c}`' for c in aspire_only)} |"
+ )
+ if neither:
+ lines.append(
+ f"| Neither | {' '.join(f'`{c}`' for c in neither)} |"
+ )
+
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_field_list(fields, fmt):
+ lines = []
+ lines.append("## Field list")
+ lines.append("")
+ lines.append("| Code | Field | Type | Category |")
+ lines.append("|---|---|---|---|")
+
+ reserved = fmt.get("reserved_codes", [])
+ all_codes = sorted(set(list(fields.keys()) + reserved))
+
+ for code in all_codes:
+ if code in reserved:
+ lines.append(f"| `{code}` | *(reserved)* | — | — |")
+ continue
+ f = fields[code]
+ ftype = f["type"]
+ mods = f.get("modifiers", {})
+ has_alt = mods.get("alternative", False)
+
+ type_str = ftype.capitalize()
+ if ftype == "direct" and has_alt:
+ type_str = "Direct, `/` ok"
+ elif ftype == "special":
+ if code == "b":
+ type_str = "Special (h/w)"
+ elif code == "v":
+ type_str = "Special (s/e)"
+ elif code == "g":
+ type_str = "Special"
+ elif code == "l":
+ type_str = "Multi (ISO)"
+ elif ftype == "multi":
+ if code == "h":
+ type_str = "Multi (1-letter)"
+ elif code == "l":
+ type_str = "Multi (ISO)"
+ elif ftype == "single":
+ type_str = "Single"
+
+ lines.append(
+ f"| `{code}` | {f['name']} | {type_str} | {f['category'].title()} |"
+ )
+
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_field_def_direct(code, f):
+ """Generate a direct-type field definition."""
+ lines = []
+ vals = f.get("values", {})
+ if vals:
+ for i in range(8):
+ v = vals.get(str(i), {})
+ label = v.get("label", "—")
+ humor = v.get("humor")
+ if humor:
+ lines.append(f"{i}. **{label}** — {humor}")
+ else:
+ lines.append(f"{i}. **{label}**")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_field_def_special_b(f):
+ """Generate build field definition."""
+ lines = []
+ lines.append(
+ "Format: `b<height>/<width>`. Each 0–7. "
+ "0 = extremely small, 4 = average, 7 = extremely large. "
+ "`+` modifier permitted."
+ )
+ lines.append("")
+ for dim_name, dim in f["dimensions"].items():
+ lines.append(f"**{dim_name.title()}:** ", )
+ vals = dim["values"]
+ parts = [f"{k} = {v}" for k, v in sorted(vals.items(), key=lambda x: int(x[0]))]
+ lines[-1] += " · ".join(parts)
+ lines.append("")
+ lines.append("> `b6/3` — tall and slim.")
+ lines.append("> `b4/4` — average. Chairs were designed for you.")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_field_def_special_v(f):
+ """Generate politics field definition."""
+ lines = []
+ lines.append("Format: `v<social>/<economic>`. No modifiers.")
+ lines.append("")
+ for dim_name, dim in f["dimensions"].items():
+ vals = dim["values"]
+ low = vals.get("0", "")
+ mid = vals.get("4", "")
+ high = vals.get("7", "")
+ lines.append(f"**{dim_name.title()}:** 0 = {low} · 4 = {mid} · 7 = {high}")
+ lines.append("")
+ lines.append("> `v5/3` — socially center-left, economically center-right.")
+ lines.append("> `v4/4` — centrist. You annoy both sides equally.")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_field_def_special_g(f):
+ """Generate geek type field definition."""
+ lines = []
+ lines.append(
+ "Mandatory. `/` separates domains. `~` prefixes custom domains. "
+ "`$` after a domain = paid. No ratings."
+ )
+ lines.append("")
+
+ groups = f.get("sub_id_groups", {})
+ for group_key, group in groups.items():
+ label = group.get("label", group_key)
+ sub_ids = group.get("sub_ids", {})
+
+ # For larger groups, use a table
+ items = list(sub_ids.items())
+ if len(items) > 7:
+ lines.append(f"#### {label}")
+ lines.append("")
+ lines.append("| Code | Domain | Code | Domain |")
+ lines.append("|---|---|---|---|")
+ # Pair them up
+ for i in range(0, len(items), 2):
+ c1, n1 = items[i]
+ if isinstance(n1, dict):
+ n1 = n1.get("name", n1.get("long", c1))
+ row = f"| `{c1}` | {n1}"
+ if i + 1 < len(items):
+ c2, n2 = items[i + 1]
+ if isinstance(n2, dict):
+ n2 = n2.get("name", n2.get("long", c2))
+ row += f" | `{c2}` | {n2} |"
+ else:
+ row += " | | |"
+ lines.append(row)
+ lines.append("")
+ else:
+ # Inline format for smaller groups
+ lines.append(f"#### {label}")
+ lines.append("")
+ parts = []
+ for c, n in items:
+ if isinstance(n, dict):
+ n = n.get("name", n.get("long", c))
+ parts.append(f"`{c}` {n}")
+ lines.append(" · ".join(parts))
+ lines.append("")
+
+ lines.append("#### Custom")
+ lines.append("")
+ lines.append(
+ "`~fermenting` `~beekeeping` `~lockpicking` `~origami` — anything goes."
+ )
+ lines.append("")
+ lines.append("> `gcs$/ai/wr~fermenting` — paid CS geek, also into AI, writing, and fermenting.")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_sub_id_table(sub_ids):
+ """Generate a sub-ID table from a flat sub_ids dict."""
+ lines = []
+ items = list(sub_ids.items())
+
+ # Check if it's a two-column table or three-column
+ has_long = any(
+ isinstance(v, dict) and "long" in v for v in sub_ids.values()
+ )
+
+ if has_long:
+ # Pair them for a wider table
+ lines.append("| Short | (Long) | Name | Short | (Long) | Name |")
+ lines.append("|---|---|---|---|---|---|")
+ for i in range(0, len(items), 2):
+ c1, v1 = items[i]
+ if isinstance(v1, dict):
+ long1 = f"`({v1['long']})`" if "long" in v1 else "—"
+ name1 = v1.get("name", c1)
+ else:
+ long1 = "—"
+ name1 = v1
+ row = f"| `{c1}` | {long1} | {name1}"
+
+ if i + 1 < len(items):
+ c2, v2 = items[i + 1]
+ if isinstance(v2, dict):
+ long2 = f"`({v2['long']})`" if "long" in v2 else "—"
+ name2 = v2.get("name", c2)
+ else:
+ long2 = "—"
+ name2 = v2
+ row += f" | `{c2}` | {long2} | {name2} |"
+ else:
+ row += " | | | |"
+ lines.append(row)
+ else:
+ lines.append("| Short | Name |")
+ lines.append("|---|---|")
+ for c, v in items:
+ if isinstance(v, dict):
+ name = v.get("name", c)
+ else:
+ name = v
+ lines.append(f"| `{c}` | {name} |")
+
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_grouped_sub_ids(groups, inline_threshold=7):
+ """Generate sub-ID tables from grouped sub_ids."""
+ lines = []
+ for group_key, group in groups.items():
+ label = group.get("label", group_key)
+ sub_ids = group.get("sub_ids", {})
+ items = list(sub_ids.items())
+
+ # Check for long forms
+ has_long = any(
+ isinstance(v, dict) and "long" in v for v in sub_ids.values()
+ )
+
+ if len(items) <= inline_threshold and not has_long:
+ # Inline format
+ parts = []
+ for c, v in items:
+ if isinstance(v, dict):
+ long_str = f"({v['long']}) " if "long" in v else ""
+ name = v.get("name", c)
+ else:
+ long_str = ""
+ name = v
+ parts.append(f"`{c}` {long_str}{name}")
+ lines.append(f"**{label}:**")
+ lines.append(" · ".join(parts))
+ lines.append("")
+ else:
+ # Table format with long forms
+ lines.append(f"**{label}:**")
+ lines.append("")
+ if has_long:
+ lines.append("| Short | (Long) | Name | Short | (Long) | Name |")
+ lines.append("|---|---|---|---|---|---|")
+ for i in range(0, len(items), 2):
+ c1, v1 = items[i]
+ if isinstance(v1, dict):
+ long1 = f"`({v1['long']})`" if "long" in v1 else "—"
+ name1 = v1.get("name", c1)
+ else:
+ long1 = "—"
+ name1 = v1
+ row = f"| `{c1}` | {long1} | {name1}"
+ if i + 1 < len(items):
+ c2, v2 = items[i + 1]
+ if isinstance(v2, dict):
+ long2 = f"`({v2['long']})`" if "long" in v2 else "—"
+ name2 = v2.get("name", c2)
+ else:
+ long2 = "—"
+ name2 = v2
+ row += f" | `{c2}` | {long2} | {name2} |"
+ else:
+ row += " | | | |"
+ lines.append(row)
+ else:
+ lines.append("| Short | Name |")
+ lines.append("|---|---|")
+ for c, v in items:
+ if isinstance(v, dict):
+ name = v.get("name", c)
+ else:
+ name = v
+ lines.append(f"| `{c}` | {name} |")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def gen_field_def_multi(code, f, scale):
+ """Generate a multi-type field definition."""
+ lines = []
+
+ # Sub-IDs
+ if "sub_id_groups" in f:
+ lines.append(gen_grouped_sub_ids(f["sub_id_groups"]))
+ elif "sub_ids" in f:
+ lines.append(gen_sub_id_table(f["sub_ids"]))
+
+ # Scale info
+ scale_type = f.get("scale_type")
+ if scale_type and scale_type != "custom":
+ lines.append(f"Scale: **{scale_type}** (see global scale table).")
+ lines.append("")
+
+ # Custom scale labels
+ if "scale_labels" in f:
+ humor = f.get("humor_scale", {}) or {}
+ for i in range(8):
+ label = f["scale_labels"].get(str(i), "—")
+ h = humor.get(str(i))
+ if h:
+ lines.append(f"{i}. **{label}** — {h}")
+ else:
+ lines.append(f"{i}. **{label}**")
+ lines.append("")
+ elif f.get("humor_scale"):
+ humor = f["humor_scale"]
+ for val in ["0", "7"]:
+ if val in humor:
+ lines.append(f"> {val}: {humor[val]}")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def gen_field_def_single(code, f):
+ """Generate a single-type field definition."""
+ lines = []
+ lines.append("**Single value only** — one tradition + devoutness rating. Parser rejects multiple sub-IDs.")
+ lines.append("")
+
+ if "sub_ids" in f:
+ lines.append(gen_sub_id_table(f["sub_ids"]))
+
+ if "scale_labels" in f:
+ sl = f["scale_labels"]
+ lines.append(f"Rating = devoutness: 0 = {sl.get('0', '')}, 4 = {sl.get('4', '')}, 7 = {sl.get('7', '')}.")
+ lines.append("")
+
+ # Example
+ if code == "r":
+ lines.append("> `ris6` — devout Muslim. `rath4` — culturally atheist.")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def gen_field_def_lang(f):
+ """Generate spoken languages field definition."""
+ lines = []
+ lines.append("ISO 639-1 codes + proficiency 0–7. `$` and `+` permitted.")
+ lines.append("")
+
+ if "scale_labels" in f:
+ humor = f.get("humor_scale", {}) or {}
+ for i in range(8):
+ label = f["scale_labels"].get(str(i), "—")
+ h = humor.get(str(i))
+ if h:
+ lines.append(f"{i}. **{label}** — {h}")
+ else:
+ lines.append(f"{i}. **{label}**")
+ lines.append("")
+
+ # Common codes
+ codes = f.get("common_codes", {})
+ if codes:
+ items = list(codes.items())
+ # Show first ~25 inline
+ parts = [f"`{c}` {n}" for c, n in items[:26]]
+ lines.append("Common codes: " + " · ".join(parts))
+ lines.append("")
+
+ lines.append(
+ "> `lar7$en6de3+5` — native Arabic (paid translator), fluent English, "
+ "learning German (beginner, aspiring proficient)."
+ )
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_field_definitions(fields, scale):
+ lines = []
+ lines.append("## Field definitions")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+
+ for code in sorted(fields.keys()):
+ f = fields[code]
+ lines.append(f"### {code} — {f['name']}")
+ lines.append("")
+
+ ftype = f["type"]
+
+ if ftype == "direct":
+ lines.append(gen_field_def_direct(code, f))
+ elif ftype == "special":
+ if code == "g":
+ lines.append(gen_field_def_special_g(f))
+ elif code == "b":
+ lines.append(gen_field_def_special_b(f))
+ elif code == "v":
+ lines.append(gen_field_def_special_v(f))
+ elif code == "l":
+ lines.append(gen_field_def_lang(f))
+ elif ftype == "multi":
+ # Special intro for certain fields
+ if code == "d":
+ lines.append("The editor holy war, now quantified.")
+ lines.append("")
+ if code == "l":
+ # l is type "multi" in JSON but uses ISO 639-1 codes
+ lines.append(gen_field_def_lang(f))
+ else:
+ lines.append(gen_field_def_multi(code, f, scale))
+ elif ftype == "single":
+ lines.append(gen_field_def_single(code, f))
+
+ # Note modifiers
+ mods = f.get("modifiers", {})
+ mod_parts = []
+ if mods.get("paid"):
+ mod_parts.append("`$` paid")
+ if mods.get("aspire"):
+ mod_parts.append("`+` aspire")
+ if mods.get("alternative"):
+ mod_parts.append("`/` alternative")
+ # Only show if there are interesting modifiers to mention
+ # (skip if already covered in special format text)
+ if mod_parts and ftype not in ("special",):
+ pass # Modifiers are covered in the permissions table
+
+ lines.append("---")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def gen_examples(examples, fields):
+ lines = []
+ lines.append("## Examples")
+ lines.append("")
+
+ for ex in examples:
+ handle = ex["handle"]
+ desc = ex["description"]
+ uri = ex["uri"]
+
+ lines.append(f"### {handle} — {desc}")
+ lines.append("")
+
+ # Generate block format from URI
+ block = uri_to_block(uri, fields)
+ lines.append("```")
+ for bl in block:
+ lines.append(bl)
+ lines.append("```")
+ lines.append("")
+ lines.append("```")
+ lines.append(uri)
+ lines.append("```")
+ lines.append("")
+
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def uri_to_block(uri, fields):
+ """Convert a URI string to block format lines."""
+ # Parse: ugi:<version>@<handle>:<fields>
+ # Just use the existing block examples from the hand-written spec
+ # since exact conversion logic is complex. For now, output the URI.
+ # Actually, let's parse it properly.
+
+ rest = uri
+ if rest.startswith("ugi:"):
+ rest = rest[4:]
+
+ # Split version@handle:fields
+ at_idx = rest.index("@")
+ version = rest[:at_idx]
+ rest = rest[at_idx + 1:]
+
+ colon_idx = rest.index(":")
+ handle = rest[:colon_idx]
+ field_str = rest[colon_idx + 1:]
+
+ field_parts = field_str.split(",")
+
+ block_lines = []
+ block_lines.append("------- BEGIN UGI BLOCK -------")
+
+ # First line: version, handle, and g field
+ first_line = f"v:{version} @{handle}"
+ remaining = []
+
+ for part in field_parts:
+ if not part:
+ continue
+ code = part[0].lower()
+ if code == "g":
+ # Geek type goes on first line
+ first_line += f" G{part[1:]}"
+ else:
+ remaining.append(part)
+
+ block_lines.append(first_line)
+
+ # Group remaining fields into lines by category
+ identity = []
+ tech = []
+ stance = []
+ entertainment = []
+ lifestyle = []
+
+ for part in remaining:
+ code = part[0].lower()
+ f = fields.get(code)
+ if not f:
+ tech.append(part)
+ continue
+ cat = f.get("category", "")
+ # Expand multi fields
+ expanded = expand_field_block(part, f)
+ if cat == "identity":
+ identity.extend(expanded)
+ elif cat in ("tech",):
+ tech.extend(expanded)
+ elif cat == "stance":
+ stance.extend(expanded)
+ elif cat == "entertainment":
+ entertainment.extend(expanded)
+ elif cat in ("lifestyle", "appearance"):
+ lifestyle.extend(expanded)
+ else:
+ tech.extend(expanded)
+
+ # Output lines
+ if identity:
+ block_lines.append(" ".join(identity))
+ if tech:
+ block_lines.append(" ".join(tech))
+ if stance:
+ block_lines.append(" ".join(stance))
+ if entertainment:
+ block_lines.append(" ".join(entertainment))
+ if lifestyle:
+ block_lines.append(" ".join(lifestyle))
+
+ block_lines.append("-------- END UGI BLOCK --------")
+ return block_lines
+
+
+def expand_field_block(part, f):
+ """Expand a URI field part into block format parts."""
+ code = part[0]
+ rest = part[1:]
+ ftype = f["type"]
+ uc = code.upper()
+
+ if ftype == "direct":
+ return [f"{uc}{rest}"]
+ elif ftype == "special":
+ if code == "b" or code == "v":
+ return [f"{uc}{rest}"]
+ elif code == "g":
+ return [f"G{rest}"]
+ elif code == "l":
+ # Parse: 2-char code + digit + mods, repeating
+ expanded = []
+ i = 0
+ while i < len(rest):
+ if rest[i] == "~":
+ # custom
+ j = i + 1
+ while j < len(rest) and rest[j].isalpha():
+ j += 1
+ sub = rest[i:j]
+ digit = rest[j] if j < len(rest) and rest[j].isdigit() else ""
+ mod = ""
+ k = j + 1
+ while k < len(rest) and rest[k] in "$+0123456789":
+ mod += rest[k]
+ k += 1
+ expanded.append(f"L{sub}{digit}{mod}")
+ i = k
+ elif rest[i].isalpha():
+ sub = rest[i:i + 2]
+ digit = rest[i + 2] if i + 2 < len(rest) else ""
+ mod = ""
+ k = i + 3
+ while k < len(rest) and rest[k] in "$+0123456789":
+ mod += rest[k]
+ k += 1
+ expanded.append(f"L{sub}{digit}{mod}")
+ i = k
+ else:
+ i += 1
+ return expanded
+ return [f"{uc}{rest}"]
+ elif ftype in ("multi", "single"):
+ # Parse sub-id + digit + mods
+ expanded = []
+ i = 0
+ while i < len(rest):
+ if rest[i] == "~":
+ j = i + 1
+ while j < len(rest) and rest[j].isalpha():
+ j += 1
+ sub = rest[i:j]
+ digit = rest[j] if j < len(rest) and rest[j].isdigit() else ""
+ mod = ""
+ k = j + 1
+ while k < len(rest) and rest[k] in "$+0123456789":
+ mod += rest[k]
+ k += 1
+ expanded.append(f"{uc}{sub}{digit}{mod}")
+ i = k
+ elif rest[i].isalpha():
+ # Read sub-id (1-4 alpha chars)
+ j = i
+ while j < len(rest) and rest[j].isalpha():
+ j += 1
+ sub = rest[i:j]
+ digit = rest[j] if j < len(rest) and rest[j].isdigit() else ""
+ mod = ""
+ k = j + 1
+ while k < len(rest) and rest[k] in "$+0123456789":
+ mod += rest[k]
+ k += 1
+ expanded.append(f"{uc}{sub}{digit}{mod}")
+ i = k
+ else:
+ i += 1
+ return expanded
+ return [f"{uc}{rest}"]
+
+
+def gen_extending():
+ lines = []
+ lines.append("## Extending UGI")
+ lines.append("")
+ lines.append("Any field with sub-IDs accepts custom entries via `~`:")
+ lines.append("")
+ lines.append("```")
+ lines.append("gcs/~mycology geek of CS and mycology")
+ lines.append("m~synthwave6 synthwave enthusiast")
+ lines.append("t~severance7 severance obsessed")
+ lines.append("j~larp5 LARP, competent")
+ lines.append("o~beos4 BeOS, neutral nostalgia")
+ lines.append("```")
+ lines.append("")
+ lines.append("Letters `n` and `u` are reserved for future spec versions.")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_comparison(comparison):
+ lines = []
+ lines.append("## Comparison with predecessors")
+ lines.append("")
+ lines.append(
+ "| | Geek Code 3.12 (1996) | Geek Code 2026 | Hacker Key v4 (2006) | UGI v0 |"
+ )
+ lines.append("|---|---|---|---|---|")
+ for row in comparison["features"]:
+ lines.append("| " + " | ".join(row) + " |")
+ lines.append("")
+
+ # Retained
+ lines.append("### Fields retained")
+ lines.append("")
+ parts = []
+ for r in comparison["retained"]:
+ parts.append(f"`{r['ugi']}` {r['note'].lower()}")
+ lines.append(
+ ", ".join(parts)
+ + " — all present in one or both predecessors."
+ )
+ lines.append("")
+
+ # Added
+ lines.append("### Fields added")
+ lines.append("")
+ for a in comparison["added"]:
+ lines.append(f"`{a['ugi']}` — {a['reason']}. ", )
+ lines.append("")
+
+ # Removed
+ lines.append("### Fields removed")
+ lines.append("")
+ parts = []
+ for r in comparison["removed"]:
+ parts.append(f"{r['field']} — {r['reason'].lower()}")
+ lines.append(". ".join(parts) + ".")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_quick_ref(fields):
+ lines = []
+ lines.append("## Quick reference")
+ lines.append("")
+ lines.append("```")
+ lines.append("FORMAT: ugi:<ver>[/<rev>]@<handle>:<fields>")
+ lines.append(
+ "SCALE: 0=hostile 1=dislike 2=meh 3=slight- 4=neutral 5=like 6=strong 7=obsessed"
+ )
+ lines.append(
+ "MODIFY: $ = paid + = aspire / = fluctuate,separate ~ = custom"
+ )
+ lines.append("")
+
+ # Field codes
+ codes = sorted(fields.keys())
+ all_codes = []
+ for c in "abcdefghijklmnopqrstuvwxyz":
+ if c in fields:
+ f = fields[c]
+ name = f["name"].lower()
+ all_codes.append(f"{c} {name}")
+ elif c in ("n", "u"):
+ all_codes.append(f"[{c} reserved]")
+
+ # Print in rows of 5
+ for i in range(0, len(all_codes), 5):
+ row = all_codes[i : i + 5]
+ lines.append(" ".join(f"{x:<14}" for x in row).rstrip())
+ lines.append("")
+
+ # Modifier groups
+ both = []
+ paid_only = []
+ aspire_only = []
+ neither = []
+ for code in sorted(fields.keys()):
+ f = fields[code]
+ mods = f.get("modifiers", {})
+ has_paid = mods.get("paid", False)
+ has_aspire = mods.get("aspire", False)
+ if has_paid and has_aspire:
+ both.append(code)
+ elif has_paid:
+ paid_only.append(code)
+ elif has_aspire:
+ aspire_only.append(code)
+ else:
+ neither.append(code)
+
+ lines.append(f"$+ {' '.join(both)} $ only {' '.join(paid_only)} + only {' '.join(aspire_only)} none {' '.join(neither)}")
+ lines.append("```")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def gen_references(spec):
+ lines = []
+ lines.append("## References")
+ lines.append("")
+ for p in spec["predecessors"]:
+ lines.append(f"- [{p['name']} (v0.1–latest)]({p['url']}) — {p['author']}, {p['years']}")
+ if "continuation" in p:
+ c = p["continuation"]
+ lines.append(f"- [{c['name']}]({c['url']}) — {c['author']}, {c['years']}")
+ for r in spec.get("references", []):
+ lines.append(f"- [{r['title']}]({r['url']})")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ lines.append("## License")
+ lines.append("")
+ lines.append(f"{spec['license']} — see [LICENSE](./LICENSE).")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(f"Usage: {sys.argv[0]} <registry.json> <output.md>", file=sys.stderr)
+ sys.exit(1)
+
+ registry_path, output_path = sys.argv[1], sys.argv[2]
+ reg = load_registry(registry_path)
+ spec = reg["spec"]
+ fmt = reg["format"]
+ scale = reg["scale"]
+ fields = reg["fields"]
+ modifiers = reg["modifiers"]
+ examples = reg["examples"]
+ comparison = reg["comparison"]
+
+ parts = [
+ gen_title(spec),
+ gen_overview(spec),
+ gen_formats(fmt),
+ gen_grammar(fields),
+ gen_scale(scale),
+ gen_modifier_permissions(fields, modifiers),
+ gen_field_list(fields, fmt),
+ gen_field_definitions(fields, scale),
+ gen_extending(),
+ gen_examples(examples, fields),
+ gen_comparison(comparison),
+ gen_quick_ref(fields),
+ gen_references(spec),
+ ]
+
+ # Each part ends with "\n" from join; ensure blank line between sections
+ output = "\n".join(p.rstrip("\n") for p in parts) + "\n"
+
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
+ with open(output_path, "w") as f:
+ f.write(output)
+
+ print(f"Generated {output_path}")
+
+
+if __name__ == "__main__":
+ main()