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.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
|
@@ -0,0 +1,1312 @@
|
|
|
1
|
+
"""HTML to readable markdown, standard library only: the core HTML reader (`cl.reader_html`).
|
|
2
|
+
|
|
3
|
+
Copied from zimlore `zimlore/markdown.py` at zimlore commit 1903b46, with zimlore's converter tests copied beside
|
|
4
|
+
it (`tests/test_readers_html_*.py`). It was first private to the ieee collections (`primarysource/ieee/_html.py`,
|
|
5
|
+
now a re-export of this module). The only changes from zimlore are behind `Profile`: with the default `GENERIC`
|
|
6
|
+
profile the output is zimlore's, byte for byte, which the copied tests pin. A collection with site-specific rules
|
|
7
|
+
defines its own `Profile` value (ieee's `POSIX` lives in `primarysource/ieee/_html.py`).
|
|
8
|
+
|
|
9
|
+
A ZIM article is whatever the scraper wrote. The devdocs ZIMs wrap each page in an application shell (a
|
|
10
|
+
navbar custom element, preload links, a stylesheet) and inline every image as a base64 data URI, so a naive
|
|
11
|
+
tag stripper returns kilobytes of base64 before the first heading. This converter keeps what an agent needs:
|
|
12
|
+
headings (with their anchors), paragraphs, lists, tables, definition lists, block quotes and, above all,
|
|
13
|
+
**code blocks**, with their language when the markup names one. It drops scripts, styles, navigation chrome
|
|
14
|
+
and data URIs.
|
|
15
|
+
|
|
16
|
+
The output is a list of *blocks*. A block is one paragraph, heading, list item, table, code fence or
|
|
17
|
+
definition: the unit that paging counts, that a `¶n` reference points at, and that is never split.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import posixpath
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from html.parser import HTMLParser
|
|
26
|
+
from urllib.parse import unquote, urlsplit
|
|
27
|
+
|
|
28
|
+
SKIP_TAGS = frozenset(
|
|
29
|
+
{"script", "style", "svg", "nav", "head", "template", "noscript", "button", "select", "iframe", "canvas"}
|
|
30
|
+
| {"devdocs-navbar", "form", "audio", "video", "object", "dialog"}
|
|
31
|
+
)
|
|
32
|
+
VOID_TAGS = frozenset({"br", "hr", "img", "meta", "link", "input", "wbr", "source", "area", "base", "col", "embed"})
|
|
33
|
+
BLOCK_TAGS = frozenset(
|
|
34
|
+
{"p", "div", "section", "article", "header", "footer", "main", "details", "summary", "figure", "figcaption"}
|
|
35
|
+
| {"dl", "dd", "address", "fieldset", "center"}
|
|
36
|
+
)
|
|
37
|
+
PAGER_RELS = frozenset({"next", "prev", "previous"})
|
|
38
|
+
"""HTML link types for pagination. A link with one of them that wraps *block* content (a card with a label and a
|
|
39
|
+
heading) is a pager widget and is dropped whole; the same link around inline prose text stays an ordinary link."""
|
|
40
|
+
HEADINGS = {f"h{n}": n for n in range(1, 7)}
|
|
41
|
+
CLOSES_HEAD = frozenset(HEADINGS) | {"dt", "dd", "pre", "ul", "ol", "li", "blockquote", "table", "hr", "dl"}
|
|
42
|
+
"""Start tags that end an open heading or `<dt>` even though its end tag is missing. Other block tags (`p`, `div`)
|
|
43
|
+
end it only once it holds text, so `<dt><p>name</p></dt>` still reads as one definition."""
|
|
44
|
+
CLOSES_HEAD_END = frozenset({"dl", "ul", "ol", "li", "table", "blockquote", "section", "article", "details"})
|
|
45
|
+
MAX_INDENT = 8
|
|
46
|
+
"""Nesting levels that are indented. Deeper list items stay at this level: every block repeats its indentation,
|
|
47
|
+
so unbounded indentation made the output quadratic in the input (n nested `<ul>` cost 130 MB at n = 8,000)."""
|
|
48
|
+
MAX_OUTPUT_CHARS = 4_000_000
|
|
49
|
+
"""Ceiling on the converted text of one document. Past it blocks are dropped and `Document.truncated` is set, so a
|
|
50
|
+
crafted or merely enormous page cannot grow the process without bound."""
|
|
51
|
+
# ARIA landmark roles that are page chrome, whatever the element is called: Sphinx's Read the Docs theme puts the
|
|
52
|
+
# whole site menu in a <div role="navigation"> and the Previous/Next buttons in <div role="navigation"> inside a
|
|
53
|
+
# <footer>; it has no <main> element to select the content with.
|
|
54
|
+
SKIP_ROLES = frozenset({"navigation", "search", "contentinfo"})
|
|
55
|
+
CHROME_CLASS_TAGS = frozenset({"div", "span", "ul", "ol", "section", "aside", "header", "footer", "table"})
|
|
56
|
+
"""Elements whose class names are read as page chrome. Not `body`, `html`, `a`, headings or `p`: a `<body
|
|
57
|
+
class="has-toolbar">` must not empty the page and a `<p class="sidebar-title">` is the sidebar directive's content."""
|
|
58
|
+
CHROME_CLASSES = frozenset(
|
|
59
|
+
{"toolbar", "breadcrumb", "breadcrumbs", "pagination", "pager", "prev-next", "page-nav"}
|
|
60
|
+
| {"toc", "table-of-contents", "nav-panel", "mini-toc", "ttc", "lineno", "permalink", "navpath"}
|
|
61
|
+
)
|
|
62
|
+
"""Whole class names that mark chrome. `nav-panel` and `mini-toc` are Texinfo's per-node Next/Previous/Up bar and
|
|
63
|
+
chapter menu, `ttc` is Doxygen's hover tooltip, `lineno` its source line number, `permalink` its member-heading
|
|
64
|
+
glyph, `navpath` its breadcrumb; `pagination`, `pager`, `prev-next` and `page-nav` are the previous/next widgets
|
|
65
|
+
of doc themes."""
|
|
66
|
+
CHROME_WORDS = frozenset({"toolbar", "breadcrumb", "breadcrumbs", "pagination"})
|
|
67
|
+
"""A class token whose hyphen- or underscore-separated words include one of these is chrome too (`tsd-page-toolbar`,
|
|
68
|
+
`md-breadcrumbs`, `s-pagination--item`). `sidebar` is handled apart: see `is_chrome`."""
|
|
69
|
+
REGION_CANDIDATES = ("main", "role", "id", "article")
|
|
70
|
+
"""Content-region rules in order of preference: a `<main>` element, an element with `role="main"`, an element
|
|
71
|
+
with `id="main-content"`, a *single* `<article>`. See `_region_candidates`."""
|
|
72
|
+
# Icon-font glyphs (Font Awesome's permalink U+F0C1, for one) live in the Private Use Area and are noise in text.
|
|
73
|
+
PRIVATE_USE = re.compile("[\ue000-\uf8ff]")
|
|
74
|
+
TABLE_PARTS = frozenset({"tr", "td", "th", "thead", "tbody", "tfoot"})
|
|
75
|
+
CELL_BREAKS = BLOCK_TAGS | frozenset({"li", "ul", "ol", "dt", "blockquote", "tr"}) | frozenset(HEADINGS)
|
|
76
|
+
"""Tags that start or end a block inside a table cell. A cell is collected as one string with `\\x01` at each block
|
|
77
|
+
boundary, `\\x03` before a list item and `\\x02<n>\\x02` where the n-th `<pre>` of the table sat; the end of the table
|
|
78
|
+
decides whether those become one flattened pipe-table cell or real blocks (see `_Builder._finish_table`)."""
|
|
79
|
+
CELL_PRE = re.compile("\x02(\\d+)\x02")
|
|
80
|
+
CELL_MARKS = re.compile("[\x01\x03]")
|
|
81
|
+
CONTROL = re.compile("[\x01-\x03]")
|
|
82
|
+
LINE_CLASSES = frozenset({"line", "code-line", "token-line"})
|
|
83
|
+
"""Class tokens of the element a highlighter wraps around one source line inside a `<pre>` (Shiki and Chroma
|
|
84
|
+
`span.line`, Prism `token-line`). Their end is a line break unless the text already ends with one."""
|
|
85
|
+
LANG_NOISE = frozenset({"default", "none", "nohighlight", "notranslate", "line", "lines", "block", "wrapper"})
|
|
86
|
+
LANG_PREFIX = re.compile(r"(?:language|lang)[-_](.+)", re.IGNORECASE)
|
|
87
|
+
LANG_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_+#.\-]{0,31}")
|
|
88
|
+
LANG_MARKERS = frozenset({"highlight", "highlighter-rouge", "sourcecode", "codehilite", "code-block", "codeblock"})
|
|
89
|
+
"""A wrapper element only names a fence language through `language-x` when it also carries one of these, so
|
|
90
|
+
`<body class="language-en">` (a page language) is not mistaken for a code language."""
|
|
91
|
+
FOLD_NOISE = frozenset({"hljs", "highlight", "numberlines", "numbersource", "sourcecode", "prettyprint", "linenums"})
|
|
92
|
+
GLYPHS = frozenset({"§", "¶"})
|
|
93
|
+
"""Permalink glyphs: a pilcrow (Sphinx) or a section sign (rustdoc) that is the whole text of an anchor."""
|
|
94
|
+
GRID_COLUMNS = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class Profile:
|
|
99
|
+
"""Site-specific rules on top of zimlore's generic ones. Each is off in `GENERIC`.
|
|
100
|
+
|
|
101
|
+
- `chrome_classes`: more whole class names that mark chrome (lowercase).
|
|
102
|
+
- `inner_anchor`: a heading with no `id` of its own takes its anchor from an `<a id>` or `<a name>` that is the
|
|
103
|
+
first thing inside it. The Open Group writes `<h4><a name="tag_17_372_03" id="tag_17_372_03"></a>DESCRIPTION
|
|
104
|
+
</h4>`; zimlore's rule reads ids on the heading or its wrappers only, so every POSIX heading lost its anchor.
|
|
105
|
+
- `small_print_centre`: a `<font size="2">` that is the first element inside a `<center>` is page furniture
|
|
106
|
+
(the edition banner and the trademark footer on every POSIX page). Other `<center>` elements hold tables
|
|
107
|
+
and stay content.
|
|
108
|
+
- `plain_blockquote`: `<blockquote>` is indentation, not quotation, so its blocks get no `> ` mark. The Open
|
|
109
|
+
Group wraps the body of every section in one.
|
|
110
|
+
- `rules`: an `<hr>` becomes a `---` block. Off for POSIX, where every rule is page furniture.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
chrome_classes: frozenset[str] = frozenset()
|
|
114
|
+
inner_anchor: bool = False
|
|
115
|
+
small_print_centre: bool = False
|
|
116
|
+
plain_blockquote: bool = False
|
|
117
|
+
rules: bool = True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
GENERIC = Profile()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def fence_language(classes: str, *, wrapper: bool = False) -> str:
|
|
124
|
+
"""The code language a class list names, or "". Understands `language-x` / `lang-x` (CommonMark, Prism,
|
|
125
|
+
highlight.js, Docusaurus), Sphinx's and GitHub's `highlight-x` / `highlight-source-x`, SyntaxHighlighter's
|
|
126
|
+
`brush: x`, and the bare second token of pandoc's `sourceCode python` and highlight.js's `hljs python`.
|
|
127
|
+
With `wrapper` set the list belongs to an enclosing element, where `language-x` needs a marker class."""
|
|
128
|
+
tokens = classes.split()
|
|
129
|
+
lowered = [token.lower() for token in tokens]
|
|
130
|
+
candidates: list[str] = []
|
|
131
|
+
for index, token in enumerate(tokens):
|
|
132
|
+
low = lowered[index]
|
|
133
|
+
named = LANG_PREFIX.match(token)
|
|
134
|
+
if named:
|
|
135
|
+
if not wrapper or LANG_MARKERS.intersection(lowered):
|
|
136
|
+
candidates.append(named.group(1))
|
|
137
|
+
elif low.startswith("highlight-"):
|
|
138
|
+
rest = token[10:]
|
|
139
|
+
candidates.append(rest[7:] if rest.lower().startswith("source-") else rest)
|
|
140
|
+
elif low.startswith("brush:"):
|
|
141
|
+
candidates.append(token[6:] or (tokens[index + 1] if index + 1 < len(tokens) else ""))
|
|
142
|
+
if not candidates and ("sourcecode" in lowered or "hljs" in lowered):
|
|
143
|
+
candidates = [t for t, low in zip(tokens, lowered, strict=True) if low not in FOLD_NOISE]
|
|
144
|
+
for candidate in candidates:
|
|
145
|
+
if LANG_NAME.fullmatch(candidate) and candidate.lower() not in LANG_NOISE:
|
|
146
|
+
return candidate
|
|
147
|
+
return ""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class Heading:
|
|
152
|
+
"""An addressable place in a document: a real heading, or a definition (`<dt id=...>`) in an API reference."""
|
|
153
|
+
|
|
154
|
+
level: int
|
|
155
|
+
text: str
|
|
156
|
+
anchor: str
|
|
157
|
+
block: int
|
|
158
|
+
kind: str = "heading"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class Link:
|
|
163
|
+
text: str
|
|
164
|
+
href: str
|
|
165
|
+
path: str | None # resolved ZIM path for an internal link, None for an external one
|
|
166
|
+
anchor: str
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class Document:
|
|
171
|
+
title: str
|
|
172
|
+
blocks: list[str]
|
|
173
|
+
headings: list[Heading] = field(default_factory=list)
|
|
174
|
+
links: list[Link] = field(default_factory=list)
|
|
175
|
+
dropped_images: int = 0
|
|
176
|
+
tight: list[bool] = field(default_factory=list)
|
|
177
|
+
truncated: bool = False
|
|
178
|
+
aliases: dict[str, int] = field(default_factory=dict)
|
|
179
|
+
"""Anchor -> position in `headings`, for ids that sit on a wrapper element next to a heading rather than on the
|
|
180
|
+
heading itself (Texinfo's `<div class="section-level-extent" id="Exit-Status">`). Not outline entries."""
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def markdown(self) -> str:
|
|
184
|
+
return join_blocks(self.blocks, self.tight)
|
|
185
|
+
|
|
186
|
+
def span(self, position: int) -> tuple[int, int]:
|
|
187
|
+
"""Half-open block range owned by `self.headings[position]`. A heading owns everything up to the next
|
|
188
|
+
heading of the same or a shallower level; a definition owns its body up to the next addressable place."""
|
|
189
|
+
head = self.headings[position]
|
|
190
|
+
end = len(self.blocks)
|
|
191
|
+
for later in self.headings[position + 1 :]:
|
|
192
|
+
if head.kind == "definition" or (later.kind == "heading" and later.level <= head.level):
|
|
193
|
+
end = later.block
|
|
194
|
+
break
|
|
195
|
+
return head.block, max(end, head.block + 1)
|
|
196
|
+
|
|
197
|
+
def section(self, anchor: str) -> tuple[int, int] | None:
|
|
198
|
+
"""Block range of the section or definition an anchor names. Anchors are unique within a document
|
|
199
|
+
(the converter suffixes repeats, see `_Builder._unique`), so there is exactly one."""
|
|
200
|
+
for position, head in enumerate(self.headings):
|
|
201
|
+
if head.anchor == anchor:
|
|
202
|
+
return self.span(position)
|
|
203
|
+
if anchor in self.aliases:
|
|
204
|
+
return self.span(self.aliases[anchor])
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def join_blocks(blocks: list[str], tight: list[bool] | None = None) -> str:
|
|
209
|
+
"""Blank line between blocks, except that consecutive list items stay together."""
|
|
210
|
+
out: list[str] = []
|
|
211
|
+
for index, block in enumerate(blocks):
|
|
212
|
+
if index:
|
|
213
|
+
out.append("\n" if tight and tight[index] and tight[index - 1] else "\n\n")
|
|
214
|
+
out.append(block)
|
|
215
|
+
return "".join(out)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def resolve_href(base: str, href: str) -> tuple[str | None, str]:
|
|
219
|
+
"""Resolve a link found in the article at `base` to (ZIM path, anchor). A scheme or `//host` target is
|
|
220
|
+
external and has no path. ZIM paths have no leading slash and `..` is resolved against the article's own
|
|
221
|
+
directory, which is how the scrapers write relative links."""
|
|
222
|
+
parts = urlsplit(href.strip())
|
|
223
|
+
anchor = unquote(parts.fragment)
|
|
224
|
+
if parts.scheme or parts.netloc:
|
|
225
|
+
return None, anchor
|
|
226
|
+
target = unquote(parts.path)
|
|
227
|
+
if not target:
|
|
228
|
+
return base, anchor
|
|
229
|
+
if target.startswith("/"):
|
|
230
|
+
return posixpath.normpath(target.lstrip("/")), anchor
|
|
231
|
+
joined = posixpath.normpath(posixpath.join(posixpath.dirname(base), target))
|
|
232
|
+
return ("" if joined == "." else joined), anchor
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class _Depth:
|
|
236
|
+
"""One element being tracked by its own tag name: open it, count only the same-named tags nested inside, and
|
|
237
|
+
it ends when that count returns to zero. Stray, void or misnested tags of other names are ignored, which is
|
|
238
|
+
what real HTML needs: Starlette's table-of-contents template writes `<li></li><a>..</a></li>` inside a `<nav>`,
|
|
239
|
+
and a counter that decremented on *every* end tag reached zero while the nav was still open."""
|
|
240
|
+
|
|
241
|
+
__slots__ = ("depth", "tag")
|
|
242
|
+
|
|
243
|
+
def __init__(self) -> None:
|
|
244
|
+
self.tag = ""
|
|
245
|
+
self.depth = 0
|
|
246
|
+
|
|
247
|
+
def __bool__(self) -> bool:
|
|
248
|
+
return self.depth > 0
|
|
249
|
+
|
|
250
|
+
def open(self, tag: str) -> None:
|
|
251
|
+
self.tag = tag
|
|
252
|
+
self.depth = 1
|
|
253
|
+
|
|
254
|
+
def nest(self, tag: str) -> None:
|
|
255
|
+
if self.depth and tag == self.tag:
|
|
256
|
+
self.depth += 1
|
|
257
|
+
|
|
258
|
+
def close(self, tag: str) -> bool:
|
|
259
|
+
"""True when this end tag closes the tracked element itself."""
|
|
260
|
+
if self.depth and tag == self.tag:
|
|
261
|
+
self.depth -= 1
|
|
262
|
+
return self.depth == 0
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def is_chrome(
|
|
267
|
+
tag: str,
|
|
268
|
+
attrs: dict[str, str],
|
|
269
|
+
in_container: bool = False,
|
|
270
|
+
*,
|
|
271
|
+
links: bool = False,
|
|
272
|
+
extra: frozenset[str] = frozenset(),
|
|
273
|
+
) -> bool:
|
|
274
|
+
"""Is this element page chrome (navigation, toolbar, tooltip) whose content is not documentation?
|
|
275
|
+
|
|
276
|
+
`in_container` says that an `<article>` or `<section>` is open, where a `<footer>` is the article's own byline
|
|
277
|
+
rather than the page footer. With `links=True` only the chrome whose links are noise too is reported."""
|
|
278
|
+
if tag in VOID_TAGS:
|
|
279
|
+
return False
|
|
280
|
+
if tag in SKIP_TAGS or attrs.get("role", "").lower() in SKIP_ROLES:
|
|
281
|
+
return True
|
|
282
|
+
if tag == "footer" and not in_container:
|
|
283
|
+
return True
|
|
284
|
+
if tag not in CHROME_CLASS_TAGS or not attrs.get("class"):
|
|
285
|
+
return False
|
|
286
|
+
for token in attrs["class"].lower().split():
|
|
287
|
+
words = re.split(r"[-_]", token)
|
|
288
|
+
# A bare `sidebar` is Sphinx's own directive, whose content is documentation (the asyncio Hello World
|
|
289
|
+
# example); a compound such as `col-sidebar` or `sidebar-drawer` is the site's column.
|
|
290
|
+
sidebar = len(words) > 1 and "sidebar" in words and tag != "span"
|
|
291
|
+
if sidebar or "toolbar" in words:
|
|
292
|
+
return True
|
|
293
|
+
# Pager, breadcrumb and Texinfo node-bar links stay in `zim_links`: they are the neighbouring chapters,
|
|
294
|
+
# while a sidebar tree is thousands of site-wide links.
|
|
295
|
+
if not links and (token in CHROME_CLASSES or token in extra or CHROME_WORDS.intersection(words)):
|
|
296
|
+
return True
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _region_candidates(html: str) -> list[str]:
|
|
301
|
+
"""The content-region rules that this page might satisfy, in order of preference. A page that has no `<main>`
|
|
302
|
+
often has `role="main"` on a div or article (Furo, Read the Docs, Stack Exchange), `id="main-content"`, or a
|
|
303
|
+
single `<article>`. Several `<article>`s (a blog index, a list of questions) are the content *list*, not one
|
|
304
|
+
region, and are never selected. The scan is textual, so a candidate can be a false positive (inside a script
|
|
305
|
+
or a comment): `convert` falls back to the next rule when the parser never enters the region."""
|
|
306
|
+
found: list[str] = []
|
|
307
|
+
if re.search(r"<main\b", html, re.IGNORECASE):
|
|
308
|
+
found.append("main")
|
|
309
|
+
if re.search(r"\brole\s*=\s*[\"']?main(?![\w-])", html, re.IGNORECASE):
|
|
310
|
+
found.append("role")
|
|
311
|
+
if re.search(r"\bid\s*=\s*[\"']?main-content(?![\w-])", html, re.IGNORECASE):
|
|
312
|
+
found.append("id")
|
|
313
|
+
if len(re.findall(r"<article\b", html, re.IGNORECASE)) == 1:
|
|
314
|
+
found.append("article")
|
|
315
|
+
return found
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class _Region:
|
|
319
|
+
"""The part of the page that is read. `kind` is one of `REGION_CANDIDATES`, or empty for the whole body."""
|
|
320
|
+
|
|
321
|
+
def __init__(self, kind: str) -> None:
|
|
322
|
+
self.kind = kind
|
|
323
|
+
self.element = _Depth()
|
|
324
|
+
self.entered = False
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def inside(self) -> bool:
|
|
328
|
+
return not self.kind or bool(self.element)
|
|
329
|
+
|
|
330
|
+
def start(self, tag: str, attrs: dict[str, str]) -> bool:
|
|
331
|
+
"""Feed a start tag; True when it opens the region's own element."""
|
|
332
|
+
if self.element:
|
|
333
|
+
self.element.nest(tag)
|
|
334
|
+
return False
|
|
335
|
+
if tag in VOID_TAGS or not self.kind:
|
|
336
|
+
return False
|
|
337
|
+
matches = {
|
|
338
|
+
"main": tag == "main",
|
|
339
|
+
"role": attrs.get("role", "").strip().lower() == "main",
|
|
340
|
+
"id": attrs.get("id", "") == "main-content",
|
|
341
|
+
"article": tag == "article",
|
|
342
|
+
}[self.kind]
|
|
343
|
+
if matches:
|
|
344
|
+
self.element.open(tag)
|
|
345
|
+
self.entered = True
|
|
346
|
+
return matches
|
|
347
|
+
|
|
348
|
+
def end(self, tag: str) -> bool:
|
|
349
|
+
"""Feed an end tag; True when it closes the region's own element."""
|
|
350
|
+
return self.element.close(tag)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class _Builder(HTMLParser):
|
|
354
|
+
def __init__(self, base: str, region: str = "", profile: Profile = GENERIC) -> None:
|
|
355
|
+
super().__init__(convert_charrefs=True)
|
|
356
|
+
self.base = base
|
|
357
|
+
self.profile = profile
|
|
358
|
+
self._after_centre = False # the last start tag was <center> (Profile.small_print_centre)
|
|
359
|
+
self._head_inherited = False # the open heading's anchor came from a wrapper, not its own id
|
|
360
|
+
self.region = _Region(region)
|
|
361
|
+
self.title_outside = False # an <h1> was seen outside the region (and outside any chrome)
|
|
362
|
+
self.title = ""
|
|
363
|
+
self.blocks: list[str] = []
|
|
364
|
+
self.headings: list[Heading] = []
|
|
365
|
+
self.links: list[Link] = []
|
|
366
|
+
self.dropped_images = 0
|
|
367
|
+
self._skip = _Depth() # the chrome element being skipped, if any
|
|
368
|
+
self._frag = _Depth() # a Doxygen `div.fragment` source listing being read as one code block
|
|
369
|
+
self._pending_ids: list[str] = [] # ids seen since the last block, candidates for a heading's aliases
|
|
370
|
+
self._head_aliases: list[str] = []
|
|
371
|
+
self.aliases: dict[str, int] = {}
|
|
372
|
+
self._in_title = False
|
|
373
|
+
self._title_parts: list[str] = []
|
|
374
|
+
self._buf: list[str] = []
|
|
375
|
+
self._ids: list[tuple[str, str, str]] = [] # (tag, id, class) of every open element
|
|
376
|
+
self._lists: list[list[int]] = [] # [ordered?, counter] per open list
|
|
377
|
+
self._quote = 0
|
|
378
|
+
self._item_prefix: str | None = None
|
|
379
|
+
self._head: tuple[int, str, str] | None = None # level, anchor, kind of the heading being read
|
|
380
|
+
self._pre: list[str] | None = None
|
|
381
|
+
self._pre_lang = ""
|
|
382
|
+
self._table: list[list[str]] | None = None
|
|
383
|
+
self._row: list[str] | None = None
|
|
384
|
+
self._cell: list[str] | None = None
|
|
385
|
+
self._cell_pre: list[str] | None = None # a <pre> inside a table cell: see _finish_table
|
|
386
|
+
self._cell_pres: list[tuple[str, str]] = [] # (language, code) of each <pre> met in the table's cells
|
|
387
|
+
self._nested = 0 # open <table>s inside a cell: their markup is flattened into the cell
|
|
388
|
+
self._grid: _Grid | None = None # a javadoc div.summary-table being read as a table
|
|
389
|
+
self._code_stack: list[str] = [] # open elements inside the current <pre>
|
|
390
|
+
self._code_break = False # a line ended inside the <pre> and its newline has not been written
|
|
391
|
+
self._code_closed = 0 # <code> children of the current <pre> already closed
|
|
392
|
+
self._tab: list[str] | None = None # id, aria-controls, text of the <button role=tab> being read
|
|
393
|
+
self._tabs: list[tuple[str, str, str]] = [] # (id, controls, label) of tabs not yet matched to a panel
|
|
394
|
+
self._lead: str | None = None # admonition kind waiting to prefix the next paragraph
|
|
395
|
+
self._title: tuple[str, int] | None = None # tag and depth of an open admonition-title
|
|
396
|
+
self._glyph: int | None = None # buffer index where an <a> inside a heading began
|
|
397
|
+
self._header_row = False
|
|
398
|
+
self._code = 0
|
|
399
|
+
self._link_mark: tuple[int, str] | None = None # buffer index of the "[" and the target
|
|
400
|
+
self._cell_link: tuple[int, str] | None = None
|
|
401
|
+
self._tight_marks: list[bool] = []
|
|
402
|
+
self._chars = 0
|
|
403
|
+
self.truncated = False
|
|
404
|
+
self._anchors: dict[str, int] = {} # anchor -> how many headings have claimed it
|
|
405
|
+
self._pager: tuple[int, int, int] | None = (
|
|
406
|
+
None # blocks, headings, buffer length at an open rel=next/prev link
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# --- region selection -------------------------------------------------------------------------------
|
|
410
|
+
@property
|
|
411
|
+
def _live(self) -> bool:
|
|
412
|
+
return not self._skip and self.region.inside
|
|
413
|
+
|
|
414
|
+
def _in_container(self) -> bool:
|
|
415
|
+
"""Is an `<article>` or `<section>` open? A `<footer>` there is its byline, not the page footer."""
|
|
416
|
+
return any(entry[0] in {"article", "section"} for entry in self._ids)
|
|
417
|
+
|
|
418
|
+
# --- output helpers ---------------------------------------------------------------------------------
|
|
419
|
+
def _text(self) -> str:
|
|
420
|
+
return re.sub(r"[ \t\r\f\v\n]+", " ", "".join(self._buf)).strip()
|
|
421
|
+
|
|
422
|
+
def _emit(self, block: str, *, tight: bool = False) -> None:
|
|
423
|
+
self._pending_ids = [] # text came between an earlier id and whatever heading follows
|
|
424
|
+
if self._lead is not None:
|
|
425
|
+
self._emit_lead() # the next block is not a paragraph (a fence, a table, a definition)
|
|
426
|
+
if self._quote:
|
|
427
|
+
block = "\n".join(("> " + line).rstrip() for line in block.split("\n"))
|
|
428
|
+
if self._chars + len(block) > MAX_OUTPUT_CHARS:
|
|
429
|
+
self.truncated = True
|
|
430
|
+
return
|
|
431
|
+
self._chars += len(block) + 2
|
|
432
|
+
self.blocks.append(block)
|
|
433
|
+
self._tight_marks.append(tight)
|
|
434
|
+
|
|
435
|
+
def _emit_lead(self) -> None:
|
|
436
|
+
"""An admonition's kind (`Note`, `Warning`) as a line of its own, when no paragraph came to carry it."""
|
|
437
|
+
lead, self._lead = self._lead, None
|
|
438
|
+
if lead is not None:
|
|
439
|
+
self._emit(f"**{lead}:**")
|
|
440
|
+
|
|
441
|
+
def _flush(self) -> None:
|
|
442
|
+
if self._head is not None:
|
|
443
|
+
return # a heading collects its own text on close
|
|
444
|
+
if self._link_mark is not None:
|
|
445
|
+
# A block boundary inside an open link (<a><h2>..</h2></a>, card links wrapping <div>s): markdown
|
|
446
|
+
# links cannot span blocks, so the link is dropped and its text kept. Without this the "[" already in
|
|
447
|
+
# the buffer became a block of its own - on 91% of mankier pages in a sample of 1,802.
|
|
448
|
+
index = self._link_mark[0]
|
|
449
|
+
if index < len(self._buf) and self._buf[index] == "[":
|
|
450
|
+
del self._buf[index]
|
|
451
|
+
self._link_mark = None
|
|
452
|
+
if self._pager is not None:
|
|
453
|
+
# Prose before a rel=next/prev link is not part of the card: emit it, then start the card here.
|
|
454
|
+
tail = self._buf[index:]
|
|
455
|
+
self._buf = self._buf[:index]
|
|
456
|
+
self._flush()
|
|
457
|
+
self._buf = tail
|
|
458
|
+
self._pager = (len(self.blocks), len(self.headings), 0)
|
|
459
|
+
text = self._text()
|
|
460
|
+
self._buf = []
|
|
461
|
+
if not text:
|
|
462
|
+
return
|
|
463
|
+
if self._lead is not None:
|
|
464
|
+
text, self._lead = f"**{self._lead}:** {text}", None
|
|
465
|
+
if self._item_prefix is not None:
|
|
466
|
+
depth = min(len(self._lists) - 1, MAX_INDENT)
|
|
467
|
+
self._emit(" " * max(depth, 0) + self._item_prefix + text, tight=True)
|
|
468
|
+
self._item_prefix = None
|
|
469
|
+
else:
|
|
470
|
+
indent = " " * min(len(self._lists), MAX_INDENT)
|
|
471
|
+
self._emit(indent + text)
|
|
472
|
+
|
|
473
|
+
def _unique(self, anchor: str) -> str:
|
|
474
|
+
"""An anchor no earlier heading has claimed: the second claimant of `content` becomes `content-2`. Without
|
|
475
|
+
this every heading under `<div id=content>` shared one anchor and only the first could be addressed."""
|
|
476
|
+
if not anchor:
|
|
477
|
+
return ""
|
|
478
|
+
seen = self._anchors.get(anchor, 0)
|
|
479
|
+
self._anchors[anchor] = seen + 1
|
|
480
|
+
if not seen:
|
|
481
|
+
return anchor
|
|
482
|
+
while True:
|
|
483
|
+
seen += 1
|
|
484
|
+
candidate = f"{anchor}-{seen}"
|
|
485
|
+
if candidate not in self._anchors:
|
|
486
|
+
self._anchors[candidate] = 1
|
|
487
|
+
self._anchors[anchor] = seen
|
|
488
|
+
return candidate
|
|
489
|
+
|
|
490
|
+
def _anchor_here(self, attrs: dict[str, str]) -> str:
|
|
491
|
+
if attrs.get("id"):
|
|
492
|
+
return self._unique(attrs["id"])
|
|
493
|
+
for _, ident, _ in reversed(self._ids):
|
|
494
|
+
if ident:
|
|
495
|
+
return self._unique(ident)
|
|
496
|
+
return ""
|
|
497
|
+
|
|
498
|
+
def _note_id(self, tag: str, attrs: dict[str, str]) -> None:
|
|
499
|
+
"""Remember an id that sits on something other than a heading, until text or a heading follows it. Texinfo
|
|
500
|
+
names a node on a wrapper `<div id="Exit-Status">` and gives the heading inside it another id, and Doxygen
|
|
501
|
+
puts `<a id=...>` just before a member's heading; links and `#anchor` reads use the wrapper's id."""
|
|
502
|
+
if tag in HEADINGS or tag == "dt":
|
|
503
|
+
return
|
|
504
|
+
ident = attrs.get("id") or (attrs.get("name", "") if tag == "a" else "")
|
|
505
|
+
if ident:
|
|
506
|
+
self._pending_ids.append(ident)
|
|
507
|
+
del self._pending_ids[:-8] # an index chapter is thousands of empty anchors in a row
|
|
508
|
+
|
|
509
|
+
def _heading_head(self, tag: str, attrs: dict[str, str]) -> tuple[int, str, str]:
|
|
510
|
+
"""What a heading start tag opens. The ids pending since the last block become its aliases, except for a
|
|
511
|
+
Doxygen member title (`h2.memtitle`): that is a definition, the way a Sphinx `<dt id>` is, anchored by the
|
|
512
|
+
`<a id>` just before it, and not a section at the level of the `Function Documentation` it sits under."""
|
|
513
|
+
pending, self._pending_ids = self._pending_ids, []
|
|
514
|
+
if "memtitle" in attrs.get("class", "").lower().split():
|
|
515
|
+
return 0, pending[-1] if pending else "", "definition"
|
|
516
|
+
self._head_aliases = pending
|
|
517
|
+
self._head_inherited = not attrs.get("id")
|
|
518
|
+
return HEADINGS[tag], self._anchor_here(attrs), "heading"
|
|
519
|
+
|
|
520
|
+
def _fragment_opens(self, tag: str, attrs: dict[str, str]) -> bool:
|
|
521
|
+
"""Doxygen writes a source listing and `@code` examples as `<div class="fragment">` holding one
|
|
522
|
+
`<div class="line">` per line: read it as a pre-formatted block instead of one paragraph per line."""
|
|
523
|
+
if tag != "div" or self._pre is not None or self._cell is not None:
|
|
524
|
+
return False
|
|
525
|
+
if "fragment" not in attrs.get("class", "").lower().split():
|
|
526
|
+
return False
|
|
527
|
+
if self._head is not None and self._text():
|
|
528
|
+
self._close_head()
|
|
529
|
+
self._flush()
|
|
530
|
+
self._pre = []
|
|
531
|
+
self._pre_lang = ""
|
|
532
|
+
self._frag.open("div")
|
|
533
|
+
return True
|
|
534
|
+
|
|
535
|
+
def _fragment_done(self) -> None:
|
|
536
|
+
body = "".join(self._pre or []).strip("\n").rstrip()
|
|
537
|
+
self._pre = None
|
|
538
|
+
if body.strip():
|
|
539
|
+
self._emit(_fenced(body))
|
|
540
|
+
|
|
541
|
+
def _close_head(self) -> None:
|
|
542
|
+
"""Finish the heading or definition being read, as if its end tag had arrived. HTML lets `</dt>`,
|
|
543
|
+
`</dd>` and `</p>` be omitted, and a browser reads `<h2>Title<p>Body</p>` as a heading and a paragraph.
|
|
544
|
+
Waiting for an end tag that never comes swallowed the rest of the page (found in review, C1)."""
|
|
545
|
+
if self._head is None:
|
|
546
|
+
return
|
|
547
|
+
level, anchor, kind = self._head
|
|
548
|
+
self._head = None
|
|
549
|
+
text = self._text().rstrip(chr(0xB6)).rstrip() # a trailing pilcrow is a permalink glyph
|
|
550
|
+
self._buf = []
|
|
551
|
+
aliases, self._head_aliases = self._head_aliases, []
|
|
552
|
+
if not text:
|
|
553
|
+
return
|
|
554
|
+
if kind == "heading":
|
|
555
|
+
for ident in aliases:
|
|
556
|
+
if ident not in self._anchors: # a real heading anchor always wins over a wrapper's id
|
|
557
|
+
self.aliases.setdefault(ident, len(self.headings))
|
|
558
|
+
self.headings.append(Heading(level, text, anchor, len(self.blocks), kind))
|
|
559
|
+
self._emit("#" * level + " " + text)
|
|
560
|
+
else:
|
|
561
|
+
if kind == "definition" and text.lower() == "expand description":
|
|
562
|
+
return # rustdoc's toggle label, not a term
|
|
563
|
+
if anchor:
|
|
564
|
+
self.headings.append(Heading(7, text, self._unique(anchor), len(self.blocks), kind))
|
|
565
|
+
self._emit("**" + _escape_asterisks(text) + "**")
|
|
566
|
+
|
|
567
|
+
# --- parser callbacks -------------------------------------------------------------------------------
|
|
568
|
+
def handle_starttag(self, tag: str, attrs_list: list[tuple[str, str | None]]) -> None:
|
|
569
|
+
attrs = {key: value or "" for key, value in attrs_list}
|
|
570
|
+
if tag == "title":
|
|
571
|
+
self._in_title = True
|
|
572
|
+
return
|
|
573
|
+
if self._skip:
|
|
574
|
+
self._skip.nest(tag)
|
|
575
|
+
return
|
|
576
|
+
after_centre, self._after_centre = self._after_centre, tag == "center"
|
|
577
|
+
small_print = tag == "font" and attrs.get("size", "").strip() == "2"
|
|
578
|
+
if self.profile.small_print_centre and after_centre and small_print:
|
|
579
|
+
self._skip.open(tag)
|
|
580
|
+
return
|
|
581
|
+
if is_chrome(tag, attrs, tag == "footer" and self._in_container(), extra=self.profile.chrome_classes):
|
|
582
|
+
if tag == "button" and attrs.get("role", "").lower() == "tab" and self._live:
|
|
583
|
+
# sphinx-tabs: the label is the only thing that says which shell or language a panel is for.
|
|
584
|
+
self._tab = [attrs.get("id", ""), attrs.get("aria-controls", ""), ""]
|
|
585
|
+
self._skip.open(tag)
|
|
586
|
+
return
|
|
587
|
+
if self.region.start(tag, attrs) and tag == "main":
|
|
588
|
+
return
|
|
589
|
+
if tag == "h1" and not self.region.inside:
|
|
590
|
+
self.title_outside = True
|
|
591
|
+
if not self._live:
|
|
592
|
+
return
|
|
593
|
+
if self._frag:
|
|
594
|
+
self._frag.nest(tag) # inside a source listing the tags are layout; the text goes to the fence
|
|
595
|
+
return
|
|
596
|
+
if self._fragment_opens(tag, attrs):
|
|
597
|
+
return
|
|
598
|
+
self._note_id(tag, attrs)
|
|
599
|
+
if tag not in VOID_TAGS:
|
|
600
|
+
self._ids.append((tag, attrs.get("id", ""), attrs.get("class", "")))
|
|
601
|
+
if self._grid is not None and self._grid_start(tag, attrs):
|
|
602
|
+
return
|
|
603
|
+
if self._table is not None and self._table_start(tag, attrs):
|
|
604
|
+
return
|
|
605
|
+
if self._cell is not None:
|
|
606
|
+
self._cell_start(tag, attrs)
|
|
607
|
+
return
|
|
608
|
+
self._start_block(tag, attrs)
|
|
609
|
+
|
|
610
|
+
# --- tables -----------------------------------------------------------------------------------------
|
|
611
|
+
def _table_start(self, tag: str, attrs: dict[str, str]) -> bool:
|
|
612
|
+
"""Row and cell structure of an open `<table>`, with HTML's implied end tags: `<tr>` closes the open row
|
|
613
|
+
and cell, `<td>`/`<th>` close the open cell. Returns True when the tag was consumed."""
|
|
614
|
+
if tag == "table" and self._cell is not None:
|
|
615
|
+
self._nested += 1 # a table inside a cell is read as text of that cell
|
|
616
|
+
self._cell.append("\x01")
|
|
617
|
+
return True
|
|
618
|
+
if tag not in TABLE_PARTS:
|
|
619
|
+
return False
|
|
620
|
+
if self._nested:
|
|
621
|
+
if self._cell is not None:
|
|
622
|
+
self._cell.append("\x01" if tag == "tr" else " ")
|
|
623
|
+
return True
|
|
624
|
+
if tag in {"td", "th"}:
|
|
625
|
+
self._close_cell()
|
|
626
|
+
if self._row is None:
|
|
627
|
+
self._row = [] # `<td>` without `<tr>`: the row is implied
|
|
628
|
+
self._cell = []
|
|
629
|
+
if tag == "th":
|
|
630
|
+
self._header_row = True
|
|
631
|
+
else:
|
|
632
|
+
self._close_row()
|
|
633
|
+
if tag == "tr":
|
|
634
|
+
self._row = []
|
|
635
|
+
return True
|
|
636
|
+
|
|
637
|
+
def _close_cell(self) -> None:
|
|
638
|
+
if self._cell is None:
|
|
639
|
+
return
|
|
640
|
+
if self._cell_pre is not None:
|
|
641
|
+
self._end_cell_pre()
|
|
642
|
+
if self._row is not None:
|
|
643
|
+
self._row.append("".join(self._cell))
|
|
644
|
+
self._cell = None
|
|
645
|
+
self._cell_link = None
|
|
646
|
+
|
|
647
|
+
def _close_row(self) -> None:
|
|
648
|
+
self._close_cell()
|
|
649
|
+
if self._row and self._table is not None:
|
|
650
|
+
self._table.append(self._row)
|
|
651
|
+
self._row = None
|
|
652
|
+
|
|
653
|
+
def _end_cell_pre(self) -> None:
|
|
654
|
+
if self._cell is None or self._cell_pre is None:
|
|
655
|
+
return
|
|
656
|
+
code = "".join(self._cell_pre).strip("\n").rstrip()
|
|
657
|
+
self._cell_pre = None
|
|
658
|
+
if code.strip():
|
|
659
|
+
self._cell_pres.append((self._pre_lang, code))
|
|
660
|
+
self._cell.append(f"\x01\x02{len(self._cell_pres) - 1}\x02\x01")
|
|
661
|
+
self._pre_lang = ""
|
|
662
|
+
|
|
663
|
+
def _table_end(self, tag: str) -> bool:
|
|
664
|
+
if self._nested:
|
|
665
|
+
if tag == "table":
|
|
666
|
+
self._nested -= 1
|
|
667
|
+
if tag in TABLE_PARTS or tag == "table":
|
|
668
|
+
if self._cell is not None:
|
|
669
|
+
self._cell.append("\x01" if tag in {"tr", "table"} else " ")
|
|
670
|
+
return True
|
|
671
|
+
return False
|
|
672
|
+
if tag in {"td", "th"}:
|
|
673
|
+
self._close_cell()
|
|
674
|
+
elif tag in TABLE_PARTS:
|
|
675
|
+
self._close_row()
|
|
676
|
+
elif tag == "table":
|
|
677
|
+
self._finish_table()
|
|
678
|
+
else:
|
|
679
|
+
return False
|
|
680
|
+
return True
|
|
681
|
+
|
|
682
|
+
def _finish_table(self) -> None:
|
|
683
|
+
self._close_row()
|
|
684
|
+
rows, self._table = self._table or [], None
|
|
685
|
+
pres, self._cell_pres = self._cell_pres, []
|
|
686
|
+
self._cell = None
|
|
687
|
+
self._row = None
|
|
688
|
+
if not rows:
|
|
689
|
+
return
|
|
690
|
+
if _is_box(rows):
|
|
691
|
+
self._emit_box(rows, pres)
|
|
692
|
+
else:
|
|
693
|
+
self._emit(_render_table([[_gfm_cell(cell, pres) for cell in row] for row in rows]))
|
|
694
|
+
|
|
695
|
+
def _emit_box(self, rows: list[list[str]], pres: list[tuple[str, str]]) -> None:
|
|
696
|
+
"""A layout table (see `_is_box`): its cells become ordinary blocks, in reading order."""
|
|
697
|
+
indent = " " * min(len(self._lists), MAX_INDENT)
|
|
698
|
+
for row in rows:
|
|
699
|
+
for cell in row:
|
|
700
|
+
for segment in cell.split("\x01"):
|
|
701
|
+
pieces = CELL_PRE.split(segment)
|
|
702
|
+
for index, piece in enumerate(pieces):
|
|
703
|
+
if index % 2:
|
|
704
|
+
lang, code = pres[int(piece)]
|
|
705
|
+
self._emit(_fenced(code, lang))
|
|
706
|
+
continue
|
|
707
|
+
bullet = "\x03" in piece
|
|
708
|
+
text = re.sub(r"\s+", " ", CELL_MARKS.sub(" ", piece)).strip()
|
|
709
|
+
if text:
|
|
710
|
+
self._emit(indent + ("- " if bullet else "") + text)
|
|
711
|
+
|
|
712
|
+
# --- div grids (javadoc 17+ summary tables) ---------------------------------------------------------
|
|
713
|
+
def _grid_start(self, tag: str, attrs: dict[str, str]) -> bool:
|
|
714
|
+
grid = self._grid
|
|
715
|
+
if grid is None or tag != "div":
|
|
716
|
+
return False
|
|
717
|
+
grid.depth += 1
|
|
718
|
+
if grid.depth != 2:
|
|
719
|
+
return False # a div inside a cell is cell content
|
|
720
|
+
grid.header = "table-header" in attrs.get("class", "").split()
|
|
721
|
+
self._cell = []
|
|
722
|
+
return True
|
|
723
|
+
|
|
724
|
+
def _grid_end(self, tag: str) -> bool:
|
|
725
|
+
grid = self._grid
|
|
726
|
+
if grid is None or tag != "div":
|
|
727
|
+
return False
|
|
728
|
+
grid.depth -= 1
|
|
729
|
+
if grid.depth == 1 and self._cell is not None:
|
|
730
|
+
if self._cell_pre is not None:
|
|
731
|
+
self._end_cell_pre()
|
|
732
|
+
grid.cells.append((grid.header, "".join(self._cell)))
|
|
733
|
+
self._cell = None
|
|
734
|
+
self._cell_link = None
|
|
735
|
+
return True
|
|
736
|
+
if grid.depth == 0:
|
|
737
|
+
self._grid = None
|
|
738
|
+
headers = [text for is_head, text in grid.cells if is_head]
|
|
739
|
+
body = [text for is_head, text in grid.cells if not is_head]
|
|
740
|
+
width = len(headers) or grid.columns
|
|
741
|
+
pres, self._cell_pres = self._cell_pres, []
|
|
742
|
+
rows = [headers or [""] * width]
|
|
743
|
+
rows += [body[i : i + width] for i in range(0, len(body), width)]
|
|
744
|
+
if body or headers:
|
|
745
|
+
self._emit(_render_table([[_gfm_cell(cell, pres) for cell in row] for row in rows]))
|
|
746
|
+
return True
|
|
747
|
+
return False
|
|
748
|
+
|
|
749
|
+
def _cell_start(self, tag: str, attrs: dict[str, str]) -> None:
|
|
750
|
+
if self._cell is None:
|
|
751
|
+
return
|
|
752
|
+
if self._cell_pre is not None:
|
|
753
|
+
self._code_start(tag, attrs, self._cell_pre)
|
|
754
|
+
return
|
|
755
|
+
if tag == "pre":
|
|
756
|
+
self._cell_pre = []
|
|
757
|
+
self._code_reset()
|
|
758
|
+
self._pre_lang = self._language_for(attrs)
|
|
759
|
+
elif tag == "br":
|
|
760
|
+
self._cell.append(" ")
|
|
761
|
+
elif tag == "code":
|
|
762
|
+
self._cell.append("`")
|
|
763
|
+
elif tag in CELL_BREAKS:
|
|
764
|
+
self._cell.append("\x01\x03" if tag == "li" else "\x01")
|
|
765
|
+
elif tag == "a" and attrs.get("href"):
|
|
766
|
+
self._cell_link = self._open_link(self._cell, attrs["href"])
|
|
767
|
+
elif tag == "img":
|
|
768
|
+
self._cell.append(self._image(attrs))
|
|
769
|
+
|
|
770
|
+
def _image(self, attrs: dict[str, str]) -> str:
|
|
771
|
+
self.dropped_images += 1
|
|
772
|
+
alt = re.sub(r"\s+", " ", attrs.get("alt", "")).strip()
|
|
773
|
+
return f"[image: {alt}]" if alt and not alt.startswith("_images/") else "[image]"
|
|
774
|
+
|
|
775
|
+
# --- code blocks ------------------------------------------------------------------------------------
|
|
776
|
+
def _code_reset(self) -> None:
|
|
777
|
+
self._code_stack = []
|
|
778
|
+
self._code_break = False
|
|
779
|
+
self._code_closed = 0
|
|
780
|
+
|
|
781
|
+
def _code_text(self, sink: list[str], data: str) -> None:
|
|
782
|
+
if self._code_break:
|
|
783
|
+
self._code_break = False
|
|
784
|
+
if not data.startswith("\n"):
|
|
785
|
+
sink.append("\n")
|
|
786
|
+
sink.append(data)
|
|
787
|
+
|
|
788
|
+
def _code_newline(self, sink: list[str]) -> None:
|
|
789
|
+
"""A line break the markup asks for. It is written lazily: a newline character that follows (Shiki writes
|
|
790
|
+
`</span>\\n<span class=line>`) is the same break, not a blank line, and a break at the very end of the
|
|
791
|
+
block is not written at all. A second break in a row does write the first, so `<br><br>` is a blank line."""
|
|
792
|
+
if self._code_break:
|
|
793
|
+
sink.append("\n")
|
|
794
|
+
self._code_break = True
|
|
795
|
+
|
|
796
|
+
def _code_start(self, tag: str, attrs: dict[str, str], sink: list[str]) -> None:
|
|
797
|
+
"""A tag inside `<pre>`: `<br>` and per-line wrappers end lines (TypeDoc, Dokka, javadoc 8 separate the
|
|
798
|
+
lines of a snippet with `<br>` and no newline character at all), `<img>` is dropped, the rest is inline
|
|
799
|
+
markup around text that already is the code."""
|
|
800
|
+
if tag == "br":
|
|
801
|
+
self._code_newline(sink)
|
|
802
|
+
return
|
|
803
|
+
if tag == "img":
|
|
804
|
+
self.dropped_images += 1 # javadoc 8's inheritance-tree gifs: not code
|
|
805
|
+
return
|
|
806
|
+
if tag in VOID_TAGS:
|
|
807
|
+
return
|
|
808
|
+
direct = not self._code_stack
|
|
809
|
+
if tag == "code" and direct:
|
|
810
|
+
if self._code_closed and sink and not sink[-1].endswith("\n"):
|
|
811
|
+
self._code_break = True # one <code> per line
|
|
812
|
+
if sink is self._pre and not self._pre_lang:
|
|
813
|
+
self._pre_lang = fence_language(attrs.get("class", "")) or attrs.get("data-language", "")
|
|
814
|
+
elif self._is_line(tag, attrs) and self._code_break:
|
|
815
|
+
self._code_break = False
|
|
816
|
+
sink.append("\n")
|
|
817
|
+
self._code_stack.append(tag + ("\x00line" if self._is_line(tag, attrs) else ""))
|
|
818
|
+
|
|
819
|
+
@staticmethod
|
|
820
|
+
def _is_line(tag: str, attrs: dict[str, str]) -> bool:
|
|
821
|
+
return tag in {"div", "span", "p", "li"} and bool(
|
|
822
|
+
LINE_CLASSES.intersection(attrs.get("class", "").lower().split())
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
def _code_pop(self, tag: str) -> bool:
|
|
826
|
+
"""Close `tag` inside the pre if it is open there. Returns whether it was."""
|
|
827
|
+
for index in range(len(self._code_stack) - 1, -1, -1):
|
|
828
|
+
if self._code_stack[index].split("\x00")[0] == tag:
|
|
829
|
+
del self._code_stack[index:]
|
|
830
|
+
return True
|
|
831
|
+
return False
|
|
832
|
+
|
|
833
|
+
def _code_end(self, tag: str, sink: list[str]) -> None:
|
|
834
|
+
for index in range(len(self._code_stack) - 1, -1, -1):
|
|
835
|
+
entry = self._code_stack[index]
|
|
836
|
+
if entry.split("\x00")[0] != tag:
|
|
837
|
+
continue
|
|
838
|
+
del self._code_stack[index:]
|
|
839
|
+
if "\x00line" in entry:
|
|
840
|
+
if sink and not sink[-1].endswith("\n"):
|
|
841
|
+
self._code_break = True
|
|
842
|
+
elif tag == "code" and index == 0:
|
|
843
|
+
self._code_closed += 1
|
|
844
|
+
return
|
|
845
|
+
|
|
846
|
+
def _language_for(self, attrs: dict[str, str]) -> str:
|
|
847
|
+
"""Fence language of a `<pre>` that has just been pushed on `_ids`: its own attributes, then its
|
|
848
|
+
wrapper (Sphinx `div.highlight-python`, Material `div.language-python.highlight`, pandoc)."""
|
|
849
|
+
own = attrs.get("data-language") or attrs.get("data-lang") or fence_language(attrs.get("class", ""))
|
|
850
|
+
if own:
|
|
851
|
+
return own
|
|
852
|
+
for _, _, classes in reversed(self._ids[-5:-1]):
|
|
853
|
+
found = fence_language(classes, wrapper=True)
|
|
854
|
+
if found:
|
|
855
|
+
return found
|
|
856
|
+
return ""
|
|
857
|
+
|
|
858
|
+
def _tab_panel(self, attrs: dict[str, str]) -> None:
|
|
859
|
+
"""The label of the `<button role=tab>` a `role=tabpanel` belongs to, as a bold line before it."""
|
|
860
|
+
if not self._tabs:
|
|
861
|
+
return
|
|
862
|
+
wanted = {attrs.get("id", ""), attrs.get("aria-labelledby", "")} - {""}
|
|
863
|
+
pick = next((i for i, (ident, controls, _) in enumerate(self._tabs) if {ident, controls} & wanted), 0)
|
|
864
|
+
label = self._tabs.pop(pick)[2]
|
|
865
|
+
self._flush()
|
|
866
|
+
self._emit("**" + _escape_asterisks(label) + "**")
|
|
867
|
+
|
|
868
|
+
def _start_block(self, tag: str, attrs: dict[str, str]) -> None:
|
|
869
|
+
if self._pre is not None:
|
|
870
|
+
self._code_start(tag, attrs, self._pre)
|
|
871
|
+
return
|
|
872
|
+
if self._head is not None and (tag in CLOSES_HEAD or (tag in BLOCK_TAGS and self._text())):
|
|
873
|
+
self._close_head()
|
|
874
|
+
if tag == "a" and self.profile.inner_anchor:
|
|
875
|
+
self._inner_anchor(attrs)
|
|
876
|
+
if attrs.get("role", "").lower() == "tabpanel":
|
|
877
|
+
self._tab_panel(attrs)
|
|
878
|
+
if "admonition-title" in attrs.get("class", "").split():
|
|
879
|
+
self._flush()
|
|
880
|
+
self._title = (tag, len(self._ids))
|
|
881
|
+
return
|
|
882
|
+
if tag in HEADINGS:
|
|
883
|
+
self._flush()
|
|
884
|
+
self._head = self._heading_head(tag, attrs)
|
|
885
|
+
elif tag == "dt":
|
|
886
|
+
self._flush()
|
|
887
|
+
self._head = (0, attrs.get("id", ""), "definition")
|
|
888
|
+
elif tag == "pre":
|
|
889
|
+
self._flush()
|
|
890
|
+
self._pre = []
|
|
891
|
+
self._code_reset()
|
|
892
|
+
self._pre_lang = self._language_for(attrs)
|
|
893
|
+
elif tag == "summary":
|
|
894
|
+
self._flush()
|
|
895
|
+
parent = self._ids[-2] if len(self._ids) > 1 else None
|
|
896
|
+
if parent is not None and parent[0] == "details" and parent[1]:
|
|
897
|
+
# <details id=...><summary>Example (X)</summary>: the summary names the part, so it is a
|
|
898
|
+
# definition and the id is its anchor. Without an id the summary stays a plain line.
|
|
899
|
+
self._head = (0, parent[1], "definition")
|
|
900
|
+
elif tag in {"ul", "ol"}:
|
|
901
|
+
self._flush()
|
|
902
|
+
self._lists.append([1 if tag == "ol" else 0, 0])
|
|
903
|
+
elif tag == "li":
|
|
904
|
+
self._flush()
|
|
905
|
+
if self._lists:
|
|
906
|
+
kind = self._lists[-1]
|
|
907
|
+
kind[1] += 1
|
|
908
|
+
self._item_prefix = f"{kind[1]}. " if kind[0] else "- "
|
|
909
|
+
else:
|
|
910
|
+
self._item_prefix = "- "
|
|
911
|
+
elif tag == "blockquote":
|
|
912
|
+
self._flush()
|
|
913
|
+
self._quote += 0 if self.profile.plain_blockquote else 1
|
|
914
|
+
elif tag == "table":
|
|
915
|
+
self._flush()
|
|
916
|
+
self._table = []
|
|
917
|
+
self._row = None
|
|
918
|
+
self._nested = 0
|
|
919
|
+
self._cell_pres = []
|
|
920
|
+
self._header_row = False
|
|
921
|
+
elif tag == "div" and "summary-table" in attrs.get("class", "").split():
|
|
922
|
+
self._flush()
|
|
923
|
+
columns = next((n for word, n in GRID_COLUMNS.items() if f"{word}-column-summary" in attrs["class"]), 1)
|
|
924
|
+
self._grid = _Grid(columns)
|
|
925
|
+
self._table = None
|
|
926
|
+
elif tag == "br":
|
|
927
|
+
self._buf.append(" ")
|
|
928
|
+
elif tag == "hr":
|
|
929
|
+
self._flush()
|
|
930
|
+
if self.profile.rules:
|
|
931
|
+
self._emit("---")
|
|
932
|
+
elif tag == "code" and self._pre is None:
|
|
933
|
+
self._code += 1
|
|
934
|
+
self._buf.append("`")
|
|
935
|
+
elif tag in {"em", "i"} and self._pre is None and self._head is None:
|
|
936
|
+
self._buf.append("*")
|
|
937
|
+
elif tag in {"strong", "b"} and self._pre is None and self._head is None:
|
|
938
|
+
self._buf.append("**")
|
|
939
|
+
elif tag == "a" and self._head is not None and not (self._head[2] == "definition" and not self._head[1]):
|
|
940
|
+
self._glyph = len(self._buf) # a link inside a heading is plain text, but its glyph is dropped
|
|
941
|
+
elif tag == "a" and attrs.get("href"):
|
|
942
|
+
if PAGER_RELS.intersection(attrs.get("rel", "").lower().split()):
|
|
943
|
+
self._pager = (len(self.blocks), len(self.headings), len(self._buf))
|
|
944
|
+
self._link_mark = self._open_link(self._buf, attrs["href"])
|
|
945
|
+
elif tag == "img":
|
|
946
|
+
self._buf.append(self._image(attrs))
|
|
947
|
+
elif tag in BLOCK_TAGS:
|
|
948
|
+
self._flush()
|
|
949
|
+
|
|
950
|
+
def _inner_anchor(self, attrs: dict[str, str]) -> None:
|
|
951
|
+
"""`Profile.inner_anchor`: an `<a id>`/`<a name>` before any text of a heading that has no id of its own
|
|
952
|
+
names that heading (The Open Group's markup)."""
|
|
953
|
+
head = self._head
|
|
954
|
+
if head is None or head[2] != "heading" or not self._head_inherited or self._text():
|
|
955
|
+
return
|
|
956
|
+
ident = attrs.get("id") or attrs.get("name", "")
|
|
957
|
+
if ident:
|
|
958
|
+
self._head = (head[0], self._unique(ident), head[2])
|
|
959
|
+
self._head_inherited = False
|
|
960
|
+
|
|
961
|
+
def _open_link(self, sink: list[str], href: str) -> tuple[int, str] | None:
|
|
962
|
+
"""Start a markdown link in `sink`. Script and mailto targets are not links; the rest is resolved
|
|
963
|
+
against this article so an internal target reads as a ZIM path an agent can follow."""
|
|
964
|
+
if href.lower().startswith(("javascript:", "data:", "mailto:", "tel:")):
|
|
965
|
+
return None
|
|
966
|
+
path, anchor = resolve_href(self.base, href)
|
|
967
|
+
target = (path or "") + ("#" + anchor if anchor else "") if path is not None else href.strip()
|
|
968
|
+
sink.append("[")
|
|
969
|
+
return len(sink) - 1, target
|
|
970
|
+
|
|
971
|
+
@staticmethod
|
|
972
|
+
def _close_link(sink: list[str], mark: tuple[int, str] | None) -> None:
|
|
973
|
+
if mark is None:
|
|
974
|
+
return
|
|
975
|
+
index, target = mark
|
|
976
|
+
label = "".join(sink[index + 1 :]).strip()
|
|
977
|
+
if not label or label in {"¶", "#", "§"}:
|
|
978
|
+
del sink[index:] # an empty or permalink-glyph anchor carries nothing
|
|
979
|
+
else:
|
|
980
|
+
sink.append(f"]({target})")
|
|
981
|
+
|
|
982
|
+
def handle_endtag(self, tag: str) -> None:
|
|
983
|
+
if tag == "title":
|
|
984
|
+
self._in_title = False
|
|
985
|
+
self.title = re.sub(r"\s+", " ", "".join(self._title_parts)).strip()
|
|
986
|
+
return
|
|
987
|
+
if self._skip:
|
|
988
|
+
if self._skip.close(tag) and self._tab is not None and tag == "button":
|
|
989
|
+
label = re.sub(r"\s+", " ", self._tab[2]).strip()
|
|
990
|
+
if label:
|
|
991
|
+
self._tabs.append((self._tab[0], self._tab[1], label))
|
|
992
|
+
self._tab = None
|
|
993
|
+
return
|
|
994
|
+
closed = self.region.end(tag)
|
|
995
|
+
if closed:
|
|
996
|
+
self._close_head()
|
|
997
|
+
self._flush()
|
|
998
|
+
if tag == "main":
|
|
999
|
+
return
|
|
1000
|
+
elif not self._live:
|
|
1001
|
+
return
|
|
1002
|
+
if self._frag:
|
|
1003
|
+
depth = self._frag.depth
|
|
1004
|
+
if self._frag.close(tag):
|
|
1005
|
+
self._fragment_done()
|
|
1006
|
+
elif tag == "div" and depth == 2 and self._pre is not None:
|
|
1007
|
+
self._pre.append("\n") # `</div>` of a `div.line`: one source line per line element
|
|
1008
|
+
return
|
|
1009
|
+
if tag not in VOID_TAGS:
|
|
1010
|
+
while self._ids:
|
|
1011
|
+
if self._ids.pop()[0] == tag:
|
|
1012
|
+
break
|
|
1013
|
+
if self._grid is not None and self._grid_end(tag):
|
|
1014
|
+
return
|
|
1015
|
+
if self._table is not None and self._table_end(tag):
|
|
1016
|
+
return
|
|
1017
|
+
if self._cell is not None:
|
|
1018
|
+
if self._cell_pre is not None:
|
|
1019
|
+
if tag == "pre" and not self._code_pop(tag):
|
|
1020
|
+
self._end_cell_pre()
|
|
1021
|
+
elif tag != "pre":
|
|
1022
|
+
self._code_end(tag, self._cell_pre)
|
|
1023
|
+
return
|
|
1024
|
+
if tag == "code":
|
|
1025
|
+
self._cell.append("`")
|
|
1026
|
+
elif tag == "a":
|
|
1027
|
+
self._close_link(self._cell, self._cell_link)
|
|
1028
|
+
self._cell_link = None
|
|
1029
|
+
elif tag in CELL_BREAKS:
|
|
1030
|
+
self._cell.append("\x01")
|
|
1031
|
+
elif tag in {"td", "th"}:
|
|
1032
|
+
self._cell.append(" ")
|
|
1033
|
+
return
|
|
1034
|
+
self._end_block(tag)
|
|
1035
|
+
|
|
1036
|
+
def _end_block(self, tag: str) -> None:
|
|
1037
|
+
if self._pre is not None and (tag != "pre" or self._code_pop(tag)):
|
|
1038
|
+
self._code_end(tag, self._pre)
|
|
1039
|
+
return
|
|
1040
|
+
if self._title is not None and tag == self._title[0] and len(self._ids) == self._title[1] - 1:
|
|
1041
|
+
kind = self._text().rstrip(":").strip()
|
|
1042
|
+
self._buf = []
|
|
1043
|
+
self._title = None
|
|
1044
|
+
self._lead = kind if kind and len(kind) <= 60 else None
|
|
1045
|
+
return
|
|
1046
|
+
if self._head is not None and tag in CLOSES_HEAD_END:
|
|
1047
|
+
self._close_head()
|
|
1048
|
+
if tag in HEADINGS or tag == "dt":
|
|
1049
|
+
self._close_head()
|
|
1050
|
+
elif tag == "summary":
|
|
1051
|
+
if self._head is not None:
|
|
1052
|
+
self._close_head()
|
|
1053
|
+
else:
|
|
1054
|
+
if self._text().lower() == "expand description":
|
|
1055
|
+
self._buf = [] # rustdoc's toggle label
|
|
1056
|
+
self._flush()
|
|
1057
|
+
elif tag == "pre" and self._pre is not None:
|
|
1058
|
+
body = "".join(self._pre).strip("\n").rstrip()
|
|
1059
|
+
lang = self._pre_lang
|
|
1060
|
+
self._pre = None
|
|
1061
|
+
self._pre_lang = ""
|
|
1062
|
+
if body.strip():
|
|
1063
|
+
self._emit(_fenced(body, lang))
|
|
1064
|
+
elif tag in {"ul", "ol"}:
|
|
1065
|
+
self._flush()
|
|
1066
|
+
if self._lists:
|
|
1067
|
+
self._lists.pop()
|
|
1068
|
+
elif tag == "li":
|
|
1069
|
+
self._flush()
|
|
1070
|
+
self._item_prefix = None
|
|
1071
|
+
elif tag == "blockquote":
|
|
1072
|
+
self._flush()
|
|
1073
|
+
if not self.profile.plain_blockquote:
|
|
1074
|
+
self._quote = max(0, self._quote - 1)
|
|
1075
|
+
elif tag == "code" and self._pre is None:
|
|
1076
|
+
self._code = max(0, self._code - 1)
|
|
1077
|
+
self._buf.append("`")
|
|
1078
|
+
elif tag in {"em", "i"} and self._pre is None and self._head is None:
|
|
1079
|
+
self._buf.append("*")
|
|
1080
|
+
elif tag in {"strong", "b"} and self._pre is None and self._head is None:
|
|
1081
|
+
self._buf.append("**")
|
|
1082
|
+
elif tag == "a" and self._glyph is not None:
|
|
1083
|
+
if "".join(self._buf[self._glyph :]).strip() in GLYPHS:
|
|
1084
|
+
del self._buf[self._glyph :]
|
|
1085
|
+
self._glyph = None
|
|
1086
|
+
elif tag == "a" and self._pager is not None:
|
|
1087
|
+
blocks, heads, buffered = self._pager
|
|
1088
|
+
self._pager = None
|
|
1089
|
+
if len(self.blocks) > blocks:
|
|
1090
|
+
# A pager card: rel=next/prev around block content. Everything it emitted is navigation, not text.
|
|
1091
|
+
del self.blocks[blocks:], self._tight_marks[blocks:], self.headings[heads:]
|
|
1092
|
+
del self._buf[buffered:]
|
|
1093
|
+
self._link_mark = None
|
|
1094
|
+
elif self._link_mark is not None:
|
|
1095
|
+
self._close_link(self._buf, self._link_mark)
|
|
1096
|
+
self._link_mark = None
|
|
1097
|
+
elif tag == "a" and self._link_mark is not None:
|
|
1098
|
+
self._close_link(self._buf, self._link_mark)
|
|
1099
|
+
self._link_mark = None
|
|
1100
|
+
elif tag in BLOCK_TAGS or tag == "dd":
|
|
1101
|
+
self._flush()
|
|
1102
|
+
if self._lead is not None and tag in {"div", "section", "article", "details", "figure"}:
|
|
1103
|
+
self._emit_lead() # the admonition ended without a paragraph to prefix
|
|
1104
|
+
|
|
1105
|
+
def handle_data(self, data: str) -> None:
|
|
1106
|
+
if self._in_title:
|
|
1107
|
+
self._title_parts.append(data)
|
|
1108
|
+
return
|
|
1109
|
+
if self._tab is not None and self._skip:
|
|
1110
|
+
self._tab[2] += data
|
|
1111
|
+
return
|
|
1112
|
+
if not self._live:
|
|
1113
|
+
return
|
|
1114
|
+
data = PRIVATE_USE.sub("", data)
|
|
1115
|
+
if self._cell is not None or self._grid is not None:
|
|
1116
|
+
data = CONTROL.sub("", data) # the cell markers are control characters; none belongs in text
|
|
1117
|
+
if not data:
|
|
1118
|
+
return
|
|
1119
|
+
if self._frag and self._frag.depth == 1 and not data.strip():
|
|
1120
|
+
return # the newline Doxygen writes between two line elements is not a line
|
|
1121
|
+
if self._pre is not None:
|
|
1122
|
+
self._code_text(self._pre, data)
|
|
1123
|
+
elif self._cell_pre is not None:
|
|
1124
|
+
self._code_text(self._cell_pre, data)
|
|
1125
|
+
elif self._cell is not None:
|
|
1126
|
+
self._cell.append(data)
|
|
1127
|
+
else:
|
|
1128
|
+
self._buf.append(data)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _fenced(body: str, lang: str = "") -> str:
|
|
1132
|
+
fence = "```"
|
|
1133
|
+
while fence in body:
|
|
1134
|
+
fence += "`"
|
|
1135
|
+
return f"{fence}{lang}\n{body}\n{fence}"
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _gfm_cell(raw: str, pres: list[tuple[str, str]]) -> str:
|
|
1139
|
+
"""One pipe-table cell from the marked-up text `_Builder` collected. A pipe table cell cannot hold a fence
|
|
1140
|
+
or a newline, so each `<pre>` in it becomes one code span with its lines joined by `<br>`."""
|
|
1141
|
+
|
|
1142
|
+
def span(match: re.Match[str]) -> str:
|
|
1143
|
+
code = pres[int(match.group(1))][1]
|
|
1144
|
+
return "`" + code.replace("`", "'").replace("\n", "<br>") + "`"
|
|
1145
|
+
|
|
1146
|
+
text = CELL_MARKS.sub(" ", CELL_PRE.sub(span, raw))
|
|
1147
|
+
return re.sub(r"\s+", " ", text).strip().replace("|", "\\|")
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def _is_box(rows: list[list[str]]) -> bool:
|
|
1151
|
+
"""Is this table a layout box rather than data? One column (or one filled cell) whose content has block
|
|
1152
|
+
structure - a `<pre>`, or two or more paragraphs/items. Texinfo's `@cartouche` is exactly that: a one-cell table
|
|
1153
|
+
around a whole box of prose and examples, and a pipe table cannot hold it. Such a table is emitted as ordinary
|
|
1154
|
+
blocks. Everything else, including a single column of plain values with a header, stays a pipe table, where a
|
|
1155
|
+
`<pre>` degrades to an inline code span."""
|
|
1156
|
+
cells = [cell for row in rows for cell in row if CELL_MARKS.sub("", cell).strip()]
|
|
1157
|
+
if max(len(row) for row in rows) != 1 and len(cells) != 1:
|
|
1158
|
+
return False
|
|
1159
|
+
return any(CELL_PRE.search(cell) or sum(bool(s.strip()) for s in CELL_MARKS.split(cell)) > 1 for cell in cells)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
_VERBATIM = re.compile(r"(`[^`]*`|\]\([^)]*\))")
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _escape_asterisks(text: str) -> str:
|
|
1166
|
+
"""Backslash every literal `*` that is outside a code span or a link target, so `*args, **kwargs` survives
|
|
1167
|
+
being wrapped in `**...**`. Deleting the asterisks instead turned `**kwargs` into `kwargs`."""
|
|
1168
|
+
parts = _VERBATIM.split(text)
|
|
1169
|
+
return "".join(part if index % 2 else part.replace("*", "\\*") for index, part in enumerate(parts))
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _render_table(rows: list[list[str]]) -> str:
|
|
1173
|
+
width = max(len(row) for row in rows)
|
|
1174
|
+
padded = [row + [""] * (width - len(row)) for row in rows]
|
|
1175
|
+
lines = ["| " + " | ".join(padded[0]) + " |", "|" + "|".join(" --- " for _ in range(width)) + "|"]
|
|
1176
|
+
lines += ["| " + " | ".join(row) + " |" for row in padded[1:]]
|
|
1177
|
+
return "\n".join(lines)
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
@dataclass
|
|
1181
|
+
class _Grid:
|
|
1182
|
+
"""A `div` grid read as a table: `depth` counts open divs (1 between cells), `cells` is (is_header, text)."""
|
|
1183
|
+
|
|
1184
|
+
columns: int
|
|
1185
|
+
depth: int = 1
|
|
1186
|
+
header: bool = False
|
|
1187
|
+
cells: list[tuple[bool, str]] = field(default_factory=list)
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
class _LinkCollector(HTMLParser):
|
|
1191
|
+
"""Second, tiny pass: anchors with their visible text. Kept separate from the block builder so that links
|
|
1192
|
+
inside skipped regions (the navbar) are not reported and the builder stays about layout."""
|
|
1193
|
+
|
|
1194
|
+
def __init__(self, base: str, region: str = "", profile: Profile = GENERIC) -> None:
|
|
1195
|
+
super().__init__(convert_charrefs=True)
|
|
1196
|
+
self.base = base
|
|
1197
|
+
self.profile = profile
|
|
1198
|
+
self.region = _Region(region)
|
|
1199
|
+
self.links: list[Link] = []
|
|
1200
|
+
self._skip = _Depth()
|
|
1201
|
+
self._open: list[str] = [] # open tags, to tell a page footer from an article's own
|
|
1202
|
+
self._href: str | None = None
|
|
1203
|
+
self._text: list[str] = []
|
|
1204
|
+
|
|
1205
|
+
def handle_starttag(self, tag: str, attrs_list: list[tuple[str, str | None]]) -> None:
|
|
1206
|
+
if tag in VOID_TAGS:
|
|
1207
|
+
return
|
|
1208
|
+
attrs = {key: value or "" for key, value in attrs_list}
|
|
1209
|
+
if self._skip:
|
|
1210
|
+
self._skip.nest(tag)
|
|
1211
|
+
return
|
|
1212
|
+
in_container = any(t in {"article", "section"} for t in self._open)
|
|
1213
|
+
if is_chrome(tag, attrs, in_container, links=True, extra=self.profile.chrome_classes):
|
|
1214
|
+
self._skip.open(tag)
|
|
1215
|
+
return
|
|
1216
|
+
self.region.start(tag, attrs)
|
|
1217
|
+
self._open.append(tag)
|
|
1218
|
+
if tag == "a":
|
|
1219
|
+
href = dict(attrs_list).get("href") or ""
|
|
1220
|
+
self._href = href if href and not href.lower().startswith(("javascript:", "data:", "mailto:")) else None
|
|
1221
|
+
self._text = []
|
|
1222
|
+
|
|
1223
|
+
def handle_endtag(self, tag: str) -> None:
|
|
1224
|
+
if tag in VOID_TAGS:
|
|
1225
|
+
return
|
|
1226
|
+
if self._skip:
|
|
1227
|
+
self._skip.close(tag)
|
|
1228
|
+
return
|
|
1229
|
+
in_region = self.region.inside
|
|
1230
|
+
self.region.end(tag)
|
|
1231
|
+
while self._open:
|
|
1232
|
+
if self._open.pop() == tag:
|
|
1233
|
+
break
|
|
1234
|
+
if tag == "a" and self._href is not None:
|
|
1235
|
+
if in_region:
|
|
1236
|
+
text = re.sub(r"\s+", " ", PRIVATE_USE.sub("", "".join(self._text))).strip()
|
|
1237
|
+
path, anchor = resolve_href(self.base, self._href)
|
|
1238
|
+
# A link with no text left (a permalink glyph, an icon) points nowhere an agent would follow.
|
|
1239
|
+
if text and (path is not None or self._href.lower().startswith(("http:", "https:"))):
|
|
1240
|
+
self.links.append(Link(text, self._href if path is None else "", path, anchor))
|
|
1241
|
+
self._href = None
|
|
1242
|
+
|
|
1243
|
+
def handle_data(self, data: str) -> None:
|
|
1244
|
+
if self._href is not None and not self._skip:
|
|
1245
|
+
self._text.append(data)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _build(html: str, base_path: str, region: str, profile: Profile = GENERIC) -> _Builder:
|
|
1249
|
+
builder = _Builder(base_path, region, profile)
|
|
1250
|
+
builder.feed(html)
|
|
1251
|
+
builder.close()
|
|
1252
|
+
builder._close_head()
|
|
1253
|
+
builder._flush()
|
|
1254
|
+
return builder
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def convert(html: str, base_path: str = "", profile: Profile = GENERIC) -> Document:
|
|
1258
|
+
"""Convert one page. `base_path` is the page's own path, against which links are resolved."""
|
|
1259
|
+
builder = None
|
|
1260
|
+
for kind in _region_candidates(html):
|
|
1261
|
+
attempt = _build(html, base_path, kind, profile)
|
|
1262
|
+
# A textual candidate inside a script or comment never opens. A `role=main` that leaves the page's own `<h1>`
|
|
1263
|
+
# outside is not the article: Stack Exchange puts the question title in a header above `#mainbar`.
|
|
1264
|
+
if attempt.region.entered and (kind == "main" or not attempt.title_outside):
|
|
1265
|
+
builder = attempt
|
|
1266
|
+
break
|
|
1267
|
+
if builder is None:
|
|
1268
|
+
builder = _build(html, base_path, "", profile)
|
|
1269
|
+
links = _LinkCollector(base_path, builder.region.kind, profile)
|
|
1270
|
+
links.feed(html)
|
|
1271
|
+
links.close()
|
|
1272
|
+
title = builder.title
|
|
1273
|
+
if not title:
|
|
1274
|
+
title = next((h.text for h in builder.headings if h.level == 1), "")
|
|
1275
|
+
return Document(
|
|
1276
|
+
title,
|
|
1277
|
+
builder.blocks,
|
|
1278
|
+
builder.headings,
|
|
1279
|
+
links.links,
|
|
1280
|
+
builder.dropped_images,
|
|
1281
|
+
builder._tight_marks,
|
|
1282
|
+
builder.truncated,
|
|
1283
|
+
builder.aliases,
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
_LINK = re.compile(r"\[([^\]]{0,500})\]\([^)]{0,2000}\)")
|
|
1288
|
+
"""Bounded on both sides. Unbounded `[^]]*` made a run of `[` quadratic: each one scanned to the end of the line
|
|
1289
|
+
looking for a `]` (40,000 of them took 2.3 s, found in review, D3)."""
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def to_plain(markdown: str) -> str:
|
|
1293
|
+
"""Plain text from the converter's own markdown: heading marks, emphasis, backticks and fences removed,
|
|
1294
|
+
links reduced to their text. Applied to the converter's output, not to arbitrary markdown."""
|
|
1295
|
+
out: list[str] = []
|
|
1296
|
+
in_fence = ""
|
|
1297
|
+
for line in markdown.split("\n"):
|
|
1298
|
+
stripped = line.lstrip("> ").lstrip()
|
|
1299
|
+
if in_fence:
|
|
1300
|
+
if stripped.startswith(in_fence):
|
|
1301
|
+
in_fence = ""
|
|
1302
|
+
continue
|
|
1303
|
+
out.append(line)
|
|
1304
|
+
continue
|
|
1305
|
+
if stripped.startswith("```"):
|
|
1306
|
+
in_fence = re.match(r"`+", stripped).group(0) # type: ignore[union-attr]
|
|
1307
|
+
continue
|
|
1308
|
+
line = re.sub(r"^(\s*)#{1,6} ", r"\1", line)
|
|
1309
|
+
line = _LINK.sub(r"\1", line)
|
|
1310
|
+
line = line.replace("**", "").replace("`", "")
|
|
1311
|
+
out.append(line)
|
|
1312
|
+
return "\n".join(out)
|