md2html-tailwind4 1.0.0__tar.gz → 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- md2html_tailwind4-1.2.0/CHANGELOG.md +41 -0
- {md2html_tailwind4-1.0.0 → md2html_tailwind4-1.2.0}/PKG-INFO +1 -1
- {md2html_tailwind4-1.0.0 → md2html_tailwind4-1.2.0}/pyproject.toml +1 -1
- {md2html_tailwind4-1.0.0 → md2html_tailwind4-1.2.0}/src/md2html_tailwind4/converter.py +66 -8
- md2html_tailwind4-1.0.0/CHANGELOG.md +0 -21
- {md2html_tailwind4-1.0.0 → md2html_tailwind4-1.2.0}/.github/workflows/python-publish.yml +0 -0
- {md2html_tailwind4-1.0.0 → md2html_tailwind4-1.2.0}/.gitignore +0 -0
- {md2html_tailwind4-1.0.0 → md2html_tailwind4-1.2.0}/.python-version +0 -0
- {md2html_tailwind4-1.0.0 → md2html_tailwind4-1.2.0}/LICENSE +0 -0
- {md2html_tailwind4-1.0.0 → md2html_tailwind4-1.2.0}/README.md +0 -0
- {md2html_tailwind4-1.0.0 → md2html_tailwind4-1.2.0}/src/md2html_tailwind4/__init__.py +0 -0
- {md2html_tailwind4-1.0.0 → md2html_tailwind4-1.2.0}/src/md2html_tailwind4/cli.py +0 -0
- {md2html_tailwind4-1.0.0 → md2html_tailwind4-1.2.0}/uv.lock +0 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.2.0] - 2026-04-17
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Badge paragraphs now render inline with `flex flex-wrap gap-2 items-center justify-center` instead of stacked with full line-height spacing
|
|
13
|
+
- Deprecated `align="center/left/right"` HTML attributes are now translated to Tailwind `text-center/left/right` classes and removed from output
|
|
14
|
+
|
|
15
|
+
## [1.1.0] - 2026-04-17
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- `md_in_html` extension was a no-op; added `_inject_markdown_attr` preprocessor to inject `markdown="1"` into block-level HTML tags so inner markdown (bold, links, badges) renders correctly
|
|
20
|
+
- Double-escaping of Django template delimiters in `<pre><code>` blocks; `<code>` inside `<pre>` is now skipped
|
|
21
|
+
- XSS: `audio_id` now sanitized with `re.sub(r'[^a-z0-9_]', '_', ...)` to strip all non-safe characters from inline JS
|
|
22
|
+
- XSS: `audio_url` validated to only accept `http://`, `https://`, or `/`-prefixed paths; `javascript:` and other schemes are rejected
|
|
23
|
+
- URL allowlist mismatch between `_is_audio_table` and `convert_tables`; both now require the same prefixes, eliminating silent row loss
|
|
24
|
+
- Badge images (inside `<a>`) no longer inherit the full-width block image classes; they receive `inline h-5 align-middle` instead
|
|
25
|
+
- Badge links (sole child is `<img>`) no longer receive text/underline Tailwind classes
|
|
26
|
+
- Removed dead `media_suffixes` variable from `_is_audio_table`
|
|
27
|
+
|
|
28
|
+
## [1.0.0] - 2026-04-15
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- Initial release of `md2html-tailwind4`
|
|
33
|
+
- `Converter` class with `convert_md_to_html()` method
|
|
34
|
+
- Tailwind CSS 4 class annotations for all standard HTML elements (h1–h6, p, ul, ol, li, blockquote, img, pre, code, strong, em, a, hr)
|
|
35
|
+
- Responsive table styling with overflow scrolling
|
|
36
|
+
- Audio table support: 3-column tables with media URLs are converted to interactive audio player widgets
|
|
37
|
+
- Django template delimiter escaping (`{{ }}`, `{% %}`, `{# #}`) inside code blocks
|
|
38
|
+
- External links automatically get `target="_blank"` and `rel="noopener noreferrer"`
|
|
39
|
+
- Dark mode support via Tailwind `dark:` variants
|
|
40
|
+
- CLI entry point: `md2html input.md output.html`
|
|
41
|
+
- `src/` layout for clean PyPI packaging with `hatchling` build backend
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "md2html-tailwind4"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "A Python package that converts Markdown to HTML using Tailwind CSS 4 for styling."
|
|
9
9
|
authors = [
|
|
10
10
|
{ name = "White Neuron Co., Ltd.", email = "contact@whiteneuron.com" },
|
|
@@ -8,10 +8,12 @@ class Converter:
|
|
|
8
8
|
pass
|
|
9
9
|
|
|
10
10
|
def convert_md_to_html(self, markdown_content):
|
|
11
|
+
# Inject markdown="1" so md_in_html processes content inside HTML blocks.
|
|
12
|
+
preprocessed = self._inject_markdown_attr(markdown_content)
|
|
11
13
|
# Convert Markdown to HTML with richer extensions for broader content support.
|
|
12
14
|
html_content = markdown.markdown(
|
|
13
|
-
|
|
14
|
-
extensions=['tables', 'attr_list', 'fenced_code', 'sane_lists', 'nl2br'],
|
|
15
|
+
preprocessed,
|
|
16
|
+
extensions=['tables', 'attr_list', 'fenced_code', 'sane_lists', 'nl2br', 'md_in_html'],
|
|
15
17
|
)
|
|
16
18
|
|
|
17
19
|
# Parse the HTML content and convert tables to custom divs
|
|
@@ -51,7 +53,10 @@ class Converter:
|
|
|
51
53
|
h6['class'] = 'text-sm font-semibold uppercase tracking-wide mt-4 mb-2 text-neutral-700 dark:text-neutral-300'
|
|
52
54
|
|
|
53
55
|
for p in soup.find_all('p'):
|
|
54
|
-
p
|
|
56
|
+
if self._is_badge_paragraph(p):
|
|
57
|
+
p['class'] = 'flex flex-wrap gap-2 items-center justify-center mb-4'
|
|
58
|
+
else:
|
|
59
|
+
p['class'] = 'mb-4 text-base leading-7 text-pretty text-justify text-neutral-800 dark:text-neutral-200'
|
|
55
60
|
|
|
56
61
|
for ul in soup.find_all('ul'):
|
|
57
62
|
ul['class'] = 'list-disc list-outside pl-6 mb-4 space-y-1 text-base leading-7 text-justify text-neutral-800 dark:text-neutral-200'
|
|
@@ -66,7 +71,10 @@ class Converter:
|
|
|
66
71
|
blockquote['class'] = 'mb-5 p-4 sm:p-5 rounded-r-xl border-l-4 bg-neutral-100 text-neutral-700 border-neutral-400 italic text-base leading-7 quote dark:bg-neutral-900/60 dark:text-neutral-300 dark:border-neutral-600'
|
|
67
72
|
|
|
68
73
|
for img in soup.find_all('img'):
|
|
69
|
-
img
|
|
74
|
+
if img.parent and img.parent.name == 'a':
|
|
75
|
+
img['class'] = 'inline h-5 align-middle'
|
|
76
|
+
else:
|
|
77
|
+
img['class'] = 'mx-auto my-5 w-full max-w-4xl h-auto rounded-xl border border-neutral-200 shadow-sm dark:border-neutral-700 dark:shadow-neutral-950/30'
|
|
70
78
|
|
|
71
79
|
for pre in soup.find_all('pre'):
|
|
72
80
|
pre['class'] = 'my-5 overflow-x-auto rounded-xl border border-neutral-200 bg-neutral-950 p-4 text-sm leading-6 text-neutral-100 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100'
|
|
@@ -84,7 +92,9 @@ class Converter:
|
|
|
84
92
|
em['class'] = 'italic text-neutral-700 dark:text-neutral-300'
|
|
85
93
|
|
|
86
94
|
for a in soup.find_all('a'):
|
|
87
|
-
|
|
95
|
+
children = [c for c in a.children if getattr(c, 'name', None) or str(c).strip()]
|
|
96
|
+
if not (len(children) == 1 and getattr(children[0], 'name', None) == 'img'):
|
|
97
|
+
a['class'] = 'text-sky-700 underline decoration-sky-300 underline-offset-2 hover:text-sky-900 transition-colors dark:text-sky-300 dark:decoration-sky-600 dark:hover:text-sky-200'
|
|
88
98
|
if a.get('href', '').startswith(('http://', 'https://')):
|
|
89
99
|
a['target'] = '_blank'
|
|
90
100
|
a['rel'] = 'noopener noreferrer'
|
|
@@ -92,6 +102,17 @@ class Converter:
|
|
|
92
102
|
for hr in soup.find_all('hr'):
|
|
93
103
|
hr['class'] = 'my-8 border-neutral-300 dark:border-neutral-700'
|
|
94
104
|
|
|
105
|
+
align_map = {'center': 'text-center', 'right': 'text-right', 'left': 'text-left'}
|
|
106
|
+
for tag in soup.find_all(align=True):
|
|
107
|
+
align_val = tag.get('align', '').lower()
|
|
108
|
+
tailwind_class = align_map.get(align_val)
|
|
109
|
+
if tailwind_class:
|
|
110
|
+
existing = tag.get('class', '')
|
|
111
|
+
if isinstance(existing, list):
|
|
112
|
+
existing = ' '.join(existing)
|
|
113
|
+
tag['class'] = f'{existing} {tailwind_class}'.strip()
|
|
114
|
+
del tag['align']
|
|
115
|
+
|
|
95
116
|
def convert_tables(self, soup):
|
|
96
117
|
for table in soup.find_all('table'):
|
|
97
118
|
rows = table.find_all('tr')
|
|
@@ -123,7 +144,9 @@ class Converter:
|
|
|
123
144
|
audio_button = soup.new_tag('button', **{'class': 'audio-button ml-2', 'data-state': 'play'})
|
|
124
145
|
audio_button.string = '▶️'
|
|
125
146
|
audio_url = cells[2].get_text().strip()
|
|
126
|
-
|
|
147
|
+
if not audio_url.startswith(('http://', 'https://', '/')):
|
|
148
|
+
continue
|
|
149
|
+
audio_id = re.sub(r'[^a-z0-9_]', '_', span2_content.lower())
|
|
127
150
|
audio = soup.new_tag('audio', **{'src': audio_url, 'class': 'hidden', 'controls': ''})
|
|
128
151
|
audio_button['onclick'] = f"togglePlayPause('{audio_id}', this);"
|
|
129
152
|
audio['id'] = audio_id
|
|
@@ -140,7 +163,6 @@ class Converter:
|
|
|
140
163
|
@staticmethod
|
|
141
164
|
def _is_audio_table(rows):
|
|
142
165
|
# Audio-table mode is only valid when the 3rd column consistently contains media URLs.
|
|
143
|
-
media_suffixes = ('.mp3', '.wav', '.ogg', '.m4a', '.aac', '.webm')
|
|
144
166
|
data_rows = rows[1:] if len(rows) > 1 else []
|
|
145
167
|
if not data_rows:
|
|
146
168
|
return False
|
|
@@ -155,7 +177,7 @@ class Converter:
|
|
|
155
177
|
if not all(third_values):
|
|
156
178
|
return False
|
|
157
179
|
|
|
158
|
-
return all(value.startswith(('http://', 'https://', '/'))
|
|
180
|
+
return all(value.startswith(('http://', 'https://', '/')) for value in third_values)
|
|
159
181
|
|
|
160
182
|
def _style_as_responsive_table(self, soup, table):
|
|
161
183
|
table['class'] = 'min-w-full border-collapse text-sm sm:text-base'
|
|
@@ -185,8 +207,44 @@ class Converter:
|
|
|
185
207
|
def create_full_html(self, soup):
|
|
186
208
|
return str(soup)
|
|
187
209
|
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _is_badge_paragraph(p):
|
|
212
|
+
"""Return True if all meaningful children of a <p> are badge links (<a><img>) or <br> tags."""
|
|
213
|
+
for child in p.children:
|
|
214
|
+
name = getattr(child, 'name', None)
|
|
215
|
+
if name == 'br':
|
|
216
|
+
continue
|
|
217
|
+
if name == 'a':
|
|
218
|
+
a_children = [c for c in child.children if getattr(c, 'name', None) or str(c).strip()]
|
|
219
|
+
if len(a_children) == 1 and getattr(a_children[0], 'name', None) == 'img':
|
|
220
|
+
continue
|
|
221
|
+
if not name and not str(child).strip():
|
|
222
|
+
continue
|
|
223
|
+
return False
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _inject_markdown_attr(text):
|
|
228
|
+
"""Inject markdown="1" into HTML block tags so md_in_html processes inner content."""
|
|
229
|
+
block_tags = r'div|section|article|header|footer|aside|main|nav|figure|figcaption|details|summary'
|
|
230
|
+
|
|
231
|
+
def inject(m):
|
|
232
|
+
if 'markdown=' in m.group(0).lower():
|
|
233
|
+
return m.group(0)
|
|
234
|
+
return m.group(0)[:-1] + ' markdown="1">'
|
|
235
|
+
|
|
236
|
+
return re.sub(
|
|
237
|
+
r'<(?:' + block_tags + r')(?:\s[^>]*)?(?<!/)>',
|
|
238
|
+
inject,
|
|
239
|
+
text,
|
|
240
|
+
flags=re.IGNORECASE,
|
|
241
|
+
)
|
|
242
|
+
|
|
188
243
|
def escape_django_template_syntax_in_code_blocks(self, soup):
|
|
189
244
|
for tag in soup.find_all(['code', 'pre']):
|
|
245
|
+
# Skip <code> inside <pre> — the <pre> pass already covers that text.
|
|
246
|
+
if tag.name == 'code' and tag.parent and tag.parent.name == 'pre':
|
|
247
|
+
continue
|
|
190
248
|
# Keep code samples readable while preventing Django template parsing.
|
|
191
249
|
if tag.string is not None:
|
|
192
250
|
tag.string.replace_with(self._escape_django_delimiters(str(tag.string)))
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to this project will be documented in this file.
|
|
4
|
-
|
|
5
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
-
|
|
8
|
-
## [1.0.0] - 2026-04-15
|
|
9
|
-
|
|
10
|
-
### Added
|
|
11
|
-
|
|
12
|
-
- Initial release of `md2html-tailwind4`
|
|
13
|
-
- `Converter` class with `convert_md_to_html()` method
|
|
14
|
-
- Tailwind CSS 4 class annotations for all standard HTML elements (h1–h6, p, ul, ol, li, blockquote, img, pre, code, strong, em, a, hr)
|
|
15
|
-
- Responsive table styling with overflow scrolling
|
|
16
|
-
- Audio table support: 3-column tables with media URLs are converted to interactive audio player widgets
|
|
17
|
-
- Django template delimiter escaping (`{{ }}`, `{% %}`, `{# #}`) inside code blocks
|
|
18
|
-
- External links automatically get `target="_blank"` and `rel="noopener noreferrer"`
|
|
19
|
-
- Dark mode support via Tailwind `dark:` variants
|
|
20
|
-
- CLI entry point: `md2html input.md output.html`
|
|
21
|
-
- `src/` layout for clean PyPI packaging with `hatchling` build backend
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|