feat(emit): --emit=html for shareable self-contained briefs (#332)
Adds a one-command shareable HTML mode to /last30days. The skill detects
HTML intent (explicit --emit=html / --emit:html / --html flag in
$ARGUMENTS, or natural-language asks like "give me a shareable brief",
"for Slack", "export as HTML"), runs the normal research + chat synthesis
flow, then saves a self-contained HTML file to
~/Documents/Last30Days/{topic}-brief.html. The synthesis appears in chat
as usual; the HTML is an additional artifact for sharing.
User experience:
/last30days OpenClaw --emit=html
/last30days OpenClaw, give me an HTML brief for Slack
Synthesis prints to chat. Last line of the response: "📎 Shareable brief
saved to ~/Documents/Last30Days/openclaw-brief.html". Open it, drag it
into a message, browser-print to PDF, email it.
Architecture:
- SKILL.md gets a small detection block (triggers + early exit +
MUST/MUST NOT rules + rationale) that points to a reference file.
- references/save-html-brief.md owns the implementation: capture the
synthesis verbatim into a temp file via heredoc, invoke the engine
with --emit=html --synthesis-file, save to disk, append the
confirmation line to chat.
- lib/render.py exposes render_for_html(report, synthesis_md=None) and
render_for_html_comparison(...) -- clean markdown for HTML
conversion. Omits debug file header, model-facing safety note, and
data quality warnings (those stay in engine stderr; recipients can't
act on them in a shared artifact).
- lib/html_render.py is a new module: ~200-line CSS template (dark
mode default, prefers-color-scheme switch, print stylesheet, mobile
breakpoint), stdlib-regex markdown-to-HTML converter, marker-based
META + engine-footer wrapping, PROSE_LABELS registry promoting plain
-text labels to <h2>, colophon builder.
- last30days.py adds --emit=html argparse choice and --synthesis-file
PATH flag (engine still callable directly without the skill in the
loop).
Design:
- Voice-led research brief, not corporate report. Inter + JetBrains
Mono via Google Fonts with full system fallbacks (no FOIT, works
offline). Brand purple #a855f7 (#7c3aed in light mode). Type ramp:
body 17px/400/muted, bold lead-in 17px/600/fg, h2 + .prose-label
20px/600/fg, monospace badge/meta/footer/colophon at 13-13.5px.
- 720px max-width, generous whitespace, no card layouts or shadows.
- Print stylesheet: light theme, A4 margins, [href]::after URL
footnotes, page-break-inside:avoid on the engine footer.
Templated (locked) shell:
- HTML5 boilerplate, Google Fonts <link> with preconnect, all CSS
inline.
- .badge / .meta / .engine-footer / .colophon containers.
Flexible (role-based):
- <h2> rendering covers BOTH plain ## headers (comparison mode per
LAW 4 exception) AND promoted prose labels via PROSE_LABELS
registry. Adding a new SKILL.md prose label is a one-line tuple
addition; no CSS or template changes.
- Marker-based engine boundaries (<!-- META: ... -->,
<!-- PASS-THROUGH FOOTER -->) survive the markdown converter and
get promoted post-conversion. Robust to engine output format
changes.
- Generic markdown-to-HTML for body content; future SKILL.md additions
(new sections, tables, blockquotes) render correctly without code
changes.
Tests: 30 new tests in tests/test_html_render.py covering snapshots
(rich/thin/comparison), CLI parsing, --synthesis-file end-to-end, prose
label promotion, warning exclusion from artifact, parseability via
html.parser, no-script self-containment.
No SKILL.md voice contract changes, no LAWs 1-8 changes, no new pip
dependencies, no JavaScript anywhere.
This commit is contained in:
@@ -0,0 +1,674 @@
|
||||
"""HTML rendering for shareable last30days reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
from . import render, schema
|
||||
|
||||
|
||||
PROSE_LABELS = [
|
||||
("What I learned:", "What I learned"),
|
||||
("KEY PATTERNS from the research:", "Key patterns from the research"),
|
||||
]
|
||||
|
||||
INVITATION_PATTERN = re.compile(r"^---\nI'm now an expert.*?Just ask\.$", re.MULTILINE | re.DOTALL)
|
||||
EVIDENCE_BLOCK_PATTERN = re.compile(r"<!-- EVIDENCE FOR SYNTHESIS.*?<!-- END EVIDENCE FOR SYNTHESIS -->", re.DOTALL)
|
||||
PASS_THROUGH_FOOTER_PATTERN = re.compile(r"<!-- PASS-THROUGH FOOTER.*?-->\n(.*?)<!-- END PASS-THROUGH FOOTER -->", re.DOTALL)
|
||||
CANONICAL_BOUNDARY_PATTERN = re.compile(r"\n?---\n# END OF last30days CANONICAL OUTPUT.*$", re.DOTALL)
|
||||
# render_for_html emits metadata as <!-- META: ... --> so it survives the
|
||||
# markdown converter (which escapes raw HTML inside paragraphs). Promoted to
|
||||
# a styled <div class="meta"> after conversion.
|
||||
META_MARKER_PATTERN = re.compile(r"<!--\s*META:\s*(.*?)\s*-->")
|
||||
|
||||
CSS = """
|
||||
:root {
|
||||
--bg: #0e0e10;
|
||||
--bg-elev: #18181b;
|
||||
--fg: #fafafa;
|
||||
--fg-muted: #a1a1aa;
|
||||
--fg-subtle: #71717a;
|
||||
--accent: #a855f7;
|
||||
--accent-soft: #c4b5fd;
|
||||
--border: #27272a;
|
||||
--code-bg: #1a1a1d;
|
||||
--max-w: 720px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-elev: #fafafa;
|
||||
--fg: #18181b;
|
||||
--fg-muted: #52525b;
|
||||
--fg-subtle: #71717a;
|
||||
--accent: #7c3aed;
|
||||
--accent-soft: #6d28d9;
|
||||
--border: #e4e4e7;
|
||||
--code-bg: #f4f4f5;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif;
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 4rem 1.5rem 6rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 0.85rem;
|
||||
margin-bottom: 2.5rem;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.badge .accent { color: var(--accent); }
|
||||
|
||||
.meta {
|
||||
margin: -1.5rem 0 2.5rem;
|
||||
color: var(--fg-subtle);
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--fg);
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h2,
|
||||
.prose-label {
|
||||
margin: 2.75rem 0 1.25rem;
|
||||
color: var(--fg);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.badge + h2,
|
||||
.badge + .prose-label { margin-top: 0.5rem; }
|
||||
|
||||
h3 {
|
||||
margin: 2rem 0 0.85rem;
|
||||
color: var(--fg);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1.4rem;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
p strong,
|
||||
li strong,
|
||||
td strong {
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
a:hover { border-bottom-color: var(--accent); }
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 1.6rem;
|
||||
padding-left: 1.5rem;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.6rem 0;
|
||||
padding-left: 0.4rem;
|
||||
}
|
||||
|
||||
li::marker {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 1rem;
|
||||
border-left: 3px solid var(--accent);
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 2.5rem 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
background: var(--code-bg);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 1.4rem 0;
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
overflow-x: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--fg-muted);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td { color: var(--fg-muted); }
|
||||
td:first-child { color: var(--fg); font-weight: 500; }
|
||||
|
||||
.engine-footer {
|
||||
margin: 3rem 0 2.5rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.engine-footer pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.75;
|
||||
color: inherit;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.colophon {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--fg-subtle);
|
||||
font-size: 13px;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.colophon .rerun {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
margin-left: 0.25rem;
|
||||
background: var(--code-bg);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-soft);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
@media print {
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-elev: #f5f5f5;
|
||||
--fg: #000000;
|
||||
--fg-muted: #1f2937;
|
||||
--fg-subtle: #4b5563;
|
||||
--accent: #6d28d9;
|
||||
--accent-soft: #6d28d9;
|
||||
--border: #d4d4d8;
|
||||
--code-bg: #f4f4f5;
|
||||
}
|
||||
|
||||
@page { size: A4; margin: 1.5cm 2cm; }
|
||||
|
||||
body {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
border-bottom: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a[href]::after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 0.85em;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
.engine-footer { page-break-inside: avoid; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 2.5rem 1.25rem 4rem;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
h1 { font-size: 25px; }
|
||||
.badge { font-size: 12px; }
|
||||
th, td { padding: 0.65rem 0.5rem; }
|
||||
}
|
||||
""".strip()
|
||||
|
||||
HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>last30days · __TITLE__</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=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
__CSS__
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
__BODY__
|
||||
__COLOPHON__
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def render_html(
|
||||
report: schema.Report,
|
||||
*,
|
||||
fun_level: str = "medium",
|
||||
save_path: str | None = None,
|
||||
synthesis_md: str | None = None,
|
||||
) -> str:
|
||||
_ = fun_level
|
||||
md = render.render_for_html(report, synthesis_md=synthesis_md, save_path=save_path)
|
||||
md = _strip_evidence_block(md)
|
||||
md = _strip_invitation(md)
|
||||
md = _strip_canonical_boundary(md)
|
||||
md = _promote_prose_labels(md)
|
||||
body = _markdown_to_html(md)
|
||||
body = _wrap_engine_footer(body)
|
||||
body = _promote_meta_marker(body)
|
||||
colophon = _build_colophon(report)
|
||||
return _wrap_in_template(body, colophon, report.topic)
|
||||
|
||||
|
||||
def render_html_comparison(
|
||||
entity_reports: list[tuple[str, schema.Report]],
|
||||
*,
|
||||
fun_level: str = "medium",
|
||||
save_path: str | None = None,
|
||||
synthesis_md: str | None = None,
|
||||
) -> str:
|
||||
_ = fun_level
|
||||
md = render.render_for_html_comparison(
|
||||
entity_reports, synthesis_md=synthesis_md, save_path=save_path,
|
||||
)
|
||||
md = _strip_evidence_block(md)
|
||||
md = _strip_invitation(md)
|
||||
md = _strip_canonical_boundary(md)
|
||||
md = _promote_prose_labels(md)
|
||||
body = _markdown_to_html(md)
|
||||
body = _wrap_engine_footer(body)
|
||||
body = _promote_meta_marker(body)
|
||||
topic = " vs ".join(label for label, _ in entity_reports)
|
||||
colophon = _build_colophon(entity_reports[0][1], topic=topic)
|
||||
return _wrap_in_template(body, colophon, topic)
|
||||
|
||||
|
||||
def _strip_evidence_block(md: str) -> str:
|
||||
return EVIDENCE_BLOCK_PATTERN.sub("", md)
|
||||
|
||||
|
||||
def _strip_invitation(md: str) -> str:
|
||||
return INVITATION_PATTERN.sub("", md)
|
||||
|
||||
|
||||
def _strip_canonical_boundary(md: str) -> str:
|
||||
return CANONICAL_BOUNDARY_PATTERN.sub("", md)
|
||||
|
||||
|
||||
def _promote_prose_labels(md: str) -> str:
|
||||
for source, normalized in PROSE_LABELS:
|
||||
md = re.sub(
|
||||
rf"^{re.escape(source)}$",
|
||||
f"## {normalized}",
|
||||
md,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
return md
|
||||
|
||||
|
||||
def _markdown_to_html(md: str) -> str:
|
||||
md, footers = _protect_engine_footers(md)
|
||||
global _ENGINE_FOOTER_STORE
|
||||
_ENGINE_FOOTER_STORE = footers
|
||||
# Strip HTML comments EXCEPT preserved markers used for post-processing
|
||||
# (META is promoted to <div class="meta"> after markdown conversion).
|
||||
md = re.sub(r"<!--(?!\s*META:).*?-->", "", md, flags=re.DOTALL)
|
||||
lines = md.splitlines()
|
||||
out: list[str] = []
|
||||
paragraph: list[str] = []
|
||||
list_type: str | None = None
|
||||
in_code = False
|
||||
code_lines: list[str] = []
|
||||
index = 0
|
||||
|
||||
def flush_paragraph() -> None:
|
||||
nonlocal paragraph
|
||||
if paragraph:
|
||||
text = " ".join(part.strip() for part in paragraph).strip()
|
||||
if text:
|
||||
out.append(f"<p>{_inline_markdown(text)}</p>")
|
||||
paragraph = []
|
||||
|
||||
def close_list() -> None:
|
||||
nonlocal list_type
|
||||
if list_type:
|
||||
out.append(f"</{list_type}>")
|
||||
list_type = None
|
||||
|
||||
while index < len(lines):
|
||||
line = lines[index]
|
||||
stripped = line.strip()
|
||||
|
||||
if in_code:
|
||||
if stripped.startswith("```"):
|
||||
out.append(f"<pre><code>{html.escape(chr(10).join(code_lines))}</code></pre>")
|
||||
code_lines = []
|
||||
in_code = False
|
||||
else:
|
||||
code_lines.append(line)
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if stripped.startswith("```"):
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
in_code = True
|
||||
code_lines = []
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if stripped in footers:
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
out.append(stripped)
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if not stripped:
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if stripped == "---":
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
out.append("<hr>")
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if index + 1 < len(lines) and _is_table_row(stripped) and _is_table_separator(lines[index + 1].strip()):
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
table_lines = [stripped]
|
||||
index += 2
|
||||
while index < len(lines) and _is_table_row(lines[index].strip()):
|
||||
table_lines.append(lines[index].strip())
|
||||
index += 1
|
||||
out.append(_render_table(table_lines))
|
||||
continue
|
||||
|
||||
heading = re.match(r"^(#{1,4})\s+(.+)$", stripped)
|
||||
if heading:
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
level = min(len(heading.group(1)), 3)
|
||||
out.append(f"<h{level}>{_inline_markdown(heading.group(2))}</h{level}>")
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if stripped.startswith(">"):
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
quote_lines = []
|
||||
while index < len(lines) and lines[index].strip().startswith(">"):
|
||||
quote_lines.append(lines[index].strip().lstrip(">").strip())
|
||||
index += 1
|
||||
out.append(f"<blockquote>{_inline_markdown(' '.join(quote_lines))}</blockquote>")
|
||||
continue
|
||||
|
||||
unordered = re.match(r"^[-*]\s+(.+)$", stripped)
|
||||
ordered = re.match(r"^\d+[.)]\s+(.+)$", stripped)
|
||||
if unordered or ordered:
|
||||
flush_paragraph()
|
||||
next_type = "ul" if unordered else "ol"
|
||||
if list_type != next_type:
|
||||
close_list()
|
||||
out.append(f"<{next_type}>")
|
||||
list_type = next_type
|
||||
item = unordered.group(1) if unordered else ordered.group(1)
|
||||
out.append(f"<li>{_inline_markdown(item)}</li>")
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if stripped.startswith("🌐 last30days"):
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
badge_text = _inline_markdown(stripped.removeprefix("🌐").strip())
|
||||
out.append(f'<div class="badge"><span class="accent">🌐</span> {badge_text}</div>')
|
||||
index += 1
|
||||
continue
|
||||
|
||||
paragraph.append(line)
|
||||
index += 1
|
||||
|
||||
if in_code:
|
||||
out.append(f"<pre><code>{html.escape(chr(10).join(code_lines))}</code></pre>")
|
||||
flush_paragraph()
|
||||
close_list()
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def _protect_engine_footers(md: str) -> tuple[str, dict[str, str]]:
|
||||
footers: dict[str, str] = {}
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
token = f"__LAST30DAYS_ENGINE_FOOTER_{len(footers)}__"
|
||||
footers[token] = match.group(1).strip("\n")
|
||||
return f"\n{token}\n"
|
||||
|
||||
return PASS_THROUGH_FOOTER_PATTERN.sub(replace, md), footers
|
||||
|
||||
|
||||
def _wrap_engine_footer(body: str) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
footer = html.escape(_ENGINE_FOOTER_STORE.get(match.group(0), ""), quote=False)
|
||||
return f'<div class="engine-footer"><pre>{footer}</pre></div>'
|
||||
|
||||
return re.sub(
|
||||
r"__LAST30DAYS_ENGINE_FOOTER_\d+__",
|
||||
replace,
|
||||
body,
|
||||
)
|
||||
|
||||
|
||||
def _promote_meta_marker(body: str) -> str:
|
||||
"""Promote ``<!-- META: ... -->`` markers into a styled ``<div class="meta">``.
|
||||
|
||||
The marker is preserved through the comment-strip pass (see
|
||||
_markdown_to_html exemption) but the markdown converter wraps it in
|
||||
``<p>`` and HTML-escapes the angle brackets. After conversion the body
|
||||
contains shapes like:
|
||||
<p><!-- META: TEXT --></p>
|
||||
<p><!-- META: TEXT --></p> (when not escaped)
|
||||
Both collapse to ``<div class="meta">TEXT</div>``.
|
||||
"""
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
text = match.group(1).strip()
|
||||
return f'<div class="meta">{text}</div>'
|
||||
|
||||
# Escaped form (most common after markdown conversion)
|
||||
body = re.sub(
|
||||
r"<p>\s*<!--\s*META:\s*(.*?)\s*-->\s*</p>",
|
||||
replace,
|
||||
body,
|
||||
)
|
||||
body = re.sub(r"<!--\s*META:\s*(.*?)\s*-->", replace, body)
|
||||
# Unescaped form (paranoid fallback)
|
||||
body = re.sub(r"<p>\s*<!--\s*META:\s*(.*?)\s*-->\s*</p>", replace, body)
|
||||
body = re.sub(r"<!--\s*META:\s*(.*?)\s*-->", replace, body)
|
||||
return body
|
||||
|
||||
|
||||
_ENGINE_FOOTER_STORE: dict[str, str] = {}
|
||||
|
||||
|
||||
def _inline_markdown(text: str) -> str:
|
||||
escaped = html.escape(text, quote=True)
|
||||
code_tokens: dict[str, str] = {}
|
||||
|
||||
def code_replace(match: re.Match[str]) -> str:
|
||||
token = f"__CODE_{len(code_tokens)}__"
|
||||
code_tokens[token] = f"<code>{match.group(1)}</code>"
|
||||
return token
|
||||
|
||||
escaped = re.sub(r"`([^`]+)`", code_replace, escaped)
|
||||
escaped = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", escaped)
|
||||
escaped = re.sub(
|
||||
r"\[([^\]]+)\]\(([^)\s]+)\)",
|
||||
r'<a href="\2">\1</a>',
|
||||
escaped,
|
||||
)
|
||||
for token, value in code_tokens.items():
|
||||
escaped = escaped.replace(token, value)
|
||||
return escaped
|
||||
|
||||
|
||||
def _is_table_row(line: str) -> bool:
|
||||
return "|" in line and len(_split_table_cells(line)) >= 2
|
||||
|
||||
|
||||
def _is_table_separator(line: str) -> bool:
|
||||
cells = _split_table_cells(line)
|
||||
return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell.strip()) for cell in cells)
|
||||
|
||||
|
||||
def _split_table_cells(line: str) -> list[str]:
|
||||
return [cell.strip() for cell in line.strip().strip("|").split("|")]
|
||||
|
||||
|
||||
def _render_table(rows: list[str]) -> str:
|
||||
header = _split_table_cells(rows[0])
|
||||
body_rows = [_split_table_cells(row) for row in rows[1:]]
|
||||
out = ["<table>", "<thead>", "<tr>"]
|
||||
out.extend(f"<th>{_inline_markdown(cell)}</th>" for cell in header)
|
||||
out.extend(["</tr>", "</thead>", "<tbody>"])
|
||||
for row in body_rows:
|
||||
out.append("<tr>")
|
||||
out.extend(f"<td>{_inline_markdown(cell)}</td>" for cell in row)
|
||||
out.append("</tr>")
|
||||
out.extend(["</tbody>", "</table>"])
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _build_colophon(report: schema.Report, *, topic: str | None = None) -> str:
|
||||
display_topic = topic or report.topic
|
||||
generated = _generated_date(report)
|
||||
version = render._skill_version()
|
||||
escaped_topic = html.escape(display_topic)
|
||||
rerun = html.escape(f"/last30days {display_topic}")
|
||||
return (
|
||||
'<div class="colophon">\n'
|
||||
f" Generated {generated} by /last30days v{html.escape(version)} · topic: {escaped_topic}<br>\n"
|
||||
f' Re-run for fresh data: <span class="rerun">{rerun}</span>\n'
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
def _generated_date(report: schema.Report) -> str:
|
||||
if report.generated_at:
|
||||
return report.generated_at[:10]
|
||||
return date.today().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _wrap_in_template(body: str, colophon: str, title: str) -> str:
|
||||
return (
|
||||
HTML_TEMPLATE
|
||||
.replace("__TITLE__", html.escape(title))
|
||||
.replace("__CSS__", CSS)
|
||||
.replace("__BODY__", body)
|
||||
.replace("__COLOPHON__", colophon)
|
||||
)
|
||||
@@ -171,6 +171,168 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
|
||||
def render_for_html(
|
||||
report: schema.Report,
|
||||
synthesis_md: str | None = None,
|
||||
*,
|
||||
save_path: str | None = None,
|
||||
) -> str:
|
||||
"""Render markdown intended for shareable HTML conversion.
|
||||
|
||||
This output keeps the public badge, compact source/date metadata, an
|
||||
optional one-line data quality note, optional synthesized brief markdown,
|
||||
and the engine footer. It deliberately omits the debug file header,
|
||||
model-facing safety note, and evidence scratchpad emitted by
|
||||
render_compact().
|
||||
|
||||
When synthesis_md is None, the body is intentionally sparse: badge,
|
||||
metadata, optional data quality note, and engine footer only.
|
||||
"""
|
||||
lines = [
|
||||
*_render_badge(),
|
||||
*_render_html_metadata(report),
|
||||
]
|
||||
if synthesis_md:
|
||||
lines.extend(["", synthesis_md.strip()])
|
||||
# Data quality warnings are NOT rendered into the HTML artifact. The HTML
|
||||
# is meant to be shared (Slack, email, Notion); recipients haven't asked
|
||||
# for technical commentary about how the run was produced. Generators see
|
||||
# the same warnings via collect_html_warnings() routed to stderr by the
|
||||
# CLI, so they can fix quality issues before sharing.
|
||||
_append_html_footer(lines, report, save_path)
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
|
||||
def render_for_html_comparison(
|
||||
entity_reports: list[tuple[str, schema.Report]],
|
||||
synthesis_md: str | None = None,
|
||||
*,
|
||||
save_path: str | None = None,
|
||||
) -> str:
|
||||
"""Render comparison markdown intended for shareable HTML conversion.
|
||||
|
||||
Same semantics as render_for_html(), but metadata and data quality notes
|
||||
are aggregated across the compared entities.
|
||||
"""
|
||||
if not entity_reports:
|
||||
raise ValueError("render_for_html_comparison requires at least one report")
|
||||
|
||||
entities = [label for label, _ in entity_reports]
|
||||
main_report = entity_reports[0][1]
|
||||
meta = (
|
||||
f"<!-- META: {main_report.range_from} to {main_report.range_to} "
|
||||
f"· comparing {len(entities)}: {', '.join(entities)} -->"
|
||||
)
|
||||
lines = [
|
||||
*_render_badge(),
|
||||
meta,
|
||||
]
|
||||
if synthesis_md:
|
||||
lines.extend(["", synthesis_md.strip()])
|
||||
# Comparison data quality notes also go to stderr, not into the artifact.
|
||||
_append_html_footer(lines, main_report, save_path)
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
|
||||
def collect_html_warnings(report: schema.Report) -> list[str]:
|
||||
"""Collect data quality warnings for stderr output (NOT for the HTML artifact).
|
||||
|
||||
Returns a list of human-readable warning strings. Empty list if the run
|
||||
was clean. Used by the CLI to emit diagnostics to stderr after writing
|
||||
the HTML to stdout/file.
|
||||
"""
|
||||
notes: list[str] = []
|
||||
if _render_degraded_run_warning(report):
|
||||
notes.append("Run was missing pre-flight resolution. Re-run with `--plan` for richer results.")
|
||||
elif _render_pre_research_warning(report):
|
||||
notes.append("Pre-research was skipped, so results may be thinner than a resolved run.")
|
||||
freshness_warning = _assess_data_freshness(report)
|
||||
if freshness_warning:
|
||||
notes.append(freshness_warning)
|
||||
notes.extend(report.warnings)
|
||||
return _dedupe_notes(notes)
|
||||
|
||||
|
||||
def collect_html_warnings_comparison(
|
||||
entity_reports: list[tuple[str, schema.Report]],
|
||||
) -> list[str]:
|
||||
"""Collect comparison-mode warnings, prefixed by entity label."""
|
||||
notes: list[str] = []
|
||||
for label, report in entity_reports:
|
||||
for w in collect_html_warnings(report):
|
||||
notes.append(f"{label}: {w}")
|
||||
return notes
|
||||
|
||||
|
||||
def _render_html_metadata(report: schema.Report) -> list[str]:
|
||||
"""Inline metadata as an HTML comment marker.
|
||||
|
||||
html_render.py post-processes ``<!-- META: ... -->`` markers into a
|
||||
``<div class="meta">`` after markdown conversion, so the metadata escapes
|
||||
the markdown converter's HTML-escaping pass cleanly. Same pattern as the
|
||||
PASS_THROUGH_FOOTER marker used for the engine tree.
|
||||
"""
|
||||
non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
|
||||
if non_empty:
|
||||
sources = ", ".join(_source_label(s) for s in non_empty)
|
||||
else:
|
||||
sources = "no active sources"
|
||||
return [
|
||||
f"<!-- META: {report.range_from} to {report.range_to} · {sources} -->",
|
||||
]
|
||||
|
||||
|
||||
def _render_html_data_quality_note(report: schema.Report) -> str | None:
|
||||
notes: list[str] = []
|
||||
degraded_warning = _render_degraded_run_warning(report)
|
||||
if degraded_warning:
|
||||
notes.append("This run was missing pre-flight resolution. Re-run with `--plan` for richer results.")
|
||||
pre_research_warning = _render_pre_research_warning(report)
|
||||
if pre_research_warning and not degraded_warning:
|
||||
notes.append("Pre-research was skipped, so results may be thinner than a resolved run.")
|
||||
freshness_warning = _assess_data_freshness(report)
|
||||
if freshness_warning:
|
||||
notes.append(freshness_warning)
|
||||
notes.extend(report.warnings)
|
||||
if not notes:
|
||||
return None
|
||||
return f"> **Data quality note:** {' '.join(_dedupe_notes(notes))}"
|
||||
|
||||
|
||||
def _render_html_comparison_data_quality_note(
|
||||
entity_reports: list[tuple[str, schema.Report]],
|
||||
) -> str | None:
|
||||
notes: list[str] = []
|
||||
for label, report in entity_reports:
|
||||
note = _render_html_data_quality_note(report)
|
||||
if note:
|
||||
clean = note.removeprefix("> **Data quality note:** ").strip()
|
||||
notes.append(f"{label}: {clean}")
|
||||
if not notes:
|
||||
return None
|
||||
return f"> **Data quality note:** {' '.join(_dedupe_notes(notes))}"
|
||||
|
||||
|
||||
def _dedupe_notes(notes: list[str]) -> list[str]:
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for note in notes:
|
||||
normalized = " ".join(str(note).split())
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
out.append(normalized)
|
||||
return out
|
||||
|
||||
|
||||
def _append_html_footer(lines: list[str], report: schema.Report, save_path: str | None) -> None:
|
||||
footer = _render_emoji_footer(report, save_path)
|
||||
lines.append("")
|
||||
lines.append("<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->")
|
||||
lines.extend(footer)
|
||||
lines.append("<!-- END PASS-THROUGH FOOTER -->")
|
||||
|
||||
|
||||
def _render_canonical_boundary() -> list[str]:
|
||||
"""Emit the explicit END-OF-CANONICAL-OUTPUT boundary.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user