steamify 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- steamify/__init__.py +12 -0
- steamify/markdown.py +284 -0
- steamify/steam.py +357 -0
- steamify-2.0.0.dist-info/METADATA +162 -0
- steamify-2.0.0.dist-info/RECORD +7 -0
- steamify-2.0.0.dist-info/WHEEL +4 -0
- steamify-2.0.0.dist-info/licenses/LICENSE +21 -0
steamify/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Package that contains a lightweight converter between Markdown and Steam-compatible markup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
|
|
7
|
+
from steamify.markdown import to_markdown
|
|
8
|
+
from steamify.steam import to_steam
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__version__ = version("steamify")
|
|
12
|
+
__all__ = ["to_markdown", "to_steam"]
|
steamify/markdown.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Module that contains the Steam-markup-to-Markdown conversion pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from dataclasses import field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_PATTERN_CODE_SPAN = re.compile(r"\[code\](.*?)\[/code\]")
|
|
12
|
+
_PATTERN_IMAGE = re.compile(r"\[img\](.*?)\[/img\]")
|
|
13
|
+
_PATTERN_LINK = re.compile(r"\[url=([^]]*)\](.*?)\[/url\]")
|
|
14
|
+
_PATTERN_BOLD_ITALIC = re.compile(r"\[b\]\[i\](.*?)\[/i\]\[/b\]")
|
|
15
|
+
_PATTERN_BOLD = re.compile(r"\[b\](.*?)\[/b\]")
|
|
16
|
+
_PATTERN_ITALIC = re.compile(r"\[i\](.*?)\[/i\]")
|
|
17
|
+
_PATTERN_STRIKETHROUGH = re.compile(r"\[strike\](.*?)\[/strike\]")
|
|
18
|
+
|
|
19
|
+
_PATTERN_HEADING = re.compile(r"^\[h([1-6])\](.*)\[/h\1\]$")
|
|
20
|
+
_PATTERN_HORIZONTAL_RULE = re.compile(r"^\[hr\]\[/hr\]$")
|
|
21
|
+
_PATTERN_LIST_OPEN = re.compile(r"^\[(list|olist)\]$")
|
|
22
|
+
_PATTERN_LIST_CLOSE = re.compile(r"^\[/(list|olist)\]$")
|
|
23
|
+
_PATTERN_LIST_ITEM = re.compile(r"^\[\*\]\s?(.*)")
|
|
24
|
+
_PATTERN_QUOTE_ONLY = re.compile(r"^(?:\[/?quote\])+$")
|
|
25
|
+
_PATTERN_QUOTE_TOKEN = re.compile(r"\[(/?)quote\]")
|
|
26
|
+
|
|
27
|
+
_INDENT_WIDTH = 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class MarkdownState: # noqa: D101
|
|
32
|
+
lines: list[str] = field(default_factory=list)
|
|
33
|
+
list_stack: list[tuple[str, int]] = field(default_factory=list)
|
|
34
|
+
current_quote_level: int = 0
|
|
35
|
+
inside_code_block: bool = False
|
|
36
|
+
code_block_accumulator: list[str] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
def add_line(self, line: str) -> None:
|
|
39
|
+
self.lines.append(line)
|
|
40
|
+
|
|
41
|
+
def prefix(self, text: str) -> str:
|
|
42
|
+
return "> " * self.current_quote_level + text
|
|
43
|
+
|
|
44
|
+
def build(self) -> str:
|
|
45
|
+
return "\n".join(self.lines)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def to_markdown(steam_text: str) -> str:
|
|
49
|
+
"""Convert Steam-compatible markup to Markdown text."""
|
|
50
|
+
state = MarkdownState()
|
|
51
|
+
lines = steam_text.splitlines()
|
|
52
|
+
|
|
53
|
+
for steam_line in lines:
|
|
54
|
+
_process_line(steam_line, state)
|
|
55
|
+
|
|
56
|
+
_close_remaining_blocks(state)
|
|
57
|
+
|
|
58
|
+
return state.build()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _close_remaining_blocks(state: MarkdownState) -> None:
|
|
62
|
+
if state.inside_code_block:
|
|
63
|
+
state.inside_code_block = False
|
|
64
|
+
_flush_code_block(state)
|
|
65
|
+
|
|
66
|
+
state.list_stack.clear()
|
|
67
|
+
state.current_quote_level = 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _process_line(steam_line: str, state: MarkdownState) -> None:
|
|
71
|
+
if state.inside_code_block:
|
|
72
|
+
_convert_code_block_content(steam_line, state)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
line_content = steam_line.strip()
|
|
76
|
+
|
|
77
|
+
if line_content == "":
|
|
78
|
+
state.add_line("")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
_try_convert_code_block_open(line_content, state)
|
|
83
|
+
or _try_convert_quote_level(line_content, state)
|
|
84
|
+
or _try_convert_list_open(line_content, state)
|
|
85
|
+
or _try_convert_list_close(line_content, state)
|
|
86
|
+
or _try_convert_list_item(line_content, state)
|
|
87
|
+
or _try_convert_heading(line_content, state)
|
|
88
|
+
or _try_convert_horizontal_rule(line_content, state)
|
|
89
|
+
):
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
state.add_line(state.prefix(_convert_inline_elements(line_content)))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _flush_code_block(state: MarkdownState) -> None:
|
|
96
|
+
state.add_line(state.prefix("```"))
|
|
97
|
+
|
|
98
|
+
for code_line in state.code_block_accumulator:
|
|
99
|
+
state.add_line(state.prefix(code_line))
|
|
100
|
+
|
|
101
|
+
state.add_line(state.prefix("```"))
|
|
102
|
+
state.code_block_accumulator.clear()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _try_convert_code_block_open(line_content: str, state: MarkdownState) -> bool:
|
|
106
|
+
"""Enter code-block mode on a `[code]` tag left unclosed on its own line."""
|
|
107
|
+
if "[code]" not in line_content:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
leading_text, _, remainder = line_content.partition("[code]")
|
|
111
|
+
if "[/code]" in remainder:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
if leading_text.strip():
|
|
115
|
+
state.add_line(state.prefix(_convert_inline_elements(leading_text)))
|
|
116
|
+
|
|
117
|
+
state.inside_code_block = True
|
|
118
|
+
state.code_block_accumulator.clear()
|
|
119
|
+
|
|
120
|
+
if remainder:
|
|
121
|
+
state.code_block_accumulator.append(remainder)
|
|
122
|
+
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _convert_code_block_content(steam_line: str, state: MarkdownState) -> None:
|
|
127
|
+
if "[/code]" not in steam_line:
|
|
128
|
+
state.code_block_accumulator.append(steam_line)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
code_content, _, trailing_text = steam_line.partition("[/code]")
|
|
132
|
+
if code_content:
|
|
133
|
+
state.code_block_accumulator.append(code_content)
|
|
134
|
+
|
|
135
|
+
state.inside_code_block = False
|
|
136
|
+
_flush_code_block(state)
|
|
137
|
+
|
|
138
|
+
if trailing_text.strip():
|
|
139
|
+
state.add_line(state.prefix(_convert_inline_elements(trailing_text)))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _try_convert_quote_level(line_content: str, state: MarkdownState) -> bool:
|
|
143
|
+
if not _PATTERN_QUOTE_ONLY.match(line_content):
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
for token in _PATTERN_QUOTE_TOKEN.finditer(line_content):
|
|
147
|
+
if token.group(1):
|
|
148
|
+
state.current_quote_level = max(0, state.current_quote_level - 1)
|
|
149
|
+
else:
|
|
150
|
+
state.current_quote_level += 1
|
|
151
|
+
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _try_convert_list_open(line_content: str, state: MarkdownState) -> bool:
|
|
156
|
+
match = _PATTERN_LIST_OPEN.match(line_content)
|
|
157
|
+
if not match:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
state.list_stack.append((match.group(1), 0))
|
|
161
|
+
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _try_convert_list_close(line_content: str, state: MarkdownState) -> bool:
|
|
166
|
+
if not _PATTERN_LIST_CLOSE.match(line_content):
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
if state.list_stack:
|
|
170
|
+
state.list_stack.pop()
|
|
171
|
+
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _try_convert_list_item(line_content: str, state: MarkdownState) -> bool:
|
|
176
|
+
match = _PATTERN_LIST_ITEM.match(line_content)
|
|
177
|
+
if not match:
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
if not state.list_stack:
|
|
181
|
+
state.list_stack.append(("list", 0))
|
|
182
|
+
|
|
183
|
+
list_type, item_number = state.list_stack[-1]
|
|
184
|
+
item_number += 1
|
|
185
|
+
state.list_stack[-1] = (list_type, item_number)
|
|
186
|
+
|
|
187
|
+
match list_type:
|
|
188
|
+
case "olist":
|
|
189
|
+
marker = f"{item_number}."
|
|
190
|
+
case _:
|
|
191
|
+
marker = "-"
|
|
192
|
+
|
|
193
|
+
indent = " " * (_INDENT_WIDTH * (len(state.list_stack) - 1))
|
|
194
|
+
item_text = _convert_inline_elements(match.group(1))
|
|
195
|
+
state.add_line(state.prefix(f"{indent}{marker} {item_text}"))
|
|
196
|
+
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _try_convert_heading(line_content: str, state: MarkdownState) -> bool:
|
|
201
|
+
match = _PATTERN_HEADING.match(line_content)
|
|
202
|
+
if not match:
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
level = int(match.group(1))
|
|
206
|
+
heading_text = _convert_inline_elements(match.group(2))
|
|
207
|
+
state.add_line(state.prefix(f"{'#' * level} {heading_text}"))
|
|
208
|
+
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _try_convert_horizontal_rule(line_content: str, state: MarkdownState) -> bool:
|
|
213
|
+
if not _PATTERN_HORIZONTAL_RULE.match(line_content):
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
state.add_line(state.prefix("---"))
|
|
217
|
+
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _convert_inline_bold(text: str) -> str:
|
|
222
|
+
"""Convert inline bold: [b]text[/b] -> **text**."""
|
|
223
|
+
text = _PATTERN_BOLD_ITALIC.sub(r"***\1***", text)
|
|
224
|
+
return _PATTERN_BOLD.sub(r"**\1**", text)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _convert_inline_elements(text: str) -> str:
|
|
228
|
+
text, code_spans = _convert_inline_code_spans(text)
|
|
229
|
+
text = _convert_inline_images(text)
|
|
230
|
+
text = _convert_inline_links(text)
|
|
231
|
+
text = _convert_inline_bold(text)
|
|
232
|
+
text = _convert_inline_italic(text)
|
|
233
|
+
text = _convert_inline_strikethrough(text)
|
|
234
|
+
text = _render_inline_code_spans(text, code_spans)
|
|
235
|
+
return text.strip()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _convert_inline_code_spans(text: str) -> tuple[str, list[str]]:
|
|
239
|
+
"""Convert inline code spans: [code]code[/code] -> @@CODE{index}@@."""
|
|
240
|
+
code_spans: list[str] = []
|
|
241
|
+
|
|
242
|
+
def code_span_repl(match: re.Match) -> str:
|
|
243
|
+
code_content = match.group(1)
|
|
244
|
+
code_spans.append(code_content)
|
|
245
|
+
return f"@@CODE{len(code_spans) - 1}@@"
|
|
246
|
+
|
|
247
|
+
text = _PATTERN_CODE_SPAN.sub(code_span_repl, text)
|
|
248
|
+
return text, code_spans
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _convert_inline_images(text: str) -> str:
|
|
252
|
+
"""Convert inline images: [img]URL[/img] -> ."""
|
|
253
|
+
return _PATTERN_IMAGE.sub(r"", text)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _convert_inline_italic(text: str) -> str:
|
|
257
|
+
"""Convert inline italic: [i]text[/i] -> *text*."""
|
|
258
|
+
return _PATTERN_ITALIC.sub(r"*\1*", text)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _convert_inline_links(text: str) -> str:
|
|
262
|
+
"""Convert inline links: [url=URL]text[/url] -> [text](URL)."""
|
|
263
|
+
|
|
264
|
+
def link_repl(match: re.Match) -> str:
|
|
265
|
+
url = match.group(1)
|
|
266
|
+
link_text = match.group(2)
|
|
267
|
+
return f"[{link_text}]({url})"
|
|
268
|
+
|
|
269
|
+
return _PATTERN_LINK.sub(link_repl, text)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _convert_inline_strikethrough(text: str) -> str:
|
|
273
|
+
"""Convert inline strikethrough: [strike]text[/strike] -> ~~text~~."""
|
|
274
|
+
return _PATTERN_STRIKETHROUGH.sub(r"~~\1~~", text)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _render_inline_code_spans(text: str, code_spans: list[str]) -> str:
|
|
278
|
+
"""Render inline code spans: @@CODE{index}@@ -> `code`."""
|
|
279
|
+
|
|
280
|
+
def render_repl(match: re.Match) -> str:
|
|
281
|
+
idx = int(match.group(1))
|
|
282
|
+
return f"`{code_spans[idx]}`"
|
|
283
|
+
|
|
284
|
+
return re.sub(r"@@CODE(\d+)@@", render_repl, text)
|
steamify/steam.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Module that contains the Markdown-to-Steam-markup conversion pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from dataclasses import field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_PATTERN_CODE_SPAN = re.compile(r"`([^`]+?)`")
|
|
12
|
+
_PATTERN_IMAGE = re.compile(r'!\[.*?\]\(([^)\s"]+)(?:\s+"[^"]*")?\)')
|
|
13
|
+
_PATTERN_LINK = re.compile(r'\[([^]]+)\]\(([^)\s"]+)(?:\s+"[^"]*")?\)')
|
|
14
|
+
_PATTERN_BOLD_ITALIC_STAR = re.compile(r"\*\*\*(.*?)\*\*\*")
|
|
15
|
+
_PATTERN_BOLD_ITALIC_UNDER = re.compile(r"___(.*?)___")
|
|
16
|
+
_PATTERN_BOLD_STAR = re.compile(r"\*\*(.*?)\*\*")
|
|
17
|
+
_PATTERN_BOLD_UNDER = re.compile(r"__(.*?)__")
|
|
18
|
+
_PATTERN_ITALIC = re.compile(r"\*(.*?)\*")
|
|
19
|
+
_PATTERN_STRIKETHROUGH = re.compile(r"~~(.*?)~~")
|
|
20
|
+
|
|
21
|
+
_PATTERN_HEADING = re.compile(r"^(#{1,6})\s+(.*)")
|
|
22
|
+
_PATTERN_HR_DASH = re.compile(r"^(-\s?){3,}$")
|
|
23
|
+
_PATTERN_HR_UNDERSCORE = re.compile(r"^(_\s?){3,}$")
|
|
24
|
+
_PATTERN_HR_STAR = re.compile(r"^(\*\s?){3,}$")
|
|
25
|
+
_PATTERN_LIST_ITEM = re.compile(r"^(\s*)([-+*]|\d+\.)\s+(.*)")
|
|
26
|
+
_PATTERN_LIST_MARKER = re.compile(r"^\s*([-+*]|\d+\.)\s+")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class SteamState: # noqa: D101
|
|
31
|
+
lines: list[str] = field(default_factory=list)
|
|
32
|
+
list_stack: list[tuple[str, int]] = field(default_factory=list)
|
|
33
|
+
current_quote_level: int = 0
|
|
34
|
+
inside_code_block: bool = False
|
|
35
|
+
code_block_accumulator: list[str] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
def add_line(self, line: str) -> None:
|
|
38
|
+
self.lines.append(line)
|
|
39
|
+
|
|
40
|
+
def close_lists(self) -> None:
|
|
41
|
+
while self.list_stack:
|
|
42
|
+
list_type, _ = self.list_stack.pop()
|
|
43
|
+
match list_type:
|
|
44
|
+
case "ol":
|
|
45
|
+
tag = "olist"
|
|
46
|
+
case _:
|
|
47
|
+
tag = "list"
|
|
48
|
+
self.add_line(f"[/{tag}]")
|
|
49
|
+
|
|
50
|
+
def close_quotes(self) -> None:
|
|
51
|
+
if self.current_quote_level > 0:
|
|
52
|
+
self.add_line("[/quote]" * self.current_quote_level)
|
|
53
|
+
self.current_quote_level = 0
|
|
54
|
+
|
|
55
|
+
def build(self) -> str:
|
|
56
|
+
return "\n".join(self.lines)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def to_steam(markdown_text: str) -> str:
|
|
60
|
+
"""Convert Markdown text to Steam-compatible markup."""
|
|
61
|
+
state = SteamState()
|
|
62
|
+
lines = markdown_text.splitlines()
|
|
63
|
+
|
|
64
|
+
for md_line in lines:
|
|
65
|
+
_process_line(md_line, state)
|
|
66
|
+
|
|
67
|
+
_close_remaining_blocks(state)
|
|
68
|
+
|
|
69
|
+
return state.build()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _close_remaining_blocks(state: SteamState) -> None:
|
|
73
|
+
if state.inside_code_block:
|
|
74
|
+
code_content = "\n".join(state.code_block_accumulator)
|
|
75
|
+
state.add_line(f"[code]{code_content}[/code]")
|
|
76
|
+
|
|
77
|
+
state.close_lists()
|
|
78
|
+
state.close_quotes()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _process_line(md_line: str, state: SteamState) -> None:
|
|
82
|
+
if state.inside_code_block:
|
|
83
|
+
_convert_code_block_content(md_line, state)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
if md_line.strip().startswith("```"):
|
|
87
|
+
state.inside_code_block = True
|
|
88
|
+
state.code_block_accumulator.clear()
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if md_line.strip() == "":
|
|
92
|
+
_convert_empty_line(state)
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
_check_list_continuation(md_line, state)
|
|
96
|
+
|
|
97
|
+
line_content, quote_depth = _extract_quotes(md_line)
|
|
98
|
+
_convert_quote_level(quote_depth, state)
|
|
99
|
+
|
|
100
|
+
if _try_convert_heading(line_content, state):
|
|
101
|
+
return
|
|
102
|
+
if _try_convert_horizontal_rule(line_content, state):
|
|
103
|
+
return
|
|
104
|
+
if _try_convert_list_item(line_content, state):
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
state.add_line(_convert_inline_elements(line_content))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _convert_code_block_content(md_line: str, state: SteamState) -> None:
|
|
111
|
+
if md_line.strip().startswith("```"):
|
|
112
|
+
state.inside_code_block = False
|
|
113
|
+
code_content = "\n".join(state.code_block_accumulator)
|
|
114
|
+
state.add_line(f"[code]{code_content}[/code]")
|
|
115
|
+
state.code_block_accumulator.clear()
|
|
116
|
+
|
|
117
|
+
else:
|
|
118
|
+
state.code_block_accumulator.append(md_line)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _convert_empty_line(state: SteamState) -> None:
|
|
122
|
+
state.close_lists()
|
|
123
|
+
state.close_quotes()
|
|
124
|
+
state.add_line("")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _adjust_list_stack(list_type: str, indent_spaces: int, state: SteamState) -> None:
|
|
128
|
+
if not state.list_stack:
|
|
129
|
+
state.list_stack.append((list_type, indent_spaces))
|
|
130
|
+
match list_type:
|
|
131
|
+
case "ol":
|
|
132
|
+
tag = "olist"
|
|
133
|
+
case _:
|
|
134
|
+
tag = "list"
|
|
135
|
+
state.add_line(f"[{tag}]")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
current_indent = state.list_stack[-1][1]
|
|
139
|
+
|
|
140
|
+
if indent_spaces > current_indent:
|
|
141
|
+
state.list_stack.append((list_type, indent_spaces))
|
|
142
|
+
match list_type:
|
|
143
|
+
case "ol":
|
|
144
|
+
tag = "olist"
|
|
145
|
+
case _:
|
|
146
|
+
tag = "list"
|
|
147
|
+
state.add_line(f"[{tag}]")
|
|
148
|
+
|
|
149
|
+
elif indent_spaces < current_indent:
|
|
150
|
+
_convert_list_dedent(list_type, indent_spaces, state)
|
|
151
|
+
|
|
152
|
+
else:
|
|
153
|
+
_convert_list_same_level(list_type, indent_spaces, state)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _check_list_continuation(md_line: str, state: SteamState) -> None:
|
|
157
|
+
if not state.list_stack:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
if not _PATTERN_LIST_MARKER.match(md_line):
|
|
161
|
+
leading_spaces = len(md_line) - len(md_line.lstrip(" "))
|
|
162
|
+
first_char = md_line.lstrip(" ")[:1]
|
|
163
|
+
is_quote_and_indented = first_char == ">" and leading_spaces > state.list_stack[-1][1]
|
|
164
|
+
if not is_quote_and_indented:
|
|
165
|
+
state.close_lists()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _convert_list_dedent(list_type: str, indent_spaces: int, state: SteamState) -> None:
|
|
169
|
+
while state.list_stack and indent_spaces < state.list_stack[-1][1]:
|
|
170
|
+
prev_type, _ = state.list_stack.pop()
|
|
171
|
+
match prev_type:
|
|
172
|
+
case "ol":
|
|
173
|
+
tag = "olist"
|
|
174
|
+
case _:
|
|
175
|
+
tag = "list"
|
|
176
|
+
state.add_line(f"[/{tag}]")
|
|
177
|
+
|
|
178
|
+
if state.list_stack and state.list_stack[-1][1] == indent_spaces:
|
|
179
|
+
if state.list_stack[-1][0] != list_type:
|
|
180
|
+
prev_type, _ = state.list_stack.pop()
|
|
181
|
+
match prev_type:
|
|
182
|
+
case "ol":
|
|
183
|
+
old_tag = "olist"
|
|
184
|
+
case _:
|
|
185
|
+
old_tag = "list"
|
|
186
|
+
match list_type:
|
|
187
|
+
case "ol":
|
|
188
|
+
new_tag = "olist"
|
|
189
|
+
case _:
|
|
190
|
+
new_tag = "list"
|
|
191
|
+
state.add_line(f"[/{old_tag}]")
|
|
192
|
+
state.list_stack.append((list_type, indent_spaces))
|
|
193
|
+
state.add_line(f"[{new_tag}]")
|
|
194
|
+
|
|
195
|
+
else:
|
|
196
|
+
state.list_stack.append((list_type, indent_spaces))
|
|
197
|
+
match list_type:
|
|
198
|
+
case "ol":
|
|
199
|
+
tag = "olist"
|
|
200
|
+
case _:
|
|
201
|
+
tag = "list"
|
|
202
|
+
state.add_line(f"[{tag}]")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _convert_list_same_level(list_type: str, indent_spaces: int, state: SteamState) -> None:
|
|
206
|
+
if state.list_stack[-1][0] != list_type:
|
|
207
|
+
prev_type, _ = state.list_stack.pop()
|
|
208
|
+
match prev_type:
|
|
209
|
+
case "ol":
|
|
210
|
+
old_tag = "olist"
|
|
211
|
+
case _:
|
|
212
|
+
old_tag = "list"
|
|
213
|
+
match list_type:
|
|
214
|
+
case "ol":
|
|
215
|
+
new_tag = "olist"
|
|
216
|
+
case _:
|
|
217
|
+
new_tag = "list"
|
|
218
|
+
state.add_line(f"[/{old_tag}]")
|
|
219
|
+
state.list_stack.append((list_type, indent_spaces))
|
|
220
|
+
state.add_line(f"[{new_tag}]")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _try_convert_list_item(line_content: str, state: SteamState) -> bool:
|
|
224
|
+
match = _PATTERN_LIST_ITEM.match(line_content)
|
|
225
|
+
if not match:
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
indent_str = match.group(1)
|
|
229
|
+
marker = match.group(2)
|
|
230
|
+
item_text = match.group(3)
|
|
231
|
+
indent_spaces = len(indent_str.expandtabs(4))
|
|
232
|
+
list_type = "ol" if marker.rstrip(".").isdigit() else "ul"
|
|
233
|
+
|
|
234
|
+
_adjust_list_stack(list_type, indent_spaces, state)
|
|
235
|
+
|
|
236
|
+
converted_item = _convert_inline_elements(item_text)
|
|
237
|
+
state.add_line(f"[*] {converted_item}")
|
|
238
|
+
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _convert_quote_level(quote_depth: int, state: SteamState) -> None:
|
|
243
|
+
match quote_depth:
|
|
244
|
+
case 0:
|
|
245
|
+
state.close_quotes()
|
|
246
|
+
case _:
|
|
247
|
+
if quote_depth > state.current_quote_level:
|
|
248
|
+
for _ in range(state.current_quote_level, quote_depth):
|
|
249
|
+
state.add_line("[quote]")
|
|
250
|
+
elif quote_depth < state.current_quote_level:
|
|
251
|
+
for _ in range(quote_depth, state.current_quote_level):
|
|
252
|
+
state.add_line("[/quote]")
|
|
253
|
+
state.current_quote_level = quote_depth
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _extract_quotes(md_line: str) -> tuple[str, int]:
|
|
257
|
+
stripped_line = md_line
|
|
258
|
+
quote_depth = 0
|
|
259
|
+
|
|
260
|
+
while stripped_line.startswith(">"):
|
|
261
|
+
quote_depth += 1
|
|
262
|
+
stripped_line = stripped_line.removeprefix(">").removeprefix(" ")
|
|
263
|
+
|
|
264
|
+
return stripped_line, quote_depth
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _try_convert_heading(line_content: str, state: SteamState) -> bool:
|
|
268
|
+
match = _PATTERN_HEADING.match(line_content)
|
|
269
|
+
if not match:
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
level = min(len(match.group(1)), 3) # Steam supports h1-h3
|
|
273
|
+
heading_text = match.group(2)
|
|
274
|
+
converted_heading = _convert_inline_elements(heading_text)
|
|
275
|
+
state.add_line(f"[h{level}]{converted_heading}[/h{level}]")
|
|
276
|
+
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _try_convert_horizontal_rule(line_content: str, state: SteamState) -> bool:
|
|
281
|
+
if (
|
|
282
|
+
_PATTERN_HR_DASH.match(line_content)
|
|
283
|
+
or _PATTERN_HR_UNDERSCORE.match(line_content)
|
|
284
|
+
or _PATTERN_HR_STAR.match(line_content)
|
|
285
|
+
):
|
|
286
|
+
state.add_line("[hr][/hr]")
|
|
287
|
+
return True
|
|
288
|
+
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _convert_inline_bold(text: str) -> str:
|
|
293
|
+
"""Convert inline bold: **text** -> [b]text[/b]."""
|
|
294
|
+
text = _PATTERN_BOLD_ITALIC_STAR.sub(r"[b][i]\1[/i][/b]", text)
|
|
295
|
+
text = _PATTERN_BOLD_ITALIC_UNDER.sub(r"[b][i]\1[/i][/b]", text)
|
|
296
|
+
text = _PATTERN_BOLD_STAR.sub(r"[b]\1[/b]", text)
|
|
297
|
+
return _PATTERN_BOLD_UNDER.sub(r"[b]\1[/b]", text)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _convert_inline_elements(text: str) -> str:
|
|
301
|
+
text, code_spans = _convert_inline_code_spans(text)
|
|
302
|
+
text = _convert_inline_images(text)
|
|
303
|
+
text = _convert_inline_links(text)
|
|
304
|
+
text = _convert_inline_bold(text)
|
|
305
|
+
text = _convert_inline_italic(text)
|
|
306
|
+
text = _convert_inline_strikethrough(text)
|
|
307
|
+
text = _render_inline_code_spans(text, code_spans)
|
|
308
|
+
return text.strip()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _convert_inline_code_spans(text: str) -> tuple[str, list[str]]:
|
|
312
|
+
"""Convert inline code spans: ``code`` -> @@CODE{index}@@."""
|
|
313
|
+
code_spans: list[str] = []
|
|
314
|
+
|
|
315
|
+
def code_span_repl(match: re.Match) -> str:
|
|
316
|
+
code_content = match.group(1)
|
|
317
|
+
code_spans.append(code_content)
|
|
318
|
+
return f"@@CODE{len(code_spans) - 1}@@"
|
|
319
|
+
|
|
320
|
+
text = _PATTERN_CODE_SPAN.sub(code_span_repl, text)
|
|
321
|
+
return text, code_spans
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _convert_inline_images(text: str) -> str:
|
|
325
|
+
"""Convert inline images:  -> [img]URL[/img]."""
|
|
326
|
+
return _PATTERN_IMAGE.sub(r"[img]\1[/img]", text)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _convert_inline_italic(text: str) -> str:
|
|
330
|
+
"""Convert inline italic: *text* -> [i]text[/i]."""
|
|
331
|
+
return _PATTERN_ITALIC.sub(r"[i]\1[/i]", text)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _convert_inline_links(text: str) -> str:
|
|
335
|
+
"""Convert inline links: [text](URL) -> [url=URL]text[/url]."""
|
|
336
|
+
|
|
337
|
+
def link_repl(match: re.Match) -> str:
|
|
338
|
+
link_text = match.group(1)
|
|
339
|
+
url = match.group(2)
|
|
340
|
+
return f"[url={url}]{link_text}[/url]"
|
|
341
|
+
|
|
342
|
+
return _PATTERN_LINK.sub(link_repl, text)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _convert_inline_strikethrough(text: str) -> str:
|
|
346
|
+
"""Convert inline strikethrough: ~~text~~ -> [strike]text[/strike]."""
|
|
347
|
+
return _PATTERN_STRIKETHROUGH.sub(r"[strike]\1[/strike]", text)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _render_inline_code_spans(text: str, code_spans: list[str]) -> str:
|
|
351
|
+
"""Render inline code spans: @@CODE{index}@@ -> [code]code[/code]."""
|
|
352
|
+
|
|
353
|
+
def render_repl(match: re.Match) -> str:
|
|
354
|
+
idx = int(match.group(1))
|
|
355
|
+
return f"[code]{code_spans[idx]}[/code]"
|
|
356
|
+
|
|
357
|
+
return re.sub(r"@@CODE(\d+)@@", render_repl, text)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: steamify
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: A lightweight package for converting Markdown into Steam-compatible markup, and back again.
|
|
5
|
+
Project-URL: Homepage, https://github.com/pivoshenko/steamify
|
|
6
|
+
Project-URL: Repository, https://github.com/pivoshenko/steamify
|
|
7
|
+
Author-email: Volodymyr Pivoshenko <contact@pivoshenko.dev>
|
|
8
|
+
Maintainer-email: Volodymyr Pivoshenko <contact@pivoshenko.dev>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: bbcode,converter,formatter,gaming,markdown,markup,parser,steam,steam-bbcode,steam-bbcode-converter,steam-workshop,steamify,text-processing
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Environment :: Other Environment
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Information Technology
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Natural Language :: English
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering
|
|
26
|
+
Classifier: Topic :: Software Development
|
|
27
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
28
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Steamify
|
|
33
|
+
|
|
34
|
+
<p align="left">
|
|
35
|
+
<a href="https://pypi.org/project/steamify">
|
|
36
|
+
<img alt="Python" src="https://img.shields.io/pypi/pyversions/steamify?style=flat-square&logo=python&logoColor=white&color=4856CD&label=Python">
|
|
37
|
+
</a>
|
|
38
|
+
<a href="https://pypi.org/project/steamify">
|
|
39
|
+
<img alt="PyPI" src="https://img.shields.io/pypi/v/steamify?style=flat-square&logo=pypi&logoColor=white&color=4856CD&label=PyPI">
|
|
40
|
+
</a>
|
|
41
|
+
<a href="https://github.com/pivoshenko/steamify/actions/workflows/ci.yaml">
|
|
42
|
+
<img alt="CI" src="https://img.shields.io/github/actions/workflow/status/pivoshenko/steamify/ci.yaml?label=CI&style=flat-square&logo=githubactions&logoColor=white&color=0A6847">
|
|
43
|
+
</a>
|
|
44
|
+
<a href="https://docs.astral.sh/ruff">
|
|
45
|
+
<img alt="Ruff" src="https://img.shields.io/badge/Style-ruff-black.svg?style=flat-square&logo=ruff&logoColor=white&color=D7FF64">
|
|
46
|
+
</a>
|
|
47
|
+
<a href="https://stand-with-ukraine.pp.ua">
|
|
48
|
+
<img alt="StandWithUkraine" src="https://img.shields.io/badge/Support-Ukraine-FFC93C?style=flat-square&labelColor=07689F">
|
|
49
|
+
</a>
|
|
50
|
+
</p>
|
|
51
|
+
|
|
52
|
+
## Overview
|
|
53
|
+
|
|
54
|
+
A lightweight package for converting Markdown into Steam-compatible markup, and back again.
|
|
55
|
+
|
|
56
|
+
### About the Name
|
|
57
|
+
|
|
58
|
+
*Steamify* is what the package does: it takes Markdown and makes it Steam-flavoured, and it reads Steam markup back into Markdown. It was extracted from [Ihroteka](https://ihroteka.com), a personal game-library app, and released standalone.
|
|
59
|
+
|
|
60
|
+
## Features
|
|
61
|
+
|
|
62
|
+
Zero runtime dependencies, and conversion in both directions.
|
|
63
|
+
|
|
64
|
+
### Markdown -> Steam
|
|
65
|
+
|
|
66
|
+
- Headings (`#` to `######`) -> `[h1]` to `[h3]`, since Steam caps at three levels
|
|
67
|
+
- Bold, italic, strikethrough, inline code
|
|
68
|
+
- Links and images -> `[url]` / `[img]`
|
|
69
|
+
- Ordered and unordered lists (nested)
|
|
70
|
+
- Blockquotes (nested)
|
|
71
|
+
- Fenced code blocks -> `[code]`
|
|
72
|
+
- Horizontal rules -> `[hr][/hr]`
|
|
73
|
+
|
|
74
|
+
### Steam -> Markdown
|
|
75
|
+
|
|
76
|
+
- `[h1]` to `[h6]` -> `#` to `######`
|
|
77
|
+
- `[b]`, `[i]`, `[strike]`, `[code]` -> `**`, `*`, `~~`, backticks
|
|
78
|
+
- `[url]` / `[img]` -> Markdown links and images
|
|
79
|
+
- `[list]` / `[olist]` (nested) -> `-` / `1.`
|
|
80
|
+
- `[quote]` (nested) -> `>`
|
|
81
|
+
- `[hr][/hr]` -> `---`
|
|
82
|
+
- Steam-only tags such as `[spoiler]`, `[noparse]`, `[table]`, and `[u]` have no Markdown
|
|
83
|
+
equivalent, so they pass through verbatim rather than being dropped
|
|
84
|
+
|
|
85
|
+
### Round-tripping
|
|
86
|
+
|
|
87
|
+
`to_steam(to_markdown(steam))` returns the original markup, so a Steam -> Markdown -> Steam cycle
|
|
88
|
+
is stable. The Markdown side is normalized rather than preserved byte for byte:
|
|
89
|
+
|
|
90
|
+
- `####` and deeper collapse into `[h3]` on the way out, and come back as `###`
|
|
91
|
+
- Image alt text is not carried by `[img]`, so it returns empty
|
|
92
|
+
- A single-line fenced block returns as an inline code span
|
|
93
|
+
|
|
94
|
+
## Installation
|
|
95
|
+
|
|
96
|
+
Install with pip or uv:
|
|
97
|
+
|
|
98
|
+
```shell
|
|
99
|
+
pip install -U steamify
|
|
100
|
+
|
|
101
|
+
uv add steamify
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Usage
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from steamify import to_steam
|
|
108
|
+
|
|
109
|
+
md_text = """
|
|
110
|
+
# My Game Guide
|
|
111
|
+
|
|
112
|
+
Welcome to the **best** game ever!
|
|
113
|
+
|
|
114
|
+
## Features
|
|
115
|
+
|
|
116
|
+
- Easy to learn
|
|
117
|
+
- *Beautiful* graphics
|
|
118
|
+
- ~~Microtransactions~~ Free to play!
|
|
119
|
+
|
|
120
|
+
Check out the [wiki](https://example.com) for tips.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
steam_text = to_steam(md_text)
|
|
124
|
+
print(steam_text)
|
|
125
|
+
|
|
126
|
+
# [h1]My Game Guide[/h1]
|
|
127
|
+
|
|
128
|
+
# Welcome to the [b]best[/b] game ever!
|
|
129
|
+
|
|
130
|
+
# [h2]Features[/h2]
|
|
131
|
+
|
|
132
|
+
# [list]
|
|
133
|
+
# [*] Easy to learn
|
|
134
|
+
# [*] [i]Beautiful[/i] graphics
|
|
135
|
+
# [*] [strike]Microtransactions[/strike] Free to play!
|
|
136
|
+
# [/list]
|
|
137
|
+
|
|
138
|
+
# Check out the [url=https://example.com]wiki[/url] for tips.
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Converting the other way:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from steamify import to_markdown
|
|
145
|
+
|
|
146
|
+
steam_text = """
|
|
147
|
+
[h1]Patch Notes[/h1]
|
|
148
|
+
|
|
149
|
+
[list]
|
|
150
|
+
[*] Fixed [b]crash[/b] on startup
|
|
151
|
+
[*] [spoiler]Secret boss[/spoiler] added
|
|
152
|
+
[/list]
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
md_text = to_markdown(steam_text)
|
|
156
|
+
print(md_text)
|
|
157
|
+
|
|
158
|
+
# # Patch Notes
|
|
159
|
+
|
|
160
|
+
# - Fixed **crash** on startup
|
|
161
|
+
# - [spoiler]Secret boss[/spoiler] added
|
|
162
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
steamify/__init__.py,sha256=Y-6ETp_g2JW9EGiP12QD-IU832iXNMUZPwU_Q5pnX2w,327
|
|
2
|
+
steamify/markdown.py,sha256=7BvOP0FJ2Fvqo5YhVVfzzgWNzyTi22K-jvLVjstOad4,8522
|
|
3
|
+
steamify/steam.py,sha256=X0mw3uEFVq6lAUoBhhLLC0hleZ9sOmorFCP63tvLWDo,11368
|
|
4
|
+
steamify-2.0.0.dist-info/METADATA,sha256=L1UdCAYyyJPyZyX-an18ROFIInglLLp5j6--asMySBo,5211
|
|
5
|
+
steamify-2.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
steamify-2.0.0.dist-info/licenses/LICENSE,sha256=R36dvI8PbhpvRn28yiVrSJLd3pppTkcbjtqvMmbOzkU,1077
|
|
7
|
+
steamify-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Volodymyr Pivoshenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|