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 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] -> ![](URL)."""
253
+ return _PATTERN_IMAGE.sub(r"![](\1)", 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: ![alt](URL) -> [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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.