Skip to content

Commit db3df5a

Browse files
committed
release: v1.1.0
- Add _inject_markdown_attr preprocessor to fix md_in_html extension - Fix double-escaping of Django delimiters in pre>code blocks - Sanitize audio_id to prevent XSS in inline onclick handler - Validate audio_url against allowlist (http/https//) to block javascript: scheme - Align _is_audio_table URL allowlist with convert_tables to fix silent row loss - Badge images: inline sizing instead of full-width block classes - Badge links: skip text/underline classes when sole child is img - Remove dead media_suffixes variable from _is_audio_table
1 parent 8246dac commit db3df5a

3 files changed

Lines changed: 49 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.0] - 2026-04-17
9+
10+
### Fixed
11+
12+
- `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
13+
- Double-escaping of Django template delimiters in `<pre><code>` blocks; `<code>` inside `<pre>` is now skipped
14+
- XSS: `audio_id` now sanitized with `re.sub(r'[^a-z0-9_]', '_', ...)` to strip all non-safe characters from inline JS
15+
- XSS: `audio_url` validated to only accept `http://`, `https://`, or `/`-prefixed paths; `javascript:` and other schemes are rejected
16+
- URL allowlist mismatch between `_is_audio_table` and `convert_tables`; both now require the same prefixes, eliminating silent row loss
17+
- Badge images (inside `<a>`) no longer inherit the full-width block image classes; they receive `inline h-5 align-middle` instead
18+
- Badge links (sole child is `<img>`) no longer receive text/underline Tailwind classes
19+
- Removed dead `media_suffixes` variable from `_is_audio_table`
20+
821
## [1.0.0] - 2026-04-15
922

1023
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "md2html-tailwind4"
7-
version = "1.0.0"
7+
version = "1.1.0"
88
description = "A Python package that converts Markdown to HTML using Tailwind CSS 4 for styling."
99
authors = [
1010
{ name = "White Neuron Co., Ltd.", email = "contact@whiteneuron.com" },

src/md2html_tailwind4/converter.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ def __init__(self):
88
pass
99

1010
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)
1113
# Convert Markdown to HTML with richer extensions for broader content support.
1214
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'],
1517
)
1618

1719
# Parse the HTML content and convert tables to custom divs
@@ -66,7 +68,10 @@ def add_tailwind_classes(self, soup):
6668
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'
6769

6870
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'
71+
if img.parent and img.parent.name == 'a':
72+
img['class'] = 'inline h-5 align-middle'
73+
else:
74+
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'
7075

7176
for pre in soup.find_all('pre'):
7277
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 +89,9 @@ def add_tailwind_classes(self, soup):
8489
em['class'] = 'italic text-neutral-700 dark:text-neutral-300'
8590

8691
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'
92+
children = [c for c in a.children if getattr(c, 'name', None) or str(c).strip()]
93+
if not (len(children) == 1 and getattr(children[0], 'name', None) == 'img'):
94+
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'
8895
if a.get('href', '').startswith(('http://', 'https://')):
8996
a['target'] = '_blank'
9097
a['rel'] = 'noopener noreferrer'
@@ -123,7 +130,9 @@ def convert_tables(self, soup):
123130
audio_button = soup.new_tag('button', **{'class': 'audio-button ml-2', 'data-state': 'play'})
124131
audio_button.string = '▶️'
125132
audio_url = cells[2].get_text().strip()
126-
audio_id = span2_content.lower().replace(' ', '_').replace('[', '').replace(']', '')
133+
if not audio_url.startswith(('http://', 'https://', '/')):
134+
continue
135+
audio_id = re.sub(r'[^a-z0-9_]', '_', span2_content.lower())
127136
audio = soup.new_tag('audio', **{'src': audio_url, 'class': 'hidden', 'controls': ''})
128137
audio_button['onclick'] = f"togglePlayPause('{audio_id}', this);"
129138
audio['id'] = audio_id
@@ -140,7 +149,6 @@ def convert_tables(self, soup):
140149
@staticmethod
141150
def _is_audio_table(rows):
142151
# Audio-table mode is only valid when the 3rd column consistently contains media URLs.
143-
media_suffixes = ('.mp3', '.wav', '.ogg', '.m4a', '.aac', '.webm')
144152
data_rows = rows[1:] if len(rows) > 1 else []
145153
if not data_rows:
146154
return False
@@ -155,7 +163,7 @@ def _is_audio_table(rows):
155163
if not all(third_values):
156164
return False
157165

158-
return all(value.startswith(('http://', 'https://', '/')) or value.endswith(media_suffixes) for value in third_values)
166+
return all(value.startswith(('http://', 'https://', '/')) for value in third_values)
159167

160168
def _style_as_responsive_table(self, soup, table):
161169
table['class'] = 'min-w-full border-collapse text-sm sm:text-base'
@@ -185,8 +193,28 @@ def _style_as_responsive_table(self, soup, table):
185193
def create_full_html(self, soup):
186194
return str(soup)
187195

196+
@staticmethod
197+
def _inject_markdown_attr(text):
198+
"""Inject markdown="1" into HTML block tags so md_in_html processes inner content."""
199+
block_tags = r'div|section|article|header|footer|aside|main|nav|figure|figcaption|details|summary'
200+
201+
def inject(m):
202+
if 'markdown=' in m.group(0).lower():
203+
return m.group(0)
204+
return m.group(0)[:-1] + ' markdown="1">'
205+
206+
return re.sub(
207+
r'<(?:' + block_tags + r')(?:\s[^>]*)?(?<!/)>',
208+
inject,
209+
text,
210+
flags=re.IGNORECASE,
211+
)
212+
188213
def escape_django_template_syntax_in_code_blocks(self, soup):
189214
for tag in soup.find_all(['code', 'pre']):
215+
# Skip <code> inside <pre> — the <pre> pass already covers that text.
216+
if tag.name == 'code' and tag.parent and tag.parent.name == 'pre':
217+
continue
190218
# Keep code samples readable while preventing Django template parsing.
191219
if tag.string is not None:
192220
tag.string.replace_with(self._escape_django_delimiters(str(tag.string)))

0 commit comments

Comments
 (0)