|
| 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