quilldown 0.1.1__tar.gz
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.
- quilldown-0.1.1/.gitignore +10 -0
- quilldown-0.1.1/CHANGELOG.md +37 -0
- quilldown-0.1.1/PKG-INFO +10 -0
- quilldown-0.1.1/README.md +16 -0
- quilldown-0.1.1/pyproject.toml +32 -0
- quilldown-0.1.1/src/quilldown/__init__.py +38 -0
- quilldown-0.1.1/src/quilldown/autolink.py +392 -0
- quilldown-0.1.1/src/quilldown/parser.py +1043 -0
- quilldown-0.1.1/src/quilldown/render.py +108 -0
- quilldown-0.1.1/src/quilldown_spec/_Block.py +1154 -0
- quilldown-0.1.1/src/quilldown_spec/_ConvertOptions.py +99 -0
- quilldown-0.1.1/src/quilldown_spec/_Document.py +133 -0
- quilldown-0.1.1/src/quilldown_spec/_Inline.py +1027 -0
- quilldown-0.1.1/src/quilldown_spec/_Quilldown.py +32 -0
- quilldown-0.1.1/src/quilldown_spec/_RenderOptions.py +99 -0
- quilldown-0.1.1/src/quilldown_spec/_RenderStats.py +162 -0
- quilldown-0.1.1/src/quilldown_spec/_Samples.py +63 -0
- quilldown-0.1.1/src/quilldown_spec/_TableCell.py +136 -0
- quilldown-0.1.1/src/quilldown_spec/_TableRow.py +136 -0
- quilldown-0.1.1/src/quilldown_spec/__init__.py +78 -0
- quilldown-0.1.1/src/quilldown_spec/_context.py +149 -0
- quilldown-0.1.1/src/quilldown_spec/py.typed +0 -0
- quilldown-0.1.1/tests/test_block.py +83 -0
- quilldown-0.1.1/tests/test_block_quote.py +78 -0
- quilldown-0.1.1/tests/test_code_block.py +81 -0
- quilldown-0.1.1/tests/test_code_inline.py +81 -0
- quilldown-0.1.1/tests/test_context.py +199 -0
- quilldown-0.1.1/tests/test_convert_options.py +62 -0
- quilldown-0.1.1/tests/test_document.py +70 -0
- quilldown-0.1.1/tests/test_emphasis_inline.py +78 -0
- quilldown-0.1.1/tests/test_hard_break_inline.py +73 -0
- quilldown-0.1.1/tests/test_heading.py +86 -0
- quilldown-0.1.1/tests/test_inline.py +78 -0
- quilldown-0.1.1/tests/test_link_inline.py +86 -0
- quilldown-0.1.1/tests/test_list_block.py +94 -0
- quilldown-0.1.1/tests/test_list_item_block.py +78 -0
- quilldown-0.1.1/tests/test_paragraph.py +78 -0
- quilldown-0.1.1/tests/test_render_options.py +62 -0
- quilldown-0.1.1/tests/test_render_stats.py +142 -0
- quilldown-0.1.1/tests/test_soft_break_inline.py +73 -0
- quilldown-0.1.1/tests/test_strikethrough_inline.py +78 -0
- quilldown-0.1.1/tests/test_strong_inline.py +78 -0
- quilldown-0.1.1/tests/test_table_block.py +97 -0
- quilldown-0.1.1/tests/test_table_cell.py +70 -0
- quilldown-0.1.1/tests/test_table_row.py +70 -0
- quilldown-0.1.1/tests/test_text_inline.py +81 -0
- quilldown-0.1.1/tests/test_thematic_break.py +73 -0
- quilldown-0.1.1/tests/test_vector_conformance.py +3178 -0
- quilldown-0.1.1/tests/vector_adapters.py +28 -0
- quilldown-0.1.1/tests/vector_runner.py +246 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.1](https://github.com/sethjuarez/quilldown/compare/quilldown-python-v0.1.0...quilldown-python-v0.1.1) (2026-09-09)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Documentation
|
|
7
|
+
|
|
8
|
+
* **python:** tighten the runtime README ([a6a24d8](https://github.com/sethjuarez/quilldown/commit/a6a24d80ceb5570291287e337d43dbb9076dfc06))
|
|
9
|
+
* **python:** tighten the runtime README ([e575ec8](https://github.com/sethjuarez/quilldown/commit/e575ec839764125657b580527a2a13e5fcdb1b5d))
|
|
10
|
+
|
|
11
|
+
## 0.1.0 (2026-09-09)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
* author spec/*.tsp and green Python runtime (ADR-0001 steps 4-5) ([4863232](https://github.com/sethjuarez/quilldown/commit/4863232e4939b04029ad45cff89aa6db55515102))
|
|
17
|
+
* **python:** add uv-managed Python runtime passing Core conformance ([90fde38](https://github.com/sethjuarez/quilldown/commit/90fde388d28486b29787d4df204dfa6de93f2cbc))
|
|
18
|
+
* **python:** autolink bare URLs, www, and emails to match comrak ([39fd350](https://github.com/sethjuarez/quilldown/commit/39fd350768d2576ed438cce5c56f7ac821d2abaa))
|
|
19
|
+
* **python:** drop leading YAML front matter to match comrak ([2787194](https://github.com/sethjuarez/quilldown/commit/27871949082afd2f557cfc2ce361980db2cbcd75))
|
|
20
|
+
* **python:** legalize comrak dollar-backtick code math to text ([1506568](https://github.com/sethjuarez/quilldown/commit/1506568542fe5fa6592fd09c3262967842f7f825))
|
|
21
|
+
* **python:** legalize footnotes to match the Rust oracle ([cbe42d5](https://github.com/sethjuarez/quilldown/commit/cbe42d5c58337bfc8c7fda299e22158354dbb56f))
|
|
22
|
+
* **python:** legalize GFM alerts to unwrapped body blocks ([bcda104](https://github.com/sethjuarez/quilldown/commit/bcda1044c5dd62bee05f9f35d5ecfc02cedfd322))
|
|
23
|
+
* **python:** legalize GFM subscript with a comrak-faithful ~ pairing pass ([9d2b1b7](https://github.com/sethjuarez/quilldown/commit/9d2b1b7c3ae03a03d2cd0ae263c858eac22d8a4b))
|
|
24
|
+
* **python:** legalize GFM superscript by flattening to inner content ([27f7e79](https://github.com/sethjuarez/quilldown/commit/27f7e793cbc1849a95d3c0ed948cb247542b89f1))
|
|
25
|
+
* **python:** legalize images to alt text to match the Rust oracle ([8c2f120](https://github.com/sethjuarez/quilldown/commit/8c2f120a3feb87eb233563c35798bf7518efafcf))
|
|
26
|
+
* **python:** legalize inline and display math to match the Rust oracle ([66f3bdb](https://github.com/sethjuarez/quilldown/commit/66f3bdb5a1679e85de8efb0102ff3162c7e0a02b))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Bug Fixes
|
|
30
|
+
|
|
31
|
+
* **python:** legalize comrak cross-marker delimiter removal for tilde spans ([f0a5fd8](https://github.com/sethjuarez/quilldown/commit/f0a5fd80f8f2cc859b6002f2f30d1263a02f0a27))
|
|
32
|
+
* **python:** unified interleaved delimiter pass for cross-marker emphasis parity ([9a4a83b](https://github.com/sethjuarez/quilldown/commit/9a4a83b8f726e3b9568a25f9739c82b3178ff7e6))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
### Documentation
|
|
36
|
+
|
|
37
|
+
* **python:** document mixed-marker re-pairing exclusion found in duck review ([ad3248f](https://github.com/sethjuarez/quilldown/commit/ad3248f50027a2bdde10fd8afec0ceaeef6d8064))
|
quilldown-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: quilldown
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python runtime for the Quilldown shared contract (Markdown -> IR -> DOCX).
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: markdown-it-py>=3.0
|
|
7
|
+
Requires-Dist: mdit-py-plugins>=0.4
|
|
8
|
+
Requires-Dist: pyyaml>=6.0
|
|
9
|
+
Provides-Extra: docx
|
|
10
|
+
Requires-Dist: python-docx>=1.1; extra == 'docx'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# quilldown (Python runtime)
|
|
2
|
+
|
|
3
|
+
The Python runtime for [quilldown](../../README.md) — Markdown → IR → `.docx` —
|
|
4
|
+
implementing the shared [`spec/`](../../spec) contract per
|
|
5
|
+
[ADR-0001](../../docs/adr/0001-polyglot-quilldown-via-shared-contract.md).
|
|
6
|
+
|
|
7
|
+
It ships its own lowering (Markdown → IR, via `markdown-it-py`) and emitter
|
|
8
|
+
(IR → `.docx`, via `python-docx`) on top of the typra-generated IR model and
|
|
9
|
+
conformance vectors, keeping it differentially faithful to the Rust reference
|
|
10
|
+
engine.
|
|
11
|
+
|
|
12
|
+
## Develop
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
uv run pytest -q # conformance + vector suite
|
|
16
|
+
```
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "quilldown"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Python runtime for the Quilldown shared contract (Markdown -> IR -> DOCX)."
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"markdown-it-py>=3.0",
|
|
8
|
+
"mdit-py-plugins>=0.4",
|
|
9
|
+
"pyyaml>=6.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
docx = ["python-docx>=1.1"]
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8",
|
|
18
|
+
"pytest-asyncio>=0.23",
|
|
19
|
+
"python-docx>=1.1",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
asyncio_mode = "auto"
|
|
24
|
+
testpaths = ["tests"]
|
|
25
|
+
pythonpath = ["src", "tests"]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/quilldown", "src/quilldown_spec"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Quilldown Python runtime: the hand-authored `lower`/`emit` seam that
|
|
2
|
+
implements the generated `Quilldown` Protocol from the shared spec."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from quilldown_spec import Document, RenderStats, RenderOptions, ConvertOptions
|
|
6
|
+
|
|
7
|
+
from .parser import markdown_to_ir
|
|
8
|
+
from .render import compute_stats, render_docx
|
|
9
|
+
|
|
10
|
+
__all__ = ["Runtime", "lower", "emit", "render_docx"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Runtime:
|
|
14
|
+
"""Concrete implementation of the `Quilldown` seam Protocol."""
|
|
15
|
+
|
|
16
|
+
def lower(self, markdown: str, options: "ConvertOptions | None" = None) -> Document:
|
|
17
|
+
return Document.load(markdown_to_ir(markdown))
|
|
18
|
+
|
|
19
|
+
async def lower_async(self, markdown: str, options: "ConvertOptions | None" = None) -> Document:
|
|
20
|
+
return self.lower(markdown, options)
|
|
21
|
+
|
|
22
|
+
def emit(self, doc: Document, options: "RenderOptions | None" = None) -> RenderStats:
|
|
23
|
+
doc_dict = doc.save() if isinstance(doc, Document) else doc
|
|
24
|
+
return RenderStats.load(compute_stats(doc_dict))
|
|
25
|
+
|
|
26
|
+
async def emit_async(self, doc: Document, options: "RenderOptions | None" = None) -> RenderStats:
|
|
27
|
+
return self.emit(doc, options)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_DEFAULT = Runtime()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def lower(markdown: str, options: "ConvertOptions | None" = None) -> Document:
|
|
34
|
+
return _DEFAULT.lower(markdown, options)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def emit(doc: Document, options: "RenderOptions | None" = None) -> RenderStats:
|
|
38
|
+
return _DEFAULT.emit(doc, options)
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""GFM autolink extension, ported from comrak's `parser/autolink.rs`.
|
|
2
|
+
|
|
3
|
+
markdown-it-py has no plugin that reproduces comrak's GFM autolinking, and
|
|
4
|
+
`linkify-it-py` cannot match it (its www/bare-domain handling is a single fuzzy
|
|
5
|
+
flag that either over-links `e.g.`/`v2.0` or drops `www.`/email). comrak links
|
|
6
|
+
only three "kinds":
|
|
7
|
+
|
|
8
|
+
* URL autolinks with a `http://`, `https://` or `ftp://` scheme (lowercase),
|
|
9
|
+
plus the bare `mailto:`/`xmpp:` schemes, matched through the email path;
|
|
10
|
+
* `www.`-prefixed hosts, rewritten with an implicit `http://`;
|
|
11
|
+
* bare email addresses, rewritten with an implicit `mailto:`.
|
|
12
|
+
|
|
13
|
+
The delimiter trimming (trailing punctuation, balanced parentheses, trailing
|
|
14
|
+
HTML entities), the domain validation (at least one dot, no underscore in the
|
|
15
|
+
last two labels), the lowercase-scheme requirement and the `[...]`-bracket
|
|
16
|
+
suppression are all reproduced from comrak so the lowered IR matches the frozen
|
|
17
|
+
Rust oracle byte for byte.
|
|
18
|
+
|
|
19
|
+
The port operates on Python `str` using character offsets; comrak uses byte
|
|
20
|
+
offsets. The two agree for ASCII, which covers realistic URLs/emails; exotic
|
|
21
|
+
multi-byte hosts and the bidi Pop-Directional-Isolate trim are approximated.
|
|
22
|
+
|
|
23
|
+
Known limitations (declared exclusions, not bugs). comrak autolinks the *raw*
|
|
24
|
+
source, decoding HTML entities only afterwards, whereas markdown-it-py decodes
|
|
25
|
+
entities eagerly into a single plain-text token with no retained raw spelling
|
|
26
|
+
(`.markup` is empty). Where an entity sits adjacent to an autolink the two can
|
|
27
|
+
therefore disagree — e.g. `https://a.com&` (comrak trims `&`; we keep a
|
|
28
|
+
decoded `&`), `https://a.com/&/b` and `https://a.com/a<b`. Recovering the
|
|
29
|
+
raw entity is not possible from markdown-it's AST, and disabling entity decoding
|
|
30
|
+
globally would corrupt the common `&`->`&` text path, so these adjacency
|
|
31
|
+
cases and the bidi PDI (U+2069) trailing trim are left unmatched.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import unicodedata
|
|
36
|
+
|
|
37
|
+
_ASCII_ALNUM = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
|
38
|
+
_ASCII_ALPHA = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
|
39
|
+
_EMAIL_OK = set(".+-_")
|
|
40
|
+
_LINK_END_ASSORTMENT = set("?!.,:*_~'\"")
|
|
41
|
+
_WWW_DELIMS = set("*_~([")
|
|
42
|
+
_SCHEMES = ("http", "https", "ftp")
|
|
43
|
+
_SPACE = set(" \t\n\r\x0b\x0c")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _isalnum(c: str) -> bool:
|
|
47
|
+
return c in _ASCII_ALNUM
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _isalpha(c: str) -> bool:
|
|
51
|
+
return c in _ASCII_ALPHA
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _isspace(c: str) -> bool:
|
|
55
|
+
return c in _SPACE
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_valid_hostchar(c: str) -> bool:
|
|
59
|
+
if c.isspace():
|
|
60
|
+
return False
|
|
61
|
+
cat = unicodedata.category(c)
|
|
62
|
+
return not (cat.startswith("P") or cat.startswith("S"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _check_domain(data: str, allow_short: bool = False) -> int | None:
|
|
66
|
+
"""Return the length of a valid domain at the start of `data`, else None.
|
|
67
|
+
|
|
68
|
+
Mirrors comrak's `check_domain`: `.`-separated labels of host characters
|
|
69
|
+
(plus `-`), rejecting a domain whose last one or two labels contain `_`,
|
|
70
|
+
and requiring at least one `.` when `allow_short` is false.
|
|
71
|
+
"""
|
|
72
|
+
np = 0
|
|
73
|
+
uscore1 = 0
|
|
74
|
+
uscore2 = 0
|
|
75
|
+
n = len(data)
|
|
76
|
+
for i, c in enumerate(data):
|
|
77
|
+
if c == "\\" and i < n - 1:
|
|
78
|
+
# Escaped characters are ignored, per cmark-gfm.
|
|
79
|
+
continue
|
|
80
|
+
if c == "_":
|
|
81
|
+
uscore2 += 1
|
|
82
|
+
elif c == ".":
|
|
83
|
+
uscore1 = uscore2
|
|
84
|
+
uscore2 = 0
|
|
85
|
+
np += 1
|
|
86
|
+
elif not _is_valid_hostchar(c) and c != "-":
|
|
87
|
+
if uscore1 == 0 and uscore2 == 0 and (allow_short or np > 0):
|
|
88
|
+
return i
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
if (uscore1 > 0 or uscore2 > 0) and np <= 10:
|
|
92
|
+
return None
|
|
93
|
+
if allow_short or np > 0:
|
|
94
|
+
return len(data)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _autolink_delim(data: str, link_end: int) -> int:
|
|
99
|
+
"""Trim trailing characters that are not part of the link (comrak's
|
|
100
|
+
`autolink_delim`): a `<` cuts the link, an assortment of trailing
|
|
101
|
+
punctuation is stripped, a trailing HTML entity (`&...;`) is removed, and
|
|
102
|
+
an unbalanced trailing `)` is dropped while balanced ones are kept."""
|
|
103
|
+
# A `<` terminates the link.
|
|
104
|
+
cut = data.find("<")
|
|
105
|
+
if 0 <= cut < link_end:
|
|
106
|
+
link_end = cut
|
|
107
|
+
|
|
108
|
+
while link_end > 0:
|
|
109
|
+
cclose = data[link_end - 1]
|
|
110
|
+
copen = "(" if cclose == ")" else None
|
|
111
|
+
|
|
112
|
+
if cclose in _LINK_END_ASSORTMENT:
|
|
113
|
+
link_end -= 1
|
|
114
|
+
elif cclose == ";":
|
|
115
|
+
new_end = link_end - 2
|
|
116
|
+
while new_end > 0 and _isalpha(data[new_end]):
|
|
117
|
+
new_end -= 1
|
|
118
|
+
if new_end < link_end - 2 and data[new_end] == "&":
|
|
119
|
+
link_end = new_end
|
|
120
|
+
else:
|
|
121
|
+
link_end -= 1
|
|
122
|
+
elif copen is not None:
|
|
123
|
+
opening = 0
|
|
124
|
+
closing = 0
|
|
125
|
+
for b in data[:link_end]:
|
|
126
|
+
if b == copen:
|
|
127
|
+
opening += 1
|
|
128
|
+
elif b == cclose:
|
|
129
|
+
closing += 1
|
|
130
|
+
if closing <= opening:
|
|
131
|
+
break
|
|
132
|
+
link_end -= 1
|
|
133
|
+
else:
|
|
134
|
+
break
|
|
135
|
+
|
|
136
|
+
return link_end
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _validate_protocol(protocol: str, contents: str, cursor: int) -> bool:
|
|
140
|
+
"""True when the alphabetic run ending at `cursor` (a `:`) equals
|
|
141
|
+
`protocol` exactly (comrak's `validate_protocol`)."""
|
|
142
|
+
rewind = 0
|
|
143
|
+
while rewind < cursor and _isalpha(contents[cursor - rewind - 1]):
|
|
144
|
+
rewind += 1
|
|
145
|
+
return contents[cursor - rewind : cursor] == protocol
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _email_match(contents: str, i: int) -> tuple[int, int, str] | None:
|
|
149
|
+
"""Match a bare email (or `mailto:`/`xmpp:` URL) around the `@` at `i`.
|
|
150
|
+
|
|
151
|
+
Returns `(start, end, url)` in character offsets, or None. Ported from
|
|
152
|
+
comrak's `email_match`.
|
|
153
|
+
"""
|
|
154
|
+
size = len(contents)
|
|
155
|
+
auto_mailto = True
|
|
156
|
+
is_xmpp = False
|
|
157
|
+
rewind = 0
|
|
158
|
+
|
|
159
|
+
while rewind < i:
|
|
160
|
+
c = contents[i - rewind - 1]
|
|
161
|
+
if _isalnum(c) or c in _EMAIL_OK:
|
|
162
|
+
rewind += 1
|
|
163
|
+
continue
|
|
164
|
+
if c == ":":
|
|
165
|
+
if _validate_protocol("mailto", contents, i - rewind - 1):
|
|
166
|
+
auto_mailto = False
|
|
167
|
+
rewind += 1
|
|
168
|
+
continue
|
|
169
|
+
if _validate_protocol("xmpp", contents, i - rewind - 1):
|
|
170
|
+
is_xmpp = True
|
|
171
|
+
auto_mailto = False
|
|
172
|
+
rewind += 1
|
|
173
|
+
continue
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
if rewind == 0:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
link_end = 1
|
|
180
|
+
np = 0
|
|
181
|
+
while link_end < size - i:
|
|
182
|
+
c = contents[i + link_end]
|
|
183
|
+
if _isalnum(c):
|
|
184
|
+
pass
|
|
185
|
+
elif c == "@":
|
|
186
|
+
return None
|
|
187
|
+
elif c == "." and link_end < size - i - 1 and _isalnum(contents[i + link_end + 1]):
|
|
188
|
+
np += 1
|
|
189
|
+
elif c == "/" and is_xmpp:
|
|
190
|
+
pass
|
|
191
|
+
elif c != "-" and c != "_":
|
|
192
|
+
break
|
|
193
|
+
link_end += 1
|
|
194
|
+
|
|
195
|
+
last = contents[i + link_end - 1]
|
|
196
|
+
if link_end < 2 or np == 0 or (not _isalpha(last) and last != "."):
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
link_end = _autolink_delim(contents[i:], link_end)
|
|
200
|
+
if link_end == 0:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
text = contents[i - rewind : i + link_end]
|
|
204
|
+
url = f"mailto:{text}" if auto_mailto else text
|
|
205
|
+
return (i - rewind, i + link_end, url)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _www_match(contents: str, i: int, prev: str | None = None) -> tuple[int, int, str] | None:
|
|
209
|
+
"""Match a `www.` autolink starting at `i` (comrak's `www_match`).
|
|
210
|
+
|
|
211
|
+
`prev` is the raw source character immediately before `i`. Within a text run
|
|
212
|
+
that is `contents[i - 1]`; at the run's start it is the trailing character of
|
|
213
|
+
the preceding inline node (or None at the very start of the paragraph), so
|
|
214
|
+
the preceder rule matches comrak's source-cursor view across node
|
|
215
|
+
boundaries.
|
|
216
|
+
"""
|
|
217
|
+
n = len(contents)
|
|
218
|
+
p = contents[i - 1] if i > 0 else prev
|
|
219
|
+
if p is not None and not _isspace(p) and p not in _WWW_DELIMS:
|
|
220
|
+
return None
|
|
221
|
+
if not contents.startswith("www.", i):
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
domain = _check_domain(contents[i + 4 :])
|
|
225
|
+
if domain is None:
|
|
226
|
+
return None
|
|
227
|
+
link_end = domain + 4
|
|
228
|
+
|
|
229
|
+
while i + link_end < n and not _isspace(contents[i + link_end]):
|
|
230
|
+
link_end += 1
|
|
231
|
+
|
|
232
|
+
link_end = _autolink_delim(contents[i:], link_end)
|
|
233
|
+
text = contents[i : i + link_end]
|
|
234
|
+
return (i, i + link_end, f"http://{text}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _url_match(contents: str, i: int) -> tuple[int, int, str] | None:
|
|
238
|
+
"""Match a scheme URL autolink where `i` is the `:` of `://`
|
|
239
|
+
(comrak's `url_match`)."""
|
|
240
|
+
n = len(contents)
|
|
241
|
+
if n - i < 4 or contents[i + 1] != "/" or contents[i + 2] != "/":
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
rewind = 0
|
|
245
|
+
while rewind < i and _isalpha(contents[i - rewind - 1]):
|
|
246
|
+
rewind += 1
|
|
247
|
+
|
|
248
|
+
scheme = contents[i - rewind : i]
|
|
249
|
+
if scheme not in _SCHEMES:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
domain = _check_domain(contents[i + 3 :])
|
|
253
|
+
if domain is None:
|
|
254
|
+
return None
|
|
255
|
+
link_end = domain + 3
|
|
256
|
+
|
|
257
|
+
while link_end < n - i and not _isspace(contents[i + link_end]):
|
|
258
|
+
link_end += 1
|
|
259
|
+
|
|
260
|
+
link_end = _autolink_delim(contents[i:], link_end)
|
|
261
|
+
text = contents[i - rewind : i + link_end]
|
|
262
|
+
return (i - rewind, i + link_end, text)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _scan_url_www(
|
|
266
|
+
text: str, within_brackets: bool = False, prev_char: str | None = None
|
|
267
|
+
) -> tuple[list[tuple[str, str] | tuple[str, str, str]], bool]:
|
|
268
|
+
"""First pass: url/www autolinks, suppressed inside `[...]`. Yields
|
|
269
|
+
`("text", s)` and `("link", display, url)` items left to right, plus the
|
|
270
|
+
bracket state on exit.
|
|
271
|
+
|
|
272
|
+
comrak recognises url/www autolinks during inline parsing, gated by a
|
|
273
|
+
parser-level `within_brackets` flag that flips true at any `[`/`![` and
|
|
274
|
+
false at any `]` and therefore spans intervening formatting nodes. This scan
|
|
275
|
+
reproduces that: `within_brackets` seeds the flag from the preceding nodes
|
|
276
|
+
and the returned bool carries it to the next node. `prev_char` is the raw
|
|
277
|
+
source character before this run, used for the `www.` preceder check at
|
|
278
|
+
offset 0.
|
|
279
|
+
"""
|
|
280
|
+
out: list = []
|
|
281
|
+
n = len(text)
|
|
282
|
+
i = 0
|
|
283
|
+
seg_start = 0
|
|
284
|
+
wb = within_brackets
|
|
285
|
+
while i < n:
|
|
286
|
+
c = text[i]
|
|
287
|
+
if c == "[":
|
|
288
|
+
wb = True
|
|
289
|
+
i += 1
|
|
290
|
+
continue
|
|
291
|
+
if c == "]":
|
|
292
|
+
wb = False
|
|
293
|
+
i += 1
|
|
294
|
+
continue
|
|
295
|
+
if wb:
|
|
296
|
+
i += 1
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
m = None
|
|
300
|
+
prev = text[i - 1] if i > 0 else prev_char
|
|
301
|
+
if text.startswith("www.", i):
|
|
302
|
+
m = _www_match(text, i, prev)
|
|
303
|
+
if m is None and c == ":":
|
|
304
|
+
m = _url_match(text, i)
|
|
305
|
+
|
|
306
|
+
if m is not None:
|
|
307
|
+
start, end, url = m
|
|
308
|
+
if start < seg_start:
|
|
309
|
+
start = seg_start
|
|
310
|
+
if start > seg_start:
|
|
311
|
+
out.append(("text", text[seg_start:start]))
|
|
312
|
+
out.append(("link", text[start:end], url))
|
|
313
|
+
i = end
|
|
314
|
+
seg_start = end
|
|
315
|
+
continue
|
|
316
|
+
i += 1
|
|
317
|
+
|
|
318
|
+
if seg_start < n:
|
|
319
|
+
out.append(("text", text[seg_start:]))
|
|
320
|
+
return out, wb
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _scan_email(text: str) -> list[tuple[str, str] | tuple[str, str, str]]:
|
|
324
|
+
"""Second pass: bare-email autolinks over a text run, suppressed inside
|
|
325
|
+
`[...]` (comrak runs this as a post-process triggered on `@`)."""
|
|
326
|
+
out: list = []
|
|
327
|
+
n = len(text)
|
|
328
|
+
i = 0
|
|
329
|
+
seg_start = 0
|
|
330
|
+
depth = 0
|
|
331
|
+
while i < n:
|
|
332
|
+
c = text[i]
|
|
333
|
+
if c == "[":
|
|
334
|
+
depth += 1
|
|
335
|
+
elif c == "]":
|
|
336
|
+
if depth > 0:
|
|
337
|
+
depth -= 1
|
|
338
|
+
elif c == "@" and depth == 0:
|
|
339
|
+
m = _email_match(text, i)
|
|
340
|
+
if m is not None:
|
|
341
|
+
start, end, url = m
|
|
342
|
+
if start < seg_start:
|
|
343
|
+
start = seg_start
|
|
344
|
+
if start > seg_start:
|
|
345
|
+
out.append(("text", text[seg_start:start]))
|
|
346
|
+
out.append(("link", text[start:end], url))
|
|
347
|
+
i = end
|
|
348
|
+
seg_start = end
|
|
349
|
+
continue
|
|
350
|
+
i += 1
|
|
351
|
+
|
|
352
|
+
if seg_start < n:
|
|
353
|
+
out.append(("text", text[seg_start:]))
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def autolink_text(
|
|
358
|
+
text: str, within_brackets: bool = False, prev_char: str | None = None
|
|
359
|
+
) -> tuple[list[dict], bool]:
|
|
360
|
+
"""Split a plain-text run into IR text/link inline dicts, applying comrak's
|
|
361
|
+
GFM autolinking.
|
|
362
|
+
|
|
363
|
+
`within_brackets` and `prev_char` carry comrak's cross-node parser state (an
|
|
364
|
+
open `[` and the preceding source character) so autolinking respects bracket
|
|
365
|
+
suppression and the `www.` preceder rule across inline-node boundaries. The
|
|
366
|
+
returned bool is the bracket state after this run, to thread into the next.
|
|
367
|
+
A run with no autolinks returns a single text dict.
|
|
368
|
+
"""
|
|
369
|
+
items: list = []
|
|
370
|
+
pieces, wb = _scan_url_www(text, within_brackets, prev_char)
|
|
371
|
+
for item in pieces:
|
|
372
|
+
if item[0] == "link":
|
|
373
|
+
items.append(item)
|
|
374
|
+
else:
|
|
375
|
+
items.extend(_scan_email(item[1]))
|
|
376
|
+
|
|
377
|
+
out: list[dict] = []
|
|
378
|
+
for item in items:
|
|
379
|
+
if item[0] == "link":
|
|
380
|
+
_, display, url = item
|
|
381
|
+
out.append(
|
|
382
|
+
{
|
|
383
|
+
"kind": "link",
|
|
384
|
+
"href": url,
|
|
385
|
+
"content": [{"kind": "text", "data": display}],
|
|
386
|
+
}
|
|
387
|
+
)
|
|
388
|
+
else:
|
|
389
|
+
data = item[1]
|
|
390
|
+
if data:
|
|
391
|
+
out.append({"kind": "text", "data": data})
|
|
392
|
+
return out, wb
|