xml2html.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. #!/usr/bin/env python3
  2. """Convert docs/pisg-doc.xml (DocBook subset) into a single self-contained HTML page.
  3. Usage: python3 docs/xml2html.py [input.xml] [output.html]
  4. Defaults: docs/pisg-doc.xml -> docs/pisg-doc.html
  5. """
  6. import html
  7. import re
  8. import sys
  9. import textwrap
  10. import urllib.parse
  11. import xml.etree.ElementTree as ET
  12. from pathlib import Path
  13. here = Path(__file__).resolve().parent
  14. src = Path(sys.argv[1]) if len(sys.argv) > 1 else here / "pisg-doc.xml"
  15. dst = Path(sys.argv[2]) if len(sys.argv) > 2 else here / "pisg-doc.html"
  16. raw = src.read_text(encoding="utf-8", errors="replace")
  17. # The file starts with a comment before the XML declaration and references a
  18. # DTD we don't have; drop both so a plain XML parser accepts it.
  19. raw = re.sub(r"^\s*<!--.*?-->\s*", "", raw, count=1, flags=re.S)
  20. raw = re.sub(r"<\?xml[^>]*\?>", "", raw, count=1)
  21. raw = re.sub(r"<!DOCTYPE.*?>", "", raw, count=1, flags=re.S)
  22. root = ET.fromstring(raw)
  23. esc = html.escape
  24. # id -> display text, used to resolve <xref linkend=...>
  25. labels = {}
  26. for el in root.iter():
  27. i = el.get("id")
  28. if not i:
  29. continue
  30. if el.tag == "refentry":
  31. labels[i] = el.findtext("refnamediv/refname") or i
  32. else:
  33. labels[i] = (el.findtext("title") or i).strip()
  34. chapter_no = 0
  35. def inline(el):
  36. """Render an element's mixed content (text + children + tails)."""
  37. out = [esc(el.text or "")]
  38. for ch in el:
  39. out.append(node(ch))
  40. out.append(esc(ch.tail or ""))
  41. return "".join(out)
  42. def node(el):
  43. global chapter_no
  44. t = el.tag
  45. if t == "para":
  46. return "<p>" + inline(el).strip() + "</p>\n"
  47. if t in ("programlisting", "screen"):
  48. text = "".join(el.itertext()).strip("\n")
  49. text = re.sub(r"^\s*\n", "", text)
  50. return "<pre>" + esc(textwrap.dedent(text).rstrip()) + "</pre>\n"
  51. if t == "itemizedlist":
  52. return "<ul>\n" + "".join(node(c) for c in el) + "</ul>\n"
  53. if t == "listitem":
  54. body = "".join(node(c) for c in el)
  55. return "<li>" + body + "</li>\n"
  56. if t == "xref":
  57. i = el.get("linkend", "")
  58. return f'<a href="#{esc(i)}">{esc(labels.get(i, i))}</a>'
  59. if t == "link":
  60. i = el.get("linkend")
  61. if i:
  62. return f'<a href="#{esc(i)}">{inline(el)}</a>'
  63. return inline(el)
  64. if t == "ulink":
  65. return f'<a href="{esc(el.get("url", ""))}">{inline(el).strip()}</a>'
  66. if t in ("command", "filename", "userinput", "prompt"):
  67. return "<code>" + inline(el) + "</code>"
  68. if t == "emphasis":
  69. return "<em>" + inline(el) + "</em>"
  70. if t == "chapter":
  71. chapter_no += 1
  72. title = el.findtext("title") or ""
  73. body = "".join(node(c) for c in el if c.tag != "title")
  74. return (f'<section class="chapter" id="{esc(el.get("id", ""))}">'
  75. f"<h2>{chapter_no}. {esc(title.strip())}</h2>\n{body}</section>\n")
  76. if t == "sect1":
  77. title = el.findtext("title") or ""
  78. body = "".join(node(c) for c in el if c.tag != "title")
  79. return (f'<section id="{esc(el.get("id", ""))}">'
  80. f"<h3>{esc(title.strip())}</h3>\n{body}</section>\n")
  81. if t == "refentry":
  82. name = el.findtext("refnamediv/refname") or ""
  83. purpose = el.findtext("refnamediv/refpurpose") or ""
  84. parts = [f'<section class="option" id="{esc(el.get("id", ""))}">',
  85. f"<h3>{esc(name.strip())}"
  86. f' <span class="purpose">{esc(" ".join(purpose.split()))}</span></h3>\n']
  87. syn = el.find("refsynopsisdiv")
  88. if syn is not None:
  89. parts.append("".join(node(c) for c in syn))
  90. for rs in el.findall("refsect1"):
  91. parts.append(node(rs))
  92. parts.append("</section>\n")
  93. return "".join(parts)
  94. if t == "refsect1":
  95. title = el.findtext("title") or ""
  96. body = "".join(node(c) for c in el if c.tag != "title")
  97. return f"<h4>{esc(title.strip())}</h4>\n{body}"
  98. if t in ("toc", "title", "subtitle", "bookinfo"):
  99. return ""
  100. # Unknown element: keep its text so nothing silently disappears.
  101. return inline(el)
  102. def toc():
  103. rows = []
  104. n = 0
  105. for ch in root.findall("chapter"):
  106. n += 1
  107. rows.append(f'<li><a href="#{esc(ch.get("id", ""))}">{n}. {esc(ch.findtext("title").strip())}</a>')
  108. subs = [(s.get("id"), (s.findtext("title") or "").strip()) for s in ch.findall("sect1")]
  109. subs += [(r.get("id"), (r.findtext("refnamediv/refname") or "").strip()) for r in ch.findall("refentry")]
  110. if subs:
  111. if len(subs) > 12: # long option lists: compact inline index
  112. rows.append('<div class="opts">' + " ".join(
  113. f'<a href="#{esc(i)}">{esc(s)}</a>' for i, s in subs) + "</div>")
  114. else:
  115. rows.append("<ul>" + "".join(
  116. f'<li><a href="#{esc(i)}">{esc(s)}</a></li>' for i, s in subs) + "</ul>")
  117. rows.append("</li>")
  118. rows.append(example_toc)
  119. return "<ul>" + "".join(rows) + "</ul>"
  120. body = "".join(node(c) for c in root if c.tag == "chapter") # numbers the chapters first
  121. # Final chapter: the complete example configuration, straight from pisg.cfg.example
  122. # (regenerate that with docs/gen-example-config.py). Skipped if the file is absent.
  123. example_file = here.parent / "pisg.cfg.example"
  124. example_html = ""
  125. example_toc = ""
  126. if example_file.exists():
  127. cfg_text = example_file.read_text(encoding="utf-8")
  128. chapter_no += 1
  129. download = "data:text/plain;charset=utf-8," + urllib.parse.quote(cfg_text)
  130. example_toc = (f'<li><a href="#example-config">{chapter_no}. Complete example configuration</a></li>')
  131. example_html = f"""<section class="chapter" id="example-config">
  132. <h2>{chapter_no}. Complete example configuration</h2>
  133. <p>A ready-to-use <code>pisg.cfg</code> for a channel called <code>#example</code>. It lists every
  134. option pisg understands, with what it does, so you can copy it and change only the lines marked
  135. <code>EDIT</code>: the log file, its format, the network name, the output file and your name.
  136. Options are at pisg's own defaults unless the comment says <em>recommended</em>.</p>
  137. <ol>
  138. <li>Copy the file below (or download it) and save it as <code>pisg.cfg</code> next to <code>pisg</code>.</li>
  139. <li>Change the <code>EDIT</code> values in the <code>&lt;channel&gt;</code> block.</li>
  140. <li>Run <code>./pisg</code>.</li>
  141. </ol>
  142. <div class="codewrap">
  143. <div class="codebar"><span>pisg.cfg</span>
  144. <a class="btn" href="{download}" download="pisg.cfg">Download</a>
  145. <button class="btn copy" type="button" data-target="cfg-example">Copy</button></div>
  146. <pre id="cfg-example" class="cfg">{esc(cfg_text)}</pre>
  147. </div>
  148. </section>
  149. """
  150. title = root.findtext("bookinfo/title") or "pisg documentation"
  151. subtitle = root.findtext("bookinfo/subtitle") or ""
  152. body += example_html
  153. page = f"""<!doctype html>
  154. <html lang="en">
  155. <head>
  156. <meta charset="utf-8">
  157. <meta name="viewport" content="width=device-width, initial-scale=1">
  158. <title>{esc(title)}</title>
  159. <style>
  160. :root {{ --bg:#fff; --fg:#1d2329; --muted:#5b6672; --line:#d9dee3; --code:#f3f5f7; --link:#0b6b3a; }}
  161. @media (prefers-color-scheme: dark) {{
  162. :root {{ --bg:#14181c; --fg:#e3e8ec; --muted:#98a4af; --line:#2a3138; --code:#1d2329; --link:#5fd08e; }}
  163. }}
  164. body {{ margin:0; background:var(--bg); color:var(--fg);
  165. font:16px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; }}
  166. main {{ max-width:60rem; margin:0 auto; padding:2rem 1rem 4rem; }}
  167. a {{ color:var(--link); }}
  168. h1 {{ margin-bottom:.2rem; }} .sub {{ color:var(--muted); margin-top:0; }}
  169. h2 {{ margin-top:3rem; padding-bottom:.3rem; border-bottom:2px solid var(--line); }}
  170. h3 {{ margin-top:2rem; }} h4 {{ margin:1rem 0 .2rem; color:var(--muted);
  171. text-transform:uppercase; font-size:.8rem; letter-spacing:.06em; }}
  172. .purpose {{ font-weight:400; font-size:.85rem; color:var(--muted); margin-left:.5rem; }}
  173. section.option {{ border-top:1px solid var(--line); padding-top:.5rem; }}
  174. pre {{ background:var(--code); border:1px solid var(--line); border-radius:6px;
  175. padding:.75rem 1rem; overflow-x:auto; font-size:.85rem; }}
  176. code {{ background:var(--code); padding:.1em .3em; border-radius:4px; font-size:.9em; }}
  177. nav {{ border:1px solid var(--line); border-radius:8px; padding:.5rem 1.2rem; margin:1.5rem 0; }}
  178. /* example config: code block with copy/download bar */
  179. .codewrap {{ border:1px solid var(--line); border-radius:8px; overflow:hidden; margin:1rem 0; }}
  180. .codebar {{ display:flex; align-items:center; gap:.5rem; padding:.4rem .75rem; background:var(--code);
  181. border-bottom:1px solid var(--line); font-size:.85rem; color:var(--muted); }}
  182. .codebar span {{ flex:1; font-family:ui-monospace,Menlo,Consolas,monospace; }}
  183. .btn {{ font:inherit; font-size:.8rem; padding:.25rem .7rem; border:1px solid var(--line); border-radius:6px;
  184. background:var(--bg); color:var(--fg); cursor:pointer; text-decoration:none; }}
  185. .btn:hover {{ border-color:var(--link); color:var(--link); }}
  186. pre.cfg {{ margin:0; border:0; border-radius:0; max-height:36rem; overflow:auto; font-size:.8rem; line-height:1.45; }}
  187. nav .opts a {{ display:inline-block; margin:.1rem .6rem .1rem 0; font-size:.85rem; }}
  188. </style>
  189. </head>
  190. <body>
  191. <main>
  192. <h1>{esc(title)}</h1>
  193. <p class="sub">{esc(subtitle)}</p>
  194. <nav aria-label="Contents">{toc()}</nav>
  195. {body}</main>
  196. <script>
  197. document.querySelectorAll('.copy').forEach(function (b) {{
  198. b.addEventListener('click', function () {{
  199. var pre = document.getElementById(b.dataset.target), text = pre.textContent;
  200. function done(ok) {{ var old = b.textContent; b.textContent = ok ? 'Copied!' : 'Press Ctrl+C';
  201. setTimeout(function () {{ b.textContent = old; }}, 1800); }}
  202. function fallback() {{ var r = document.createRange(); r.selectNodeContents(pre);
  203. var s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
  204. var ok = false; try {{ ok = document.execCommand('copy'); }} catch (e) {{}} done(ok); }}
  205. if (navigator.clipboard && window.isSecureContext) {{ navigator.clipboard.writeText(text).then(function () {{ done(true); }}, fallback); }}
  206. else {{ fallback(); }}
  207. }});
  208. }});
  209. </script>
  210. </body>
  211. </html>
  212. """
  213. dst.write_text(page, encoding="utf-8")
  214. print(f"wrote {dst} ({len(page)//1024} KiB, {chapter_no} chapters)")