Skip to content

Commit f57fda0

Browse files
committed
chore: restructure as installable PyPI package v1.0.0
- Move logic into src/md2html_tailwind4/ (converter.py, cli.py, __init__.py) - Add pyproject.toml with hatchling build backend and CLI entry point - Update README with installation and usage instructions - Add CHANGELOG.md
1 parent da330f9 commit f57fda0

9 files changed

Lines changed: 400 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,4 @@ cython_debug/
205205
marimo/_static/
206206
marimo/_lsp/
207207
__marimo__/
208+
markdown2html_tailwind.py

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.11

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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

README.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,47 @@
1-
# md2html-tailwind4
1+
# md2html-tailwind4
2+
3+
A Python package that converts Markdown to clean, styled HTML using **Tailwind CSS 4** classes.
4+
5+
Designed for projects that render HTML inside a Tailwind-powered frontend (Django, FastAPI, static sites, etc.). No build step required — just pass in Markdown, get back ready-to-render HTML.
6+
7+
## Features
8+
9+
- Converts Markdown to HTML with full Tailwind CSS 4 class annotations
10+
- Responsive tables with overflow scrolling
11+
- Audio table support (3-column tables with media URLs become interactive audio players)
12+
- Automatically escapes Django template delimiters (`{{ }}`, `{% %}`) inside code blocks
13+
- External links get `target="_blank"` and `rel="noopener noreferrer"` automatically
14+
- Dark mode support via Tailwind's `dark:` variants
15+
- CLI tool included
16+
17+
## Installation
18+
19+
```bash
20+
pip install md2html-tailwind4
21+
```
22+
23+
## Usage
24+
25+
### Python API
26+
27+
```python
28+
from md2html_tailwind4 import Converter
29+
30+
converter = Converter()
31+
html = converter.convert_md_to_html("# Hello\n\nThis is **Markdown**.")
32+
print(html)
33+
```
34+
35+
### Command Line
36+
37+
```bash
38+
md2html input.md output.html
39+
```
40+
41+
## Requirements
42+
43+
- Python >= 3.11
44+
45+
## License
46+
47+
MIT — White Neuron Co., Ltd.

pyproject.toml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "md2html-tailwind4"
7+
version = "1.0.0"
8+
description = "A Python package that converts Markdown to HTML using Tailwind CSS 4 for styling."
9+
authors = [
10+
{ name = "White Neuron Co., Ltd.", email = "contact@whiteneuron.com" },
11+
]
12+
license = { file = "LICENSE" }
13+
readme = "README.md"
14+
requires-python = ">=3.11"
15+
keywords = ["markdown", "html", "tailwind", "tailwindcss", "converter"]
16+
classifiers = [
17+
"Development Status :: 5 - Production/Stable",
18+
"Intended Audience :: Developers",
19+
"License :: OSI Approved :: MIT License",
20+
"Programming Language :: Python :: 3",
21+
"Programming Language :: Python :: 3.11",
22+
"Programming Language :: Python :: 3.12",
23+
"Programming Language :: Python :: 3.13",
24+
"Topic :: Text Processing :: Markup :: HTML",
25+
"Topic :: Text Processing :: Markup :: Markdown",
26+
]
27+
dependencies = [
28+
"beautifulsoup4>=4.14.3",
29+
"markdown>=3.10.2",
30+
]
31+
32+
[project.scripts]
33+
md2html = "md2html_tailwind4.cli:main"
34+
35+
[tool.hatch.build.targets.wheel]
36+
packages = ["src/md2html_tailwind4"]

src/md2html_tailwind4/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .converter import Converter
2+
3+
__all__ = ["Converter"]

src/md2html_tailwind4/cli.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import argparse
2+
3+
from .converter import Converter
4+
5+
6+
def main():
7+
parser = argparse.ArgumentParser(
8+
prog='md2html',
9+
description='Convert Markdown to HTML with Tailwind CSS 4 classes.',
10+
)
11+
parser.add_argument('input', help='Path to the input Markdown file')
12+
parser.add_argument('output', help='Path to the output HTML file')
13+
args = parser.parse_args()
14+
15+
with open(args.input, 'r', encoding='utf-8') as f:
16+
markdown_content = f.read()
17+
18+
converter = Converter()
19+
html_content = converter.convert_md_to_html(markdown_content)
20+
21+
with open(args.output, 'w', encoding='utf-8') as f:
22+
f.write(html_content)
23+
24+
25+
if __name__ == '__main__':
26+
main()

src/md2html_tailwind4/converter.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import markdown
2+
import re
3+
from bs4 import BeautifulSoup
4+
5+
6+
class Converter:
7+
def __init__(self):
8+
pass
9+
10+
def convert_md_to_html(self, markdown_content):
11+
# Convert Markdown to HTML with richer extensions for broader content support.
12+
html_content = markdown.markdown(
13+
markdown_content,
14+
extensions=['tables', 'attr_list', 'fenced_code', 'sane_lists', 'nl2br'],
15+
)
16+
17+
# Parse the HTML content and convert tables to custom divs
18+
soup = BeautifulSoup(html_content, 'html.parser')
19+
20+
# Add Tailwind classes to various elements
21+
self.add_tailwind_classes(soup)
22+
23+
# Convert tables to custom divs
24+
self.convert_tables(soup)
25+
26+
# Escape Django template delimiters in code examples so they render literally.
27+
self.escape_django_template_syntax_in_code_blocks(soup)
28+
29+
# Create the full HTML content with JavaScript for play/pause toggle
30+
full_html_content = self.create_full_html(soup)
31+
32+
return full_html_content
33+
34+
def add_tailwind_classes(self, soup):
35+
for h1 in soup.find_all('h1'):
36+
h1['class'] = 'text-2xl sm:text-3xl lg:text-4xl font-bold tracking-tight mb-4 sm:mb-5 text-neutral-900 dark:text-neutral-100'
37+
38+
for h2 in soup.find_all('h2'):
39+
h2['class'] = 'text-xl sm:text-2xl lg:text-3xl font-bold tracking-tight mt-8 mb-3 text-neutral-900 dark:text-neutral-100'
40+
41+
for h3 in soup.find_all('h3'):
42+
h3['class'] = 'text-lg sm:text-xl lg:text-2xl font-semibold mt-6 mb-3 text-neutral-900 dark:text-neutral-100'
43+
44+
for h4 in soup.find_all('h4'):
45+
h4['class'] = 'text-base sm:text-lg font-semibold mt-5 mb-2 text-neutral-800 dark:text-neutral-200'
46+
47+
for h5 in soup.find_all('h5'):
48+
h5['class'] = 'text-base font-semibold mt-4 mb-2 text-neutral-800 dark:text-neutral-200'
49+
50+
for h6 in soup.find_all('h6'):
51+
h6['class'] = 'text-sm font-semibold uppercase tracking-wide mt-4 mb-2 text-neutral-700 dark:text-neutral-300'
52+
53+
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'
55+
56+
for ul in soup.find_all('ul'):
57+
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'
58+
59+
for ol in soup.find_all('ol'):
60+
ol['class'] = 'list-decimal list-outside pl-6 mb-4 space-y-1 text-base leading-7 text-justify text-neutral-800 dark:text-neutral-200'
61+
62+
for li in soup.find_all('li'):
63+
li['class'] = 'leading-7'
64+
65+
for blockquote in soup.find_all('blockquote'):
66+
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+
68+
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'
70+
71+
for pre in soup.find_all('pre'):
72+
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'
73+
74+
for code in soup.find_all('code'):
75+
if code.parent and code.parent.name == 'pre':
76+
code['class'] = 'bg-transparent p-0 text-inherit font-mono'
77+
else:
78+
code['class'] = 'px-1.5 py-0.5 rounded-md bg-neutral-100 text-[0.9em] font-mono text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'
79+
80+
for strong in soup.find_all('strong'):
81+
strong['class'] = 'font-semibold text-neutral-900 dark:text-neutral-100'
82+
83+
for em in soup.find_all('em'):
84+
em['class'] = 'italic text-neutral-700 dark:text-neutral-300'
85+
86+
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'
88+
if a.get('href', '').startswith(('http://', 'https://')):
89+
a['target'] = '_blank'
90+
a['rel'] = 'noopener noreferrer'
91+
92+
for hr in soup.find_all('hr'):
93+
hr['class'] = 'my-8 border-neutral-300 dark:border-neutral-700'
94+
95+
def convert_tables(self, soup):
96+
for table in soup.find_all('table'):
97+
rows = table.find_all('tr')
98+
if len(rows) == 0:
99+
continue
100+
101+
# Check if the first row is a header
102+
first_row_cells = rows[0].find_all(['th', 'td'])
103+
has_headers = len(first_row_cells) == 3 and all(th.name == 'th' for th in first_row_cells)
104+
105+
# Only convert dedicated audio tables; keep all other tables semantic/responsive.
106+
if not has_headers or not self._is_audio_table(rows):
107+
self._style_as_responsive_table(soup, table)
108+
continue
109+
110+
start_index = 1 if has_headers else 0
111+
divs = []
112+
113+
for row in rows[start_index:]:
114+
cells = row.find_all(['td', 'th'])
115+
if len(cells) >= 2: # Ensure there are at least two cells per row
116+
div = soup.new_tag('div', **{'class': 'my-3 rounded-xl border border-neutral-200 bg-white p-3 sm:p-4 shadow-sm flex flex-col sm:flex-row sm:items-center sm:justify-center gap-2 sm:gap-3 dark:border-neutral-700 dark:bg-neutral-900 dark:shadow-neutral-950/30'})
117+
span1 = soup.new_tag('span', **{'class': 'font-semibold rounded-lg bg-neutral-800 px-3 py-2 text-white text-sm sm:text-base dark:bg-neutral-700'})
118+
span1.string = cells[0].get_text()
119+
span2 = soup.new_tag('span', **{'class': 'font-semibold rounded-lg bg-neutral-700 px-3 py-2 text-white text-sm sm:text-base break-words dark:bg-neutral-600'})
120+
span2_content = cells[1].get_text()
121+
span2.append(soup.new_string(span2_content))
122+
if len(cells) == 3 and cells[2].get_text().strip():
123+
audio_button = soup.new_tag('button', **{'class': 'audio-button ml-2', 'data-state': 'play'})
124+
audio_button.string = '▶️'
125+
audio_url = cells[2].get_text().strip()
126+
audio_id = span2_content.lower().replace(' ', '_').replace('[', '').replace(']', '')
127+
audio = soup.new_tag('audio', **{'src': audio_url, 'class': 'hidden', 'controls': ''})
128+
audio_button['onclick'] = f"togglePlayPause('{audio_id}', this);"
129+
audio['id'] = audio_id
130+
span2.append(audio_button)
131+
span2.append(audio)
132+
div.append(span1)
133+
div.append(span2)
134+
divs.append(div)
135+
if divs:
136+
table.replace_with(*divs)
137+
else:
138+
table.decompose()
139+
140+
@staticmethod
141+
def _is_audio_table(rows):
142+
# Audio-table mode is only valid when the 3rd column consistently contains media URLs.
143+
media_suffixes = ('.mp3', '.wav', '.ogg', '.m4a', '.aac', '.webm')
144+
data_rows = rows[1:] if len(rows) > 1 else []
145+
if not data_rows:
146+
return False
147+
148+
third_values = []
149+
for row in data_rows:
150+
cells = row.find_all(['td', 'th'])
151+
if len(cells) < 3:
152+
return False
153+
third_values.append(cells[2].get_text().strip().lower())
154+
155+
if not all(third_values):
156+
return False
157+
158+
return all(value.startswith(('http://', 'https://', '/')) or value.endswith(media_suffixes) for value in third_values)
159+
160+
def _style_as_responsive_table(self, soup, table):
161+
table['class'] = 'min-w-full border-collapse text-sm sm:text-base'
162+
table['style'] = 'color: var(--color-base-content); border-color: var(--color-base-300);'
163+
164+
thead = table.find('thead')
165+
if thead:
166+
thead['class'] = ''
167+
thead['style'] = 'background-color: var(--color-base-300); color: var(--color-base-content);'
168+
for th in table.find_all('th'):
169+
th['class'] = 'border px-3 py-2 text-left font-semibold whitespace-nowrap'
170+
th['style'] = 'border-color: var(--color-base-300); color: var(--color-base-content);'
171+
172+
for td in table.find_all('td'):
173+
td['class'] = 'border px-3 py-2 align-top'
174+
td['style'] = 'border-color: var(--color-base-300); color: var(--color-base-content);'
175+
176+
wrapper = soup.new_tag(
177+
'div',
178+
**{
179+
'class': 'my-5 overflow-x-auto rounded-xl border shadow-sm',
180+
'style': 'border-color: var(--color-base-300); background-color: var(--color-base-100); color: var(--color-base-content);',
181+
},
182+
)
183+
table.wrap(wrapper)
184+
185+
def create_full_html(self, soup):
186+
return str(soup)
187+
188+
def escape_django_template_syntax_in_code_blocks(self, soup):
189+
for tag in soup.find_all(['code', 'pre']):
190+
# Keep code samples readable while preventing Django template parsing.
191+
if tag.string is not None:
192+
tag.string.replace_with(self._escape_django_delimiters(str(tag.string)))
193+
continue
194+
195+
for text_node in tag.find_all(string=True):
196+
text_node.replace_with(self._escape_django_delimiters(str(text_node)))
197+
198+
@staticmethod
199+
def _escape_django_delimiters(text):
200+
token_map = {
201+
'{{': '{% templatetag openvariable %}',
202+
'}}': '{% templatetag closevariable %}',
203+
'{%': '{% templatetag openblock %}',
204+
'%}': '{% templatetag closeblock %}',
205+
'{#': '{% templatetag opencomment %}',
206+
'#}': '{% templatetag closecomment %}',
207+
}
208+
return re.sub(r'\{\{|\}\}|\{%|%\}|\{#|#\}', lambda m: token_map[m.group(0)], text)

0 commit comments

Comments
 (0)