#!/usr/bin/env python3 """Convert HotNow Savannah v2 markdown to Anita/DM Serif HTML.""" import re import html as html_mod def read_md(path): with open(path, 'r') as f: return f.read() def clean_text(text): """Strip markdown bold/italic but keep content, handle inline code.""" # Bold text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) # Italic text = re.sub(r'\*(.+?)\*', r'\1', text) # Inline code text = re.sub(r'`([^`]+)`', r'\1', text) # Links text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', text) return text def escape_for_html(text): """Escape HTML entities but preserve our strong/em/code/a tags.""" # Protect tags we want to keep text = text.replace('', '\x00S\x00') text = text.replace('', '\x00/S\x00') text = text.replace('', '\x00E\x00') text = text.replace('', '\x00/E\x00') text = text.replace('', '\x00C\x00') text = text.replace('', '\x00/C\x00') text = text.replace('', '\x00/A\x00') text = text.replace('">', '\x00Q\x00') # Escape text = html_mod.escape(text, quote=False) # Restore text = text.replace('\x00S\x00', '') text = text.replace('\x00/S\x00', '') text = text.replace('\x00E\x00', '') text = text.replace('\x00/E\x00', '') text = text.replace('\x00C\x00', '') text = text.replace('\x00/C\x00', '') text = text.replace('\x00A\x00', '') text = text.replace('\x00Q\x00', '">') return text def parse_table(lines, start_idx): """Parse a markdown table starting at start_idx. Returns (html_rows, next_idx).""" i = start_idx # Find header row if i >= len(lines) or not lines[i].strip().startswith('|'): return None, start_idx header_line = lines[i].strip() header_cells = [c.strip() for c in header_line.split('|')[1:-1]] i += 1 # Skip separator line if i < len(lines) and re.match(r'^\|[\s\-:|]+\|$', lines[i].strip()): i += 1 # Parse data rows data_rows = [] while i < len(lines): line = lines[i].strip() if not line.startswith('|'): break cells = [c.strip() for c in line.split('|')[1:-1]] data_rows.append(cells) i += 1 # Build HTML html = '\n\n\n' for cell in header_cells: html += f'\n' html += '\n\n\n' for row in data_rows: html += '\n' for cell in row: html += f'\n' html += '\n' html += '\n
{escape_for_html(clean_text(cell))}
{escape_for_html(clean_text(cell))}
\n' return html, i def parse_code_block(lines, start_idx): """Parse a code block. Returns (html, next_idx).""" i = start_idx if not lines[i].strip().startswith('```'): return None, start_idx i += 1 content_lines = [] while i < len(lines): if lines[i].strip().startswith('```'): i += 1 break content_lines.append(lines[i]) i += 1 content = ''.join(content_lines) content = escape_for_html(content) html = f'
{content}
\n' return html, i def parse_list(lines, start_idx): """Parse a markdown list (ordered or unordered). Returns (html, next_idx).""" i = start_idx items = [] while i < len(lines): line = lines[i].strip() # Ordered list m = re.match(r'^(\d+)\.\s+(.+)$', line) if m: items.append(('ol', escape_for_html(clean_text(m.group(2))))) i += 1 continue # Unordered list m = re.match(r'^[-*]\s+(.+)$', line) if m: items.append(('ul', escape_for_html(clean_text(m.group(1))))) i += 1 continue break if not items: return None, start_idx list_type = items[0][0] html = f'<{list_type}>\n' for lt, item_text in items: html += f'
  • {item_text}
  • \n' html += f'\n' return html, i def parse_blockquote(lines, start_idx): """Parse blockquote lines. Returns (html, next_idx).""" i = start_idx content_lines = [] while i < len(lines): line = lines[i].strip() if line.startswith('>'): content_lines.append(line[1:].strip()) i += 1 else: break if not content_lines: return None, start_idx content = ' '.join(content_lines) content = escape_for_html(clean_text(content)) html = f'

    {content}

    \n' return html, i def build_toc(sections): """Build TOC HTML from section list.""" toc_html = '
    \n' return toc_html def convert_md_to_html(md_text): """Main conversion function.""" lines = md_text.split('\n') sections = [] # (id, title) html_body = '' current_section_id = None extra_sections = [] # appendices i = 0 # Skip first line (# title) and metadata # We'll handle hero separately while i < len(lines) and not lines[i].startswith('## Table of Contents'): i += 1 # Skip TOC and horizontal rule while i < len(lines) and not lines[i].startswith('## 1. Executive Summary'): i += 1 # Now process each ## section while i < len(lines): line = lines[i].strip() # Check for ## section header m = re.match(r'^##\s+(.+)$', line) if m: section_title = m.group(1).strip() # Generate ID sec_id = section_title.lower() # Extract number prefix num_match = re.match(r'^(\d+)\.\s+(.+)$', section_title) if num_match: num = num_match.group(1) sec_name = num_match.group(2) sec_id = f'section-{num}' sections.append((sec_id, f'{num}. {sec_name}')) elif section_title.startswith('Appendix'): sec_id = section_title.lower().replace(' ', '-').replace(':', '').replace('(', '').replace(')', '') extra_sections.append(sec_id) sections.append((sec_id, section_title)) else: sec_id = section_title.lower().replace(' ', '-').replace('.', '') sections.append((sec_id, section_title)) current_section_id = sec_id # Decide if this is in the appendix (outside main card section) is_appendix = section_title.startswith('Appendix') if not is_appendix: html_body += f'
    \n' html_body += f'

    {escape_for_html(clean_text(section_title))}

    \n' else: html_body += f'
    \n' html_body += f'

    {escape_for_html(clean_text(section_title))}

    \n' i += 1 continue # Check for ### sub-section m = re.match(r'^###\s+(.+)$', line) if m: sub_title = m.group(1).strip() html_body += f'

    {escape_for_html(clean_text(sub_title))}

    \n' i += 1 continue # Horizontal rule if line.startswith('---'): if current_section_id and not current_section_id.startswith('appendix'): html_body += '
    \n' current_section_id = None i += 1 continue # Code block if line.startswith('```'): code_html, i = parse_code_block(lines, i) if code_html: html_body += code_html continue # Table if line.startswith('|'): table_html, i = parse_table(lines, i) if table_html: html_body += table_html continue # Blockquote if line.startswith('>'): bq_html, i = parse_blockquote(lines, i) if bq_html: html_body += bq_html continue # List list_html, new_i = parse_list(lines, i) if list_html: html_body += list_html i = new_i continue # Regular paragraph (non-empty) if line: # Check for bold-only short lines (like TL;DR) para = escape_for_html(clean_text(line)) html_body += f'

    {para}

    \n' i += 1 # Close last section if open if current_section_id: html_body += '
    \n' return sections, html_body # CSS template CSS = ''' :root { --accent: #2563eb; --accent-dark: #1d4ed8; --accent-light: #eff6ff; --accent-glow: rgba(37,99,235,0.15); --navy: #0f172a; --navy-light: #1e293b; --bg: #f8f9fb; --card: #ffffff; --border: #e5e7eb; --text: #1a1a2e; --text-secondary: #6b7280; --text-muted: #9ca3af; --green: #10b981; --green-bg: #ecfdf5; --amber: #d97706; --amber-bg: #fffbeb; --red: #dc2626; --red-bg: #fef2f2; --radius: 12px; --radius-sm: 8px; --shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04); } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; font-weight: 400; } h1, h2, h3, .price, .stat-value { font-family: 'DM Serif Display', Georgia, 'Times New Roman', serif; } /* Hero */ .hero { background: linear-gradient(135deg, #0f172a, #1e293b 40%, #1e3a5f 70%, #1d4ed8); color: #ffffff; padding: 80px 24px 70px; text-align: center; position: relative; overflow: hidden; } .hero::after { content: ''; position: absolute; top: -50%; left: -25%; width: 150%; height: 200%; background: radial-gradient(ellipse at 30% 50%, var(--accent-glow) 0%, transparent 60%); pointer-events: none; } .hero > * { position: relative; z-index: 1; } .hero .badge { display: inline-block; padding: 6px 18px; border-radius: 20px; font-size: 13px; font-weight: 600; font-family: 'DM Sans', sans-serif; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.2); margin-bottom: 24px; letter-spacing: 0.5px; } .hero h1 { font-size: 52px; font-weight: 400; margin-bottom: 12px; letter-spacing: -0.5px; } .hero .subtitle { font-size: 20px; color: rgba(255,255,255,0.75); max-width: 700px; margin: 0 auto 24px; font-weight: 400; } .hero .meta { font-size: 14px; color: rgba(255,255,255,0.55); font-weight: 500; } /* TOC */ .toc-wrap { max-width: 960px; margin: -28px auto 0; padding: 0 24px; position: relative; z-index: 2; } .toc { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px 24px; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; } .toc a { display: inline-block; padding: 6px 14px; border-radius: 20px; font-size: 13px; font-weight: 500; background: var(--accent-light); color: var(--accent); text-decoration: none; transition: background 0.2s, color 0.2s; } .toc a:hover { background: var(--accent); color: #fff; } /* Section Cards */ .sections { max-width: 960px; margin: 32px auto 0; padding: 0 24px; } .section-card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); padding: 40px; margin-bottom: 24px; transition: box-shadow 0.2s; } .section-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04); } .section-card h2 { font-size: 28px; font-weight: 400; color: var(--navy); margin-bottom: 24px; padding-bottom: 16px; border-bottom: 2px solid var(--accent-light); } .section-card h3 { font-size: 20px; font-weight: 400; color: var(--text); margin: 28px 0 12px; } .section-card h3:first-of-type { margin-top: 0; } .section-card p { margin-bottom: 16px; color: var(--text); font-size: 16px; } .section-card p:last-child { margin-bottom: 0; } /* Tables */ table { width: 100%; border-collapse: collapse; margin: 20px 0 28px; font-size: 14px; } thead th { background: var(--accent-light); color: var(--accent-dark); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; padding: 12px 16px; text-align: left; border-bottom: 2px solid var(--accent); } tbody td { padding: 10px 16px; border-bottom: 1px solid var(--border); color: var(--text); vertical-align: top; } tbody tr:last-child td { border-bottom: none; } tbody tr:hover { background: var(--accent-light); } /* Code blocks */ pre { background: #1e293b; color: #e2e8f0; padding: 20px 24px; border-radius: var(--radius-sm); overflow-x: auto; font-size: 12px; line-height: 1.5; margin: 16px 0 24px; font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; } pre code { background: none; padding: 0; font-size: inherit; } code { background: var(--accent-light); color: var(--accent-dark); padding: 2px 6px; border-radius: 4px; font-size: 13px; font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; } /* Blockquotes */ blockquote { border-left: 4px solid var(--accent); background: var(--accent-light); padding: 16px 20px; margin: 16px 0 24px; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; font-style: italic; } blockquote p { margin: 0 !important; color: var(--navy-light); } /* Lists */ ul, ol { margin: 12px 0 20px 24px; color: var(--text); } ul li, ol li { margin-bottom: 8px; font-size: 16px; } /* Pricing grid */ .pricing-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin: 20px 0 28px; } .pricing-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; } .pricing-card.featured { border: 2px solid var(--accent); background: var(--accent-light); } .pricing-card h4 { font-family: 'DM Serif Display', Georgia, serif; font-size: 20px; font-weight: 400; margin-bottom: 4px; color: var(--navy); } .pricing-card .price { font-size: 28px; font-weight: 400; color: var(--accent); margin-bottom: 16px; } .pricing-card ul { margin: 0 0 16px 18px; list-style: none; } .pricing-card ul li { position: relative; padding-left: 20px; font-size: 14px; margin-bottom: 6px; color: var(--text-secondary); } .pricing-card ul li::before { content: '+'; position: absolute; left: 0; color: var(--green); font-weight: 700; } /* Tags */ .tag-critical { background: var(--red-bg); color: var(--red); } .tag-high { background: #fef3c7; color: #b45309; } .tag-medium { background: var(--amber-bg); color: var(--amber); } .tag-low { background: var(--green-bg); color: #059669; } .tag-phase { background: var(--accent-light); color: var(--accent); } .tag { display: inline-block; padding: 4px 10px; border-radius: 4px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; } /* VerdictTank */ .verdicttank { background: var(--red-bg); border: 1px solid #fecaca; border-left: 4px solid var(--red); border-radius: var(--radius); padding: 28px 32px; margin: 24px auto 0; max-width: 960px; text-align: center; } .verdicttank h2 { font-family: 'DM Serif Display', Georgia, serif; font-size: 24px; font-weight: 400; color: var(--red); margin-bottom: 8px; text-align: center; } .verdicttank p { color: #991b1b; font-size: 15px; margin: 0; text-align: center; } .verdicttank a { color: var(--red); font-weight: 600; } /* Footer */ .footer { background: var(--navy); color: rgba(255,255,255,0.8); text-align: center; padding: 40px 24px; margin-top: 48px; } .footer p { font-size: 14px; margin-bottom: 8px; color: rgba(255,255,255,0.7); } .footer .title { font-family: 'DM Serif Display', Georgia, serif; font-size: 18px; color: #ffffff; margin-bottom: 8px; } .footer .links { margin-top: 16px; display: flex; flex-wrap: wrap; justify-content: center; gap: 16px; } .footer .links a { color: rgba(255,255,255,0.7); text-decoration: none; font-size: 13px; transition: color 0.2s; } .footer .links a:hover { color: #ffffff; } .footer .links a.current { color: #ffffff; font-weight: 700; } /* Appendices */ .appendix-section { max-width: 960px; margin: 0 auto 24px; padding: 32px 40px; background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); border-left: 3px solid var(--border); } .appendix-section h2 { font-family: 'DM Serif Display', Georgia, serif; font-size: 22px; font-weight: 400; color: var(--text-secondary); margin-bottom: 20px; } .appendix-section table { font-size: 13px; } /* Print styles */ @media print { body { background: #fff; } .hero { print-color-adjust: exact; -webkit-print-color-adjust: exact; } .section-card { box-shadow: none; break-inside: avoid; } .toc-wrap { display: none; } .footer { print-color-adjust: exact; -webkit-print-color-adjust: exact; } } /* Mobile */ @media (max-width: 640px) { .hero { padding: 60px 20px 50px; } .hero h1 { font-size: 36px; } .hero .subtitle { font-size: 16px; } .section-card { padding: 24px 20px; } .section-card h2 { font-size: 22px; } table { font-size: 12px; } thead th, tbody td { padding: 8px 10px; } .pricing-grid { grid-template-columns: 1fr; } .toc { padding: 14px 16px; } .toc a { font-size: 12px; padding: 5px 10px; } } ''' def build_full_html(sections, body_html): """Build the complete HTML document.""" toc_html = build_toc(sections) html = f''' HotNow Savannah - Business Proposal v2.0
    IT Pro Partner - Product Division

    HotNow Savannah

    Real-Time Local Discovery Engine - Savannah, GA Launch

    Confidential - Version 2.0 - August 11, 2026

    {toc_html}
    {body_html}

    VerdictTank Reviewed

    This proposal has not yet been reviewed by VerdictTank. Schedule a review at verdicttank.com.

    ''' return html def main(): md_path = '/root/projects/itpp-infrastructure/projects/hotnow-savannah-v2.md' out_path = '/root/projects/itpp-infrastructure/projects/hotnow-savannah-v2.html' md_text = read_md(md_path) sections, body_html = convert_md_to_html(md_text) full_html = build_full_html(sections, body_html) # Post-process: strip any remaining em dashes, en dashes, smart quotes full_html = full_html.replace('\u2014', '-') # em dash full_html = full_html.replace('\u2013', '-') # en dash full_html = full_html.replace('\u201c', '"') # left smart quote full_html = full_html.replace('\u201d', '"') # right smart quote full_html = full_html.replace('\u2018', "'") # left smart apostrophe full_html = full_html.replace('\u2019', "'") # right smart apostrophe # Replace any -- with " - " but ONLY outside ', protect_style, text, flags=re.DOTALL) text = re.sub(r'', protect_comment, text, flags=re.DOTALL) # Now safe to replace -- in remaining content text = re.sub(r'--', ' - ', text) # Restore protected blocks for key, value in protected.items(): text = text.replace(key, value) return text full_html = fix_double_hyphens(full_html) with open(out_path, 'w') as f: f.write(full_html) print(f"Written {len(full_html)} chars to {out_path}") print(f"Sections: {len(sections)}") if __name__ == '__main__': main()