返回 last30days-skill
html_render.py
根目录 / skills / last30days / scripts / lib / html_render.py
1 """HTML rendering for shareable last30days reports."""
2
3 from __future__ import annotations
4
5 import html
6 import re
7 from collections import OrderedDict
8 from collections.abc import Mapping, Sequence
9 from datetime import date
10
11 from . import registers, render, schema
12 from .library import LibraryEntry
13
14
15 PROSE_LABELS = [
16 ("What I learned:", "What I learned"),
17 ("KEY PATTERNS from the research:", "Key patterns from the research"),
18 ]
19
20 INVITATION_PATTERN = re.compile(r"^---\nI'm now an expert.*?Just ask\.$", re.MULTILINE | re.DOTALL)
21 EVIDENCE_BLOCK_PATTERN = re.compile(r"<!-- EVIDENCE FOR SYNTHESIS.*?<!-- END EVIDENCE FOR SYNTHESIS -->", re.DOTALL)
22 PASS_THROUGH_FOOTER_PATTERN = re.compile(r"<!-- PASS-THROUGH FOOTER.*?-->\n(.*?)<!-- END PASS-THROUGH FOOTER -->", re.DOTALL)
23 CANONICAL_BOUNDARY_PATTERN = re.compile(r"\n?---\n# END OF last30days CANONICAL OUTPUT.*$", re.DOTALL)
24 # render_for_html emits metadata as <!-- META: ... --> so it survives the
25 # markdown converter (which escapes raw HTML inside paragraphs). Promoted to
26 # a styled <div class="meta"> after conversion.
27 META_MARKER_PATTERN = re.compile(r"<!--\s*META:\s*(.*?)\s*-->")
28
29 CSS = """
30 :root {
31 --bg: #0e0e10;
32 --bg-elev: #18181b;
33 --fg: #fafafa;
34 --fg-muted: #a1a1aa;
35 --fg-subtle: #71717a;
36 --accent: #a855f7;
37 --accent-soft: #c4b5fd;
38 --border: #27272a;
39 --code-bg: #1a1a1d;
40 --max-w: 720px;
41 }
42
43 @media (prefers-color-scheme: light) {
44 :root {
45 --bg: #ffffff;
46 --bg-elev: #fafafa;
47 --fg: #18181b;
48 --fg-muted: #52525b;
49 --fg-subtle: #71717a;
50 --accent: #7c3aed;
51 --accent-soft: #6d28d9;
52 --border: #e4e4e7;
53 --code-bg: #f4f4f5;
54 }
55 }
56
57 * { box-sizing: border-box; }
58
59 html, body {
60 margin: 0;
61 padding: 0;
62 background: var(--bg);
63 color: var(--fg);
64 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif;
65 font-size: 17px;
66 line-height: 1.65;
67 -webkit-font-smoothing: antialiased;
68 -moz-osx-font-smoothing: grayscale;
69 text-rendering: optimizeLegibility;
70 }
71
72 body {
73 max-width: var(--max-w);
74 margin: 0 auto;
75 padding: 4rem 1.5rem 6rem;
76 }
77
78 .badge {
79 display: inline-block;
80 padding: 0.4rem 0.85rem;
81 margin-bottom: 2.5rem;
82 background: var(--bg-elev);
83 border: 1px solid var(--border);
84 border-radius: 999px;
85 font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
86 font-size: 13px;
87 font-weight: 500;
88 color: var(--fg-muted);
89 letter-spacing: 0;
90 }
91
92 .badge .accent { color: var(--accent); }
93
94 .meta {
95 margin: -1.5rem 0 2.5rem;
96 color: var(--fg-subtle);
97 font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
98 font-size: 13px;
99 letter-spacing: 0.01em;
100 }
101
102 h1 {
103 margin: 0 0 1.5rem;
104 color: var(--fg);
105 font-size: 30px;
106 font-weight: 700;
107 line-height: 1.2;
108 letter-spacing: 0;
109 }
110
111 h2,
112 .prose-label {
113 margin: 2.75rem 0 1.25rem;
114 color: var(--fg);
115 font-size: 20px;
116 font-weight: 600;
117 line-height: 1.35;
118 letter-spacing: 0;
119 }
120
121 .badge + h2,
122 .badge + .prose-label { margin-top: 0.5rem; }
123
124 h3 {
125 margin: 2rem 0 0.85rem;
126 color: var(--fg);
127 font-size: 17px;
128 font-weight: 600;
129 line-height: 1.4;
130 letter-spacing: 0;
131 }
132
133 p {
134 margin: 0 0 1.4rem;
135 color: var(--fg-muted);
136 }
137
138 p strong,
139 li strong,
140 td strong {
141 color: var(--fg);
142 font-weight: 600;
143 }
144
145 a {
146 color: var(--accent);
147 text-decoration: none;
148 border-bottom: 1px solid transparent;
149 transition: border-color 0.15s ease;
150 }
151
152 a:hover { border-bottom-color: var(--accent); }
153
154 ul,
155 ol {
156 margin: 0 0 1.6rem;
157 padding-left: 1.5rem;
158 color: var(--fg-muted);
159 }
160
161 li {
162 margin: 0.6rem 0;
163 padding-left: 0.4rem;
164 }
165
166 li::marker {
167 color: var(--accent);
168 font-weight: 600;
169 }
170
171 blockquote {
172 margin: 1.5rem 0;
173 padding-left: 1rem;
174 border-left: 3px solid var(--accent);
175 color: var(--fg-muted);
176 }
177
178 hr {
179 margin: 2.5rem 0;
180 border: 0;
181 border-top: 1px solid var(--border);
182 }
183
184 code {
185 font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
186 font-size: 0.92em;
187 background: var(--code-bg);
188 padding: 0.15rem 0.4rem;
189 border-radius: 4px;
190 color: var(--accent-soft);
191 }
192
193 pre {
194 margin: 1.4rem 0;
195 background: var(--code-bg);
196 border: 1px solid var(--border);
197 border-radius: 8px;
198 padding: 1rem 1.25rem;
199 overflow-x: auto;
200 font-size: 14px;
201 line-height: 1.6;
202 }
203
204 pre code {
205 background: none;
206 padding: 0;
207 color: var(--fg);
208 }
209
210 table {
211 width: 100%;
212 border-collapse: collapse;
213 margin: 1.5rem 0;
214 font-size: 15px;
215 }
216
217 th,
218 td {
219 text-align: left;
220 padding: 0.75rem 1rem;
221 border-bottom: 1px solid var(--border);
222 vertical-align: top;
223 }
224
225 th {
226 color: var(--fg-muted);
227 font-weight: 600;
228 font-size: 13px;
229 letter-spacing: 0;
230 text-transform: uppercase;
231 }
232
233 td { color: var(--fg-muted); }
234 td:first-child { color: var(--fg); font-weight: 500; }
235
236 .engine-footer {
237 margin: 3rem 0 2.5rem;
238 padding: 1.25rem 1.5rem;
239 background: var(--bg-elev);
240 border: 1px solid var(--border);
241 border-radius: 8px;
242 color: var(--fg-muted);
243 }
244
245 .engine-footer pre {
246 margin: 0;
247 padding: 0;
248 background: transparent;
249 border: 0;
250 border-radius: 0;
251 font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
252 font-size: 13.5px;
253 font-weight: 400;
254 line-height: 1.75;
255 color: inherit;
256 white-space: pre-wrap;
257 word-break: break-word;
258 }
259
260 .colophon {
261 margin-top: 4rem;
262 padding-top: 2rem;
263 border-top: 1px solid var(--border);
264 color: var(--fg-subtle);
265 font-size: 13px;
266 font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
267 line-height: 1.7;
268 }
269
270 .colophon .rerun {
271 display: inline-block;
272 padding: 0.15rem 0.5rem;
273 margin-left: 0.25rem;
274 background: var(--code-bg);
275 border-radius: 4px;
276 color: var(--accent-soft);
277 font-size: 0.95em;
278 }
279
280 .library-hero {
281 padding: 1.5rem 0 2.5rem;
282 border-bottom: 1px solid var(--border);
283 }
284
285 .library-hero h1 { margin-bottom: 0.65rem; }
286 .library-hero p { max-width: 42rem; color: var(--fg-muted); }
287 .library-hero .subscribe { font-weight: 700; }
288
289 .library-topic { margin-top: 3rem; }
290
291 .library-topic-heading {
292 display: flex;
293 align-items: baseline;
294 justify-content: space-between;
295 gap: 1rem;
296 border-bottom: 1px solid var(--border);
297 }
298
299 .library-topic-heading h2 { margin-bottom: 0.65rem; }
300 .library-topic-heading a { font-size: 0.85rem; }
301
302 .library-entry {
303 display: grid;
304 grid-template-columns: 7rem minmax(0, 1fr);
305 column-gap: 1.25rem;
306 padding: 1.35rem 0;
307 border-bottom: 1px solid var(--border);
308 }
309
310 .library-entry time {
311 grid-row: 1 / span 2;
312 color: var(--fg-subtle);
313 font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
314 font-size: 0.8rem;
315 }
316
317 .library-entry h3 { margin: 0 0 0.35rem; font-size: 1.1rem; }
318 .library-entry p { margin: 0; color: var(--fg-muted); }
319 .library-empty { padding: 3rem 0; }
320
321 @media print {
322 :root {
323 --bg: #ffffff;
324 --bg-elev: #f5f5f5;
325 --fg: #000000;
326 --fg-muted: #1f2937;
327 --fg-subtle: #4b5563;
328 --accent: #6d28d9;
329 --accent-soft: #6d28d9;
330 --border: #d4d4d8;
331 --code-bg: #f4f4f5;
332 }
333
334 @page { size: A4; margin: 1.5cm 2cm; }
335
336 body {
337 max-width: none;
338 padding: 0;
339 font-size: 11pt;
340 }
341
342 a {
343 color: inherit;
344 border-bottom: 0;
345 text-decoration: underline;
346 }
347
348 a[href]::after {
349 content: " (" attr(href) ")";
350 font-size: 0.85em;
351 color: var(--fg-subtle);
352 }
353
354 .engine-footer { page-break-inside: avoid; }
355 }
356
357 @media (max-width: 600px) {
358 body {
359 padding: 2.5rem 1.25rem 4rem;
360 font-size: 16px;
361 }
362
363 h1 { font-size: 25px; }
364 .badge { font-size: 12px; }
365 th, td { padding: 0.65rem 0.5rem; }
366 .library-entry { grid-template-columns: 1fr; }
367 .library-entry time { grid-row: auto; margin-bottom: 0.5rem; }
368 }
369 """.strip()
370
371 HTML_TEMPLATE = """<!DOCTYPE html>
372 <html lang="en">
373 <head>
374 <meta charset="utf-8">
375 <meta name="viewport" content="width=device-width, initial-scale=1">
376 <title>last30days · __TITLE__</title>
377 <style>
378 __CSS__
379 </style>
380 </head>
381 <body>
382 __BODY__
383 __COLOPHON__
384 </body>
385 </html>
386 """
387
388
389 def render_html(
390 report: schema.Report,
391 *,
392 fun_level: str = "medium",
393 save_path: str | None = None,
394 synthesis_md: str | None = None,
395 register: str = "default",
396 ) -> str:
397 md = render.render_for_html(
398 report,
399 synthesis_md=synthesis_md,
400 save_path=save_path,
401 fun_level=fun_level,
402 register=register,
403 )
404 md = _strip_evidence_block(md)
405 md = _strip_invitation(md)
406 md = _strip_canonical_boundary(md)
407 md = _promote_prose_labels(md)
408 body = _markdown_to_html(md)
409 body = _wrap_engine_footer(body)
410 body = _promote_meta_marker(body)
411 colophon = _build_colophon(report)
412 return _wrap_in_template(body, colophon, report.topic)
413
414
415 def render_html_comparison(
416 entity_reports: list[tuple[str, schema.Report]],
417 *,
418 fun_level: str = "medium",
419 save_path: str | None = None,
420 synthesis_md: str | None = None,
421 ) -> str:
422 _ = fun_level
423 md = render.render_for_html_comparison(
424 entity_reports, synthesis_md=synthesis_md, save_path=save_path,
425 )
426 md = _strip_evidence_block(md)
427 md = _strip_invitation(md)
428 md = _strip_canonical_boundary(md)
429 md = _promote_prose_labels(md)
430 body = _markdown_to_html(md)
431 body = _wrap_engine_footer(body)
432 body = _promote_meta_marker(body)
433 topic = " vs ".join(label for label, _ in entity_reports)
434 colophon = _build_colophon(entity_reports[0][1], topic=topic)
435 return _wrap_in_template(body, colophon, topic)
436
437
438 LIBRARY_BRIEF_MARKER = "<!-- generated by last30days library feed -->"
439
440
441 def render_library_brief(entry: LibraryEntry, *, include_private: bool = True) -> str:
442 """Render a scanned Markdown or JSON briefing as a safe standalone page."""
443 md = _strip_invitation(entry.content)
444 md = _strip_canonical_boundary(md)
445 if not include_private:
446 md = _strip_private_corpus(md)
447 body = _markdown_to_html(md)
448 body = _wrap_engine_footer(body)
449 colophon = (
450 '<footer class="colophon">'
451 f"Saved research · {html.escape(entry.published_date.isoformat())} · "
452 f"{html.escape(entry.topic)}"
453 "</footer>"
454 )
455 rendered = scrub_publishable_digit_runs(
456 _wrap_in_template(body, colophon, entry.headline)
457 )
458 # Ownership marker consumed by the library-feed prune: a generated-looking
459 # filename alone must never be grounds for deletion.
460 return rendered.replace("</body>", f"{LIBRARY_BRIEF_MARKER}\n</body>", 1)
461
462
463 _PRIVATE_CORPUS_BLOCK = re.compile(
464 r"<!-- LAST30DAYS_PRIVATE_CORPUS_START -->.*?"
465 r"<!-- LAST30DAYS_PRIVATE_CORPUS_END -->\s*",
466 re.DOTALL,
467 )
468
469
470 def _strip_private_corpus(markdown: str) -> str:
471 """Remove the renderer-marked local corpus section before publication."""
472 return _PRIVATE_CORPUS_BLOCK.sub("", markdown)
473
474
475 def render_library_index(
476 entries: Sequence[LibraryEntry],
477 *,
478 entry_urls: Mapping[str, str] | None = None,
479 feed_url: str | None = "feed.xml",
480 ) -> str:
481 """Render the reverse-chronological, topic-grouped research index."""
482 urls = entry_urls or {}
483 grouped: OrderedDict[str, list[LibraryEntry]] = OrderedDict()
484 for entry in entries:
485 grouped.setdefault(entry.topic, []).append(entry)
486
487 parts = [
488 '<header class="library-hero">',
489 '<span class="badge">RESEARCH LIBRARY</span>',
490 '<h1>What the community is learning</h1>',
491 ]
492 if feed_url is None:
493 parts.append('<p>Saved last30days briefs, newest first.</p>')
494 else:
495 parts.extend([
496 '<p>Saved last30days briefs, newest first. Follow the Atom feed to keep up.</p>',
497 f'<p><a class="subscribe" href="{html.escape(feed_url, quote=True)}">Subscribe via Atom</a></p>',
498 ])
499 parts.append('</header>')
500 if not entries:
501 parts.append('<section class="library-empty"><h2>No saved briefs yet</h2><p>Run last30days research and this library will fill itself.</p></section>')
502 for topic, topic_entries in grouped.items():
503 latest = topic_entries[0]
504 latest_url = urls.get(latest.entry_id, f"briefs/{latest.output_name}")
505 parts.extend([
506 '<section class="library-topic">',
507 '<div class="library-topic-heading">',
508 f'<h2>{html.escape(topic)}</h2>',
509 f'<a href="{html.escape(latest_url, quote=True)}">Latest</a>',
510 '</div>',
511 ])
512 for entry in topic_entries:
513 url = urls.get(entry.entry_id, f"briefs/{entry.output_name}")
514 parts.extend([
515 '<article class="library-entry">',
516 f'<time datetime="{entry.published_date.isoformat()}">{entry.published_date.isoformat()}</time>',
517 f'<h3><a href="{html.escape(url, quote=True)}">{html.escape(entry.headline)}</a></h3>',
518 f'<p>{html.escape(entry.summary)}</p>',
519 '</article>',
520 ])
521 parts.append('</section>')
522 colophon = '<footer class="colophon">Generated locally by <strong>last30days</strong>.</footer>'
523 rendered = _wrap_in_template("\n".join(parts), colophon, "Research library")
524 return scrub_publishable_digit_runs(rendered)
525
526
527 _HREF_PATTERN = re.compile(r'(?P<prefix>\bhref\s*=\s*)(?P<quote>["\'])(?P<url>.*?)(?P=quote)', re.IGNORECASE)
528 _LONG_DIGIT_RUN = re.compile(r"\d{13,19}")
529
530
531 def scrub_publishable_digit_runs(html_content: str) -> str:
532 """Defuse payment-card-shaped digit runs before hosted publishing.
533
534 ht-ml.app rejects pages containing 13-19 digit runs during its safety scan.
535 Social post IDs commonly have that shape. Link targets are percent-encoded
536 so they still resolve; visible occurrences are shortened for readability.
537 """
538 def scrub_href(match: re.Match[str]) -> str:
539 url = _LONG_DIGIT_RUN.sub(
540 lambda digits: "".join(f"%{ord(char):02X}" for char in digits.group(0)),
541 match.group("url"),
542 )
543 return f'{match.group("prefix")}{match.group("quote")}{url}{match.group("quote")}'
544
545 with_safe_hrefs = _HREF_PATTERN.sub(scrub_href, html_content)
546 return _LONG_DIGIT_RUN.sub(
547 lambda digits: f"{digits.group(0)[:6]}…{digits.group(0)[-4:]}",
548 with_safe_hrefs,
549 )
550
551
552 def _strip_evidence_block(md: str) -> str:
553 return EVIDENCE_BLOCK_PATTERN.sub("", md)
554
555
556 def _strip_invitation(md: str) -> str:
557 return INVITATION_PATTERN.sub("", md)
558
559
560 def _strip_canonical_boundary(md: str) -> str:
561 return CANONICAL_BOUNDARY_PATTERN.sub("", md)
562
563
564 def _promote_prose_labels(md: str) -> str:
565 for source, normalized in PROSE_LABELS:
566 md = re.sub(
567 rf"^{re.escape(source)}$",
568 f"## {normalized}",
569 md,
570 flags=re.MULTILINE,
571 )
572 return md
573
574
575 def _markdown_to_html(md: str) -> str:
576 md, footers = _protect_engine_footers(md)
577 global _ENGINE_FOOTER_STORE
578 _ENGINE_FOOTER_STORE = footers
579 # Strip HTML comments EXCEPT preserved markers used for post-processing
580 # (META is promoted to <div class="meta"> after markdown conversion).
581 md = re.sub(r"<!--(?!\s*META:).*?-->", "", md, flags=re.DOTALL)
582 lines = md.splitlines()
583 out: list[str] = []
584 paragraph: list[str] = []
585 list_type: str | None = None
586 in_code = False
587 code_lines: list[str] = []
588 index = 0
589
590 def flush_paragraph() -> None:
591 nonlocal paragraph
592 if paragraph:
593 text = " ".join(part.strip() for part in paragraph).strip()
594 if text:
595 out.append(f"<p>{_inline_markdown(text)}</p>")
596 paragraph = []
597
598 def close_list() -> None:
599 nonlocal list_type
600 if list_type:
601 out.append(f"</{list_type}>")
602 list_type = None
603
604 while index < len(lines):
605 line = lines[index]
606 stripped = line.strip()
607
608 if in_code:
609 if stripped.startswith("```"):
610 out.append(f"<pre><code>{html.escape(chr(10).join(code_lines))}</code></pre>")
611 code_lines = []
612 in_code = False
613 else:
614 code_lines.append(line)
615 index += 1
616 continue
617
618 if stripped.startswith("```"):
619 flush_paragraph()
620 close_list()
621 in_code = True
622 code_lines = []
623 index += 1
624 continue
625
626 if stripped in footers:
627 flush_paragraph()
628 close_list()
629 out.append(stripped)
630 index += 1
631 continue
632
633 if not stripped:
634 flush_paragraph()
635 close_list()
636 index += 1
637 continue
638
639 if stripped == "---":
640 flush_paragraph()
641 close_list()
642 out.append("<hr>")
643 index += 1
644 continue
645
646 if index + 1 < len(lines) and _is_table_row(stripped) and _is_table_separator(lines[index + 1].strip()):
647 flush_paragraph()
648 close_list()
649 table_lines = [stripped]
650 index += 2
651 while index < len(lines) and _is_table_row(lines[index].strip()):
652 table_lines.append(lines[index].strip())
653 index += 1
654 out.append(_render_table(table_lines))
655 continue
656
657 heading = re.match(r"^(#{1,4})\s+(.+)$", stripped)
658 if heading:
659 flush_paragraph()
660 close_list()
661 level = min(len(heading.group(1)), 3)
662 out.append(f"<h{level}>{_inline_markdown(heading.group(2))}</h{level}>")
663 index += 1
664 continue
665
666 if stripped.startswith(">"):
667 flush_paragraph()
668 close_list()
669 quote_lines = []
670 while index < len(lines) and lines[index].strip().startswith(">"):
671 quote_lines.append(lines[index].strip().lstrip(">").strip())
672 index += 1
673 out.append(f"<blockquote>{_inline_markdown(' '.join(quote_lines))}</blockquote>")
674 continue
675
676 unordered = re.match(r"^[-*]\s+(.+)$", stripped)
677 ordered = re.match(r"^\d+[.)]\s+(.+)$", stripped)
678 if unordered or ordered:
679 flush_paragraph()
680 next_type = "ul" if unordered else "ol"
681 if list_type != next_type:
682 close_list()
683 out.append(f"<{next_type}>")
684 list_type = next_type
685 item = unordered.group(1) if unordered else ordered.group(1)
686 out.append(f"<li>{_inline_markdown(item)}</li>")
687 index += 1
688 continue
689
690 if stripped.startswith("🌐 last30days"):
691 flush_paragraph()
692 close_list()
693 badge_text = _inline_markdown(stripped.removeprefix("🌐").strip())
694 out.append(f'<div class="badge"><span class="accent">🌐</span> {badge_text}</div>')
695 index += 1
696 continue
697
698 paragraph.append(line)
699 index += 1
700
701 if in_code:
702 out.append(f"<pre><code>{html.escape(chr(10).join(code_lines))}</code></pre>")
703 flush_paragraph()
704 close_list()
705 return "\n".join(out).strip()
706
707
708 def _protect_engine_footers(md: str) -> tuple[str, dict[str, str]]:
709 footers: dict[str, str] = {}
710
711 def replace(match: re.Match[str]) -> str:
712 token = f"__LAST30DAYS_ENGINE_FOOTER_{len(footers)}__"
713 footers[token] = match.group(1).strip("\n")
714 return f"\n{token}\n"
715
716 return PASS_THROUGH_FOOTER_PATTERN.sub(replace, md), footers
717
718
719 def _wrap_engine_footer(body: str) -> str:
720 def replace(match: re.Match[str]) -> str:
721 footer = html.escape(_ENGINE_FOOTER_STORE.get(match.group(0), ""), quote=False)
722 return f'<div class="engine-footer"><pre>{footer}</pre></div>'
723
724 return re.sub(
725 r"__LAST30DAYS_ENGINE_FOOTER_\d+__",
726 replace,
727 body,
728 )
729
730
731 def _promote_meta_marker(body: str) -> str:
732 """Promote ``<!-- META: ... -->`` markers into a styled ``<div class="meta">``.
733
734 The marker is preserved through the comment-strip pass (see
735 _markdown_to_html exemption) but the markdown converter wraps it in
736 ``<p>`` and HTML-escapes the angle brackets. After conversion the body
737 contains shapes like:
738 <p>&lt;!-- META: TEXT --&gt;</p>
739 <p><!-- META: TEXT --></p> (when not escaped)
740 Both collapse to ``<div class="meta">TEXT</div>``.
741 """
742 def replace(match: re.Match[str]) -> str:
743 # The marker survives the comment-strip pass, and the markdown reaching
744 # this point can include LLM-synthesized content derived from untrusted
745 # web/social bodies. The escaped-form branches below carry text the
746 # markdown pass already entity-escaped, while the raw-form fallbacks do
747 # not — so normalize with unescape, then escape exactly once. A crafted
748 # `<!-- META: <img src=x onerror=...> -->` thus cannot render as live
749 # markup in the saved, shareable HTML artifact, and legitimate
750 # date/source-name markers render unchanged.
751 text = html.escape(html.unescape(match.group(1).strip()))
752 return f'<div class="meta">{text}</div>'
753
754 # Escaped form (most common after markdown conversion)
755 body = re.sub(
756 r"<p>\s*&lt;!--\s*META:\s*(.*?)\s*--&gt;\s*</p>",
757 replace,
758 body,
759 )
760 body = re.sub(r"&lt;!--\s*META:\s*(.*?)\s*--&gt;", replace, body)
761 # Unescaped form (paranoid fallback)
762 body = re.sub(r"<p>\s*<!--\s*META:\s*(.*?)\s*-->\s*</p>", replace, body)
763 body = re.sub(r"<!--\s*META:\s*(.*?)\s*-->", replace, body)
764 return body
765
766
767 _ENGINE_FOOTER_STORE: dict[str, str] = {}
768
769 # Schemes allowed in synthesized markdown links. The HTML artifact is opened in
770 # a browser, so a permissive link parser exposes a stored-XSS vector: a
771 # `[label](javascript:...)` or `[label](data:text/html,...)` link surviving the
772 # LLM synthesis would render as a clickable script payload in the saved file.
773 # Restrict link URLs to a small allowlist of schemes and accept relative URLs
774 # (no scheme, no `:` before the first `/` or `?`). Anything else is rendered as
775 # plain bracketed text so the label remains readable.
776 _SAFE_LINK_SCHEMES = frozenset({"http", "https", "mailto"})
777
778
779 def _is_safe_link_url(url: str) -> bool:
780 """Return True if a markdown link URL is safe to render as an `<a href>`.
781
782 A URL is safe if it either:
783 - has no scheme (relative URL, fragment, or path-only), or
784 - uses a scheme in the allowlist (http, https, mailto).
785
786 The scheme check is case-insensitive and tolerant of surrounding
787 whitespace per RFC 3986.
788
789 Precondition: ``url`` must already have been through ``html.escape`` (as it
790 is at the sole caller in ``_inline_markdown``). The safety of the no-scheme
791 branch relies on ``&`` having been escaped to ``&amp;`` so an entity-encoded
792 colon like ``&#58;`` cannot survive into the rendered ``href`` and be
793 decoded back to ``:`` by the browser. Passing a *raw* URL here (e.g.
794 ``javascript&#58;alert(1)``) would see no literal ``:``, return ``True``,
795 and emit a clickable payload — do not call this on un-escaped input.
796 """
797 stripped = url.strip()
798 if not stripped:
799 return False
800 # Reject any control characters (e.g. `java\x0Dscript:` where a bare CR is
801 # smuggled into the scheme name and stripped by the browser's URL parser).
802 if any(ord(ch) < 0x20 for ch in stripped):
803 return False
804 # No scheme — relative URL or fragment. Allow.
805 colon = stripped.find(":")
806 if colon == -1:
807 return True
808 slash = stripped.find("/")
809 question = stripped.find("?")
810 hash_ = stripped.find("#")
811 # The first `:` that comes after a `/`, `?`, or `#` is path/query/fragment,
812 # not a scheme separator. e.g. `/path:with:colons` is a relative URL.
813 earlier = [pos for pos in (slash, question, hash_) if 0 <= pos < colon]
814 if earlier:
815 return True
816 scheme = stripped[:colon].lower()
817 return scheme in _SAFE_LINK_SCHEMES
818
819
820 def _inline_markdown(text: str) -> str:
821 escaped = html.escape(text, quote=True)
822 code_tokens: dict[str, str] = {}
823
824 def code_replace(match: re.Match[str]) -> str:
825 token = f"__CODE_{len(code_tokens)}__"
826 code_tokens[token] = f"<code>{match.group(1)}</code>"
827 return token
828
829 escaped = re.sub(r"`([^`]+)`", code_replace, escaped)
830 escaped = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", escaped)
831
832 def link_replace(match: re.Match[str]) -> str:
833 label = match.group(1)
834 url = match.group(2)
835 # `url` is HTML-escaped (so `"` is `&quot;`, etc.) but
836 # `html.unescape` decoding before the scheme check would let a
837 # `javascript&#58;alert(1)` payload through. Inspect the literal
838 # bytes the regex captured instead — that matches what the browser
839 # ultimately sees in the href attribute.
840 if not _is_safe_link_url(url):
841 return f"[{label}]({url})"
842 return f'<a href="{url}" rel="noopener noreferrer">{label}</a>'
843
844 escaped = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", link_replace, escaped)
845 for token, value in code_tokens.items():
846 escaped = escaped.replace(token, value)
847 return escaped
848
849
850 def _is_table_row(line: str) -> bool:
851 return "|" in line and len(_split_table_cells(line)) >= 2
852
853
854 def _is_table_separator(line: str) -> bool:
855 cells = _split_table_cells(line)
856 return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell.strip()) for cell in cells)
857
858
859 def _split_table_cells(line: str) -> list[str]:
860 return [cell.strip() for cell in line.strip().strip("|").split("|")]
861
862
863 def _render_table(rows: list[str]) -> str:
864 header = _split_table_cells(rows[0])
865 body_rows = [_split_table_cells(row) for row in rows[1:]]
866 out = ["<table>", "<thead>", "<tr>"]
867 out.extend(f"<th>{_inline_markdown(cell)}</th>" for cell in header)
868 out.extend(["</tr>", "</thead>", "<tbody>"])
869 for row in body_rows:
870 out.append("<tr>")
871 out.extend(f"<td>{_inline_markdown(cell)}</td>" for cell in row)
872 out.append("</tr>")
873 out.extend(["</tbody>", "</table>"])
874 return "\n".join(out)
875
876
877 def _build_colophon(report: schema.Report, *, topic: str | None = None) -> str:
878 display_topic = topic or report.topic
879 generated = _generated_date(report)
880 version = render._skill_version()
881 escaped_topic = html.escape(display_topic)
882 rerun = html.escape(f"/last30days {display_topic}")
883 return (
884 '<div class="colophon">\n'
885 f" Generated {generated} by /last30days v{html.escape(version)} · topic: {escaped_topic}<br>\n"
886 f' Re-run for fresh data: <span class="rerun">{rerun}</span>\n'
887 "</div>"
888 )
889
890
891 def _generated_date(report: schema.Report) -> str:
892 if report.generated_at:
893 return report.generated_at[:10]
894 return date.today().strftime("%Y-%m-%d")
895
896
897 def _wrap_in_template(body: str, colophon: str, title: str) -> str:
898 return (
899 HTML_TEMPLATE
900 .replace("__TITLE__", html.escape(title))
901 .replace("__CSS__", CSS)
902 .replace("__BODY__", body)
903 .replace("__COLOPHON__", colophon)
904 )
905
905 lines PYTHON