create-hedgeboard 1.0.7 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +16 -18
- package/package.json +1 -1
- package/hedgeboard/CLAUDE.md +0 -284
- package/hedgeboard/README.md +0 -64
- package/hedgeboard/brand/base.css +0 -419
- package/hedgeboard/brand/theme.json +0 -25
- package/hedgeboard/data/__init__.py +0 -1
- package/hedgeboard/data/sec.py +0 -218
- package/hedgeboard/modules/__init__.py +0 -1
- package/hedgeboard/modules/company_overview.py +0 -157
- package/hedgeboard/requirements.txt +0 -2
- package/hedgeboard/viz/__init__.py +0 -1
- package/hedgeboard/viz/charts.py +0 -198
- package/hedgeboard/viz/components.py +0 -391
- package/hedgeboard/viz/dashboard.py +0 -330
- package/hedgeboard/viz/tables.py +0 -140
|
@@ -1,330 +0,0 @@
|
|
|
1
|
-
"""Dashboard renderer.
|
|
2
|
-
|
|
3
|
-
Generates full HTML pages with branded styling, Chart.js charts,
|
|
4
|
-
and financial tables. Saves to output/ and serves on localhost.
|
|
5
|
-
|
|
6
|
-
Usage:
|
|
7
|
-
from viz.dashboard import render_dashboard
|
|
8
|
-
|
|
9
|
-
render_dashboard(
|
|
10
|
-
title="Twilio Company Overview",
|
|
11
|
-
sections=[chart_html, table_html, "## Analysis\nKey insight here."],
|
|
12
|
-
output_name="twilio_overview"
|
|
13
|
-
)
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
import http.server
|
|
19
|
-
import json
|
|
20
|
-
import os
|
|
21
|
-
import re
|
|
22
|
-
import threading
|
|
23
|
-
import webbrowser
|
|
24
|
-
from datetime import datetime
|
|
25
|
-
from pathlib import Path
|
|
26
|
-
|
|
27
|
-
_ROOT = Path(__file__).resolve().parent.parent
|
|
28
|
-
_THEME_PATH = _ROOT / "brand" / "theme.json"
|
|
29
|
-
_CSS_PATH = _ROOT / "brand" / "base.css"
|
|
30
|
-
_OUTPUT_DIR = _ROOT / "output"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _load_theme() -> dict:
|
|
34
|
-
try:
|
|
35
|
-
with open(_THEME_PATH) as f:
|
|
36
|
-
return json.load(f)
|
|
37
|
-
except (FileNotFoundError, json.JSONDecodeError):
|
|
38
|
-
return {
|
|
39
|
-
"name": "HedgeBoard",
|
|
40
|
-
"colors": {
|
|
41
|
-
"primary": "#EAEAEA",
|
|
42
|
-
"accent": "#CACACA",
|
|
43
|
-
"positive": "#4ADE80",
|
|
44
|
-
"negative": "#F87171",
|
|
45
|
-
"background": "#0C0C10",
|
|
46
|
-
"surface": "#151519",
|
|
47
|
-
"text": "#EAEAEA",
|
|
48
|
-
"text_secondary": "#B0B0B8",
|
|
49
|
-
"muted": "#6B6B78",
|
|
50
|
-
"border": "#2A2A33",
|
|
51
|
-
},
|
|
52
|
-
"font": {"family": "Space Grotesk", "headings": "Space Grotesk", "mono": "IBM Plex Mono"},
|
|
53
|
-
"mode": "dark",
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _load_css() -> str:
|
|
58
|
-
try:
|
|
59
|
-
return _CSS_PATH.read_text()
|
|
60
|
-
except FileNotFoundError:
|
|
61
|
-
return ""
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _markdown_to_html(text: str) -> str:
|
|
65
|
-
"""Minimal markdown conversion for analysis sections."""
|
|
66
|
-
# Headers
|
|
67
|
-
text = re.sub(r"^### (.+)$", r"<h3>\1</h3>", text, flags=re.MULTILINE)
|
|
68
|
-
text = re.sub(r"^## (.+)$", r"<h2>\1</h2>", text, flags=re.MULTILINE)
|
|
69
|
-
text = re.sub(r"^# (.+)$", r"<h1>\1</h1>", text, flags=re.MULTILINE)
|
|
70
|
-
# Bold and italic
|
|
71
|
-
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
|
72
|
-
text = re.sub(r"\*(.+?)\*", r"<em>\1</em>", text)
|
|
73
|
-
# Line breaks → paragraphs
|
|
74
|
-
paragraphs = text.split("\n\n")
|
|
75
|
-
result = []
|
|
76
|
-
for p in paragraphs:
|
|
77
|
-
p = p.strip()
|
|
78
|
-
if not p:
|
|
79
|
-
continue
|
|
80
|
-
if p.startswith("<h") or p.startswith("<div") or p.startswith("<table"):
|
|
81
|
-
result.append(p)
|
|
82
|
-
else:
|
|
83
|
-
result.append(f"<p>{p}</p>")
|
|
84
|
-
return "\n".join(result)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def render_dashboard(
|
|
88
|
-
title: str,
|
|
89
|
-
sections: list[str],
|
|
90
|
-
output_name: str | None = None,
|
|
91
|
-
serve: bool = True,
|
|
92
|
-
port: int = 4747,
|
|
93
|
-
) -> Path:
|
|
94
|
-
"""Render a full branded dashboard page.
|
|
95
|
-
|
|
96
|
-
Args:
|
|
97
|
-
title: Dashboard title.
|
|
98
|
-
sections: List of HTML snippets or markdown text. Each becomes
|
|
99
|
-
a section in the dashboard.
|
|
100
|
-
output_name: Folder name under output/. Auto-generated if None.
|
|
101
|
-
serve: If True, start a local HTTP server and open in browser.
|
|
102
|
-
port: Port for the local server.
|
|
103
|
-
|
|
104
|
-
Returns:
|
|
105
|
-
Path to the generated index.html.
|
|
106
|
-
"""
|
|
107
|
-
theme = _load_theme()
|
|
108
|
-
colors = theme.get("colors", {})
|
|
109
|
-
font = theme.get("font", {})
|
|
110
|
-
brand_name = theme.get("name", "HedgeBoard")
|
|
111
|
-
css = _load_css()
|
|
112
|
-
|
|
113
|
-
# Generate output folder name
|
|
114
|
-
if not output_name:
|
|
115
|
-
slug = re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_")
|
|
116
|
-
output_name = f"{slug}_{datetime.now().strftime('%Y%m%d_%H%M')}"
|
|
117
|
-
|
|
118
|
-
output_dir = _OUTPUT_DIR / output_name
|
|
119
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
120
|
-
|
|
121
|
-
# Logo
|
|
122
|
-
logo_path = theme.get("logo", "")
|
|
123
|
-
logo_html = ""
|
|
124
|
-
if logo_path:
|
|
125
|
-
abs_logo = _ROOT / logo_path
|
|
126
|
-
if abs_logo.exists():
|
|
127
|
-
logo_html = f'<img src="../../{logo_path}" alt="{brand_name}" style="height:32px;margin-right:12px;">'
|
|
128
|
-
|
|
129
|
-
# Build sections HTML
|
|
130
|
-
sections_html = []
|
|
131
|
-
for section in sections:
|
|
132
|
-
if section.strip().startswith("<"):
|
|
133
|
-
# Already HTML (chart or table)
|
|
134
|
-
sections_html.append(
|
|
135
|
-
f'<div class="section">{section}</div>'
|
|
136
|
-
)
|
|
137
|
-
else:
|
|
138
|
-
# Markdown text
|
|
139
|
-
sections_html.append(
|
|
140
|
-
f'<div class="section text-section">{_markdown_to_html(section)}</div>'
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
font_family = font.get("family", "Space Grotesk")
|
|
144
|
-
heading_font = font.get("headings", font_family)
|
|
145
|
-
mono_font = font.get("mono", "IBM Plex Mono")
|
|
146
|
-
|
|
147
|
-
html = f"""<!DOCTYPE html>
|
|
148
|
-
<html lang="en">
|
|
149
|
-
<head>
|
|
150
|
-
<meta charset="UTF-8">
|
|
151
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
152
|
-
<title>{title} — {brand_name}</title>
|
|
153
|
-
<link href="https://fonts.googleapis.com/css2?family={font_family.replace(' ', '+')}:wght@400;500;600;700&family={heading_font.replace(' ', '+')}:wght@600;700&family={mono_font.replace(' ', '+')}:wght@400;500;600&display=swap" rel="stylesheet">
|
|
154
|
-
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
|
155
|
-
<style>
|
|
156
|
-
:root {{
|
|
157
|
-
--color-primary: {colors.get('primary', '#EAEAEA')};
|
|
158
|
-
--color-accent: {colors.get('accent', '#CACACA')};
|
|
159
|
-
--color-positive: {colors.get('positive', '#4ADE80')};
|
|
160
|
-
--color-negative: {colors.get('negative', '#F87171')};
|
|
161
|
-
--color-bg: {colors.get('background', '#0C0C10')};
|
|
162
|
-
--color-surface: {colors.get('surface', '#151519')};
|
|
163
|
-
--color-text: {colors.get('text', '#EAEAEA')};
|
|
164
|
-
--color-muted: {colors.get('muted', '#6B6B78')};
|
|
165
|
-
--color-border: {colors.get('border', '#2A2A33')};
|
|
166
|
-
--font-body: '{font_family}', system-ui, sans-serif;
|
|
167
|
-
--font-heading: '{heading_font}', system-ui, sans-serif;
|
|
168
|
-
--font-mono: '{mono_font}', monospace;
|
|
169
|
-
}}
|
|
170
|
-
|
|
171
|
-
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
172
|
-
|
|
173
|
-
body {{
|
|
174
|
-
background: var(--color-bg);
|
|
175
|
-
color: var(--color-text);
|
|
176
|
-
font-family: var(--font-body);
|
|
177
|
-
line-height: 1.6;
|
|
178
|
-
min-height: 100vh;
|
|
179
|
-
}}
|
|
180
|
-
|
|
181
|
-
.header {{
|
|
182
|
-
display: flex;
|
|
183
|
-
align-items: center;
|
|
184
|
-
padding: 20px 40px;
|
|
185
|
-
border-bottom: 1px solid var(--color-surface);
|
|
186
|
-
}}
|
|
187
|
-
|
|
188
|
-
.header h1 {{
|
|
189
|
-
font-family: var(--font-heading);
|
|
190
|
-
font-size: 20px;
|
|
191
|
-
font-weight: 700;
|
|
192
|
-
color: var(--color-text);
|
|
193
|
-
}}
|
|
194
|
-
|
|
195
|
-
.header .brand {{
|
|
196
|
-
margin-left: auto;
|
|
197
|
-
font-family: var(--font-mono);
|
|
198
|
-
font-size: 11px;
|
|
199
|
-
color: var(--color-muted);
|
|
200
|
-
text-transform: uppercase;
|
|
201
|
-
letter-spacing: 1.5px;
|
|
202
|
-
}}
|
|
203
|
-
|
|
204
|
-
.container {{
|
|
205
|
-
max-width: 1200px;
|
|
206
|
-
margin: 0 auto;
|
|
207
|
-
padding: 32px 40px;
|
|
208
|
-
}}
|
|
209
|
-
|
|
210
|
-
.section {{
|
|
211
|
-
margin-bottom: 24px;
|
|
212
|
-
}}
|
|
213
|
-
|
|
214
|
-
.text-section h2 {{
|
|
215
|
-
font-family: var(--font-heading);
|
|
216
|
-
font-size: 20px;
|
|
217
|
-
font-weight: 700;
|
|
218
|
-
color: var(--color-text);
|
|
219
|
-
margin-bottom: 12px;
|
|
220
|
-
margin-top: 24px;
|
|
221
|
-
}}
|
|
222
|
-
|
|
223
|
-
.text-section h3 {{
|
|
224
|
-
font-family: var(--font-heading);
|
|
225
|
-
font-size: 16px;
|
|
226
|
-
font-weight: 600;
|
|
227
|
-
color: var(--color-text);
|
|
228
|
-
margin-bottom: 8px;
|
|
229
|
-
margin-top: 16px;
|
|
230
|
-
}}
|
|
231
|
-
|
|
232
|
-
.text-section p {{
|
|
233
|
-
color: var(--color-muted);
|
|
234
|
-
font-size: 14px;
|
|
235
|
-
margin-bottom: 12px;
|
|
236
|
-
line-height: 1.7;
|
|
237
|
-
}}
|
|
238
|
-
|
|
239
|
-
.text-section strong {{
|
|
240
|
-
color: var(--color-text);
|
|
241
|
-
}}
|
|
242
|
-
|
|
243
|
-
.footer {{
|
|
244
|
-
padding: 20px 40px;
|
|
245
|
-
border-top: 1px solid var(--color-surface);
|
|
246
|
-
text-align: center;
|
|
247
|
-
font-family: var(--font-mono);
|
|
248
|
-
font-size: 11px;
|
|
249
|
-
color: var(--color-muted);
|
|
250
|
-
}}
|
|
251
|
-
|
|
252
|
-
{css}
|
|
253
|
-
</style>
|
|
254
|
-
</head>
|
|
255
|
-
<body>
|
|
256
|
-
<div class="header">
|
|
257
|
-
{logo_html}
|
|
258
|
-
<h1>{title}</h1>
|
|
259
|
-
<div class="brand">{brand_name}</div>
|
|
260
|
-
</div>
|
|
261
|
-
<div class="container">
|
|
262
|
-
{''.join(sections_html)}
|
|
263
|
-
</div>
|
|
264
|
-
<div class="footer">
|
|
265
|
-
Generated {datetime.now().strftime('%b %d, %Y at %H:%M')} · {brand_name}
|
|
266
|
-
</div>
|
|
267
|
-
</body>
|
|
268
|
-
</html>"""
|
|
269
|
-
|
|
270
|
-
# Write output
|
|
271
|
-
index_path = output_dir / "index.html"
|
|
272
|
-
index_path.write_text(html)
|
|
273
|
-
|
|
274
|
-
print(f"📊 Dashboard saved to {index_path}")
|
|
275
|
-
|
|
276
|
-
if serve:
|
|
277
|
-
_serve_and_open(output_dir, index_path, port)
|
|
278
|
-
|
|
279
|
-
return index_path
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
def _serve_and_open(directory: Path, index_path: Path, port: int) -> None:
|
|
283
|
-
"""Start a local HTTP server and open the dashboard in a browser.
|
|
284
|
-
|
|
285
|
-
The server runs on the main thread so it stays alive until the user
|
|
286
|
-
presses Ctrl+C. The browser is opened from a background thread after
|
|
287
|
-
a short delay so it doesn't race the server startup.
|
|
288
|
-
"""
|
|
289
|
-
|
|
290
|
-
class QuietHandler(http.server.SimpleHTTPRequestHandler):
|
|
291
|
-
def __init__(self, *args, **kwargs):
|
|
292
|
-
super().__init__(*args, directory=str(directory), **kwargs)
|
|
293
|
-
|
|
294
|
-
def log_message(self, format, *args):
|
|
295
|
-
pass # Suppress logs
|
|
296
|
-
|
|
297
|
-
# Find an available port
|
|
298
|
-
server = None
|
|
299
|
-
for p in (port, port + 1):
|
|
300
|
-
try:
|
|
301
|
-
server = http.server.HTTPServer(("", p), QuietHandler)
|
|
302
|
-
port = p
|
|
303
|
-
break
|
|
304
|
-
except OSError:
|
|
305
|
-
continue
|
|
306
|
-
|
|
307
|
-
if server is None:
|
|
308
|
-
print("⚠️ Could not start HTTP server, opening file directly")
|
|
309
|
-
webbrowser.open(f"file://{index_path.resolve()}")
|
|
310
|
-
return
|
|
311
|
-
|
|
312
|
-
server_url = f"http://localhost:{port}"
|
|
313
|
-
print(f"🌐 Serving at {server_url}")
|
|
314
|
-
print(" Press Ctrl+C to stop the server.")
|
|
315
|
-
|
|
316
|
-
# Open browser from a background thread (small delay so server is ready)
|
|
317
|
-
def _open():
|
|
318
|
-
import time
|
|
319
|
-
time.sleep(0.4)
|
|
320
|
-
webbrowser.open(server_url)
|
|
321
|
-
|
|
322
|
-
threading.Thread(target=_open, daemon=True).start()
|
|
323
|
-
|
|
324
|
-
# Block the main thread — keeps the server alive
|
|
325
|
-
try:
|
|
326
|
-
server.serve_forever()
|
|
327
|
-
except KeyboardInterrupt:
|
|
328
|
-
print("\n🛑 Server stopped.")
|
|
329
|
-
server.shutdown()
|
|
330
|
-
|
package/hedgeboard/viz/tables.py
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
"""Financial table generation.
|
|
2
|
-
|
|
3
|
-
Generates styled HTML tables with number formatting, branded colors,
|
|
4
|
-
and support for positive/negative value highlighting.
|
|
5
|
-
|
|
6
|
-
Usage:
|
|
7
|
-
from viz.tables import financial_table
|
|
8
|
-
|
|
9
|
-
html = financial_table(
|
|
10
|
-
headers=["Metric", "2023", "2024"],
|
|
11
|
-
rows=[
|
|
12
|
-
["Revenue", 3826000000, 4150000000],
|
|
13
|
-
["Net Income", -398000000, 120000000],
|
|
14
|
-
]
|
|
15
|
-
)
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
from __future__ import annotations
|
|
19
|
-
|
|
20
|
-
import json
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
|
|
23
|
-
_THEME_PATH = Path(__file__).resolve().parent.parent / "brand" / "theme.json"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _load_theme() -> dict:
|
|
27
|
-
try:
|
|
28
|
-
with open(_THEME_PATH) as f:
|
|
29
|
-
return json.load(f)
|
|
30
|
-
except (FileNotFoundError, json.JSONDecodeError):
|
|
31
|
-
return {
|
|
32
|
-
"colors": {
|
|
33
|
-
"primary": "#6366F1",
|
|
34
|
-
"accent": "#22D3EE",
|
|
35
|
-
"background": "#0D1117",
|
|
36
|
-
"surface": "#161B22",
|
|
37
|
-
"text": "#E6EDF3",
|
|
38
|
-
"muted": "#8B949E",
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _format_number(val) -> str:
|
|
44
|
-
"""Format a number for financial display."""
|
|
45
|
-
if val is None:
|
|
46
|
-
return "—"
|
|
47
|
-
if isinstance(val, str):
|
|
48
|
-
return val
|
|
49
|
-
|
|
50
|
-
num = float(val)
|
|
51
|
-
abs_num = abs(num)
|
|
52
|
-
|
|
53
|
-
if abs_num >= 1_000_000_000:
|
|
54
|
-
formatted = f"${abs_num / 1_000_000_000:.1f}B"
|
|
55
|
-
elif abs_num >= 1_000_000:
|
|
56
|
-
formatted = f"${abs_num / 1_000_000:.1f}M"
|
|
57
|
-
elif abs_num >= 1_000:
|
|
58
|
-
formatted = f"${abs_num / 1_000:.1f}K"
|
|
59
|
-
else:
|
|
60
|
-
formatted = f"${abs_num:,.2f}"
|
|
61
|
-
|
|
62
|
-
if num < 0:
|
|
63
|
-
return f"({formatted})"
|
|
64
|
-
return formatted
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def financial_table(
|
|
68
|
-
headers: list[str],
|
|
69
|
-
rows: list[list],
|
|
70
|
-
title: str = "",
|
|
71
|
-
format_numbers: bool = True,
|
|
72
|
-
) -> str:
|
|
73
|
-
"""Generate a styled financial table.
|
|
74
|
-
|
|
75
|
-
Args:
|
|
76
|
-
headers: Column headers.
|
|
77
|
-
rows: List of rows, each a list of values. First column is
|
|
78
|
-
typically the metric name (string), rest are numbers.
|
|
79
|
-
title: Optional table title.
|
|
80
|
-
format_numbers: Auto-format numbers (B/M/K, negative in parens).
|
|
81
|
-
|
|
82
|
-
Returns:
|
|
83
|
-
HTML string with a styled table.
|
|
84
|
-
"""
|
|
85
|
-
theme = _load_theme()
|
|
86
|
-
colors = theme.get("colors", {})
|
|
87
|
-
|
|
88
|
-
bg = colors.get("surface", "#161B22")
|
|
89
|
-
border = colors.get("background", "#0D1117")
|
|
90
|
-
text = colors.get("text", "#E6EDF3")
|
|
91
|
-
muted = colors.get("muted", "#8B949E")
|
|
92
|
-
accent = colors.get("accent", "#22D3EE")
|
|
93
|
-
|
|
94
|
-
# Build header row
|
|
95
|
-
header_cells = "".join(
|
|
96
|
-
f'<th style="padding:12px 16px;text-align:{"left" if i == 0 else "right"};'
|
|
97
|
-
f'color:{accent};font-weight:600;border-bottom:2px solid {border};'
|
|
98
|
-
f'font-size:13px;text-transform:uppercase;letter-spacing:0.5px;">'
|
|
99
|
-
f"{h}</th>"
|
|
100
|
-
for i, h in enumerate(headers)
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
# Build body rows
|
|
104
|
-
body_rows = []
|
|
105
|
-
for row in rows:
|
|
106
|
-
cells = []
|
|
107
|
-
for i, val in enumerate(row):
|
|
108
|
-
if i == 0:
|
|
109
|
-
# Metric name column
|
|
110
|
-
cell = (
|
|
111
|
-
f'<td style="padding:10px 16px;color:{text};font-weight:500;'
|
|
112
|
-
f'border-bottom:1px solid {border};">{val}</td>'
|
|
113
|
-
)
|
|
114
|
-
else:
|
|
115
|
-
# Value column
|
|
116
|
-
display = _format_number(val) if format_numbers else str(val)
|
|
117
|
-
is_negative = isinstance(val, (int, float)) and val < 0
|
|
118
|
-
val_color = "#EF4444" if is_negative else text
|
|
119
|
-
cell = (
|
|
120
|
-
f'<td style="padding:10px 16px;text-align:right;color:{val_color};'
|
|
121
|
-
f'font-variant-numeric:tabular-nums;border-bottom:1px solid {border};'
|
|
122
|
-
f'font-family:monospace;">{display}</td>'
|
|
123
|
-
)
|
|
124
|
-
cells.append(cell)
|
|
125
|
-
body_rows.append(f"<tr>{''.join(cells)}</tr>")
|
|
126
|
-
|
|
127
|
-
title_html = ""
|
|
128
|
-
if title:
|
|
129
|
-
title_html = (
|
|
130
|
-
f'<div style="padding:16px 16px 0;color:{text};font-size:16px;'
|
|
131
|
-
f'font-weight:600;">{title}</div>'
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
return f"""{title_html}
|
|
135
|
-
<div style="background:{bg};border-radius:12px;overflow:hidden;">
|
|
136
|
-
<table style="width:100%;border-collapse:collapse;font-size:14px;">
|
|
137
|
-
<thead><tr>{header_cells}</tr></thead>
|
|
138
|
-
<tbody>{''.join(body_rows)}</tbody>
|
|
139
|
-
</table>
|
|
140
|
-
</div>"""
|