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 +32 -0
- md2gemtext/__main__.py +123 -0
- md2gemtext/_converter.py +983 -0
- md2gemtext/convert.py +23 -0
- md2gemtext/options.py +47 -0
- md2gemtext/py.typed +1 -0
- md2gemtext-0.0.1.dist-info/METADATA +139 -0
- md2gemtext-0.0.1.dist-info/RECORD +10 -0
- md2gemtext-0.0.1.dist-info/WHEEL +4 -0
- md2gemtext-0.0.1.dist-info/entry_points.txt +3 -0
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
|
md2gemtext/_converter.py
ADDED
|
@@ -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,,
|