Files
root f5175f1ce0 Sync docs, audit artifacts, project notes, and VerdictTank proposal docs
- audit/phase-one + phase-two: security audit briefs, findings, credential-rotation plan, Docker-USER hardening scripts, rollback refs
- disaster-recovery/restore-test-log.md + backup-dr-audit-2026-08-10.md
- clients/ (modelortho SEO audit, ai-biz-dev competitive landscape), notes/ (tiktok strategy)
- projects/: front-desk-voice-agent, seo-visibility-checker product plan, hotnow-savannah HTML, resend-transactional-email, backup-dashboard-enhancements, code-review-graph, seo-ci-architecture
- proposals/verdicttank/: architecture v4.0, methodology, judge-pool review, consolidation reasoning, cross-check review
- docs/super-search/firecrawl-provider-strategy.md
- updates: CHANGELOG, model-chain, projects-master-readme, intelsight.io
- .gitignore: exclude nested standalone repos (seo-tool, venturebuilt)
2026-08-26 02:27:28 -04:00

876 lines
24 KiB
Python

#!/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'<strong>\1</strong>', text)
# Italic
text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
# Inline code
text = re.sub(r'`([^`]+)`', r'<code>\1</code>', text)
# Links
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', 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('<strong>', '\x00S\x00')
text = text.replace('</strong>', '\x00/S\x00')
text = text.replace('<em>', '\x00E\x00')
text = text.replace('</em>', '\x00/E\x00')
text = text.replace('<code>', '\x00C\x00')
text = text.replace('</code>', '\x00/C\x00')
text = text.replace('<a href=', '\x00A\x00')
text = text.replace('</a>', '\x00/A\x00')
text = text.replace('">', '\x00Q\x00')
# Escape
text = html_mod.escape(text, quote=False)
# Restore
text = text.replace('\x00S\x00', '<strong>')
text = text.replace('\x00/S\x00', '</strong>')
text = text.replace('\x00E\x00', '<em>')
text = text.replace('\x00/E\x00', '</em>')
text = text.replace('\x00C\x00', '<code>')
text = text.replace('\x00/C\x00', '</code>')
text = text.replace('\x00A\x00', '<a href=')
text = text.replace('\x00/A\x00', '</a>')
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 = '<table>\n<thead>\n<tr>\n'
for cell in header_cells:
html += f'<th>{escape_for_html(clean_text(cell))}</th>\n'
html += '</tr>\n</thead>\n<tbody>\n'
for row in data_rows:
html += '<tr>\n'
for cell in row:
html += f'<td>{escape_for_html(clean_text(cell))}</td>\n'
html += '</tr>\n'
html += '</tbody>\n</table>\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'<pre><code>{content}</code></pre>\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'<li>{item_text}</li>\n'
html += f'</{list_type}>\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'<blockquote><p>{content}</p></blockquote>\n'
return html, i
def build_toc(sections):
"""Build TOC HTML from section list."""
toc_html = '<nav class="toc">\n'
for sec_id, sec_title in sections:
toc_html += f'<a href="#{sec_id}">{sec_title}</a>\n'
toc_html += '</nav>\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'<section class="section-card" id="{sec_id}">\n'
html_body += f'<h2>{escape_for_html(clean_text(section_title))}</h2>\n'
else:
html_body += f'<section class="appendix-section" id="{sec_id}">\n'
html_body += f'<h2>{escape_for_html(clean_text(section_title))}</h2>\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'<h3>{escape_for_html(clean_text(sub_title))}</h3>\n'
i += 1
continue
# Horizontal rule
if line.startswith('---'):
if current_section_id and not current_section_id.startswith('appendix'):
html_body += '</section>\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'<p>{para}</p>\n'
i += 1
# Close last section if open
if current_section_id:
html_body += '</section>\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'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HotNow Savannah - Business Proposal v2.0</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;0,9..40,800;1,9..40,400&family=DM+Serif+Display&display=swap" rel="stylesheet">
<style>
{CSS}
</style>
</head>
<body>
<!-- Hero -->
<header class="hero">
<div class="badge">IT Pro Partner - Product Division</div>
<h1>HotNow Savannah</h1>
<p class="subtitle">Real-Time Local Discovery Engine - Savannah, GA Launch</p>
<p class="meta">Confidential - Version 2.0 - August 11, 2026</p>
</header>
<!-- TOC -->
<div class="toc-wrap">
{toc_html}
</div>
<!-- Sections -->
<div class="sections">
{body_html}
</div>
<!-- VerdictTank -->
<div class="verdicttank">
<h2>VerdictTank Reviewed</h2>
<p>This proposal has not yet been reviewed by VerdictTank. Schedule a review at <a href="https://verdicttank.com">verdicttank.com</a>.</p>
</div>
<!-- Footer -->
<footer class="footer">
<p class="title">HotNow Savannah - Confidential Business Proposal</p>
<p>Prepared by IT Pro Partner - Product Division &middot; August 11, 2026</p>
<div class="links">
<a href="/hotnow/">Original Proposal (v1)</a>
<a href="#">Pipeline Verdict (pending)</a>
<a href="#" class="current">Post-Review Proposal (v2)</a>
<a href="https://itpropartner.com">IT Pro Partner</a>
</div>
</footer>
</body>
</html>'''
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 <style> tags and HTML comments
def fix_double_hyphens(text):
# Protect <style> blocks and HTML comments
protected = {}
counter = [0]
def protect_style(m):
key = f'\x00STYLE{counter[0]}\x00'
counter[0] += 1
protected[key] = m.group(0)
return key
def protect_comment(m):
key = f'\x00COMMENT{counter[0]}\x00'
counter[0] += 1
protected[key] = m.group(0)
return key
text = re.sub(r'<style>.*?</style>', 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()