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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: md2html-tailwind4
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: A Python package that converts Markdown to HTML using Tailwind CSS 4 for styling.
5
5
  Author-email: "White Neuron Co., Ltd." <contact@whiteneuron.com>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "md2html-tailwind4"
7
- version = "1.0.0"
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
- markdown_content,
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['class'] = 'mb-4 text-base leading-7 text-pretty text-justify text-neutral-800 dark:text-neutral-200'
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['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'
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
- 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'
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
- audio_id = span2_content.lower().replace(' ', '_').replace('[', '').replace(']', '')
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://', '/')) or value.endswith(media_suffixes) for value in third_values)
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