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.
Files changed (50) hide show
  1. quilldown-0.1.1/.gitignore +10 -0
  2. quilldown-0.1.1/CHANGELOG.md +37 -0
  3. quilldown-0.1.1/PKG-INFO +10 -0
  4. quilldown-0.1.1/README.md +16 -0
  5. quilldown-0.1.1/pyproject.toml +32 -0
  6. quilldown-0.1.1/src/quilldown/__init__.py +38 -0
  7. quilldown-0.1.1/src/quilldown/autolink.py +392 -0
  8. quilldown-0.1.1/src/quilldown/parser.py +1043 -0
  9. quilldown-0.1.1/src/quilldown/render.py +108 -0
  10. quilldown-0.1.1/src/quilldown_spec/_Block.py +1154 -0
  11. quilldown-0.1.1/src/quilldown_spec/_ConvertOptions.py +99 -0
  12. quilldown-0.1.1/src/quilldown_spec/_Document.py +133 -0
  13. quilldown-0.1.1/src/quilldown_spec/_Inline.py +1027 -0
  14. quilldown-0.1.1/src/quilldown_spec/_Quilldown.py +32 -0
  15. quilldown-0.1.1/src/quilldown_spec/_RenderOptions.py +99 -0
  16. quilldown-0.1.1/src/quilldown_spec/_RenderStats.py +162 -0
  17. quilldown-0.1.1/src/quilldown_spec/_Samples.py +63 -0
  18. quilldown-0.1.1/src/quilldown_spec/_TableCell.py +136 -0
  19. quilldown-0.1.1/src/quilldown_spec/_TableRow.py +136 -0
  20. quilldown-0.1.1/src/quilldown_spec/__init__.py +78 -0
  21. quilldown-0.1.1/src/quilldown_spec/_context.py +149 -0
  22. quilldown-0.1.1/src/quilldown_spec/py.typed +0 -0
  23. quilldown-0.1.1/tests/test_block.py +83 -0
  24. quilldown-0.1.1/tests/test_block_quote.py +78 -0
  25. quilldown-0.1.1/tests/test_code_block.py +81 -0
  26. quilldown-0.1.1/tests/test_code_inline.py +81 -0
  27. quilldown-0.1.1/tests/test_context.py +199 -0
  28. quilldown-0.1.1/tests/test_convert_options.py +62 -0
  29. quilldown-0.1.1/tests/test_document.py +70 -0
  30. quilldown-0.1.1/tests/test_emphasis_inline.py +78 -0
  31. quilldown-0.1.1/tests/test_hard_break_inline.py +73 -0
  32. quilldown-0.1.1/tests/test_heading.py +86 -0
  33. quilldown-0.1.1/tests/test_inline.py +78 -0
  34. quilldown-0.1.1/tests/test_link_inline.py +86 -0
  35. quilldown-0.1.1/tests/test_list_block.py +94 -0
  36. quilldown-0.1.1/tests/test_list_item_block.py +78 -0
  37. quilldown-0.1.1/tests/test_paragraph.py +78 -0
  38. quilldown-0.1.1/tests/test_render_options.py +62 -0
  39. quilldown-0.1.1/tests/test_render_stats.py +142 -0
  40. quilldown-0.1.1/tests/test_soft_break_inline.py +73 -0
  41. quilldown-0.1.1/tests/test_strikethrough_inline.py +78 -0
  42. quilldown-0.1.1/tests/test_strong_inline.py +78 -0
  43. quilldown-0.1.1/tests/test_table_block.py +97 -0
  44. quilldown-0.1.1/tests/test_table_cell.py +70 -0
  45. quilldown-0.1.1/tests/test_table_row.py +70 -0
  46. quilldown-0.1.1/tests/test_text_inline.py +81 -0
  47. quilldown-0.1.1/tests/test_thematic_break.py +73 -0
  48. quilldown-0.1.1/tests/test_vector_conformance.py +3178 -0
  49. quilldown-0.1.1/tests/vector_adapters.py +28 -0
  50. quilldown-0.1.1/tests/vector_runner.py +246 -0
@@ -0,0 +1,10 @@
1
+ # typra emit metadata (regenerated from ../../spec by `npm run generate`)
2
+ .typra-generated/
3
+ json-ast/
4
+
5
+ # Python
6
+ .venv/
7
+ __pycache__/
8
+ *.pyc
9
+ .pytest_cache/
10
+ uv.lock
@@ -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))
@@ -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