md2gemtext 0.0.1__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.
md2gemtext/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """A simple Markdown to Gemtext converter."""
2
+
3
+ ##############################################################################
4
+ # Python imports.
5
+ from importlib.metadata import version
6
+
7
+ ######################################################################
8
+ # Main library information.
9
+ __author__ = "Dave Pearson"
10
+ __copyright__ = "Copyright 2026, Dave Pearson"
11
+ __credits__ = ["Dave Pearson"]
12
+ __maintainer__ = "Dave Pearson"
13
+ __email__ = "davep@davep.org"
14
+ __version__: str = version("md2gemtext")
15
+ __licence__ = "MIT"
16
+
17
+ ##############################################################################
18
+ # Local imports.
19
+ from .convert import markdown_to_gemtext
20
+ from .options import HTMLBlockHandling, HTMLInlineHandling, Options
21
+
22
+ ##############################################################################
23
+ # Exports.
24
+ __all__ = [
25
+ "HTMLBlockHandling",
26
+ "HTMLInlineHandling",
27
+ "markdown_to_gemtext",
28
+ "Options",
29
+ ]
30
+
31
+
32
+ ### __init__.py ends here
md2gemtext/__main__.py ADDED
@@ -0,0 +1,123 @@
1
+ """Command line interface for md2gemtext."""
2
+
3
+ ##############################################################################
4
+ # Python imports.
5
+ import argparse
6
+ import sys
7
+
8
+ ##############################################################################
9
+ # Local imports.
10
+ from .convert import markdown_to_gemtext
11
+ from .options import Options
12
+
13
+
14
+ ##############################################################################
15
+ def convert() -> None:
16
+ """Parse the input from stdin or files and print the parsed Gemtext."""
17
+ parser = argparse.ArgumentParser(
18
+ prog="md2gemtext",
19
+ description="A simple Markdown to Gemtext converter.",
20
+ )
21
+ parser.add_argument(
22
+ "files",
23
+ nargs="*",
24
+ default=["-"],
25
+ help="Markdown file(s) to convert (default: stdin).",
26
+ )
27
+ parser.add_argument(
28
+ "--strip-inline-markup",
29
+ "--no-retain-inline-markup",
30
+ dest="retain_inline_markup",
31
+ action="store_false",
32
+ default=True,
33
+ help="Strip inline Markdown markup (bold, italics, etc.).",
34
+ )
35
+ parser.add_argument(
36
+ "--no-space-after-paragraphs",
37
+ dest="space_after_paragraphs",
38
+ action="store_false",
39
+ default=True,
40
+ help="Do not add an empty line after paragraphs.",
41
+ )
42
+ parser.add_argument(
43
+ "--no-space-after-blockquotes",
44
+ dest="space_after_blockquotes",
45
+ action="store_false",
46
+ default=True,
47
+ help="Do not add an empty line after blockquotes.",
48
+ )
49
+ parser.add_argument(
50
+ "--no-space-after-lists",
51
+ dest="space_after_lists",
52
+ action="store_false",
53
+ default=True,
54
+ help="Do not add an empty line after a run of list items.",
55
+ )
56
+ parser.add_argument(
57
+ "--hide-front-matter",
58
+ dest="hide_front_matter",
59
+ action="store_true",
60
+ default=False,
61
+ help="Hide/filter out front matter from output (default: false).",
62
+ )
63
+ parser.add_argument(
64
+ "--extra-protocol",
65
+ action="append",
66
+ default=[],
67
+ dest="extra_linkable_protocols",
68
+ help="Additional protocol to linkify (e.g. --extra-protocol spartan).",
69
+ )
70
+ parser.add_argument(
71
+ "--no-preformat-tables",
72
+ dest="preformat_tables",
73
+ action="store_false",
74
+ default=True,
75
+ help="Do not emit tables as preformatted blocks (emit as plain aligned text).",
76
+ )
77
+ parser.add_argument(
78
+ "--html-block-handling",
79
+ choices=["convert", "preformat", "striptags"],
80
+ default="convert",
81
+ dest="html_block_handling",
82
+ help="How to handle HTML blocks: 'convert', 'preformat', or 'striptags' (default: convert).",
83
+ )
84
+ parser.add_argument(
85
+ "--html-inline-handling",
86
+ choices=["keep", "striptags"],
87
+ default="striptags",
88
+ dest="html_inline_handling",
89
+ help="How to handle inline HTML tags: 'keep' or 'striptags' (default: striptags).",
90
+ )
91
+ args = parser.parse_args()
92
+
93
+ options = Options(
94
+ retain_inline_markup=args.retain_inline_markup,
95
+ space_after_paragraphs=args.space_after_paragraphs,
96
+ space_after_blockquotes=args.space_after_blockquotes,
97
+ space_after_lists=args.space_after_lists,
98
+ hide_front_matter=args.hide_front_matter,
99
+ extra_linkable_protocols=args.extra_linkable_protocols,
100
+ preformat_tables=args.preformat_tables,
101
+ html_block_handling=args.html_block_handling,
102
+ html_inline_handling=args.html_inline_handling,
103
+ )
104
+
105
+ content_list: list[str] = []
106
+ for file_path in args.files:
107
+ if file_path == "-":
108
+ content_list.append(sys.stdin.read())
109
+ else:
110
+ with open(file_path, encoding="utf-8") as f:
111
+ content_list.append(f.read())
112
+
113
+ output = markdown_to_gemtext("".join(content_list), options=options)
114
+ if output:
115
+ print(output)
116
+
117
+
118
+ ##############################################################################
119
+ if __name__ == "__main__":
120
+ convert()
121
+
122
+
123
+ ### __main__.py ends here
@@ -0,0 +1,983 @@
1
+ """Provides the core Markdown to Gemtext converter."""
2
+
3
+ ##############################################################################
4
+ # Python imports.
5
+ import re
6
+ from html.parser import HTMLParser
7
+ from typing import Final
8
+
9
+ ##############################################################################
10
+ # HTML to Gemtext imports.
11
+ from html2gemtext import Options as HTMLOptions
12
+ from html2gemtext import html_to_gemtext
13
+
14
+ ##############################################################################
15
+ # Markdown-it imports.
16
+ from markdown_it import MarkdownIt
17
+ from markdown_it.token import Token
18
+ from mdit_py_plugins.footnote import footnote_plugin
19
+ from mdit_py_plugins.front_matter import front_matter_plugin
20
+
21
+ ##############################################################################
22
+ # Local imports.
23
+ from .options import Options
24
+
25
+ ##############################################################################
26
+ # Superscript conversion mapping for digits.
27
+ SUPERSCRIPT_DIGITS: Final[dict[str, str]] = {
28
+ "0": "⁰",
29
+ "1": "¹",
30
+ "2": "²",
31
+ "3": "³",
32
+ "4": "⁴",
33
+ "5": "⁵",
34
+ "6": "⁶",
35
+ "7": "⁷",
36
+ "8": "⁸",
37
+ "9": "⁹",
38
+ }
39
+
40
+
41
+ ##############################################################################
42
+ def to_superscript_number(num: int) -> str:
43
+ """Convert an integer number to UTF-8 superscript digits.
44
+
45
+ Args:
46
+ num: The number to convert (e.g. 1, 10, 25).
47
+
48
+ Returns:
49
+ The superscript string representation (e.g. '¹', '¹⁰', '²⁵').
50
+ """
51
+ return "".join(SUPERSCRIPT_DIGITS.get(d, d) for d in str(num))
52
+
53
+
54
+ ##############################################################################
55
+ def get_marker(index: int) -> str:
56
+ """Generate a link marker string for the given 0-based index.
57
+
58
+ Generates markers {a}, {b}, ..., {z}, {aa}, {ab}, etc.
59
+
60
+ Args:
61
+ index: The 0-based index of the link.
62
+
63
+ Returns:
64
+ The formatted marker string, e.g. '{a}'.
65
+ """
66
+ n = index + 1
67
+ result: list[str] = []
68
+ while n > 0:
69
+ n, rem = divmod(n - 1, 26)
70
+ result.append(chr(ord("a") + rem))
71
+ return f"{{{''.join(reversed(result))}}}"
72
+
73
+
74
+ ##############################################################################
75
+ def _escape_code_lines(content: str) -> str:
76
+ """Escape lines inside preformatted text that start with backticks.
77
+
78
+ Prepends a space to any line whose first three characters are backticks
79
+ to prevent Gemtext parsers from interpreting them as preformat toggles.
80
+
81
+ Args:
82
+ content: The code content.
83
+
84
+ Returns:
85
+ The escaped code content.
86
+ """
87
+ return "\n".join(
88
+ f" {line}" if line.startswith("```") else line for line in content.splitlines()
89
+ )
90
+
91
+
92
+ ##############################################################################
93
+ class HTMLTagStripper(HTMLParser):
94
+ """HTML parser to extract text content while stripping tags."""
95
+
96
+ def __init__(self) -> None:
97
+ """Initialise the tag stripper."""
98
+ super().__init__()
99
+ self._text_chunks: list[str] = []
100
+
101
+ def handle_data(self, data: str) -> None:
102
+ """Collect text data.
103
+
104
+ Args:
105
+ data: Raw text data inside HTML elements.
106
+ """
107
+ self._text_chunks.append(data)
108
+
109
+ def get_text(self) -> str:
110
+ """Return the consolidated stripped text.
111
+
112
+ Returns:
113
+ The text content with HTML tags removed.
114
+ """
115
+ return "".join(self._text_chunks).strip()
116
+
117
+
118
+ ##############################################################################
119
+ def strip_html_tags(html_content: str) -> str:
120
+ """Strip HTML tags from HTML content, returning the raw text.
121
+
122
+ Args:
123
+ html_content: The HTML content to strip.
124
+
125
+ Returns:
126
+ The stripped plain text.
127
+ """
128
+ stripper = HTMLTagStripper()
129
+ stripper.feed(html_content)
130
+ stripper.close()
131
+ return stripper.get_text()
132
+
133
+
134
+ ##############################################################################
135
+ class ContentCapture:
136
+ """Base class to capture Gemtext block content."""
137
+
138
+ def __init__(self, options: Options) -> None:
139
+ """Initialise the object.
140
+
141
+ Args:
142
+ options: The options for the converter.
143
+ """
144
+ self._options = options
145
+
146
+
147
+ ##############################################################################
148
+ class Paragraph(ContentCapture):
149
+ """A class to capture a paragraph."""
150
+
151
+ def __init__(
152
+ self,
153
+ text: str,
154
+ links: list[tuple[str, str, str]],
155
+ options: Options,
156
+ ) -> None:
157
+ """Initialise the paragraph.
158
+
159
+ Args:
160
+ text: The paragraph text.
161
+ links: List of (href, marker, text) tuples.
162
+ options: Conversion options.
163
+ """
164
+ super().__init__(options)
165
+ self._text = text
166
+ self._links = links
167
+ self._final_newline = options.space_after_paragraphs
168
+
169
+ def __str__(self) -> str:
170
+ """Return the paragraph as a Gemtext string."""
171
+ parts: list[str] = [self._text]
172
+ if self._links:
173
+ parts.append("")
174
+ for href, marker, text in self._links:
175
+ parts.append(
176
+ f"=> {href} {marker} {text}" if text else f"=> {href} {marker}"
177
+ )
178
+ if self._final_newline:
179
+ parts.append("")
180
+ return "\n".join(parts)
181
+
182
+
183
+ ##############################################################################
184
+ class Heading(ContentCapture):
185
+ """A class to capture a heading."""
186
+
187
+ def __init__(
188
+ self,
189
+ level: int,
190
+ text: str,
191
+ links: list[tuple[str, str, str]],
192
+ options: Options,
193
+ ) -> None:
194
+ """Initialise the heading.
195
+
196
+ Args:
197
+ level: The heading level (1-6).
198
+ text: The heading text.
199
+ links: List of (href, marker, text) tuples.
200
+ options: Conversion options.
201
+ """
202
+ super().__init__(options)
203
+ self._level = min(level, 3)
204
+ self._text = text
205
+ self._links = links
206
+
207
+ def __str__(self) -> str:
208
+ """Return the heading as a Gemtext string."""
209
+ prefix = "#" * self._level
210
+ parts: list[str] = [f"{prefix} {self._text}".rstrip()]
211
+ if self._links:
212
+ for href, marker, text in self._links:
213
+ parts.append(
214
+ f"=> {href} {marker} {text}" if text else f"=> {href} {marker}"
215
+ )
216
+ return "\n".join(parts)
217
+
218
+
219
+ ##############################################################################
220
+ class ListItem(ContentCapture):
221
+ """A class to capture an unordered list item."""
222
+
223
+ def __init__(
224
+ self,
225
+ text: str,
226
+ links: list[tuple[str, str, str]],
227
+ is_solo_link: bool,
228
+ options: Options,
229
+ is_last_in_list: bool = False,
230
+ ) -> None:
231
+ """Initialise the list item.
232
+
233
+ Args:
234
+ text: The item text.
235
+ links: List of (href, marker, text) tuples.
236
+ is_solo_link: Whether the item consists solely of a link.
237
+ options: Conversion options.
238
+ is_last_in_list: Whether this is the last item in a run of list items.
239
+ """
240
+ super().__init__(options)
241
+ self._text = text
242
+ self._links = links
243
+ self._is_solo_link = is_solo_link
244
+ self._final_newline = is_last_in_list and options.space_after_lists
245
+
246
+ def mark_last_in_list(self) -> None:
247
+ """Mark this item as the last item in a run of list items."""
248
+ self._final_newline = self._options.space_after_lists
249
+
250
+ def __str__(self) -> str:
251
+ """Return the list item as a Gemtext string."""
252
+ if self._is_solo_link and self._links:
253
+ href, _, text = self._links[0]
254
+ line = f"=> {href} {text}".rstrip()
255
+ return f"{line}\n" if self._final_newline else line
256
+
257
+ parts: list[str] = [f"* {self._text}"]
258
+ if self._links:
259
+ for href, marker, text in self._links:
260
+ parts.append(
261
+ f"=> {href} {marker} {text}" if text else f"=> {href} {marker}"
262
+ )
263
+ if self._final_newline:
264
+ parts.append("")
265
+ return "\n".join(parts)
266
+
267
+
268
+ ##############################################################################
269
+ class NumberedListItem(ContentCapture):
270
+ """A class to capture an ordered / numbered list item as a paragraph."""
271
+
272
+ def __init__(
273
+ self,
274
+ prefix: str,
275
+ text: str,
276
+ links: list[tuple[str, str, str]],
277
+ options: Options,
278
+ ) -> None:
279
+ """Initialise the numbered list item.
280
+
281
+ Args:
282
+ prefix: The numbering prefix (e.g. '1.').
283
+ text: The item text.
284
+ links: List of (href, marker, text) tuples.
285
+ options: Conversion options.
286
+ """
287
+ super().__init__(options)
288
+ self._prefix = prefix
289
+ self._text = text
290
+ self._links = links
291
+ self._final_newline = options.space_after_paragraphs
292
+
293
+ def __str__(self) -> str:
294
+ """Return the numbered list item as a Gemtext string."""
295
+ parts: list[str] = [f"{self._prefix} {self._text}".rstrip()]
296
+ if self._links:
297
+ parts.append("")
298
+ for href, marker, text in self._links:
299
+ parts.append(
300
+ f"=> {href} {marker} {text}" if text else f"=> {href} {marker}"
301
+ )
302
+ if self._final_newline:
303
+ parts.append("")
304
+ return "\n".join(parts)
305
+
306
+
307
+ ##############################################################################
308
+ class Blockquote(ContentCapture):
309
+ """A class to capture a blockquote."""
310
+
311
+ def __init__(
312
+ self,
313
+ paragraphs: list[str],
314
+ links: list[tuple[str, str, str]],
315
+ options: Options,
316
+ ) -> None:
317
+ """Initialise the blockquote.
318
+
319
+ Args:
320
+ paragraphs: List of paragraph texts within the blockquote.
321
+ links: List of (href, marker, text) tuples.
322
+ options: Conversion options.
323
+ """
324
+ super().__init__(options)
325
+ self._paragraphs = paragraphs
326
+ self._links = links
327
+ self._final_newline = options.space_after_blockquotes
328
+
329
+ def __str__(self) -> str:
330
+ """Return the blockquote as a Gemtext string."""
331
+ all_lines: list[str] = []
332
+ for idx, paragraph in enumerate(self._paragraphs):
333
+ if idx > 0:
334
+ all_lines.append("")
335
+ all_lines.extend(paragraph.splitlines())
336
+
337
+ parts: list[str] = [f"> {line}" if line else ">" for line in all_lines]
338
+ if self._links:
339
+ for href, marker, text in self._links:
340
+ parts.append(
341
+ f"=> {href} {marker} {text}" if text else f"=> {href} {marker}"
342
+ )
343
+ if self._final_newline:
344
+ parts.append("")
345
+ return "\n".join(parts)
346
+
347
+
348
+ ##############################################################################
349
+ class Preformatted(ContentCapture):
350
+ """A class to capture preformatted text."""
351
+
352
+ def __init__(
353
+ self,
354
+ info: str,
355
+ content: str,
356
+ options: Options,
357
+ ) -> None:
358
+ """Initialise the preformatted block.
359
+
360
+ Args:
361
+ info: The alt text / language identifier.
362
+ content: The code content.
363
+ options: Conversion options.
364
+ """
365
+ super().__init__(options)
366
+ self._info = info
367
+ self._content = content
368
+ self._final_newline = options.space_after_paragraphs
369
+
370
+ def __str__(self) -> str:
371
+ """Return the preformatted block as a Gemtext string."""
372
+ escaped = _escape_code_lines(self._content)
373
+ parts = [
374
+ f"```{self._info}".rstrip(),
375
+ escaped,
376
+ "```",
377
+ ]
378
+ if self._final_newline:
379
+ parts.append("")
380
+ return "\n".join(parts)
381
+
382
+
383
+ ##############################################################################
384
+ class SoloLink(ContentCapture):
385
+ """A class to capture a standalone link or image."""
386
+
387
+ def __init__(self, href: str, text: str, options: Options) -> None:
388
+ """Initialise the solo link.
389
+
390
+ Args:
391
+ href: The URL.
392
+ text: The user-friendly link description.
393
+ options: Conversion options.
394
+ """
395
+ super().__init__(options)
396
+ self._href = href
397
+ self._text = text
398
+ self._final_newline = options.space_after_paragraphs
399
+
400
+ def __str__(self) -> str:
401
+ """Return the solo link as a Gemtext string."""
402
+ line = f"=> {self._href} {self._text}".rstrip()
403
+ return f"{line}\n" if self._final_newline else line
404
+
405
+
406
+ ##############################################################################
407
+ class Table(ContentCapture):
408
+ """A class to capture a formatted table."""
409
+
410
+ def __init__(
411
+ self,
412
+ formatted_table: str,
413
+ links: list[tuple[str, str, str]],
414
+ options: Options,
415
+ ) -> None:
416
+ """Initialise the table.
417
+
418
+ Args:
419
+ formatted_table: The formatted table text.
420
+ links: Extracted links from table cells.
421
+ options: Conversion options.
422
+ """
423
+ super().__init__(options)
424
+ self._table = formatted_table
425
+ self._links = links
426
+ self._preformat = options.preformat_tables
427
+ self._final_newline = options.space_after_paragraphs
428
+
429
+ def __str__(self) -> str:
430
+ """Return the table as a Gemtext string."""
431
+ parts: list[str] = []
432
+ if self._preformat:
433
+ escaped = _escape_code_lines(self._table)
434
+ parts.extend(["```table", escaped, "```"])
435
+ else:
436
+ parts.append(self._table)
437
+
438
+ if self._links:
439
+ parts.append("")
440
+ for href, marker, text in self._links:
441
+ parts.append(
442
+ f"=> {href} {marker} {text}" if text else f"=> {href} {marker}"
443
+ )
444
+
445
+ if self._final_newline:
446
+ parts.append("")
447
+ return "\n".join(parts)
448
+
449
+
450
+ ##############################################################################
451
+ class RawBlock(ContentCapture):
452
+ """A class to capture raw content passed through as-is (e.g. tables, HRs)."""
453
+
454
+ def __init__(self, content: str, options: Options) -> None:
455
+ """Initialise the raw block.
456
+
457
+ Args:
458
+ content: The raw text content.
459
+ options: Conversion options.
460
+ """
461
+ super().__init__(options)
462
+ self._content = content
463
+
464
+ def __str__(self) -> str:
465
+ """Return the raw block content."""
466
+ return self._content
467
+
468
+
469
+ ##############################################################################
470
+ class MarkdownToGemtextConverter:
471
+ """Converts Markdown documents to Gemtext using markdown-it-py."""
472
+
473
+ def __init__(self, options: Options | None = None) -> None:
474
+ """Initialise the converter.
475
+
476
+ Args:
477
+ options: Optional conversion options.
478
+ """
479
+ self._options = options or Options()
480
+ md = MarkdownIt("gfm-like").use(footnote_plugin).use(front_matter_plugin)
481
+ if md.linkify is not None:
482
+ md.linkify.add("gemini:", "http:")
483
+ for proto in self._options.extra_linkable_protocols:
484
+ schema = proto if proto.endswith(":") else f"{proto}:"
485
+ md.linkify.add(schema, "http:")
486
+ self._md: Final[MarkdownIt] = md
487
+
488
+ def _render_inline(
489
+ self,
490
+ inline_token: Token,
491
+ with_markers: bool = True,
492
+ start_marker_index: int = 0,
493
+ ) -> tuple[str, list[tuple[str, str, str]]]:
494
+ """Render an inline token into text and extracted links.
495
+
496
+ Args:
497
+ inline_token: The inline Token containing child tokens.
498
+ with_markers: Whether to append {a-z} markers to links.
499
+ start_marker_index: Starting index for link markers.
500
+
501
+ Returns:
502
+ A tuple of (rendered_text, list_of_links).
503
+ """
504
+ if not inline_token.children:
505
+ return "", []
506
+
507
+ tokens = inline_token.children
508
+ result: list[str] = []
509
+ links: list[tuple[str, str, str]] = []
510
+ link_counter = start_marker_index
511
+
512
+ i = 0
513
+ while i < len(tokens):
514
+ tok = tokens[i]
515
+
516
+ if tok.type == "text":
517
+ result.append(tok.content)
518
+ i += 1
519
+ elif tok.type == "code_inline":
520
+ if self._options.retain_inline_markup:
521
+ result.append(f"`{tok.content}`")
522
+ else:
523
+ result.append(tok.content)
524
+ i += 1
525
+ elif tok.type in (
526
+ "em_open",
527
+ "em_close",
528
+ "strong_open",
529
+ "strong_close",
530
+ "s_open",
531
+ "s_close",
532
+ ):
533
+ if self._options.retain_inline_markup:
534
+ result.append(tok.markup)
535
+ i += 1
536
+ elif tok.type == "softbreak":
537
+ result.append(" ")
538
+ i += 1
539
+ elif tok.type == "hardbreak":
540
+ result.append("\n")
541
+ i += 1
542
+ elif tok.type == "html_inline":
543
+ if self._options.html_inline_handling == "keep":
544
+ result.append(tok.content)
545
+ i += 1
546
+ elif tok.type == "footnote_ref":
547
+ fn_id = int(tok.meta.get("id", 0)) if tok.meta else 0
548
+ result.append(to_superscript_number(fn_id + 1))
549
+ i += 1
550
+ elif tok.type == "footnote_anchor":
551
+ # Suppress backlink anchor in Gemtext
552
+ i += 1
553
+ elif tok.type == "image":
554
+ src = str(tok.attrs.get("src", "")) if tok.attrs else ""
555
+ alt = str(
556
+ tok.content or (tok.attrs.get("alt", "") if tok.attrs else "")
557
+ )
558
+ links.append((src, "", alt))
559
+ result.append(alt)
560
+ i += 1
561
+ elif tok.type == "link_open":
562
+ href = str(tok.attrs.get("href", "")) if tok.attrs else ""
563
+ inner_tokens: list[Token] = []
564
+ i += 1
565
+ depth = 1
566
+ while i < len(tokens) and depth > 0:
567
+ if tokens[i].type == "link_open":
568
+ depth += 1
569
+ elif tokens[i].type == "link_close":
570
+ depth -= 1
571
+ if depth == 0:
572
+ break
573
+ inner_tokens.append(tokens[i])
574
+ i += 1
575
+ i += 1 # Skip link_close
576
+
577
+ inner_inline = Token(
578
+ type="inline", tag="", nesting=0, children=inner_tokens
579
+ )
580
+ link_text, _ = self._render_inline(inner_inline, with_markers=False)
581
+
582
+ if with_markers:
583
+ marker = get_marker(link_counter)
584
+ link_counter += 1
585
+ links.append((href, marker, link_text))
586
+ result.append(f"{link_text}{marker}")
587
+ else:
588
+ links.append((href, "", link_text))
589
+ result.append(link_text)
590
+ else:
591
+ if tok.content:
592
+ result.append(tok.content)
593
+ i += 1
594
+
595
+ full_text = "".join(result)
596
+ cleaned_lines = [
597
+ re.sub(r"[ \t]+", " ", line).strip() for line in full_text.split("\n")
598
+ ]
599
+ cleaned_text = "\n".join(line for line in cleaned_lines if line)
600
+ return cleaned_text, links
601
+
602
+ def _is_solo_link_inline(self, inline_token: Token) -> bool:
603
+ """Check if an inline token consists solely of a single link.
604
+
605
+ Args:
606
+ inline_token: The inline token.
607
+
608
+ Returns:
609
+ True if the inline token is purely a link, False otherwise.
610
+ """
611
+ if not inline_token.children:
612
+ return False
613
+ tokens = [t for t in inline_token.children if t.type != "softbreak"]
614
+ if not tokens or tokens[0].type != "link_open":
615
+ return False
616
+ depth = 0
617
+ for idx, t in enumerate(tokens):
618
+ if t.type == "link_open":
619
+ depth += 1
620
+ elif t.type == "link_close":
621
+ depth -= 1
622
+ if depth == 0:
623
+ return idx == len(tokens) - 1
624
+ return False
625
+
626
+ def convert(self, markdown_content: str) -> str:
627
+ """Convert a Markdown string to Gemtext.
628
+
629
+ Args:
630
+ markdown_content: The Markdown source string.
631
+
632
+ Returns:
633
+ The converted Gemtext string.
634
+ """
635
+ if not markdown_content.strip():
636
+ return ""
637
+
638
+ tokens = self._md.parse(markdown_content)
639
+ document: list[ContentCapture] = []
640
+
641
+ i = 0
642
+ while i < len(tokens):
643
+ token = tokens[i]
644
+
645
+ match token.type:
646
+ # Front matter
647
+ case "front_matter":
648
+ if not self._options.hide_front_matter:
649
+ content = token.content.strip("\n")
650
+ document.append(
651
+ Preformatted("frontmatter", content, self._options)
652
+ )
653
+
654
+ # Heading
655
+ case "heading_open":
656
+ level = int(token.tag[1:]) if len(token.tag) > 1 else 1
657
+ i += 1
658
+ inline_token = tokens[i]
659
+ text, links = self._render_inline(inline_token, with_markers=True)
660
+ i += 1 # heading_close
661
+ document.append(Heading(level, text, links, self._options))
662
+
663
+ # Paragraph
664
+ case "paragraph_open":
665
+ i += 1
666
+ inline_token = tokens[i]
667
+ if (
668
+ inline_token.children
669
+ and len(inline_token.children) == 1
670
+ and inline_token.children[0].type == "image"
671
+ ):
672
+ img = inline_token.children[0]
673
+ src = str(img.attrs.get("src", "")) if img.attrs else ""
674
+ alt = str(
675
+ img.content
676
+ or (img.attrs.get("alt", "") if img.attrs else "")
677
+ )
678
+ document.append(SoloLink(src, alt, self._options))
679
+ else:
680
+ text, links = self._render_inline(
681
+ inline_token, with_markers=True
682
+ )
683
+ document.append(Paragraph(text, links, self._options))
684
+ i += 1 # paragraph_close
685
+
686
+ # Bullet / Unordered list
687
+ case "bullet_list_open":
688
+ i += 1
689
+ list_items: list[ListItem] = []
690
+ while i < len(tokens) and tokens[i].type != "bullet_list_close":
691
+ if tokens[i].type == "list_item_open":
692
+ i += 1
693
+ item_inline_tokens: list[Token] = []
694
+ while (
695
+ i < len(tokens) and tokens[i].type != "list_item_close"
696
+ ):
697
+ if tokens[i].type == "inline":
698
+ item_inline_tokens.append(tokens[i])
699
+ i += 1
700
+ for inline_tok in item_inline_tokens:
701
+ if self._is_solo_link_inline(inline_tok):
702
+ text, links = self._render_inline(
703
+ inline_tok, with_markers=False
704
+ )
705
+ list_items.append(
706
+ ListItem(
707
+ text,
708
+ links,
709
+ is_solo_link=True,
710
+ options=self._options,
711
+ )
712
+ )
713
+ else:
714
+ text, links = self._render_inline(
715
+ inline_tok, with_markers=True
716
+ )
717
+ list_items.append(
718
+ ListItem(
719
+ text,
720
+ links,
721
+ is_solo_link=False,
722
+ options=self._options,
723
+ )
724
+ )
725
+ i += 1
726
+ if list_items:
727
+ list_items[-1].mark_last_in_list()
728
+ document.extend(list_items)
729
+
730
+ # Ordered / Numbered list
731
+ case "ordered_list_open":
732
+ start_num = int(token.attrs.get("start", 1)) if token.attrs else 1
733
+ curr_num = start_num
734
+ i += 1
735
+ while i < len(tokens) and tokens[i].type != "ordered_list_close":
736
+ if tokens[i].type == "list_item_open":
737
+ if tokens[i].info and tokens[i].info.isdigit():
738
+ curr_num = int(tokens[i].info)
739
+ i += 1
740
+ item_inline_tokens = []
741
+ while (
742
+ i < len(tokens) and tokens[i].type != "list_item_close"
743
+ ):
744
+ if tokens[i].type == "inline":
745
+ item_inline_tokens.append(tokens[i])
746
+ i += 1
747
+ for inline_tok in item_inline_tokens:
748
+ text, links = self._render_inline(
749
+ inline_tok, with_markers=True
750
+ )
751
+ document.append(
752
+ NumberedListItem(
753
+ prefix=f"{curr_num}.",
754
+ text=text,
755
+ links=links,
756
+ options=self._options,
757
+ )
758
+ )
759
+ curr_num += 1
760
+ i += 1
761
+
762
+ # Fenced code block
763
+ case "fence":
764
+ info = token.info.strip() if token.info else ""
765
+ content = token.content.rstrip("\n")
766
+ document.append(Preformatted(info, content, self._options))
767
+
768
+ # Indented code block
769
+ case "code_block":
770
+ content = token.content.rstrip("\n")
771
+ document.append(Preformatted("", content, self._options))
772
+
773
+ # Blockquotes (including merging consecutive blockquotes)
774
+ case "blockquote_open":
775
+ quote_paragraphs: list[str] = []
776
+ quote_links: list[tuple[str, str, str]] = []
777
+ quote_link_counter = 0
778
+ while i < len(tokens) and tokens[i].type == "blockquote_open":
779
+ i += 1
780
+ while i < len(tokens) and tokens[i].type != "blockquote_close":
781
+ if tokens[i].type == "inline":
782
+ text, links = self._render_inline(
783
+ tokens[i],
784
+ with_markers=True,
785
+ start_marker_index=quote_link_counter,
786
+ )
787
+ quote_link_counter += len(links)
788
+ if text:
789
+ quote_paragraphs.append(text)
790
+ quote_links.extend(links)
791
+ i += 1
792
+ i += 1 # Skip blockquote_close
793
+
794
+ document.append(
795
+ Blockquote(quote_paragraphs, quote_links, self._options)
796
+ )
797
+ continue
798
+
799
+ # Footnotes block
800
+ case "footnote_block_open":
801
+ i += 1
802
+ while i < len(tokens) and tokens[i].type != "footnote_block_close":
803
+ if tokens[i].type == "footnote_open":
804
+ fn_id = (
805
+ int(tokens[i].meta.get("id", 0))
806
+ if tokens[i].meta
807
+ else 0
808
+ )
809
+ sup_label = to_superscript_number(fn_id + 1)
810
+ i += 1
811
+ fn_inline_tokens: list[Token] = []
812
+ while (
813
+ i < len(tokens) and tokens[i].type != "footnote_close"
814
+ ):
815
+ if tokens[i].type == "inline":
816
+ fn_inline_tokens.append(tokens[i])
817
+ i += 1
818
+ for inline_tok in fn_inline_tokens:
819
+ text, links = self._render_inline(
820
+ inline_tok, with_markers=True
821
+ )
822
+ document.append(
823
+ NumberedListItem(
824
+ prefix=sup_label,
825
+ text=text,
826
+ links=links,
827
+ options=self._options,
828
+ )
829
+ )
830
+ i += 1
831
+
832
+ # Thematic break / Horizontal rule
833
+ case "hr":
834
+ document.append(RawBlock("---", self._options))
835
+
836
+ # Table
837
+ case "table_open":
838
+ header_cells: list[str] = []
839
+ alignments: list[str] = []
840
+ table_rows: list[list[str]] = []
841
+ table_links: list[tuple[str, str, str]] = []
842
+ table_link_counter = 0
843
+ in_thead = False
844
+ current_row: list[str] = []
845
+
846
+ i += 1
847
+ while i < len(tokens) and tokens[i].type != "table_close":
848
+ tok = tokens[i]
849
+ if tok.type == "thead_open":
850
+ in_thead = True
851
+ elif tok.type == "thead_close":
852
+ in_thead = False
853
+ elif tok.type == "tr_open":
854
+ current_row = []
855
+ elif tok.type == "tr_close":
856
+ if not in_thead and current_row:
857
+ table_rows.append(current_row)
858
+ elif tok.type == "th_open":
859
+ style = str(tok.attrs.get("style", "")) if tok.attrs else ""
860
+ align = (
861
+ "center"
862
+ if "center" in style
863
+ else ("right" if "right" in style else "left")
864
+ )
865
+ alignments.append(align)
866
+ elif tok.type == "td_open":
867
+ pass
868
+ elif tok.type == "inline":
869
+ text, links = self._render_inline(
870
+ tok,
871
+ with_markers=True,
872
+ start_marker_index=table_link_counter,
873
+ )
874
+ table_link_counter += len(links)
875
+ if in_thead:
876
+ header_cells.append(text)
877
+ else:
878
+ current_row.append(text)
879
+ table_links.extend(links)
880
+ i += 1
881
+
882
+ num_cols = (
883
+ len(header_cells)
884
+ if header_cells
885
+ else (max(len(r) for r in table_rows) if table_rows else 0)
886
+ )
887
+ while len(alignments) < num_cols:
888
+ alignments.append("left")
889
+
890
+ col_widths = [
891
+ max(
892
+ len(header_cells[c]) if c < len(header_cells) else 0,
893
+ *(len(r[c]) if c < len(r) else 0 for r in table_rows),
894
+ 3,
895
+ )
896
+ for c in range(num_cols)
897
+ ]
898
+
899
+ def fmt_cell(txt: str, w: int, al: str) -> str:
900
+ if al == "right":
901
+ return txt.rjust(w)
902
+ elif al == "center":
903
+ return txt.center(w)
904
+ return txt.ljust(w)
905
+
906
+ def fmt_sep(w: int, al: str) -> str:
907
+ if al == "center":
908
+ return f":{'-' * (w - 2)}:"
909
+ elif al == "right":
910
+ return f"{'-' * (w - 1)}:"
911
+ return f":{'-' * (w - 1)}"
912
+
913
+ table_lines: list[str] = []
914
+ if header_cells:
915
+ table_lines.append(
916
+ "| "
917
+ + " | ".join(
918
+ fmt_cell(
919
+ header_cells[c],
920
+ col_widths[c],
921
+ alignments[c],
922
+ )
923
+ for c in range(num_cols)
924
+ )
925
+ + " |"
926
+ )
927
+ table_lines.append(
928
+ "| "
929
+ + " | ".join(
930
+ fmt_sep(col_widths[c], alignments[c])
931
+ for c in range(num_cols)
932
+ )
933
+ + " |"
934
+ )
935
+ for r in table_rows:
936
+ table_lines.append(
937
+ "| "
938
+ + " | ".join(
939
+ fmt_cell(
940
+ r[c] if c < len(r) else "",
941
+ col_widths[c],
942
+ alignments[c] if c < len(alignments) else "left",
943
+ )
944
+ for c in range(num_cols)
945
+ )
946
+ + " |"
947
+ )
948
+
949
+ document.append(
950
+ Table("\n".join(table_lines), table_links, self._options)
951
+ )
952
+
953
+ # HTML block
954
+ case "html_block":
955
+ match self._options.html_block_handling:
956
+ case "convert":
957
+ html_opts = HTMLOptions(
958
+ space_after_paragraphs=self._options.space_after_paragraphs
959
+ )
960
+ converted = html_to_gemtext(
961
+ token.content, options=html_opts
962
+ )
963
+ if converted.strip():
964
+ document.append(RawBlock(converted, self._options))
965
+ case "preformat":
966
+ content = token.content.rstrip("\n")
967
+ document.append(
968
+ Preformatted("html", content, self._options)
969
+ )
970
+ case "striptags":
971
+ stripped = strip_html_tags(token.content)
972
+ if stripped.strip():
973
+ document.append(Paragraph(stripped, [], self._options))
974
+
975
+ case _:
976
+ pass
977
+
978
+ i += 1
979
+
980
+ return "\n".join(str(capture) for capture in document)
981
+
982
+
983
+ ### _converter.py ends here
md2gemtext/convert.py ADDED
@@ -0,0 +1,23 @@
1
+ """Provides a simple Markdown to Gemtext converter."""
2
+
3
+ ##############################################################################
4
+ # Local imports.
5
+ from ._converter import MarkdownToGemtextConverter
6
+ from .options import Options
7
+
8
+
9
+ ##############################################################################
10
+ def markdown_to_gemtext(markdown_content: str, options: Options | None = None) -> str:
11
+ """Convert Markdown content to Gemtext.
12
+
13
+ Args:
14
+ markdown_content: The Markdown content to convert.
15
+ options: Optional conversion options.
16
+
17
+ Returns:
18
+ The converted Gemtext content.
19
+ """
20
+ return MarkdownToGemtextConverter(options).convert(markdown_content)
21
+
22
+
23
+ ### convert.py ends here
md2gemtext/options.py ADDED
@@ -0,0 +1,47 @@
1
+ """Configuration options for the converter."""
2
+
3
+ ##############################################################################
4
+ # Python imports.
5
+ from collections.abc import Sequence
6
+ from typing import Literal, NamedTuple
7
+
8
+ ##############################################################################
9
+ type HTMLBlockHandling = Literal["convert", "preformat", "striptags"]
10
+ """Options for handling the conversion of HTML blocks."""
11
+ type HTMLInlineHandling = Literal["keep", "striptags"]
12
+ """Options for handling the conversion of inline HTML tags."""
13
+
14
+
15
+ ##############################################################################
16
+ class Options(NamedTuple):
17
+ """Configuration options for the converter."""
18
+
19
+ retain_inline_markup: bool = True
20
+ """Whether to retain inline Markdown markup (e.g. *italic*, **bold**, `code`)."""
21
+
22
+ space_after_paragraphs: bool = True
23
+ """Whether to add an empty line after paragraphs."""
24
+
25
+ space_after_blockquotes: bool = True
26
+ """Whether to add an empty line after blockquotes."""
27
+
28
+ space_after_lists: bool = True
29
+ """Whether to add an empty line after a run of list items is broken."""
30
+
31
+ hide_front_matter: bool = False
32
+ """Whether to hide front matter. If False (default), emits it as preformatted text."""
33
+
34
+ extra_linkable_protocols: Sequence[str] = ()
35
+ """Additional protocol names to linkify (e.g. ['spartan', 'finger', 'nex'])."""
36
+
37
+ preformat_tables: bool = True
38
+ """Whether to format tables as preformatted blocks with alt-text 'table'."""
39
+
40
+ html_block_handling: HTMLBlockHandling = "convert"
41
+ """How to handle HTML blocks: 'convert', 'preformat', or 'striptags'."""
42
+
43
+ html_inline_handling: HTMLInlineHandling = "striptags"
44
+ """How to handle inline HTML tags: 'keep' or 'striptags'."""
45
+
46
+
47
+ ### options.py ends here
md2gemtext/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561.
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: md2gemtext
3
+ Version: 0.0.1
4
+ Summary: A simple library for converting Markdown to Gemtext
5
+ Keywords: converter,Gemini,Gemtext,Markdown,Hypertext,library,Markup,parser,Small Web,smolweb
6
+ Author: Dave Pearson
7
+ Author-email: Dave Pearson <davep@davep.org>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Topic :: Text Processing :: Filters
18
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
19
+ Classifier: Topic :: Text Processing :: Markup
20
+ Classifier: Topic :: Text Processing
21
+ Classifier: Topic :: Utilities
22
+ Classifier: Typing :: Typed
23
+ Requires-Dist: html2gemtext>=0.1.0
24
+ Requires-Dist: linkify-it-py>=2.1.0
25
+ Requires-Dist: markdown-it-py>=4.2.0
26
+ Requires-Dist: mdit-py-plugins>=0.6.1
27
+ Requires-Python: >=3.12
28
+ Project-URL: Homepage, https://md2gemtext.davep.dev/
29
+ Project-URL: Repository, https://github.com/davep/md2gemtext
30
+ Project-URL: Documentation, https://md2gemtext.davep.dev/
31
+ Project-URL: Source, https://github.com/davep/md2gemtext
32
+ Project-URL: Issues, https://github.com/davep/md2gemtext/issues
33
+ Project-URL: Discussions, https://github.com/davep/md2gemtext/discussions
34
+ Description-Content-Type: text/markdown
35
+
36
+ # md2gemtext - A simple library for converting Markdown to Gemtext
37
+
38
+ ## Introduction
39
+
40
+ `md2gemtext` is a small and simple library that provides code for
41
+ converting Markdown into [the hypertext markup language of the Gemini
42
+ project](https://geminiprotocol.net/docs/gemtext-specification.gmi).
43
+
44
+ ## Installation
45
+
46
+ `md2gemtext` is available from PyPI and can be installed with your
47
+ package installer of choice.
48
+
49
+ With `pip`:
50
+
51
+ ```shell
52
+ pip install md2gemtext
53
+ ```
54
+
55
+ With `uv`:
56
+
57
+ ```shell
58
+ uv add md2gemtext
59
+ ```
60
+
61
+ ## Quick start
62
+
63
+ The library provides a main conversion function called `markdown_to_gemtext`.
64
+ It is passed a string of Markdown you wish to convert, and returns the converted
65
+ Gemtext string:
66
+
67
+ ```python
68
+ from md2gemtext import markdown_to_gemtext, Options
69
+
70
+ markdown = """
71
+ # Welcome
72
+
73
+ This is a paragraph with [a link](https://example.com) inside.
74
+ """
75
+
76
+ gemtext = markdown_to_gemtext(markdown)
77
+ print(gemtext)
78
+ ```
79
+
80
+ Output:
81
+
82
+ ```gemtext
83
+ # Welcome
84
+
85
+ This is a paragraph with a link{a} inside.
86
+
87
+ => https://example.com {a} a link
88
+ ```
89
+
90
+ ### Options
91
+
92
+ Conversion behavior can be configured using the `Options` class:
93
+
94
+ ```python
95
+ from md2gemtext import Options, markdown_to_gemtext
96
+
97
+ options = Options(
98
+ retain_inline_markup=False, # Strip bold, italic, code markup
99
+ space_after_paragraphs=True, # Add empty line after paragraphs
100
+ space_after_blockquotes=True, # Add empty line after blockquotes
101
+ space_after_lists=True, # Add empty line after runs of list items
102
+ hide_front_matter=True, # Hide/filter out front matter (default is False, which emits it)
103
+ extra_linkable_protocols=["spartan", "finger", "nex"], # Additional protocols to linkify
104
+ preformat_tables=True, # Emit tables as preformatted blocks with alt-text 'table'
105
+ html_block_handling="convert", # HTML blocks: 'convert', 'preformat', or 'striptags'
106
+ html_inline_handling="striptags", # Inline HTML tags: 'striptags' (default) or 'keep'
107
+ )
108
+
109
+ gemtext = markdown_to_gemtext(markdown, options=options)
110
+ ```
111
+
112
+ ## Command Line Interface
113
+
114
+ `md2gemtext` includes a command-line tool which reads from files or stdin:
115
+
116
+ ```shell
117
+ # Read from file
118
+ md2gemtext document.md
119
+
120
+ # Read from stdin
121
+ cat document.md | md2gemtext
122
+
123
+ # Strip inline markup
124
+ md2gemtext --strip-inline-markup document.md
125
+
126
+ # Add extra protocols to linkify
127
+ md2gemtext --extra-protocol spartan --extra-protocol nex document.md
128
+
129
+ # Emit tables as plain aligned text instead of preformatted blocks
130
+ md2gemtext --no-preformat-tables document.md
131
+
132
+ # Set HTML block handling mode ('convert', 'preformat', or 'striptags')
133
+ md2gemtext --html-block-handling preformat document.md
134
+
135
+ # Keep inline HTML tags rather than stripping them
136
+ md2gemtext --html-inline-handling keep document.md
137
+ ```
138
+
139
+ [//]: # (README.md ends here)
@@ -0,0 +1,10 @@
1
+ md2gemtext/__init__.py,sha256=-10t-6ogrv3_mWjRWdJ4-x49yn1fZ8SH3clpDwpDcB0,944
2
+ md2gemtext/__main__.py,sha256=wmCA4evcgPkl57jwT5hKqy-BlBuctoMNG6b4ZxdNbiw,4039
3
+ md2gemtext/_converter.py,sha256=F40_7GD5bU7YtedDjo0PKyrkTMxrF-LUQQoqLjuvYhQ,36578
4
+ md2gemtext/convert.py,sha256=zgM8CAbWu56L7UZ2M_CnrJw6dgbV7Ts6fdUhWVIDJlI,721
5
+ md2gemtext/options.py,sha256=IfHQSCrps25V8PIRwHNk0zYVOTbngBzuyDwO5No1Q-I,1804
6
+ md2gemtext/py.typed,sha256=bWew9mHgMy8LqMu7RuqQXFXLBxh2CRx0dUbSx-3wE48,27
7
+ md2gemtext-0.0.1.dist-info/WHEEL,sha256=4OL6Foqnnp3xRY5wMkjgc25_i5YJC6dKsC6LPcjqEoU,80
8
+ md2gemtext-0.0.1.dist-info/entry_points.txt,sha256=iIOaFpqId0g8gxnsiJ-E1dsNffjWq1O9bkbvHZl_cl8,60
9
+ md2gemtext-0.0.1.dist-info/METADATA,sha256=UgryV_q_ivSnDbmADhncm9DoIb7nbXCUoOLt3fE7dJA,4274
10
+ md2gemtext-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.5
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ md2gemtext = md2gemtext.__main__:convert
3
+