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)
This commit is contained in:
@@ -0,0 +1,875 @@
|
||||
#!/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 · 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()
|
||||
Reference in New Issue
Block a user