primarysource 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. primarysource/__init__.py +5 -0
  2. primarysource/bbf/__init__.py +56 -0
  3. primarysource/bbf/_xml.py +63 -0
  4. primarysource/bbf/catalogue.json +777 -0
  5. primarysource/bbf/catalogue.py +137 -0
  6. primarysource/bbf/cli.py +79 -0
  7. primarysource/bbf/datamodel.py +281 -0
  8. primarysource/bbf/importer.py +151 -0
  9. primarysource/bbf/library.py +380 -0
  10. primarysource/bbf/server.py +72 -0
  11. primarysource/budget.py +63 -0
  12. primarysource/c2sp/__init__.py +53 -0
  13. primarysource/c2sp/_fetch.py +5 -0
  14. primarysource/c2sp/catalogue.json +231 -0
  15. primarysource/c2sp/catalogue.py +95 -0
  16. primarysource/c2sp/cli.py +62 -0
  17. primarysource/c2sp/importer.py +90 -0
  18. primarysource/c2sp/library.py +309 -0
  19. primarysource/c2sp/markdown.py +31 -0
  20. primarysource/c2sp/server.py +68 -0
  21. primarysource/cavp/__init__.py +42 -0
  22. primarysource/cavp/cli.py +84 -0
  23. primarysource/cavp/data/vectors.toml +47 -0
  24. primarysource/cavp/importer.py +156 -0
  25. primarysource/cavp/model.py +279 -0
  26. primarysource/cavp/parse.py +269 -0
  27. primarysource/cavp/paths.py +17 -0
  28. primarysource/cavp/server.py +67 -0
  29. primarysource/cavp/tools.py +169 -0
  30. primarysource/cli.py +107 -0
  31. primarysource/cursor.py +82 -0
  32. primarysource/cve/__init__.py +5 -0
  33. primarysource/cve/__main__.py +6 -0
  34. primarysource/cve/cli.py +144 -0
  35. primarysource/cve/collection.py +20 -0
  36. primarysource/cve/config.py +58 -0
  37. primarysource/cve/errors.py +16 -0
  38. primarysource/cve/kernel.py +232 -0
  39. primarysource/cve/mcp.py +139 -0
  40. primarysource/cve/parse.py +318 -0
  41. primarysource/cve/py.typed +0 -0
  42. primarysource/cve/service.py +760 -0
  43. primarysource/cve/store.py +214 -0
  44. primarysource/cve/sync.py +438 -0
  45. primarysource/cve/views.py +304 -0
  46. primarysource/debpolicy/__init__.py +16 -0
  47. primarysource/debpolicy/catalogue.py +263 -0
  48. primarysource/debpolicy/cli.py +12 -0
  49. primarysource/debpolicy/server.py +11 -0
  50. primarysource/draft/__init__.py +11 -0
  51. primarysource/draft/catalogue.py +117 -0
  52. primarysource/draft/cli.py +149 -0
  53. primarysource/draft/collection.py +21 -0
  54. primarysource/draft/datatracker.py +158 -0
  55. primarysource/draft/importer.py +163 -0
  56. primarysource/draft/licence.py +89 -0
  57. primarysource/draft/names.py +64 -0
  58. primarysource/draft/net.py +133 -0
  59. primarysource/draft/segment.py +192 -0
  60. primarysource/draft/server.py +91 -0
  61. primarysource/draft/status.py +146 -0
  62. primarysource/draft/store.py +199 -0
  63. primarysource/draft/tracked.json +104 -0
  64. primarysource/draft/views.py +430 -0
  65. primarysource/draft/xmlmeta.py +74 -0
  66. primarysource/ecma/__init__.py +52 -0
  67. primarysource/ecma/_pdf.py +143 -0
  68. primarysource/ecma/catalogue.json +22 -0
  69. primarysource/ecma/catalogue.py +100 -0
  70. primarysource/ecma/cli.py +67 -0
  71. primarysource/ecma/identify.py +25 -0
  72. primarysource/ecma/importer.py +108 -0
  73. primarysource/ecma/library.py +240 -0
  74. primarysource/ecma/server.py +67 -0
  75. primarysource/errors.py +29 -0
  76. primarysource/fetch.py +360 -0
  77. primarysource/fsutil.py +150 -0
  78. primarysource/gitdocs/__init__.py +15 -0
  79. primarysource/gitdocs/catalogue.py +116 -0
  80. primarysource/gitdocs/cli.py +12 -0
  81. primarysource/gitdocs/server.py +8 -0
  82. primarysource/home.py +65 -0
  83. primarysource/iana/__init__.py +32 -0
  84. primarysource/iana/catalogue.json +244 -0
  85. primarysource/iana/catalogue.py +69 -0
  86. primarysource/iana/cli.py +69 -0
  87. primarysource/iana/importer.py +157 -0
  88. primarysource/iana/model.py +325 -0
  89. primarysource/iana/reader.py +315 -0
  90. primarysource/iana/server.py +67 -0
  91. primarysource/iana/store.py +83 -0
  92. primarysource/iana/xmlsafe.py +47 -0
  93. primarysource/identify.py +186 -0
  94. primarysource/ieee/__init__.py +58 -0
  95. primarysource/ieee/_html.py +49 -0
  96. primarysource/ieee/catalogue.json +74 -0
  97. primarysource/ieee/catalogue.py +123 -0
  98. primarysource/ieee/cli.py +79 -0
  99. primarysource/ieee/identify.py +24 -0
  100. primarysource/ieee/ieee802.py +47 -0
  101. primarysource/ieee/importer.py +305 -0
  102. primarysource/ieee/posix.py +233 -0
  103. primarysource/ieee/server.py +79 -0
  104. primarysource/index.py +446 -0
  105. primarysource/iso/__init__.py +51 -0
  106. primarysource/iso/cli.py +98 -0
  107. primarysource/iso/designation.py +95 -0
  108. primarysource/iso/identify.py +100 -0
  109. primarysource/iso/importer.py +224 -0
  110. primarysource/iso/library.py +249 -0
  111. primarysource/iso/server.py +69 -0
  112. primarysource/itu/__init__.py +41 -0
  113. primarysource/itu/__main__.py +8 -0
  114. primarysource/itu/asn1mod.py +478 -0
  115. primarysource/itu/catalog.py +472 -0
  116. primarysource/itu/cli.py +445 -0
  117. primarysource/itu/collection.py +20 -0
  118. primarysource/itu/corpus.py +118 -0
  119. primarysource/itu/errata.py +16 -0
  120. primarysource/itu/formal.py +565 -0
  121. primarysource/itu/identify.py +26 -0
  122. primarysource/itu/index.py +490 -0
  123. primarysource/itu/mcp.py +474 -0
  124. primarysource/itu/py.typed +0 -0
  125. primarysource/itu/sources.py +600 -0
  126. primarysource/itu/text.py +609 -0
  127. primarysource/kconfig/__init__.py +16 -0
  128. primarysource/kconfig/catalogue.py +98 -0
  129. primarysource/kconfig/cli.py +12 -0
  130. primarysource/kconfig/server.py +8 -0
  131. primarysource/model.py +63 -0
  132. primarysource/nist/__init__.py +5 -0
  133. primarysource/nist/collection.py +46 -0
  134. primarysource/nist/data/acquired-nist.json +1657 -0
  135. primarysource/nist/data/nist-catalog.json +12090 -0
  136. primarysource/nist/data/sources.toml +1996 -0
  137. primarysource/nist/identify.py +41 -0
  138. primarysource/nist/ingest/__init__.py +1 -0
  139. primarysource/nist/ingest/acquire_nist.py +128 -0
  140. primarysource/nist/ingest/controls.py +82 -0
  141. primarysource/nist/ingest/discover_nist.py +218 -0
  142. primarysource/nist/ingest/extract.py +48 -0
  143. primarysource/nist/ingest/fetch.py +211 -0
  144. primarysource/nist/ingest/index.py +14 -0
  145. primarysource/nist/ingest/oscal.py +480 -0
  146. primarysource/nist/ingest/prose_layouts.py +236 -0
  147. primarysource/nist/ingest/sources.py +94 -0
  148. primarysource/nist/ingest/status.py +255 -0
  149. primarysource/nist/models.py +7 -0
  150. primarysource/nist/paths.py +33 -0
  151. primarysource/nist/server/__init__.py +3 -0
  152. primarysource/nist/server/corpus.py +38 -0
  153. primarysource/nist/server/mcp.py +19 -0
  154. primarysource/nist/server/tools.py +35 -0
  155. primarysource/nist/spec.py +38 -0
  156. primarysource/pdfcorpus/__init__.py +16 -0
  157. primarysource/pdfcorpus/corpus.py +159 -0
  158. primarysource/pdfcorpus/fts.py +175 -0
  159. primarysource/pdfcorpus/index.py +70 -0
  160. primarysource/pdfcorpus/models.py +30 -0
  161. primarysource/pdfcorpus/server.py +96 -0
  162. primarysource/pdfcorpus/spec.py +37 -0
  163. primarysource/pdfcorpus/split.py +391 -0
  164. primarysource/pdfcorpus/tools.py +244 -0
  165. primarysource/pep/__init__.py +15 -0
  166. primarysource/pep/catalogue.py +191 -0
  167. primarysource/pep/cli.py +12 -0
  168. primarysource/pep/server.py +8 -0
  169. primarysource/pkgdocs/__init__.py +19 -0
  170. primarysource/pkgdocs/fetch.py +356 -0
  171. primarysource/pkgdocs/index.py +89 -0
  172. primarysource/pkgdocs/model.py +76 -0
  173. primarysource/pkgdocs/readers/__init__.py +1 -0
  174. primarysource/pkgdocs/readers/_common.py +134 -0
  175. primarysource/pkgdocs/readers/asciidoc.py +160 -0
  176. primarysource/pkgdocs/readers/latex.py +315 -0
  177. primarysource/pkgdocs/readers/markdown.py +173 -0
  178. primarysource/pkgdocs/readers/pod.py +132 -0
  179. primarysource/pkgdocs/readers/rst.py +239 -0
  180. primarysource/pkgdocs/readers/scdoc.py +108 -0
  181. primarysource/pkgdocs/runtime.py +552 -0
  182. primarysource/pkgdocs/serving.py +158 -0
  183. primarysource/pkgdocs/store.py +167 -0
  184. primarysource/pms/__init__.py +16 -0
  185. primarysource/pms/catalogue.py +88 -0
  186. primarysource/pms/cli.py +12 -0
  187. primarysource/pms/server.py +8 -0
  188. primarysource/py.typed +0 -0
  189. primarysource/readers/__init__.py +10 -0
  190. primarysource/readers/bookmarks.py +192 -0
  191. primarysource/readers/html.py +1312 -0
  192. primarysource/readers/markdown.py +189 -0
  193. primarysource/readers/pdf.py +187 -0
  194. primarysource/readers/xml.py +230 -0
  195. primarysource/refs.py +87 -0
  196. primarysource/registry.py +161 -0
  197. primarysource/render.py +49 -0
  198. primarysource/rfc/__init__.py +3 -0
  199. primarysource/rfc/__main__.py +6 -0
  200. primarysource/rfc/cite.py +204 -0
  201. primarysource/rfc/cli.py +903 -0
  202. primarysource/rfc/collection.py +19 -0
  203. primarysource/rfc/config.py +138 -0
  204. primarysource/rfc/errata.py +203 -0
  205. primarysource/rfc/fsutil.py +82 -0
  206. primarysource/rfc/graph.py +213 -0
  207. primarysource/rfc/index/__init__.py +1 -0
  208. primarysource/rfc/index/chunker.py +281 -0
  209. primarysource/rfc/index/embed.py +166 -0
  210. primarysource/rfc/index/hybrid.py +305 -0
  211. primarysource/rfc/index/lexical.py +223 -0
  212. primarysource/rfc/index/rfcxml.py +673 -0
  213. primarysource/rfc/index/search.py +508 -0
  214. primarysource/rfc/index/txtseg.py +585 -0
  215. primarysource/rfc/library.py +294 -0
  216. primarysource/rfc/mcp/__init__.py +1 -0
  217. primarysource/rfc/mcp/server.py +381 -0
  218. primarysource/rfc/packs/__init__.py +1 -0
  219. primarysource/rfc/packs/builder.py +228 -0
  220. primarysource/rfc/packs/collections/all-current-txt.json +10 -0
  221. primarysource/rfc/packs/collections/essentials.json +19 -0
  222. primarysource/rfc/packs/collections/fyi-current.json +10 -0
  223. primarysource/rfc/packs/collections/standards-current.json +10 -0
  224. primarysource/rfc/packs/collections/year-2025.json +10 -0
  225. primarysource/rfc/packs/collections/year-2026.json +10 -0
  226. primarysource/rfc/packs/collections.py +163 -0
  227. primarysource/rfc/packs/docsetexport.py +163 -0
  228. primarysource/rfc/packs/epub.py +159 -0
  229. primarysource/rfc/packs/export.py +306 -0
  230. primarysource/rfc/packs/htmlexport.py +82 -0
  231. primarysource/rfc/packs/serve.py +143 -0
  232. primarysource/rfc/packs/zimexport.py +171 -0
  233. primarysource/rfc/query.py +466 -0
  234. primarysource/rfc/subjects.py +457 -0
  235. primarysource/rfc/sync/__init__.py +1 -0
  236. primarysource/rfc/sync/downloader.py +132 -0
  237. primarysource/rfc/sync/manifest.py +162 -0
  238. primarysource/rfc/sync/scanner.py +49 -0
  239. primarysource/rfc/sync/state.py +464 -0
  240. primarysource/rfc/views.py +1067 -0
  241. primarysource/rpmdocs/__init__.py +15 -0
  242. primarysource/rpmdocs/catalogue.py +117 -0
  243. primarysource/rpmdocs/cli.py +12 -0
  244. primarysource/rpmdocs/server.py +8 -0
  245. primarysource/scaffold.py +223 -0
  246. primarysource/serve.py +75 -0
  247. primarysource/settings.py +152 -0
  248. primarysource/usb/__init__.py +5 -0
  249. primarysource/usb/collection.py +37 -0
  250. primarysource/usb/data/acquired.json +2025 -0
  251. primarysource/usb/data/sources-usb.toml +852 -0
  252. primarysource/usb/data/sources.toml +10 -0
  253. primarysource/usb/data/usb-catalog.json +1663 -0
  254. primarysource/usb/data/usb-classification.json +1986 -0
  255. primarysource/usb/data/usb-corpus-build.json +608 -0
  256. primarysource/usb/data/usb-pdf-choice.json +839 -0
  257. primarysource/usb/data/usb-split-measure.json +1803 -0
  258. primarysource/usb/ingest/__init__.py +1 -0
  259. primarysource/usb/ingest/acquire.py +130 -0
  260. primarysource/usb/ingest/discover_usb.py +88 -0
  261. primarysource/usb/ingest/extract.py +48 -0
  262. primarysource/usb/ingest/fetch.py +197 -0
  263. primarysource/usb/ingest/index.py +14 -0
  264. primarysource/usb/ingest/sources.py +85 -0
  265. primarysource/usb/models.py +7 -0
  266. primarysource/usb/paths.py +33 -0
  267. primarysource/usb/server/__init__.py +3 -0
  268. primarysource/usb/server/corpus.py +38 -0
  269. primarysource/usb/server/mcp.py +19 -0
  270. primarysource/usb/server/tools.py +35 -0
  271. primarysource/usb/spec.py +20 -0
  272. primarysource/usbids/__init__.py +56 -0
  273. primarysource/usbids/cli.py +65 -0
  274. primarysource/usbids/importer.py +272 -0
  275. primarysource/usbids/parse.py +283 -0
  276. primarysource/usbids/server.py +55 -0
  277. primarysource/usbids/store.py +80 -0
  278. primarysource/usbids/tools.py +174 -0
  279. primarysource/zim/__init__.py +5 -0
  280. primarysource/zim/__main__.py +8 -0
  281. primarysource/zim/_libzim.py +37 -0
  282. primarysource/zim/catalog.py +594 -0
  283. primarysource/zim/cli.py +555 -0
  284. primarysource/zim/collection.py +41 -0
  285. primarysource/zim/errors.py +100 -0
  286. primarysource/zim/groups.py +30 -0
  287. primarysource/zim/library.py +668 -0
  288. primarysource/zim/manage.py +636 -0
  289. primarysource/zim/markdown.py +1247 -0
  290. primarysource/zim/mcp.py +201 -0
  291. primarysource/zim/purl.py +130 -0
  292. primarysource/zim/py.typed +0 -0
  293. primarysource/zim/refs.py +90 -0
  294. primarysource/zim/service.py +1182 -0
  295. primarysource/zim/snapshot.py +197 -0
  296. primarysource/zim/sync.py +269 -0
  297. primarysource/zim/views.py +230 -0
  298. primarysource-0.1.0.dist-info/METADATA +118 -0
  299. primarysource-0.1.0.dist-info/RECORD +303 -0
  300. primarysource-0.1.0.dist-info/WHEEL +4 -0
  301. primarysource-0.1.0.dist-info/entry_points.txt +2 -0
  302. primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
  303. primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
@@ -0,0 +1,159 @@
1
+ """EPUB 3 export: one collection -> one .epub (pure stdlib).
2
+
3
+ EPUB is an OCF zip: uncompressed `mimetype` first, container.xml pointing
4
+ at package.opf, EPUB3 nav.xhtml for the table of contents. Chapters are
5
+ the same XHTML pages the HTML export produces, so an RFC's ASCII diagrams
6
+ and section anchors survive intact; search is the reader's job (ReadEra,
7
+ Apple Books, Play Books all index chapter text). No NCX: EPUB3 readers
8
+ use the nav document.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import zipfile
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from xml.sax.saxutils import escape as xesc
18
+
19
+ from .collections import Collection
20
+ from .export import (ExportDoc, STYLE_CSS, make_resolver, render_doc_page,
21
+ slug)
22
+
23
+ MIMETYPE = "application/epub+zip"
24
+ _NAV_MAX_SECTIONS = 150 # nested entries per document chapter
25
+
26
+
27
+ def _now() -> str:
28
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
29
+
30
+
31
+ def _opf(coll: Collection, entries: list[tuple[str, str, str]],
32
+ spine: list[str], now: str) -> str:
33
+ """entries: (id, href, title); spine: manifest ids in reading order."""
34
+ ident = "urn:uuid:" + hashlib.sha1(
35
+ ("rfclib:" + coll.id).encode()).hexdigest()[:32]
36
+ manifest = "\n".join(
37
+ f' <item id="{i}" href="{xesc(h)}" '
38
+ f'media-type="application/xhtml+xml"/>' for i, h, _t in entries)
39
+ manifest += ('\n <item id="title" href="title.xhtml" '
40
+ 'media-type="application/xhtml+xml"/>'
41
+ '\n <item id="nav" href="nav.xhtml" '
42
+ 'media-type="application/xhtml+xml" properties="nav"/>'
43
+ '\n <item id="css" href="style.css" media-type="text/css"/>')
44
+ sp = "\n".join(f' <itemref idref="{i}"/>'
45
+ for i in ["title"] + spine + ["nav"])
46
+ return f"""<?xml version="1.0" encoding="utf-8"?>
47
+ <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bid">
48
+ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
49
+ <dc:identifier id="bid">{ident}</dc:identifier>
50
+ <dc:title>{xesc(coll.name)}</dc:title>
51
+ <dc:language>en</dc:language>
52
+ <dc:creator>rfclib</dc:creator>
53
+ <dc:publisher>rfclib</dc:publisher>
54
+ <dc:date>{now[:10]}</dc:date>
55
+ <meta property="dcterms:modified">{now}</meta>
56
+ </metadata>
57
+ <manifest>
58
+ {manifest}
59
+ </manifest>
60
+ <spine>
61
+ {sp}
62
+ </spine>
63
+ </package>
64
+ """
65
+
66
+
67
+ def _nav(title_href: str, chapters: list[tuple[str, str, list]]) -> str:
68
+ """chapters: (href, doc label, [(level, id, text), ...])."""
69
+ items = []
70
+ for href, label, heads in chapters:
71
+ nested = ""
72
+ shown = [h for h in heads if h[0] <= 3][:_NAV_MAX_SECTIONS]
73
+ if shown:
74
+ inner = "".join(
75
+ f'<li><a href="{xesc(href)}#{i}">{xesc(t)}</a></li>'
76
+ for _lvl, i, t in shown)
77
+ nested = f"<ol>{inner}</ol>"
78
+ items.append(f'<li><a href="{xesc(href)}">{xesc(label)}</a>{nested}</li>')
79
+ return f"""<!DOCTYPE html>
80
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en">
81
+ <head><meta charset="utf-8"/><title>Table of Contents</title></head>
82
+ <body>
83
+ <nav epub:type="toc">
84
+ <h1>Table of Contents</h1>
85
+ <ol>
86
+ <li><a href="{xesc(title_href)}">Cover</a></li>
87
+ {"".join(items)}
88
+ </ol>
89
+ </nav>
90
+ </body>
91
+ </html>
92
+ """
93
+
94
+
95
+ def _cover(coll: Collection, docs: list[ExportDoc], now: str) -> str:
96
+ rows = "".join(
97
+ f'<tr><td><a href="text/{d.doc_id}.xhtml">{d.doc_id}</a></td>'
98
+ f"<td>{xesc(d.title)}</td></tr>" for d in docs)
99
+ return f"""<!DOCTYPE html>
100
+ <html xmlns="http://www.w3.org/1999/xhtml" lang="en">
101
+ <head><meta charset="utf-8"/><title>{xesc(coll.name)}</title>
102
+ <link rel="stylesheet" type="text/css" href="style.css"/></head>
103
+ <body>
104
+ <h1>{xesc(coll.name)}</h1>
105
+ <p class="meta">{len(docs)} documents · generated {now} · rfclib export</p>
106
+ <p>{xesc(coll.description)}</p>
107
+ <table class="index">
108
+ <tr><th>Doc</th><th>Title</th></tr>
109
+ {rows}
110
+ </table>
111
+ </body>
112
+ </html>
113
+ """
114
+
115
+
116
+ def build_epub(coll: Collection, docs: list[ExportDoc], out_path: Path,
117
+ link_refs: str = "internal", progress=None) -> dict:
118
+ """Write <out_path>/<coll.id>.epub; returns a summary dict."""
119
+ out_path = Path(out_path)
120
+ out_path.mkdir(parents=True, exist_ok=True)
121
+ known = {d.doc_id: f"text/{d.doc_id}.xhtml" for d in docs}
122
+ resolver = make_resolver(known, link_refs)
123
+ now = _now()
124
+
125
+ chapters: list[tuple[str, str, list]] = []
126
+ pages: list[tuple[str, str]] = [] # (arcname, content)
127
+ for n, doc in enumerate(docs, 1):
128
+ page, heads = render_doc_page(doc, resolver, css_href="../style.css")
129
+ arc = f"OEBPS/text/{doc.doc_id}.xhtml"
130
+ pages.append((arc, page))
131
+ chapters.append((f"text/{doc.doc_id}.xhtml",
132
+ f"{doc.doc_id}: {doc.title}", heads))
133
+ if progress:
134
+ progress(f"epub {n}/{len(docs)} {doc.doc_id}")
135
+
136
+ entries = [(f"doc-{slug(d.doc_id)}", f"text/{d.doc_id}.xhtml", d.title)
137
+ for d in docs]
138
+ spine = [i for i, _h, _t in entries]
139
+ tmp = out_path / (coll.id + ".epub.part")
140
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
141
+ mi = zipfile.ZipInfo("mimetype", date_time=(1980, 1, 1, 0, 0, 0))
142
+ zf.writestr(mi, MIMETYPE, compress_type=zipfile.ZIP_STORED)
143
+ zf.writestr("META-INF/container.xml", """<?xml version="1.0" encoding="utf-8"?>
144
+ <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
145
+ <rootfiles>
146
+ <rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/>
147
+ </rootfiles>
148
+ </container>
149
+ """)
150
+ zf.writestr("OEBPS/package.opf", _opf(coll, entries, spine, now))
151
+ zf.writestr("OEBPS/nav.xhtml", _nav("title.xhtml", chapters))
152
+ zf.writestr("OEBPS/title.xhtml", _cover(coll, docs, now))
153
+ zf.writestr("OEBPS/style.css", STYLE_CSS)
154
+ for arc, content in pages:
155
+ zf.writestr(arc, content)
156
+ final = out_path / (coll.id + ".epub")
157
+ tmp.replace(final)
158
+ return {"id": coll.id, "format": "epub", "path": str(final),
159
+ "docs": len(docs), "bytes": final.stat().st_size}
@@ -0,0 +1,306 @@
1
+ """Shared export layer for third-party reader formats (EPUB / HTML / ZIM / docset).
2
+
3
+ One RFC .txt -> one clean XHTML chapter:
4
+ - page furniture removed (running footers "[Page N]", post-break headers,
5
+ form feeds) while every surviving line keeps its exact whitespace, so
6
+ ASCII diagrams stay pixel-faithful inside <pre>;
7
+ - section anchors reuse chunker.find_headings — the same heuristics that
8
+ back the search index — so EPUB nav / docset TOC / deep links agree with
9
+ search results;
10
+ - optional linkification of "RFC 9000" style references (internal to the
11
+ exported collection and/or rfc-editor.org) plus bare URLs.
12
+
13
+ Every exporter (epub.py, htmlexport.py, later zim/docset) shares
14
+ iter_export_docs() so collection resolution happens exactly once, the same
15
+ way builder.build_pack does.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import html
21
+ import re
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Callable, Optional
25
+
26
+ from ..index.chunker import find_headings
27
+ from .builder import MissingFiles, resolve_files
28
+ from .collections import Collection, resolve, sort_key
29
+
30
+ # Running footer (old format: "Bray ... [Page 1]"; new format: bare
31
+ # centered "[Page 7]"); also catches the standalone marker line.
32
+ FOOTER_RE = re.compile(r"\[Page \d+\]\s*$")
33
+ # Post-page-break header, only dropped when seen right after a break:
34
+ # "RFC 8259 JSON December 2017" / "Internet-Draft ...".
35
+ HEADER_RE = re.compile(r"^\s{0,3}(RFC\s+\d+|BCP\s+\d+|STD\s+\d+|FYI\s+\d+|"
36
+ r"Internet-Draft).{0,60}\s{2,}[A-Z][a-z]+ \d{4}\s*$")
37
+ REF_RE = re.compile(r"\b(RFC|BCP|STD|FYI)\s?(\d{1,5})\b")
38
+ URL_RE = re.compile(r"https?://[^\s<>\"'()]+")
39
+ # Ancient RFCs (RFC 768 etc.) have no modern section structure, and the
40
+ # chunker heading heuristics catch their date lines ("28 Aug 1980").
41
+ # Harmless in search chunks, noisy in a nav — reject at export time;
42
+ # rejected lines just flow back into the <pre> body.
43
+ _DATEISH = re.compile(
44
+ r"\d{0,2}\s*(January|February|March|April|May|June|July|August|"
45
+ r"September|October|November|December|Jan|Feb|Mar|Apr|Jun|Jul|Aug|"
46
+ r"Sep|Sept|Oct|Nov|Dec)\s+\d{4}[.,]?\s*\d{0,2}", re.I)
47
+
48
+ LinkResolver = Callable[[str], Optional[str]] # "RFC9000" -> href or None
49
+
50
+
51
+ @dataclass
52
+ class ExportDoc:
53
+ doc_id: str
54
+ title: str
55
+ status: str
56
+ pub_date: str
57
+ stream: str
58
+ wg: str
59
+ current: bool
60
+ txt_path: Path
61
+
62
+
63
+ def iter_export_docs(conn, coll: Collection, progress=None) -> list[ExportDoc]:
64
+ """Resolve a collection to ExportDocs with local txt files.
65
+
66
+ Same pipeline as builder.build_pack: resolve ids, drop content-free
67
+ alias pointers (STD/FYI without files), require local txt (raises
68
+ MissingFiles with sync hint).
69
+ """
70
+ doc_ids = resolve(conn, coll)
71
+ if not doc_ids:
72
+ raise LookupError(f"collection '{coll.id}' resolves to 0 docs")
73
+ doc_ids = [d for d in doc_ids if d.startswith("RFC") or conn.execute(
74
+ "SELECT 1 FROM files WHERE doc_id=? LIMIT 1", (d,)).fetchone()]
75
+ files = resolve_files(conn, doc_ids, ["txt"])
76
+ docs: list[ExportDoc] = []
77
+ for doc_id in sorted((d for d in doc_ids if d in files), key=sort_key):
78
+ row = conn.execute(
79
+ """SELECT title, status, pub_date, is_current, stream, wg
80
+ FROM docs WHERE doc_id=?""", (doc_id,)).fetchone()
81
+ if row is None:
82
+ continue
83
+ docs.append(ExportDoc(
84
+ doc_id=doc_id, title=row["title"] or doc_id,
85
+ status=row["status"] or "", pub_date=row["pub_date"] or "",
86
+ stream=row["stream"] or "", wg=row["wg"] or "",
87
+ current=bool(row["is_current"]), txt_path=files[doc_id]["txt"]))
88
+ if progress:
89
+ progress(f"{len(docs)} docs resolved")
90
+ return docs
91
+
92
+
93
+ def _decode(path: Path) -> str:
94
+ raw = path.read_bytes()
95
+ for enc in ("utf-8", "cp1252"):
96
+ try:
97
+ return raw.decode(enc)
98
+ except UnicodeDecodeError:
99
+ continue
100
+ return raw.decode("latin-1", errors="replace")
101
+
102
+
103
+ def slug(s: str) -> str:
104
+ s = re.sub(r"[^A-Za-z0-9]+", "-", s.strip()).strip("-").lower()
105
+ return s or "sec"
106
+
107
+
108
+ def _heading_id(num: str, title: str, used: set[str]) -> str:
109
+ if num.startswith("Appendix"):
110
+ base = "app-" + slug(num.split()[-1])
111
+ elif num:
112
+ base = "sec-" + slug(num)
113
+ else:
114
+ base = slug(title)
115
+ hid, n = base, 2
116
+ while hid in used:
117
+ hid, n = f"{base}-{n}", n + 1
118
+ used.add(hid)
119
+ return hid
120
+
121
+
122
+ def _heading_level(num: str) -> int:
123
+ if not num or num.startswith("Appendix"):
124
+ return 2
125
+ parts = num.split(".")
126
+ if parts[0].isalpha(): # "A" / "A.1": letter subsections
127
+ return min(2 + len(parts) - 1, 6)
128
+ return min(2 + len(parts) - 1, 6)
129
+
130
+
131
+ def _esc_refs(text: str, resolver: Optional[LinkResolver]) -> str:
132
+ esc = html.escape(text, quote=False)
133
+ if resolver is None:
134
+ return esc
135
+
136
+ def repl(m):
137
+ href = resolver(m.group(1) + m.group(2))
138
+ if not href:
139
+ return m.group(0)
140
+ return (f'<a class="xref" '
141
+ f'href="{html.escape(href, quote=True)}">{m.group(0)}</a>')
142
+ return REF_RE.sub(repl, esc)
143
+
144
+
145
+ def linkify_segment(raw: str, resolver: Optional[LinkResolver]) -> str:
146
+ """Escape a raw text run, linkifying URLs and RFC/BCP/STD/FYI refs."""
147
+ out, pos = [], 0
148
+ for m in URL_RE.finditer(raw):
149
+ out.append(_esc_refs(raw[pos:m.start()], resolver))
150
+ url = m.group(0)
151
+ trail = ""
152
+ while url and url[-1] in ".,;:!?\"'": # trailing punctuation is prose
153
+ trail = url[-1] + trail
154
+ url = url[:-1]
155
+ out.append(f'<a class="url" href="{html.escape(url, quote=True)}">'
156
+ f'{html.escape(url)}</a>')
157
+ out.append(_esc_refs(trail, resolver))
158
+ pos = m.end()
159
+ out.append(_esc_refs(raw[pos:], resolver))
160
+ return "".join(out)
161
+
162
+
163
+ def render_body(doc: ExportDoc,
164
+ resolver: Optional[LinkResolver] = None,
165
+ ) -> tuple[str, list[tuple[int, str, str]]]:
166
+ """Render the txt body to XHTML.
167
+
168
+ Returns (body_html, headings) where headings are (level, id, text)
169
+ in document order — exporters reuse them for nav/TOC/search indexes.
170
+ """
171
+ lines = _decode(doc.txt_path).lstrip("\ufeff").splitlines()
172
+ heads = [(i, num, title) for i, num, title in find_headings(lines)
173
+ if not _DATEISH.fullmatch(lines[i].strip())]
174
+ hmap = {i: (num, title) for i, num, title in heads}
175
+ toc_ranges = set()
176
+ for k, (i, _num, title) in enumerate(heads):
177
+ if title == "Table of Contents": # real nav replaces the dot-leader ToC
178
+ nxt = heads[k + 1][0] if k + 1 < len(heads) else len(lines)
179
+ toc_ranges.update(range(i + 1, nxt))
180
+
181
+ parts: list[str] = []
182
+ buf: list[str] = []
183
+ headings: list[tuple[int, str, str]] = []
184
+ used: set[str] = set()
185
+
186
+ def flush():
187
+ while buf and not buf[-1].strip():
188
+ buf.pop()
189
+ if buf and any(ln.strip() for ln in buf):
190
+ parts.append("<pre>" + linkify_segment("\n".join(buf), resolver)
191
+ + "</pre>")
192
+ buf.clear()
193
+
194
+ after_break = False
195
+ for i, raw in enumerate(lines):
196
+ if i in hmap:
197
+ flush()
198
+ num, title = hmap[i]
199
+ if title == "Table of Contents":
200
+ hid = _heading_id("toc", "toc", used)
201
+ else:
202
+ hid = _heading_id(num, title, used)
203
+ level = _heading_level(num)
204
+ text = re.sub(r"\s{2,}", " ", lines[i].strip())
205
+ parts.append(f'<h{level} id="{hid}">{html.escape(text)}</h{level}>')
206
+ headings.append((level, hid, text))
207
+ after_break = False
208
+ continue
209
+ if i in toc_ranges:
210
+ continue
211
+ was_break = "\f" in raw or bool(FOOTER_RE.search(raw))
212
+ if was_break:
213
+ raw = raw.replace("\f", "")
214
+ if not raw.strip():
215
+ after_break = True
216
+ continue
217
+ if after_break and HEADER_RE.match(raw):
218
+ after_break = False
219
+ continue
220
+ after_break = False
221
+ buf.append(raw.rstrip())
222
+ flush()
223
+ return "\n".join(parts), headings
224
+
225
+
226
+ def render_doc_page(doc: ExportDoc, resolver: Optional[LinkResolver] = None,
227
+ css_href: str = "../style.css",
228
+ back_href: Optional[str] = None,
229
+ ) -> tuple[str, list[tuple[int, str, str]]]:
230
+ """Full standalone page for one document; also returns its headings."""
231
+ body, headings = render_body(doc, resolver)
232
+ meta_bits = [b for b in (
233
+ doc.status,
234
+ f"published {doc.pub_date}" if doc.pub_date else "",
235
+ doc.stream, doc.wg,
236
+ "current" if doc.current else "obsolete") if b]
237
+ back = (f'<p class="back"><a href="{html.escape(back_href, quote=True)}">'
238
+ f'index</a></p>\n' if back_href else "")
239
+ page = f"""<!DOCTYPE html>
240
+ <html xmlns="http://www.w3.org/1999/xhtml" lang="en">
241
+ <head>
242
+ <meta charset="utf-8"/>
243
+ <title>{html.escape(f'{doc.doc_id}: {doc.title}')}</title>
244
+ <link rel="stylesheet" type="text/css" href="{html.escape(css_href, quote=True)}"/>
245
+ </head>
246
+ <body>
247
+ {back}<header class="doc-head">
248
+ <h1>{html.escape(doc.doc_id)}: {html.escape(doc.title)}</h1>
249
+ <p class="meta">{html.escape(" · ".join(meta_bits))}</p>
250
+ </header>
251
+ {body}
252
+ </body>
253
+ </html>
254
+ """
255
+ return page, headings
256
+
257
+
258
+ def make_resolver(known_files: dict[str, str], mode: str) -> Optional[LinkResolver]:
259
+ """Build the reference resolver for 'none|internal|external' modes."""
260
+ if mode == "none":
261
+ return None
262
+
263
+ def resolve_ref(ref: str) -> Optional[str]:
264
+ if ref in known_files:
265
+ return known_files[ref]
266
+ if mode == "external" and ref.startswith("RFC"):
267
+ return "https://www.rfc-editor.org/rfc/" + ref.lower()
268
+ if mode == "external": # BCP/STD/FYI info pages
269
+ return "https://www.rfc-editor.org/info/" + ref.lower()
270
+ return None
271
+ return resolve_ref
272
+
273
+
274
+ STYLE_CSS = """\
275
+ body { font-family: Georgia, 'Times New Roman', serif; margin: 1.5em auto;
276
+ max-width: 72ch; padding: 0 1em; line-height: 1.5; color: #1b1f23; }
277
+ h1 { font-size: 1.45em; }
278
+ h2 { font-size: 1.25em; margin-top: 1.6em; border-bottom: 1px solid #d0d7de;
279
+ padding-bottom: .2em; }
280
+ h3, h4, h5, h6 { font-size: 1.08em; margin-top: 1.3em; }
281
+ pre { font-family: ui-monospace, 'Cascadia Mono', Consolas, monospace;
282
+ font-size: .82em; background: #f6f8fa; border: 1px solid #d0d7de;
283
+ border-radius: 6px; padding: .8em; overflow-x: auto;
284
+ white-space: pre; tab-size: 4; }
285
+ code, kbd { font-family: inherit; }
286
+ a { color: #0969da; text-decoration: none; }
287
+ a:hover { text-decoration: underline; }
288
+ .meta, .back { color: #57606a; font-size: .9em; font-family: sans-serif; }
289
+ table.index { border-collapse: collapse; width: 100%; font-size: .95em; }
290
+ table.index th, table.index td { border-bottom: 1px solid #d0d7de;
291
+ padding: .35em .6em; text-align: left; vertical-align: top; }
292
+ table.index td.num { white-space: nowrap; font-family: monospace; }
293
+ @media (prefers-color-scheme: dark) {
294
+ body { color: #e6edf3; background: #0d1117; }
295
+ pre, table.index th, table.index td { border-color: #30363d; }
296
+ pre { background: #161b22; }
297
+ h2 { border-color: #30363d; }
298
+ .meta, .back { color: #8b949e; }
299
+ a { color: #4493f8; }
300
+ }
301
+ """
302
+
303
+ __all__ = ["ExportDoc", "MissingFiles", "STYLE_CSS", "iter_export_docs",
304
+ "linkify_segment", "make_resolver", "render_body",
305
+ "render_doc_page", "slug"]
306
+
@@ -0,0 +1,82 @@
1
+ """Static HTML mirror export: one collection -> a browsable folder tree.
2
+
3
+ <out>/<coll.id>/
4
+ index.html sortable-in-browser table of all docs
5
+ style.css
6
+ text/RFC9000.html one page per doc, section anchors, cross links
7
+
8
+ The point: grep/Everything/Windows-Search friendly, opens in any browser,
9
+ forever — the workflow CHM used to own on Windows without CHM's dead
10
+ tooling. Same page renderer as the EPUB export (export.py), so both stay
11
+ in lockstep.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import html as htmllib
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ from .collections import Collection
21
+ from .export import ExportDoc, STYLE_CSS, make_resolver, render_doc_page
22
+
23
+
24
+ def _index_page(coll: Collection, docs: list[ExportDoc], now: str) -> str:
25
+ rows = []
26
+ for d in docs:
27
+ rows.append(
28
+ "<tr>"
29
+ f'<td class="num"><a href="text/{htmllib.escape(d.doc_id)}.html">'
30
+ f"{htmllib.escape(d.doc_id)}</a></td>"
31
+ f"<td>{htmllib.escape(d.title)}</td>"
32
+ f"<td>{htmllib.escape(d.status)}</td>"
33
+ f'<td class="num">{htmllib.escape(d.pub_date)}</td>'
34
+ f"<td>{'current' if d.current else 'obsolete'}</td>"
35
+ "</tr>")
36
+ return f"""<!DOCTYPE html>
37
+ <html lang="en">
38
+ <head>
39
+ <meta charset="utf-8"/>
40
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
41
+ <title>{htmllib.escape(coll.name)} — rfclib export</title>
42
+ <link rel="stylesheet" type="text/css" href="style.css"/>
43
+ </head>
44
+ <body>
45
+ <h1>{htmllib.escape(coll.name)}</h1>
46
+ <p class="meta">{len(docs)} documents · generated {now} · rfclib HTML mirror
47
+ (offline; searchable via browser find / grep / Everything)</p>
48
+ <p>{htmllib.escape(coll.description)}</p>
49
+ <table class="index">
50
+ <tr><th>Doc</th><th>Title</th><th>Status</th><th>Published</th><th></th></tr>
51
+ {"".join(rows)}
52
+ </table>
53
+ </body>
54
+ </html>
55
+ """
56
+
57
+
58
+ def build_html(coll: Collection, docs: list[ExportDoc], out_dir: Path,
59
+ link_refs: str = "internal", progress=None) -> dict:
60
+ """Write <out_dir>/<coll.id>/ HTML mirror; returns a summary dict."""
61
+ root = Path(out_dir) / coll.id
62
+ text_dir = root / "text"
63
+ text_dir.mkdir(parents=True, exist_ok=True)
64
+ known = {d.doc_id: f"{d.doc_id}.html" for d in docs}
65
+ resolver = make_resolver(known, link_refs)
66
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
67
+
68
+ nbytes = 0
69
+ (root / "style.css").write_text(STYLE_CSS, "utf-8")
70
+ for n, doc in enumerate(docs, 1):
71
+ page, _heads = render_doc_page(doc, resolver, css_href="../style.css",
72
+ back_href="../index.html")
73
+ p = text_dir / f"{doc.doc_id}.html"
74
+ p.write_text(page, "utf-8")
75
+ nbytes += len(page.encode("utf-8"))
76
+ if progress:
77
+ progress(f"html {n}/{len(docs)} {doc.doc_id}")
78
+ idx = _index_page(coll, docs, now)
79
+ (root / "index.html").write_text(idx, "utf-8")
80
+ nbytes += len(idx.encode("utf-8")) + len(STYLE_CSS)
81
+ return {"id": coll.id, "format": "html", "path": str(root),
82
+ "docs": len(docs), "bytes": nbytes}