staticdash 2026.6__py3-none-any.whl → 2026.8__py3-none-any.whl
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.
- staticdash/assets/vendor/mermaid/mermaid.min.js +2029 -0
- staticdash/dashboard.py +568 -807
- staticdash-2026.8.dist-info/METADATA +98 -0
- {staticdash-2026.6.dist-info → staticdash-2026.8.dist-info}/RECORD +6 -5
- {staticdash-2026.6.dist-info → staticdash-2026.8.dist-info}/WHEEL +1 -1
- staticdash-2026.6.dist-info/METADATA +0 -71
- {staticdash-2026.6.dist-info → staticdash-2026.8.dist-info}/top_level.txt +0 -0
staticdash/dashboard.py
CHANGED
|
@@ -2,10 +2,11 @@ import os
|
|
|
2
2
|
import shutil
|
|
3
3
|
import uuid
|
|
4
4
|
import re
|
|
5
|
+
from typing import Optional, Tuple, List, Any
|
|
5
6
|
import pandas as pd
|
|
6
7
|
import plotly.graph_objects as go
|
|
7
8
|
from dominate import document
|
|
8
|
-
from dominate.tags import div, h1, h2, h3, h4, p, a, script, link, span
|
|
9
|
+
from dominate.tags import div, h1, h2, h3, h4, p, a, script, link, span, ul, li
|
|
9
10
|
from dominate.util import raw as raw_util
|
|
10
11
|
import html
|
|
11
12
|
import io
|
|
@@ -13,6 +14,62 @@ import base64
|
|
|
13
14
|
import matplotlib
|
|
14
15
|
from matplotlib import rc_context
|
|
15
16
|
import json
|
|
17
|
+
import markdown
|
|
18
|
+
|
|
19
|
+
# Constants
|
|
20
|
+
DEFAULT_PAGE_WIDTH = 900
|
|
21
|
+
DEFAULT_SIDEBAR_WIDTH = 240
|
|
22
|
+
DEFAULT_CONTENT_PADDING = 20
|
|
23
|
+
HEADER_TAGS = {1: h1, 2: h2, 3: h3, 4: h4}
|
|
24
|
+
VALID_ALIGNMENTS = {"left", "center", "right"}
|
|
25
|
+
DEFAULT_FONT_FAMILY = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_markdown_text(text):
|
|
29
|
+
"""
|
|
30
|
+
Render markdown text to HTML, preserving math expressions.
|
|
31
|
+
|
|
32
|
+
Math expressions in $...$ (inline) or $$...$$ (display) format are
|
|
33
|
+
protected during markdown processing and passed through for MathJax.
|
|
34
|
+
"""
|
|
35
|
+
math_blocks = []
|
|
36
|
+
|
|
37
|
+
def replace_math(match):
|
|
38
|
+
math_blocks.append(match.group(0))
|
|
39
|
+
# Use a placeholder that won't be affected by markdown processing
|
|
40
|
+
return f"STATICDASHMATH{len(math_blocks)-1}ENDMATH"
|
|
41
|
+
|
|
42
|
+
# Protect math expressions from markdown processing
|
|
43
|
+
text = re.sub(r'\$\$([^$]+)\$\$', replace_math, text, flags=re.DOTALL)
|
|
44
|
+
text = re.sub(r'\$([^$]+)\$', replace_math, text, flags=re.DOTALL)
|
|
45
|
+
|
|
46
|
+
# Process markdown with extended features
|
|
47
|
+
md = markdown.Markdown(extensions=[
|
|
48
|
+
'extra', # Tables, fenced code blocks, etc.
|
|
49
|
+
'nl2br', # Convert newlines to <br>
|
|
50
|
+
'sane_lists', # Better list handling
|
|
51
|
+
'pymdownx.tilde', # Strikethrough with ~~text~~
|
|
52
|
+
'pymdownx.superfences', # Better code blocks with custom fence support
|
|
53
|
+
], extension_configs={
|
|
54
|
+
'pymdownx.superfences': {
|
|
55
|
+
'custom_fences': [
|
|
56
|
+
{
|
|
57
|
+
'name': 'mermaid',
|
|
58
|
+
'class': 'mermaid',
|
|
59
|
+
'format': lambda source, language, css_class, options, md, **kwargs:
|
|
60
|
+
f'<div class="mermaid">\n{source}\n</div>'
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
html_content = md.convert(text)
|
|
66
|
+
|
|
67
|
+
# Restore math expressions
|
|
68
|
+
for i, block in enumerate(math_blocks):
|
|
69
|
+
html_content = html_content.replace(f"STATICDASHMATH{i}ENDMATH", block)
|
|
70
|
+
|
|
71
|
+
return html_content
|
|
72
|
+
|
|
16
73
|
|
|
17
74
|
def split_paragraphs_preserving_math(text):
|
|
18
75
|
"""
|
|
@@ -21,7 +78,6 @@ def split_paragraphs_preserving_math(text):
|
|
|
21
78
|
"""
|
|
22
79
|
math_blocks = []
|
|
23
80
|
|
|
24
|
-
# Replace math with placeholders
|
|
25
81
|
def replace_math(match):
|
|
26
82
|
math_blocks.append(match.group(0))
|
|
27
83
|
return f"__MATH_BLOCK_{len(math_blocks)-1}__"
|
|
@@ -30,18 +86,171 @@ def split_paragraphs_preserving_math(text):
|
|
|
30
86
|
text = re.sub(r'\$\$([^$]+)\$\$', replace_math, text, flags=re.DOTALL)
|
|
31
87
|
text = re.sub(r'\$([^$]+)\$', replace_math, text, flags=re.DOTALL)
|
|
32
88
|
|
|
33
|
-
# Split on double newlines
|
|
34
|
-
paragraphs =
|
|
35
|
-
|
|
36
|
-
# Restore math in each paragraph
|
|
37
|
-
restored_paragraphs = []
|
|
38
|
-
for para in paragraphs:
|
|
89
|
+
# Split on double newlines and restore math
|
|
90
|
+
paragraphs = []
|
|
91
|
+
for para in text.split('\n\n'):
|
|
39
92
|
for i, block in enumerate(math_blocks):
|
|
40
93
|
para = para.replace(f"__MATH_BLOCK_{i}__", block)
|
|
41
|
-
|
|
94
|
+
if para.strip():
|
|
95
|
+
paragraphs.append(para.strip())
|
|
42
96
|
|
|
43
|
-
|
|
44
|
-
|
|
97
|
+
return paragraphs
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_alignment_style(align: str) -> str:
|
|
101
|
+
"""Generate CSS flexbox alignment style."""
|
|
102
|
+
align = align if align in VALID_ALIGNMENTS else "center"
|
|
103
|
+
justify_map = {"center": "center", "left": "flex-start", "right": "flex-end"}
|
|
104
|
+
return f"display:flex; justify-content:{justify_map[align]}; align-items:center;"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_plot_params(content: Any) -> Tuple[Any, Optional[int], Optional[int], str]:
|
|
108
|
+
"""Extract figure, height, width, and alignment from plot content tuple."""
|
|
109
|
+
if not isinstance(content, (list, tuple)):
|
|
110
|
+
return content, None, None, "center"
|
|
111
|
+
|
|
112
|
+
if len(content) == 4:
|
|
113
|
+
return content
|
|
114
|
+
elif len(content) == 3:
|
|
115
|
+
return (*content, "center")
|
|
116
|
+
elif len(content) == 2:
|
|
117
|
+
return (*content, None, "center")
|
|
118
|
+
else:
|
|
119
|
+
return content[0], None, None, "center"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_figure_layout_attr(fig, attr: str, default=None):
|
|
123
|
+
"""Safely get a layout attribute from a figure."""
|
|
124
|
+
try:
|
|
125
|
+
return getattr(getattr(fig, 'layout', None), attr, default)
|
|
126
|
+
except Exception:
|
|
127
|
+
return default
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def set_figure_dimensions(fig, height: Optional[int], width: Optional[int]) -> Tuple[Optional[int], Optional[int]]:
|
|
131
|
+
"""Set figure dimensions and return original values."""
|
|
132
|
+
orig_height = get_figure_layout_attr(fig, 'height')
|
|
133
|
+
orig_width = get_figure_layout_attr(fig, 'width')
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
if height is not None:
|
|
137
|
+
fig.update_layout(height=height)
|
|
138
|
+
if width is not None:
|
|
139
|
+
fig.update_layout(width=width)
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
return orig_height, orig_width
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def restore_figure_dimensions(fig, orig_height: Optional[int], orig_width: Optional[int]):
|
|
147
|
+
"""Restore original figure dimensions."""
|
|
148
|
+
try:
|
|
149
|
+
if orig_height is not None or orig_width is not None:
|
|
150
|
+
fig.update_layout(height=orig_height, width=orig_width)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def ensure_figure_font(fig):
|
|
156
|
+
"""Ensure figure has a robust font family."""
|
|
157
|
+
try:
|
|
158
|
+
font_family = get_figure_layout_attr(fig, 'font.family')
|
|
159
|
+
if not font_family:
|
|
160
|
+
fig.update_layout(font_family=DEFAULT_FONT_FAMILY)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def build_container_style(width: Optional[int] = None, height: Optional[int] = None) -> str:
|
|
166
|
+
"""Build container style string for plots."""
|
|
167
|
+
style = "width:100%;" if width is None else f"width:{width}px;"
|
|
168
|
+
if height is not None:
|
|
169
|
+
style += f" height:{height}px;"
|
|
170
|
+
return style
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def render_plotly_figure(fig, specified_height: Optional[int], specified_width: Optional[int], specified_align: str) -> str:
|
|
174
|
+
"""Render a Plotly figure to HTML with deferred loading."""
|
|
175
|
+
ensure_figure_font(fig)
|
|
176
|
+
orig_height, orig_width = set_figure_dimensions(fig, specified_height, specified_width)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
fig_json = fig.to_json()
|
|
180
|
+
fig_json = fig_json.replace('</script>', '<\\/script>')
|
|
181
|
+
div_id = f'plot-{uuid.uuid4()}'
|
|
182
|
+
container_style = build_container_style(specified_width, specified_height)
|
|
183
|
+
|
|
184
|
+
plot_div = f'<div id="{div_id}" class="plotly-graph-div" style="{container_style}"></div>'
|
|
185
|
+
loader = (
|
|
186
|
+
'<script type="text/javascript">(function(){'
|
|
187
|
+
f'var fig = {fig_json};'
|
|
188
|
+
'function tryPlot(){'
|
|
189
|
+
'if(window.Plotly && typeof window.Plotly.newPlot === "function"){'
|
|
190
|
+
f'Plotly.newPlot("{div_id}", fig.data, fig.layout, {json.dumps({"responsive": True})});'
|
|
191
|
+
'} else { setTimeout(tryPlot, 50); }'
|
|
192
|
+
'}'
|
|
193
|
+
'if(document.readyState === "complete"){ tryPlot(); } else { window.addEventListener("load", tryPlot); }'
|
|
194
|
+
'})();</script>'
|
|
195
|
+
)
|
|
196
|
+
plot_wrapped = plot_div + loader
|
|
197
|
+
except Exception:
|
|
198
|
+
# Fallback to older method
|
|
199
|
+
plotly_html = fig.to_html(full_html=False, include_plotlyjs=False, config={'responsive': True})
|
|
200
|
+
container_style = build_container_style(specified_width, specified_height)
|
|
201
|
+
plot_wrapped = f'<div style="{container_style}">{plotly_html}</div>'
|
|
202
|
+
finally:
|
|
203
|
+
restore_figure_dimensions(fig, orig_height, orig_width)
|
|
204
|
+
|
|
205
|
+
align_style = get_alignment_style(specified_align)
|
|
206
|
+
return f'<div style="{align_style}">{plot_wrapped}</div>'
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def render_matplotlib_figure(fig, specified_height: Optional[int], specified_width: Optional[int], specified_align: str) -> str:
|
|
210
|
+
"""Render a Matplotlib figure to base64 PNG."""
|
|
211
|
+
buf = io.BytesIO()
|
|
212
|
+
try:
|
|
213
|
+
# Adjust figure size if dimensions specified
|
|
214
|
+
orig_size = None
|
|
215
|
+
try:
|
|
216
|
+
dpi = fig.get_dpi()
|
|
217
|
+
if dpi and (specified_height or specified_width):
|
|
218
|
+
orig_size = fig.get_size_inches()
|
|
219
|
+
new_w, new_h = orig_size
|
|
220
|
+
if specified_width:
|
|
221
|
+
new_w = specified_width / dpi
|
|
222
|
+
if specified_height:
|
|
223
|
+
new_h = specified_height / dpi
|
|
224
|
+
fig.set_size_inches(new_w, new_h)
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
with rc_context({"axes.unicode_minus": False}):
|
|
229
|
+
fig.savefig(buf, format="png", bbox_inches="tight")
|
|
230
|
+
|
|
231
|
+
# Restore original size
|
|
232
|
+
if orig_size is not None:
|
|
233
|
+
try:
|
|
234
|
+
fig.set_size_inches(orig_size)
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
buf.seek(0)
|
|
239
|
+
img_base64 = base64.b64encode(buf.read()).decode("utf-8")
|
|
240
|
+
|
|
241
|
+
img_style = "max-width:100%;"
|
|
242
|
+
if specified_height:
|
|
243
|
+
img_style += f" height:{specified_height}px;"
|
|
244
|
+
if specified_width:
|
|
245
|
+
img_style += f" width:{specified_width}px;"
|
|
246
|
+
|
|
247
|
+
align_style = get_alignment_style(specified_align)
|
|
248
|
+
return f'<div style="{align_style}"><img src="data:image/png;base64,{img_base64}" style="{img_style}"></div>'
|
|
249
|
+
except Exception as e:
|
|
250
|
+
return f'<div>Matplotlib figure could not be rendered: {e}</div>'
|
|
251
|
+
finally:
|
|
252
|
+
buf.close()
|
|
253
|
+
|
|
45
254
|
|
|
46
255
|
class AbstractPage:
|
|
47
256
|
def __init__(self):
|
|
@@ -56,14 +265,7 @@ class AbstractPage:
|
|
|
56
265
|
self.elements.append(("text", text, width))
|
|
57
266
|
|
|
58
267
|
def add_plot(self, plot, el_width=None, height=None, width=None, align="center"):
|
|
59
|
-
|
|
60
|
-
# `height` and `width` are pixel dimensions for the rendered figure/image.
|
|
61
|
-
# `align` controls horizontal alignment: 'left', 'center' (default), or 'right'.
|
|
62
|
-
# We store a tuple (plot, height, width, align) and keep `el_width` as
|
|
63
|
-
# the element-level fractional width for compatibility with page layout.
|
|
64
|
-
specified_height = height
|
|
65
|
-
specified_width = width
|
|
66
|
-
self.elements.append(("plot", (plot, specified_height, specified_width, align), el_width))
|
|
268
|
+
self.elements.append(("plot", (plot, height, width, align), el_width))
|
|
67
269
|
|
|
68
270
|
def add_table(self, df, table_id=None, sortable=True, width=None):
|
|
69
271
|
self.elements.append(("table", df, width))
|
|
@@ -79,6 +281,68 @@ class AbstractPage:
|
|
|
79
281
|
def add_syntax(self, code, language="python", width=None):
|
|
80
282
|
self.elements.append(("syntax", (code, language), width))
|
|
81
283
|
|
|
284
|
+
def _render_element(self, kind, content, index=None, downloads_dir=None,
|
|
285
|
+
relative_prefix="", inherited_width=None,
|
|
286
|
+
inherited_marking=None, inherited_distribution=None):
|
|
287
|
+
"""Common element rendering logic shared by Page and MiniPage."""
|
|
288
|
+
if kind == "text":
|
|
289
|
+
# Render markdown to HTML
|
|
290
|
+
html_content = render_markdown_text(content)
|
|
291
|
+
return div(raw_util(html_content))
|
|
292
|
+
|
|
293
|
+
elif kind == "header":
|
|
294
|
+
text, level = content
|
|
295
|
+
return HEADER_TAGS[level](text)
|
|
296
|
+
|
|
297
|
+
elif kind == "plot":
|
|
298
|
+
fig, specified_height, specified_width, specified_align = extract_plot_params(content)
|
|
299
|
+
|
|
300
|
+
if hasattr(fig, "to_html"):
|
|
301
|
+
html_content = render_plotly_figure(fig, specified_height, specified_width, specified_align)
|
|
302
|
+
else:
|
|
303
|
+
html_content = render_matplotlib_figure(fig, specified_height, specified_width, specified_align)
|
|
304
|
+
|
|
305
|
+
return div(raw_util(html_content))
|
|
306
|
+
|
|
307
|
+
elif kind == "table":
|
|
308
|
+
df = content
|
|
309
|
+
try:
|
|
310
|
+
html_table = df.to_html(
|
|
311
|
+
classes="table-hover table-striped",
|
|
312
|
+
index=False,
|
|
313
|
+
border=0,
|
|
314
|
+
table_id=f"table-{index}" if index else None,
|
|
315
|
+
escape=False
|
|
316
|
+
)
|
|
317
|
+
return div(raw_util(html_table))
|
|
318
|
+
except Exception as e:
|
|
319
|
+
return div(f"Table could not be rendered: {e}")
|
|
320
|
+
|
|
321
|
+
elif kind == "download":
|
|
322
|
+
file_path, label = content
|
|
323
|
+
btn = a(label or os.path.basename(file_path), href=file_path,
|
|
324
|
+
cls="download-button", download=True)
|
|
325
|
+
return div(btn)
|
|
326
|
+
|
|
327
|
+
elif kind == "syntax":
|
|
328
|
+
code, language = content
|
|
329
|
+
return div(
|
|
330
|
+
raw_util(f'<pre class="syntax-block"><code class="language-{language}">{html.escape(code)}</code></pre>'),
|
|
331
|
+
cls="syntax-block"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
elif kind == "minipage":
|
|
335
|
+
return content.render(
|
|
336
|
+
index,
|
|
337
|
+
downloads_dir=downloads_dir,
|
|
338
|
+
relative_prefix=relative_prefix,
|
|
339
|
+
inherited_width=inherited_width,
|
|
340
|
+
inherited_marking=inherited_marking,
|
|
341
|
+
inherited_distribution=inherited_distribution
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return div("Unknown element type")
|
|
345
|
+
|
|
82
346
|
|
|
83
347
|
class Page(AbstractPage):
|
|
84
348
|
def __init__(self, slug, title, page_width=None, marking=None):
|
|
@@ -92,288 +356,60 @@ class Page(AbstractPage):
|
|
|
92
356
|
def add_subpage(self, page):
|
|
93
357
|
self.children.append(page)
|
|
94
358
|
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
elements = [
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"z-index: 1000; "
|
|
123
|
-
"font-weight: normal;"
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
elements.append(div(
|
|
127
|
-
effective_marking,
|
|
128
|
-
cls="floating-header",
|
|
129
|
-
style=f"{shared_pos} top: 0;"
|
|
130
|
-
))
|
|
359
|
+
def _render_marking_bars(self, effective_marking, effective_distribution):
|
|
360
|
+
"""Render top and bottom marking bars."""
|
|
361
|
+
if not effective_marking:
|
|
362
|
+
return []
|
|
363
|
+
|
|
364
|
+
shared_pos = (
|
|
365
|
+
"position: fixed; "
|
|
366
|
+
f"left: calc(var(--sidebar-width, {DEFAULT_SIDEBAR_WIDTH}px) + var(--content-padding-x, {DEFAULT_CONTENT_PADDING}px)); "
|
|
367
|
+
f"width: calc(100vw - var(--sidebar-width, {DEFAULT_SIDEBAR_WIDTH}px) - 2*var(--content-padding-x, {DEFAULT_CONTENT_PADDING}px)); "
|
|
368
|
+
"text-align: center; background-color: #f8f9fa; padding: 10px; "
|
|
369
|
+
"z-index: 1000; font-weight: normal;"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
elements = [
|
|
373
|
+
div(effective_marking, cls="floating-header", style=f"{shared_pos} top: 0;")
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
footer_content = []
|
|
377
|
+
if effective_distribution:
|
|
378
|
+
footer_content.append(div(effective_distribution, style="margin-bottom: 4px; font-size: 10pt;"))
|
|
379
|
+
footer_content.append(div(effective_marking))
|
|
380
|
+
|
|
381
|
+
elements.append(
|
|
382
|
+
div(*footer_content, cls="floating-footer", style=f"{shared_pos} bottom: 0;")
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return elements
|
|
131
386
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
387
|
+
def render(self, index, downloads_dir=None, relative_prefix="",
|
|
388
|
+
inherited_width=None, inherited_marking=None, inherited_distribution=None):
|
|
389
|
+
effective_width = self.page_width or inherited_width or DEFAULT_PAGE_WIDTH
|
|
390
|
+
effective_marking = self.marking if self.marking is not None else inherited_marking
|
|
391
|
+
effective_distribution = getattr(self, "distribution", None) or inherited_distribution
|
|
136
392
|
|
|
137
|
-
|
|
138
|
-
*footer_block,
|
|
139
|
-
cls="floating-footer",
|
|
140
|
-
style=f"{shared_pos} bottom: 0;"
|
|
141
|
-
))
|
|
393
|
+
elements = self._render_marking_bars(effective_marking, effective_distribution)
|
|
142
394
|
|
|
143
|
-
# Render elements
|
|
395
|
+
# Render all page elements
|
|
144
396
|
for kind, content, el_width in self.elements:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
elem = None
|
|
151
|
-
if kind == "text":
|
|
152
|
-
paragraphs = split_paragraphs_preserving_math(content)
|
|
153
|
-
if paragraphs:
|
|
154
|
-
elem = div(*[p(para) for para in paragraphs])
|
|
155
|
-
else:
|
|
156
|
-
elem = p(content)
|
|
157
|
-
elif kind == "header":
|
|
158
|
-
text, level = content
|
|
159
|
-
header_tag = {1: h1, 2: h2, 3: h3, 4: h4}[level]
|
|
160
|
-
elem = header_tag(text)
|
|
161
|
-
elif kind == "plot":
|
|
162
|
-
# content may be stored as (figure, height), (figure, height, width)
|
|
163
|
-
# or (figure, height, width, align)
|
|
164
|
-
specified_height = None
|
|
165
|
-
specified_width = None
|
|
166
|
-
specified_align = "center"
|
|
167
|
-
if isinstance(content, (list, tuple)):
|
|
168
|
-
if len(content) == 4:
|
|
169
|
-
fig, specified_height, specified_width, specified_align = content
|
|
170
|
-
elif len(content) == 3:
|
|
171
|
-
fig, specified_height, specified_width = content
|
|
172
|
-
elif len(content) == 2:
|
|
173
|
-
fig, specified_height = content
|
|
174
|
-
else:
|
|
175
|
-
fig = content
|
|
176
|
-
else:
|
|
177
|
-
fig = content
|
|
178
|
-
|
|
179
|
-
if hasattr(fig, "to_html"):
|
|
180
|
-
# Use local Plotly loaded in <head>
|
|
181
|
-
# Ensure the figure uses a robust font family so minus signs and other
|
|
182
|
-
# glyphs render correctly in the browser (some system fonts lack U+2212)
|
|
183
|
-
try:
|
|
184
|
-
font_family = None
|
|
185
|
-
if getattr(fig, 'layout', None) and getattr(fig.layout, 'font', None):
|
|
186
|
-
font_family = getattr(fig.layout.font, 'family', None)
|
|
187
|
-
if not font_family:
|
|
188
|
-
fig.update_layout(font_family='-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif')
|
|
189
|
-
except Exception:
|
|
190
|
-
# Be defensive: don't fail rendering if layout manipulation isn't available
|
|
191
|
-
pass
|
|
192
|
-
|
|
193
|
-
try:
|
|
194
|
-
# Temporarily set layout height/width if specified (pixels)
|
|
195
|
-
orig_height = None
|
|
196
|
-
orig_width = None
|
|
197
|
-
try:
|
|
198
|
-
orig_height = getattr(fig.layout, 'height', None)
|
|
199
|
-
except Exception:
|
|
200
|
-
orig_height = None
|
|
201
|
-
try:
|
|
202
|
-
orig_width = getattr(fig.layout, 'width', None)
|
|
203
|
-
except Exception:
|
|
204
|
-
orig_width = None
|
|
205
|
-
|
|
206
|
-
try:
|
|
207
|
-
if specified_height is not None:
|
|
208
|
-
fig.update_layout(height=specified_height)
|
|
209
|
-
if specified_width is not None:
|
|
210
|
-
fig.update_layout(width=specified_width)
|
|
211
|
-
except Exception:
|
|
212
|
-
pass
|
|
213
|
-
|
|
214
|
-
# Defer Plotly.newPlot until the Plotly bundle is available
|
|
215
|
-
# by embedding the figure JSON and calling newPlot from a
|
|
216
|
-
# small loader that polls for `window.Plotly`.
|
|
217
|
-
try:
|
|
218
|
-
fig_json = fig.to_json()
|
|
219
|
-
except Exception:
|
|
220
|
-
# Fallback: use the html fragment (older path)
|
|
221
|
-
plotly_html = fig.to_html(full_html=False, include_plotlyjs=False, config={'responsive': True})
|
|
222
|
-
container_style = "width:100%;"
|
|
223
|
-
if specified_width is not None:
|
|
224
|
-
container_style = f"width:{specified_width}px;"
|
|
225
|
-
if specified_height is not None:
|
|
226
|
-
container_style = container_style + f" height:{specified_height}px;"
|
|
227
|
-
plot_wrapped = f'<div style="{container_style}">{plotly_html}</div>'
|
|
228
|
-
else:
|
|
229
|
-
fig_json = fig_json.replace('</script>', '<\\/script>')
|
|
230
|
-
div_id = f'plot-{uuid.uuid4()}'
|
|
231
|
-
container_style = "width:100%;"
|
|
232
|
-
if specified_width is not None:
|
|
233
|
-
container_style = f"width:{specified_width}px;"
|
|
234
|
-
if specified_height is not None:
|
|
235
|
-
container_style = container_style + f" height:{specified_height}px;"
|
|
236
|
-
|
|
237
|
-
plot_div = f'<div id="{div_id}" class="plotly-graph-div" style="{container_style}"></div>'
|
|
238
|
-
loader = (
|
|
239
|
-
'<script type="text/javascript">(function(){' \
|
|
240
|
-
f'var fig = {fig_json};' \
|
|
241
|
-
'function tryPlot(){' \
|
|
242
|
-
'if(window.Plotly && typeof window.Plotly.newPlot === "function"){' \
|
|
243
|
-
f'Plotly.newPlot("{div_id}", fig.data, fig.layout, {json.dumps({"responsive": True})});' \
|
|
244
|
-
'} else { setTimeout(tryPlot, 50); }' \
|
|
245
|
-
'}' \
|
|
246
|
-
'if(document.readyState === "complete"){ tryPlot(); } else { window.addEventListener("load", tryPlot); }' \
|
|
247
|
-
'})();</script>'
|
|
248
|
-
)
|
|
249
|
-
plot_wrapped = plot_div + loader
|
|
250
|
-
|
|
251
|
-
# Apply alignment wrapper
|
|
252
|
-
if specified_align not in ("left", "right", "center"):
|
|
253
|
-
specified_align = "center"
|
|
254
|
-
if specified_align == "center":
|
|
255
|
-
align_style = "display:flex; justify-content:center; align-items:center;"
|
|
256
|
-
elif specified_align == "left":
|
|
257
|
-
align_style = "display:flex; justify-content:flex-start; align-items:center;"
|
|
258
|
-
else:
|
|
259
|
-
align_style = "display:flex; justify-content:flex-end; align-items:center;"
|
|
260
|
-
|
|
261
|
-
outer = f'<div style="{align_style}">{plot_wrapped}</div>'
|
|
262
|
-
elem = div(raw_util(outer))
|
|
263
|
-
|
|
264
|
-
# restore original height/width if we changed them
|
|
265
|
-
try:
|
|
266
|
-
if specified_height is not None:
|
|
267
|
-
fig.update_layout(height=orig_height)
|
|
268
|
-
if specified_width is not None:
|
|
269
|
-
fig.update_layout(width=orig_width)
|
|
270
|
-
except Exception:
|
|
271
|
-
pass
|
|
272
|
-
except Exception as e:
|
|
273
|
-
elem = div(f"Plotly figure could not be rendered: {e}")
|
|
274
|
-
else:
|
|
275
|
-
# Robust Matplotlib -> PNG path. Ensure `buf` exists and is closed.
|
|
276
|
-
buf = io.BytesIO()
|
|
277
|
-
try:
|
|
278
|
-
# If pixel width/height specified, attempt to adjust figure size
|
|
279
|
-
orig_size = None
|
|
280
|
-
try:
|
|
281
|
-
dpi = fig.get_dpi()
|
|
282
|
-
except Exception:
|
|
283
|
-
dpi = None
|
|
284
|
-
try:
|
|
285
|
-
if dpi is not None and (specified_height is not None or specified_width is not None):
|
|
286
|
-
orig_size = fig.get_size_inches()
|
|
287
|
-
new_w = orig_size[0]
|
|
288
|
-
new_h = orig_size[1]
|
|
289
|
-
if specified_width is not None:
|
|
290
|
-
new_w = specified_width / dpi
|
|
291
|
-
if specified_height is not None:
|
|
292
|
-
new_h = specified_height / dpi
|
|
293
|
-
fig.set_size_inches(new_w, new_h)
|
|
294
|
-
except Exception:
|
|
295
|
-
orig_size = None
|
|
296
|
-
|
|
297
|
-
with rc_context({"axes.unicode_minus": False}):
|
|
298
|
-
fig.savefig(buf, format="png", bbox_inches="tight")
|
|
299
|
-
|
|
300
|
-
# restore original size if changed
|
|
301
|
-
try:
|
|
302
|
-
if orig_size is not None:
|
|
303
|
-
fig.set_size_inches(orig_size)
|
|
304
|
-
except Exception:
|
|
305
|
-
pass
|
|
306
|
-
|
|
307
|
-
buf.seek(0)
|
|
308
|
-
img_base64 = base64.b64encode(buf.read()).decode("utf-8")
|
|
309
|
-
img_style = "max-width:100%;"
|
|
310
|
-
if specified_height is not None:
|
|
311
|
-
img_style = img_style + f" height:{specified_height}px;"
|
|
312
|
-
if specified_width is not None:
|
|
313
|
-
img_style = img_style + f" width:{specified_width}px;"
|
|
314
|
-
|
|
315
|
-
if specified_align not in ("left", "right", "center"):
|
|
316
|
-
specified_align = "center"
|
|
317
|
-
if specified_align == "center":
|
|
318
|
-
align_style = "display:flex; justify-content:center; align-items:center;"
|
|
319
|
-
elif specified_align == "left":
|
|
320
|
-
align_style = "display:flex; justify-content:flex-start; align-items:center;"
|
|
321
|
-
else:
|
|
322
|
-
align_style = "display:flex; justify-content:flex-end; align-items:center;"
|
|
323
|
-
|
|
324
|
-
elem = div(
|
|
325
|
-
raw_util(f'<img src="data:image/png;base64,{img_base64}" style="{img_style}">'),
|
|
326
|
-
style=align_style
|
|
327
|
-
)
|
|
328
|
-
except Exception as e:
|
|
329
|
-
elem = div(f"Matplotlib figure could not be rendered: {e}")
|
|
330
|
-
finally:
|
|
331
|
-
try:
|
|
332
|
-
buf.close()
|
|
333
|
-
except Exception:
|
|
334
|
-
pass
|
|
335
|
-
elif kind == "table":
|
|
336
|
-
df = content
|
|
337
|
-
try:
|
|
338
|
-
html_table = df.to_html(classes="table-hover table-striped", index=False, border=0, table_id=f"table-{index}", escape=False)
|
|
339
|
-
elem = div(raw_util(html_table))
|
|
340
|
-
except Exception as e:
|
|
341
|
-
elem = div(f"Table could not be rendered: {e}")
|
|
342
|
-
elif kind == "download":
|
|
343
|
-
file_path, label = content
|
|
344
|
-
btn = a(label or os.path.basename(file_path), href=file_path, cls="download-button", download=True)
|
|
345
|
-
elem = div(btn)
|
|
346
|
-
elif kind == "syntax":
|
|
347
|
-
code, language = content
|
|
348
|
-
elem = div(
|
|
349
|
-
raw_util(
|
|
350
|
-
f'<pre class="syntax-block"><code class="language-{language}">{html.escape(code)}</code></pre>'
|
|
351
|
-
),
|
|
352
|
-
cls="syntax-block"
|
|
353
|
-
)
|
|
354
|
-
elif kind == "minipage":
|
|
355
|
-
elem = content.render(
|
|
356
|
-
index,
|
|
357
|
-
downloads_dir=downloads_dir,
|
|
358
|
-
relative_prefix=relative_prefix,
|
|
359
|
-
inherited_width=effective_width,
|
|
360
|
-
inherited_marking=effective_marking,
|
|
361
|
-
inherited_distribution=effective_distribution
|
|
362
|
-
)
|
|
397
|
+
elem = self._render_element(
|
|
398
|
+
kind, content, index, downloads_dir, relative_prefix,
|
|
399
|
+
effective_width, effective_marking, effective_distribution
|
|
400
|
+
)
|
|
401
|
+
|
|
363
402
|
if el_width is not None:
|
|
364
|
-
elem = div(elem, style=
|
|
365
|
-
elem = div(elem, style=
|
|
403
|
+
elem = div(elem, style=f"width: {el_width * 100}%;")
|
|
404
|
+
elem = div(elem, style="display: flex; justify-content: center; margin: 0 auto;")
|
|
405
|
+
|
|
366
406
|
elements.append(elem)
|
|
367
407
|
|
|
368
|
-
# Expose --content-width if you need it later, but centering no longer depends on it
|
|
369
408
|
wrapper_style = (
|
|
370
|
-
f"max-width: {effective_width}px; "
|
|
371
|
-
"
|
|
372
|
-
"padding-top: 80px; padding-bottom: 80px; "
|
|
373
|
-
f"--content-width: {effective_width}px;"
|
|
409
|
+
f"max-width: {effective_width}px; margin: 0 auto; width: 100%; "
|
|
410
|
+
f"padding-top: 80px; padding-bottom: 80px; --content-width: {effective_width}px;"
|
|
374
411
|
)
|
|
375
|
-
|
|
376
|
-
return [wrapper]
|
|
412
|
+
return [div(*elements, style=wrapper_style)]
|
|
377
413
|
|
|
378
414
|
|
|
379
415
|
class MiniPage(AbstractPage):
|
|
@@ -381,225 +417,31 @@ class MiniPage(AbstractPage):
|
|
|
381
417
|
super().__init__()
|
|
382
418
|
self.page_width = page_width
|
|
383
419
|
|
|
384
|
-
def render(self, index=None, downloads_dir=None, relative_prefix="",
|
|
385
|
-
|
|
386
|
-
|
|
420
|
+
def render(self, index=None, downloads_dir=None, relative_prefix="",
|
|
421
|
+
inherited_width=None, inherited_marking=None, inherited_distribution=None):
|
|
422
|
+
effective_width = self.page_width or inherited_width or DEFAULT_PAGE_WIDTH
|
|
423
|
+
row_div = div(
|
|
424
|
+
cls="minipage-row",
|
|
425
|
+
style=f"max-width: {effective_width}px; margin: 0 auto; width: 100%;"
|
|
426
|
+
)
|
|
427
|
+
|
|
387
428
|
for kind, content, el_width in self.elements:
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
elem = None
|
|
394
|
-
if kind == "text":
|
|
395
|
-
paragraphs = split_paragraphs_preserving_math(content)
|
|
396
|
-
if paragraphs:
|
|
397
|
-
elem = div(*[p(para) for para in paragraphs])
|
|
398
|
-
else:
|
|
399
|
-
elem = p(content)
|
|
400
|
-
elif kind == "header":
|
|
401
|
-
text, level = content
|
|
402
|
-
header_tag = {1: h1, 2: h2, 3: h3, 4: h4}[level]
|
|
403
|
-
elem = header_tag(text)
|
|
404
|
-
elif kind == "plot":
|
|
405
|
-
# content may be stored as (figure, height, width, align)
|
|
406
|
-
specified_height = None
|
|
407
|
-
specified_width = None
|
|
408
|
-
specified_align = "center"
|
|
409
|
-
if isinstance(content, (list, tuple)):
|
|
410
|
-
if len(content) == 4:
|
|
411
|
-
fig, specified_height, specified_width, specified_align = content
|
|
412
|
-
elif len(content) == 3:
|
|
413
|
-
fig, specified_height, specified_width = content
|
|
414
|
-
elif len(content) == 2:
|
|
415
|
-
fig, specified_height = content
|
|
416
|
-
else:
|
|
417
|
-
fig = content
|
|
418
|
-
else:
|
|
419
|
-
fig = content
|
|
420
|
-
|
|
421
|
-
if hasattr(fig, "to_html"):
|
|
422
|
-
# Use local Plotly loaded in <head>
|
|
423
|
-
# Ensure the figure uses a robust font family so minus signs and other
|
|
424
|
-
# glyphs render correctly in the browser (some system fonts lack U+2212)
|
|
425
|
-
try:
|
|
426
|
-
font_family = None
|
|
427
|
-
if getattr(fig, 'layout', None) and getattr(fig.layout, 'font', None):
|
|
428
|
-
font_family = getattr(fig.layout.font, 'family', None)
|
|
429
|
-
if not font_family:
|
|
430
|
-
fig.update_layout(font_family='-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif')
|
|
431
|
-
except Exception:
|
|
432
|
-
pass
|
|
433
|
-
try:
|
|
434
|
-
orig_height = None
|
|
435
|
-
orig_width = None
|
|
436
|
-
try:
|
|
437
|
-
orig_height = getattr(fig.layout, 'height', None)
|
|
438
|
-
except Exception:
|
|
439
|
-
orig_height = None
|
|
440
|
-
try:
|
|
441
|
-
orig_width = getattr(fig.layout, 'width', None)
|
|
442
|
-
except Exception:
|
|
443
|
-
orig_width = None
|
|
444
|
-
|
|
445
|
-
try:
|
|
446
|
-
if specified_height is not None:
|
|
447
|
-
fig.update_layout(height=specified_height)
|
|
448
|
-
if specified_width is not None:
|
|
449
|
-
fig.update_layout(width=specified_width)
|
|
450
|
-
except Exception:
|
|
451
|
-
pass
|
|
452
|
-
|
|
453
|
-
# Defer Plotly.newPlot until the Plotly bundle is available
|
|
454
|
-
# by embedding the figure JSON and calling newPlot from a
|
|
455
|
-
# small loader that polls for `window.Plotly`.
|
|
456
|
-
try:
|
|
457
|
-
fig_json = fig.to_json()
|
|
458
|
-
except Exception:
|
|
459
|
-
# Fallback: use the html fragment (older path)
|
|
460
|
-
plotly_html = fig.to_html(full_html=False, include_plotlyjs=False, config={'responsive': True})
|
|
461
|
-
container_style = "width:100%;"
|
|
462
|
-
if specified_width is not None:
|
|
463
|
-
container_style = f"width:{specified_width}px;"
|
|
464
|
-
if specified_height is not None:
|
|
465
|
-
container_style = container_style + f" height:{specified_height}px;"
|
|
466
|
-
plot_wrapped = f'<div style="{container_style}">{plotly_html}</div>'
|
|
467
|
-
else:
|
|
468
|
-
fig_json = fig_json.replace('</script>', '<\\/script>')
|
|
469
|
-
div_id = f'plot-{uuid.uuid4()}'
|
|
470
|
-
container_style = "width:100%;"
|
|
471
|
-
if specified_width is not None:
|
|
472
|
-
container_style = f"width:{specified_width}px;"
|
|
473
|
-
if specified_height is not None:
|
|
474
|
-
container_style = container_style + f" height:{specified_height}px;"
|
|
475
|
-
|
|
476
|
-
plot_div = f'<div id="{div_id}" class="plotly-graph-div" style="{container_style}"></div>'
|
|
477
|
-
loader = (
|
|
478
|
-
'<script type="text/javascript">(function(){' \
|
|
479
|
-
f'var fig = {fig_json};' \
|
|
480
|
-
'function tryPlot(){' \
|
|
481
|
-
'if(window.Plotly && typeof window.Plotly.newPlot === "function"){' \
|
|
482
|
-
f'Plotly.newPlot("{div_id}", fig.data, fig.layout, {json.dumps({"responsive": True})});' \
|
|
483
|
-
'} else { setTimeout(tryPlot, 50); }' \
|
|
484
|
-
'}' \
|
|
485
|
-
'if(document.readyState === "complete"){ tryPlot(); } else { window.addEventListener("load", tryPlot); }' \
|
|
486
|
-
'})();</script>'
|
|
487
|
-
)
|
|
488
|
-
plot_wrapped = plot_div + loader
|
|
489
|
-
if specified_align not in ("left", "right", "center"):
|
|
490
|
-
specified_align = "center"
|
|
491
|
-
if specified_align == "center":
|
|
492
|
-
align_style = "display:flex; justify-content:center; align-items:center;"
|
|
493
|
-
elif specified_align == "left":
|
|
494
|
-
align_style = "display:flex; justify-content:flex-start; align-items:center;"
|
|
495
|
-
else:
|
|
496
|
-
align_style = "display:flex; justify-content:flex-end; align-items:center;"
|
|
497
|
-
outer = f'<div style="{align_style}">{plot_wrapped}</div>'
|
|
498
|
-
elem = div(raw_util(outer))
|
|
499
|
-
|
|
500
|
-
try:
|
|
501
|
-
if specified_height is not None:
|
|
502
|
-
fig.update_layout(height=orig_height)
|
|
503
|
-
if specified_width is not None:
|
|
504
|
-
fig.update_layout(width=orig_width)
|
|
505
|
-
except Exception:
|
|
506
|
-
pass
|
|
507
|
-
except Exception as e:
|
|
508
|
-
elem = div(f"Plotly figure could not be rendered: {e}")
|
|
509
|
-
else:
|
|
510
|
-
# Robust Matplotlib -> PNG path. Ensure `buf` exists and is closed.
|
|
511
|
-
buf = io.BytesIO()
|
|
512
|
-
try:
|
|
513
|
-
# If pixel width/height specified, attempt to adjust figure size
|
|
514
|
-
orig_size = None
|
|
515
|
-
try:
|
|
516
|
-
dpi = fig.get_dpi()
|
|
517
|
-
except Exception:
|
|
518
|
-
dpi = None
|
|
519
|
-
try:
|
|
520
|
-
if dpi is not None and (specified_height is not None or specified_width is not None):
|
|
521
|
-
orig_size = fig.get_size_inches()
|
|
522
|
-
new_w = orig_size[0]
|
|
523
|
-
new_h = orig_size[1]
|
|
524
|
-
if specified_width is not None:
|
|
525
|
-
new_w = specified_width / dpi
|
|
526
|
-
if specified_height is not None:
|
|
527
|
-
new_h = specified_height / dpi
|
|
528
|
-
fig.set_size_inches(new_w, new_h)
|
|
529
|
-
except Exception:
|
|
530
|
-
orig_size = None
|
|
531
|
-
|
|
532
|
-
with rc_context({"axes.unicode_minus": False}):
|
|
533
|
-
fig.savefig(buf, format="png", bbox_inches="tight")
|
|
534
|
-
|
|
535
|
-
# restore original size if changed
|
|
536
|
-
try:
|
|
537
|
-
if orig_size is not None:
|
|
538
|
-
fig.set_size_inches(orig_size)
|
|
539
|
-
except Exception:
|
|
540
|
-
pass
|
|
541
|
-
|
|
542
|
-
buf.seek(0)
|
|
543
|
-
img_base64 = base64.b64encode(buf.read()).decode("utf-8")
|
|
544
|
-
img_style = "max-width:100%;"
|
|
545
|
-
if specified_height is not None:
|
|
546
|
-
img_style = img_style + f" height:{specified_height}px;"
|
|
547
|
-
if specified_width is not None:
|
|
548
|
-
img_style = img_style + f" width:{specified_width}px;"
|
|
549
|
-
if specified_align not in ("left", "right", "center"):
|
|
550
|
-
specified_align = "center"
|
|
551
|
-
if specified_align == "center":
|
|
552
|
-
align_style = "display:flex; justify-content:center; align-items:center;"
|
|
553
|
-
elif specified_align == "left":
|
|
554
|
-
align_style = "display:flex; justify-content:flex-start; align-items:center;"
|
|
555
|
-
else:
|
|
556
|
-
align_style = "display:flex; justify-content:flex-end; align-items:center;"
|
|
557
|
-
elem = div(
|
|
558
|
-
raw_util(f'<img src="data:image/png;base64,{img_base64}" style="{img_style}">'),
|
|
559
|
-
style=align_style
|
|
560
|
-
)
|
|
561
|
-
except Exception as e:
|
|
562
|
-
elem = div(f"Matplotlib figure could not be rendered: {e}")
|
|
563
|
-
finally:
|
|
564
|
-
try:
|
|
565
|
-
buf.close()
|
|
566
|
-
except Exception:
|
|
567
|
-
pass
|
|
568
|
-
elif kind == "table":
|
|
569
|
-
df = content
|
|
570
|
-
html_table = df.to_html(classes="table-hover table-striped", index=False, border=0, table_id=f"table-{index}", escape=False)
|
|
571
|
-
elem = raw_util(html_table)
|
|
572
|
-
elif kind == "download":
|
|
573
|
-
file_path, label = content
|
|
574
|
-
btn = a(label or os.path.basename(file_path), href=file_path, cls="download-button", download=True)
|
|
575
|
-
elem = div(btn)
|
|
576
|
-
elif kind == "syntax":
|
|
577
|
-
code, language = content
|
|
578
|
-
elem = div(
|
|
579
|
-
raw_util(
|
|
580
|
-
f'<pre class="syntax-block"><code class="language-{language}">{html.escape(code)}</code></pre>'
|
|
581
|
-
),
|
|
582
|
-
cls="syntax-block"
|
|
583
|
-
)
|
|
584
|
-
elif kind == "minipage":
|
|
585
|
-
elem = content.render(
|
|
586
|
-
index,
|
|
587
|
-
downloads_dir=downloads_dir,
|
|
588
|
-
relative_prefix=relative_prefix,
|
|
589
|
-
inherited_width=effective_width,
|
|
590
|
-
inherited_marking=inherited_marking,
|
|
591
|
-
inherited_distribution=inherited_distribution
|
|
592
|
-
)
|
|
429
|
+
elem = self._render_element(
|
|
430
|
+
kind, content, index, downloads_dir, relative_prefix,
|
|
431
|
+
effective_width, inherited_marking, inherited_distribution
|
|
432
|
+
)
|
|
433
|
+
|
|
593
434
|
if el_width is not None:
|
|
594
|
-
elem = div(elem, style=
|
|
595
|
-
elem = div(elem, style=
|
|
596
|
-
|
|
597
|
-
row_div += cell
|
|
435
|
+
elem = div(elem, style=f"width: {el_width * 100}%;")
|
|
436
|
+
elem = div(elem, style="display: flex; justify-content: center; margin: 0 auto;")
|
|
437
|
+
|
|
438
|
+
row_div += div(elem, cls="minipage-cell")
|
|
439
|
+
|
|
598
440
|
return row_div
|
|
599
441
|
|
|
600
442
|
|
|
601
443
|
class Dashboard:
|
|
602
|
-
def __init__(self, title="Dashboard", page_width=
|
|
444
|
+
def __init__(self, title="Dashboard", page_width=DEFAULT_PAGE_WIDTH, marking=None, distribution=None):
|
|
603
445
|
self.title = title
|
|
604
446
|
self.pages = []
|
|
605
447
|
self.page_width = page_width
|
|
@@ -609,351 +451,260 @@ class Dashboard:
|
|
|
609
451
|
def add_page(self, page):
|
|
610
452
|
self.pages.append(page)
|
|
611
453
|
|
|
612
|
-
def
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
# <div class="sidebar-children"> ... </div>
|
|
619
|
-
# </div>
|
|
620
|
-
for page in pages:
|
|
621
|
-
page_href = f"{prefix}{page.slug}.html"
|
|
622
|
-
is_active = (page.slug == current_slug)
|
|
454
|
+
def _has_active_child(self, page, current_slug):
|
|
455
|
+
"""Check if page has an active child recursively."""
|
|
456
|
+
return any(
|
|
457
|
+
child.slug == current_slug or self._has_active_child(child, current_slug)
|
|
458
|
+
for child in getattr(page, "children", [])
|
|
459
|
+
)
|
|
623
460
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
461
|
+
def _get_page_href(self, page, prefix="", is_first_page=False, viewing_from_index=False):
|
|
462
|
+
"""
|
|
463
|
+
Get the href for a page, handling first page as index.html.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
page: The page to link to
|
|
467
|
+
prefix: The prefix for non-first pages ("pages/" or "")
|
|
468
|
+
is_first_page: Whether this is the first page (becomes index.html)
|
|
469
|
+
viewing_from_index: Whether we're viewing from index.html (vs pages/*.html)
|
|
470
|
+
"""
|
|
471
|
+
if is_first_page:
|
|
472
|
+
# First page is always index.html
|
|
473
|
+
if viewing_from_index:
|
|
474
|
+
return "index.html"
|
|
475
|
+
else:
|
|
476
|
+
return "../index.html"
|
|
477
|
+
else:
|
|
478
|
+
return f"{prefix}{page.slug}.html"
|
|
479
|
+
|
|
480
|
+
def _render_sidebar(self, pages, prefix="", current_slug=None, viewing_from_index=False, is_top_level=True):
|
|
481
|
+
"""Render sidebar navigation."""
|
|
482
|
+
for i, page in enumerate(pages):
|
|
483
|
+
# Only the first top-level page becomes index.html
|
|
484
|
+
is_first_page = (i == 0 and is_top_level)
|
|
485
|
+
page_href = self._get_page_href(page, prefix, is_first_page, viewing_from_index)
|
|
486
|
+
is_active = (page.slug == current_slug)
|
|
487
|
+
has_children = bool(getattr(page, "children", []))
|
|
488
|
+
group_open = self._has_active_child(page, current_slug)
|
|
629
489
|
|
|
630
|
-
group_open = has_active_child(page)
|
|
631
490
|
link_classes = "nav-link"
|
|
632
|
-
if
|
|
491
|
+
if has_children:
|
|
633
492
|
link_classes += " sidebar-parent"
|
|
634
493
|
if is_active:
|
|
635
494
|
link_classes += " active"
|
|
636
495
|
|
|
637
|
-
if
|
|
638
|
-
group_cls = "sidebar-group"
|
|
639
|
-
if group_open or is_active:
|
|
640
|
-
group_cls += " open"
|
|
496
|
+
if has_children:
|
|
497
|
+
group_cls = "sidebar-group open" if (group_open or is_active) else "sidebar-group"
|
|
641
498
|
with div(cls=group_cls):
|
|
642
|
-
a([
|
|
643
|
-
span("", cls="sidebar-arrow"), # pure-CSS triangle (no Unicode)
|
|
644
|
-
page.title
|
|
645
|
-
], cls=link_classes, href=page_href)
|
|
499
|
+
a([span("", cls="sidebar-arrow"), page.title], cls=link_classes, href=page_href)
|
|
646
500
|
with div(cls="sidebar-children"):
|
|
647
|
-
|
|
501
|
+
# Children are not top-level, so they won't be treated as index.html
|
|
502
|
+
self._render_sidebar(page.children, prefix, current_slug, viewing_from_index, is_top_level=False)
|
|
648
503
|
else:
|
|
649
504
|
a(page.title, cls="nav-link", href=page_href)
|
|
650
505
|
|
|
651
|
-
def
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
506
|
+
def _add_head_assets(self, head, rel_prefix, effective_width):
|
|
507
|
+
"""Add CSS, JavaScript, and other assets to document head."""
|
|
508
|
+
head.add(raw_util('<meta charset="utf-8" />'))
|
|
509
|
+
head.add(raw_util('<meta name="viewport" content="width=device-width, initial-scale=1" />'))
|
|
510
|
+
head.add(link(rel="stylesheet", href=f"{rel_prefix}assets/css/style.css"))
|
|
511
|
+
head.add(script(type="text/javascript", src=f"{rel_prefix}assets/js/script.js"))
|
|
512
|
+
|
|
513
|
+
# MathJax config
|
|
514
|
+
head.add(raw_util(
|
|
515
|
+
"<script>window.MathJax={tex:{inlineMath:[['$','$'],['\\\\(','\\\\)']],displayMath:[['$$','$$'],['\\\\[','\\\\]']]}};</script>"
|
|
516
|
+
))
|
|
517
|
+
head.add(raw_util(
|
|
518
|
+
f'<script src="{rel_prefix}assets/vendor/mathjax/tex-svg.js" '
|
|
519
|
+
'onerror="var s=document.createElement(\'script\');'
|
|
520
|
+
's.src=\'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js\';'
|
|
521
|
+
'document.head.appendChild(s);"></script>'
|
|
522
|
+
))
|
|
523
|
+
|
|
524
|
+
# Prism syntax highlighting
|
|
525
|
+
head.add(link(rel="stylesheet", href=f"{rel_prefix}assets/vendor/prism/prism-tomorrow.min.css"))
|
|
526
|
+
head.add(script(src=f"{rel_prefix}assets/vendor/prism/prism.min.js"))
|
|
527
|
+
head.add(script(src=f"{rel_prefix}assets/vendor/prism/components/prism-python.min.js"))
|
|
528
|
+
head.add(script(src=f"{rel_prefix}assets/vendor/prism/components/prism-javascript.min.js"))
|
|
529
|
+
|
|
530
|
+
# Plotly with CDN fallback
|
|
531
|
+
head.add(raw_util(
|
|
532
|
+
f'<script src="{rel_prefix}assets/vendor/plotly/plotly.min.js" '
|
|
533
|
+
'onerror="var s=document.createElement(\'script\');'
|
|
534
|
+
's.src=\'https://cdn.plot.ly/plotly-2.32.0.min.js\';'
|
|
535
|
+
'document.head.appendChild(s);"></script>'
|
|
536
|
+
))
|
|
537
|
+
|
|
538
|
+
# Mermaid.js for diagrams (local-first, CDN fallback)
|
|
539
|
+
head.add(raw_util(
|
|
540
|
+
f'<script src="{rel_prefix}assets/vendor/mermaid/mermaid.min.js" '
|
|
541
|
+
'onerror="var s=document.createElement(\'script\');'
|
|
542
|
+
's.src=\'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\';'
|
|
543
|
+
's.onload=function(){{mermaid.initialize({{startOnLoad:true,theme:\'default\'}});}};'
|
|
544
|
+
'document.head.appendChild(s);"></script>'
|
|
545
|
+
))
|
|
546
|
+
head.add(raw_util(
|
|
547
|
+
'<script>if(typeof mermaid!=="undefined"){mermaid.initialize({ startOnLoad: true, theme: "default" });}</script>'
|
|
548
|
+
))
|
|
549
|
+
|
|
550
|
+
# CSS variables
|
|
551
|
+
head.add(raw_util(
|
|
552
|
+
f"<style>:root{{--sidebar-width:{DEFAULT_SIDEBAR_WIDTH}px;--content-padding-x:{DEFAULT_CONTENT_PADDING}px;}}</style>"
|
|
553
|
+
))
|
|
554
|
+
head.add(raw_util(f"<style>.content-inner {{ max-width: {effective_width}px !important; }}</style>"))
|
|
555
|
+
|
|
556
|
+
def _write_page_html(self, page, output_dir, pages_dir, downloads_dir, rel_prefix="../"):
|
|
557
|
+
"""Write a single page HTML file."""
|
|
558
|
+
doc = document(title=page.title)
|
|
559
|
+
effective_width = page.page_width or self.page_width
|
|
657
560
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
|
|
661
|
-
|
|
662
|
-
def _add_head_assets(head, rel_prefix, effective_width):
|
|
663
|
-
# Ensure pages declare UTF-8 to avoid character misinterpretation
|
|
664
|
-
head.add(raw_util('<meta charset="utf-8" />'))
|
|
665
|
-
head.add(raw_util('<meta name="viewport" content="width=device-width, initial-scale=1" />'))
|
|
666
|
-
# Your CSS/JS
|
|
667
|
-
head.add(link(rel="stylesheet", href=f"{rel_prefix}assets/css/style.css"))
|
|
668
|
-
head.add(script(type="text/javascript", src=f"{rel_prefix}assets/js/script.js"))
|
|
669
|
-
|
|
670
|
-
# MathJax: config for $...$ and $$...$$
|
|
671
|
-
head.add(raw_util(
|
|
672
|
-
"<script>window.MathJax={tex:{inlineMath:[['$','$'],['\\\\(','\\\\)']],displayMath:[['$$','$$'],['\\\\[','\\\\]']]}};</script>"
|
|
673
|
-
))
|
|
674
|
-
# Local-first, CDN-fallback (for editable installs without vendored files)
|
|
675
|
-
head.add(raw_util(
|
|
676
|
-
f"<script src=\"{rel_prefix}assets/vendor/mathjax/tex-svg.js\" "
|
|
677
|
-
"onerror=\"var s=document.createElement('script');"
|
|
678
|
-
"s.src='https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js';"
|
|
679
|
-
"document.head.appendChild(s);\"></script>"
|
|
680
|
-
))
|
|
681
|
-
|
|
682
|
-
# Prism (theme + core + languages) — still local
|
|
683
|
-
head.add(link(rel="stylesheet", href=f"{rel_prefix}assets/vendor/prism/prism-tomorrow.min.css"))
|
|
684
|
-
head.add(script(src=f"{rel_prefix}assets/vendor/prism/prism.min.js"))
|
|
685
|
-
head.add(script(src=f"{rel_prefix}assets/vendor/prism/components/prism-python.min.js"))
|
|
686
|
-
head.add(script(src=f"{rel_prefix}assets/vendor/prism/components/prism-javascript.min.js"))
|
|
687
|
-
|
|
688
|
-
# Plotly local-first, CDN-fallback
|
|
689
|
-
head.add(raw_util(
|
|
690
|
-
f"<script src=\"{rel_prefix}assets/vendor/plotly/plotly.min.js\" "
|
|
691
|
-
"onerror=\"var s=document.createElement('script');"
|
|
692
|
-
"s.src='https://cdn.plot.ly/plotly-2.32.0.min.js';"
|
|
693
|
-
"document.head.appendChild(s);\"></script>"
|
|
694
|
-
))
|
|
695
|
-
|
|
696
|
-
# Defaults that match your CSS; override in CSS if they change
|
|
697
|
-
head.add(raw_util("<style>:root{--sidebar-width:240px;--content-padding-x:20px;}</style>"))
|
|
698
|
-
head.add(raw_util(f"<style>.content-inner {{ max-width: {effective_width}px !important; }}</style>"))
|
|
699
|
-
|
|
700
|
-
def write_page(page):
|
|
701
|
-
doc = document(title=page.title)
|
|
702
|
-
effective_width = page.page_width or self.page_width or 900
|
|
703
|
-
with doc.head:
|
|
704
|
-
_add_head_assets(doc.head, rel_prefix="../", effective_width=effective_width)
|
|
705
|
-
with doc:
|
|
706
|
-
with div(id="sidebar"):
|
|
707
|
-
a(self.title, href="../index.html", cls="sidebar-title")
|
|
708
|
-
self._render_sidebar(self.pages, prefix="", current_slug=page.slug)
|
|
709
|
-
with div(id="sidebar-footer"):
|
|
710
|
-
a("Produced by staticdash", href="../index.html")
|
|
711
|
-
with div(id="content"):
|
|
712
|
-
with div(cls="content-inner"):
|
|
713
|
-
for el in page.render(
|
|
714
|
-
0,
|
|
715
|
-
downloads_dir=downloads_dir,
|
|
716
|
-
relative_prefix="../",
|
|
717
|
-
inherited_width=self.page_width,
|
|
718
|
-
inherited_marking=self.marking,
|
|
719
|
-
inherited_distribution=self.distribution
|
|
720
|
-
):
|
|
721
|
-
div(el)
|
|
722
|
-
|
|
723
|
-
with open(os.path.join(pages_dir, f"{page.slug}.html"), "w", encoding="utf-8") as f:
|
|
724
|
-
f.write(str(doc))
|
|
725
|
-
|
|
726
|
-
for child in getattr(page, "children", []):
|
|
727
|
-
write_page(child)
|
|
728
|
-
|
|
729
|
-
for page in self.pages:
|
|
730
|
-
write_page(page)
|
|
561
|
+
with doc.head:
|
|
562
|
+
self._add_head_assets(doc.head, rel_prefix=rel_prefix, effective_width=effective_width)
|
|
731
563
|
|
|
732
|
-
|
|
733
|
-
index_doc = document(title=self.title)
|
|
734
|
-
effective_width = self.pages[0].page_width or self.page_width or 900
|
|
735
|
-
with index_doc.head:
|
|
736
|
-
_add_head_assets(index_doc.head, rel_prefix="", effective_width=effective_width)
|
|
737
|
-
with index_doc:
|
|
564
|
+
with doc:
|
|
738
565
|
with div(id="sidebar"):
|
|
739
|
-
a(self.title, href="index.html", cls="sidebar-title")
|
|
740
|
-
|
|
566
|
+
a(self.title, href=f"{rel_prefix}index.html", cls="sidebar-title")
|
|
567
|
+
# When on index.html (rel_prefix=""), other pages are in pages/ directory
|
|
568
|
+
# When on pages/*.html (rel_prefix="../"), other pages are in same directory (no prefix needed)
|
|
569
|
+
viewing_from_index = (rel_prefix == "")
|
|
570
|
+
sidebar_prefix = "pages/" if viewing_from_index else ""
|
|
571
|
+
self._render_sidebar(self.pages, prefix=sidebar_prefix, current_slug=page.slug,
|
|
572
|
+
viewing_from_index=viewing_from_index)
|
|
741
573
|
with div(id="sidebar-footer"):
|
|
742
|
-
a("Produced by staticdash", href="index.html")
|
|
574
|
+
a("Produced by staticdash", href=f"{rel_prefix}index.html" if rel_prefix else "index.html")
|
|
743
575
|
with div(id="content"):
|
|
744
576
|
with div(cls="content-inner"):
|
|
745
|
-
for el in
|
|
577
|
+
for el in page.render(
|
|
746
578
|
0,
|
|
747
579
|
downloads_dir=downloads_dir,
|
|
748
|
-
relative_prefix=
|
|
580
|
+
relative_prefix=rel_prefix,
|
|
749
581
|
inherited_width=self.page_width,
|
|
750
582
|
inherited_marking=self.marking,
|
|
751
583
|
inherited_distribution=self.distribution
|
|
752
584
|
):
|
|
753
585
|
div(el)
|
|
754
586
|
|
|
755
|
-
|
|
756
|
-
|
|
587
|
+
# Determine output path: index.html for first page (rel_prefix=""), otherwise pages/{slug}.html
|
|
588
|
+
if rel_prefix == "":
|
|
589
|
+
output_path = os.path.join(output_dir, "index.html")
|
|
590
|
+
else:
|
|
591
|
+
output_path = os.path.join(pages_dir, f"{page.slug}.html")
|
|
592
|
+
|
|
593
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
594
|
+
f.write(str(doc))
|
|
595
|
+
|
|
596
|
+
def publish(self, output_dir="output"):
|
|
597
|
+
output_dir = os.path.abspath(output_dir)
|
|
598
|
+
pages_dir = os.path.join(output_dir, "pages")
|
|
599
|
+
downloads_dir = os.path.join(output_dir, "downloads")
|
|
600
|
+
assets_src = os.path.join(os.path.dirname(__file__), "assets")
|
|
601
|
+
assets_dst = os.path.join(output_dir, "assets")
|
|
602
|
+
|
|
603
|
+
os.makedirs(pages_dir, exist_ok=True)
|
|
604
|
+
os.makedirs(downloads_dir, exist_ok=True)
|
|
605
|
+
shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
|
|
606
|
+
|
|
607
|
+
def write_page(page, is_first=False):
|
|
608
|
+
# First page goes to index.html, others go to pages/ directory
|
|
609
|
+
if is_first:
|
|
610
|
+
self._write_page_html(page, output_dir, pages_dir, downloads_dir, rel_prefix="")
|
|
611
|
+
else:
|
|
612
|
+
self._write_page_html(page, output_dir, pages_dir, downloads_dir)
|
|
613
|
+
|
|
614
|
+
# Recursively write children
|
|
615
|
+
for child in getattr(page, "children", []):
|
|
616
|
+
write_page(child, is_first=False)
|
|
617
|
+
|
|
618
|
+
# Write all pages
|
|
619
|
+
for i, page in enumerate(self.pages):
|
|
620
|
+
write_page(page, is_first=(i == 0))
|
|
757
621
|
|
|
758
622
|
|
|
759
623
|
class Directory:
|
|
760
624
|
"""
|
|
761
625
|
A Directory aggregates multiple Dashboard instances and publishes them
|
|
762
|
-
as a landing page listing multiple dashboards.
|
|
763
|
-
into its own subfolder under the output directory.
|
|
626
|
+
as a landing page listing multiple dashboards.
|
|
764
627
|
"""
|
|
765
628
|
|
|
766
|
-
def __init__(self, title="Dashboard Directory", page_width=
|
|
767
|
-
"""
|
|
768
|
-
Initialize a Directory.
|
|
769
|
-
|
|
770
|
-
Args:
|
|
771
|
-
title (str): The title of the directory landing page
|
|
772
|
-
page_width (int): The default page width for the landing page
|
|
773
|
-
"""
|
|
629
|
+
def __init__(self, title="Dashboard Directory", page_width=DEFAULT_PAGE_WIDTH):
|
|
774
630
|
self.title = title
|
|
775
631
|
self.page_width = page_width
|
|
776
632
|
self.dashboards = [] # List of (slug, dashboard) tuples
|
|
777
633
|
|
|
778
634
|
def add_dashboard(self, dashboard, slug=None):
|
|
779
|
-
"""
|
|
780
|
-
Add a Dashboard instance to the directory.
|
|
781
|
-
|
|
782
|
-
Args:
|
|
783
|
-
dashboard (Dashboard): The Dashboard instance to add
|
|
784
|
-
slug (str, optional): URL-friendly identifier for the dashboard.
|
|
785
|
-
If None, derived from dashboard title.
|
|
786
|
-
"""
|
|
635
|
+
"""Add a Dashboard instance to the directory."""
|
|
787
636
|
if slug is None:
|
|
788
|
-
|
|
789
|
-
slug = dashboard.title.lower().replace(" ", "-")
|
|
790
|
-
# Remove special characters
|
|
791
|
-
slug = "".join(c for c in slug if c.isalnum() or c == "-")
|
|
792
|
-
# Clean up multiple consecutive hyphens
|
|
793
|
-
slug = re.sub(r'-+', '-', slug)
|
|
794
|
-
# Remove leading/trailing hyphens
|
|
795
|
-
slug = slug.strip("-")
|
|
796
|
-
|
|
637
|
+
slug = self._generate_slug(dashboard.title)
|
|
797
638
|
self.dashboards.append((slug, dashboard))
|
|
798
639
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
Args:
|
|
807
|
-
output_dir (str): The output directory path
|
|
808
|
-
"""
|
|
809
|
-
output_dir = os.path.abspath(output_dir)
|
|
810
|
-
os.makedirs(output_dir, exist_ok=True)
|
|
811
|
-
|
|
812
|
-
# Copy assets to the root output directory
|
|
813
|
-
assets_src = os.path.join(os.path.dirname(__file__), "assets")
|
|
814
|
-
assets_dst = os.path.join(output_dir, "assets")
|
|
815
|
-
shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
|
|
816
|
-
|
|
817
|
-
# Publish each dashboard to its own subfolder
|
|
818
|
-
for slug, dashboard in self.dashboards:
|
|
819
|
-
dashboard_dir = os.path.join(output_dir, slug)
|
|
820
|
-
dashboard.publish(output_dir=dashboard_dir)
|
|
821
|
-
|
|
822
|
-
# Add a "Back to Directory" link to each dashboard's index page
|
|
823
|
-
self._add_back_link(dashboard_dir, slug)
|
|
824
|
-
|
|
825
|
-
# Create the landing page
|
|
826
|
-
self._create_landing_page(output_dir)
|
|
827
|
-
|
|
828
|
-
def _add_back_link(self, dashboard_dir, slug):
|
|
829
|
-
"""
|
|
830
|
-
Add a navigation link back to the directory landing page in the dashboard.
|
|
831
|
-
|
|
832
|
-
Args:
|
|
833
|
-
dashboard_dir (str): Path to the dashboard output directory
|
|
834
|
-
slug (str): The slug of the dashboard
|
|
835
|
-
"""
|
|
836
|
-
index_path = os.path.join(dashboard_dir, "index.html")
|
|
837
|
-
if not os.path.exists(index_path):
|
|
838
|
-
return
|
|
839
|
-
|
|
840
|
-
# Read the existing index.html
|
|
841
|
-
with open(index_path, "r", encoding="utf-8") as f:
|
|
842
|
-
content = f.read()
|
|
843
|
-
|
|
844
|
-
# Add a back link in the sidebar footer
|
|
845
|
-
# Replace the sidebar-footer section with one that includes a back link
|
|
846
|
-
back_link = '<div id="sidebar-footer"><a href="../index.html">← Back to Directory</a></div>'
|
|
847
|
-
|
|
848
|
-
# Find and replace the sidebar-footer
|
|
849
|
-
pattern = r'<div id="sidebar-footer">.*?</div>'
|
|
850
|
-
content = re.sub(pattern, back_link, content, flags=re.DOTALL)
|
|
851
|
-
|
|
852
|
-
# Write back the modified content
|
|
853
|
-
with open(index_path, "w", encoding="utf-8") as f:
|
|
854
|
-
f.write(content)
|
|
855
|
-
|
|
856
|
-
# Also update all page HTML files to have the back link
|
|
857
|
-
pages_dir = os.path.join(dashboard_dir, "pages")
|
|
858
|
-
if os.path.exists(pages_dir):
|
|
859
|
-
for page_file in os.listdir(pages_dir):
|
|
860
|
-
if page_file.endswith(".html"):
|
|
861
|
-
page_path = os.path.join(pages_dir, page_file)
|
|
862
|
-
with open(page_path, "r", encoding="utf-8") as f:
|
|
863
|
-
page_content = f.read()
|
|
864
|
-
|
|
865
|
-
# For pages, the back link needs to go up two levels
|
|
866
|
-
back_link_pages = '<div id="sidebar-footer"><a href="../../index.html">← Back to Directory</a></div>'
|
|
867
|
-
page_content = re.sub(pattern, back_link_pages, page_content, flags=re.DOTALL)
|
|
868
|
-
|
|
869
|
-
with open(page_path, "w", encoding="utf-8") as f:
|
|
870
|
-
f.write(page_content)
|
|
640
|
+
@staticmethod
|
|
641
|
+
def _generate_slug(title):
|
|
642
|
+
"""Generate a URL-friendly slug from a title."""
|
|
643
|
+
slug = title.lower().replace(" ", "-")
|
|
644
|
+
slug = "".join(c for c in slug if c.isalnum() or c == "-")
|
|
645
|
+
slug = re.sub(r'-+', '-', slug)
|
|
646
|
+
return slug.strip("-")
|
|
871
647
|
|
|
872
648
|
def _create_landing_page(self, output_dir):
|
|
873
|
-
"""
|
|
874
|
-
Create the landing page HTML that lists all dashboards.
|
|
875
|
-
|
|
876
|
-
Args:
|
|
877
|
-
output_dir (str): Path to the output directory
|
|
878
|
-
"""
|
|
649
|
+
"""Create the landing page HTML that lists all dashboards."""
|
|
879
650
|
doc = document(title=self.title)
|
|
880
651
|
|
|
881
|
-
# Add CSS and basic styling
|
|
882
652
|
with doc.head:
|
|
883
|
-
# Ensure charset is declared for the landing page too
|
|
884
653
|
doc.head.add(raw_util('<meta charset="utf-8" />'))
|
|
885
654
|
link(rel="stylesheet", href="assets/css/style.css")
|
|
886
|
-
raw_util("""
|
|
655
|
+
raw_util(f"""
|
|
887
656
|
<style>
|
|
888
|
-
body {
|
|
889
|
-
font-family:
|
|
890
|
-
margin: 0;
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
}
|
|
894
|
-
.directory-container {
|
|
895
|
-
max-width:
|
|
657
|
+
body {{
|
|
658
|
+
font-family: {DEFAULT_FONT_FAMILY};
|
|
659
|
+
margin: 0; padding: 60px 40px;
|
|
660
|
+
background-color: #2c3e50;
|
|
661
|
+
min-height: 100vh;
|
|
662
|
+
}}
|
|
663
|
+
.directory-container {{
|
|
664
|
+
max-width: {self.page_width}px;
|
|
896
665
|
margin: 0 auto;
|
|
897
|
-
padding: 40px 20px;
|
|
898
|
-
}
|
|
899
|
-
.directory-header {
|
|
900
|
-
text-align: center;
|
|
901
|
-
margin-bottom: 50px;
|
|
902
|
-
}
|
|
903
|
-
.directory-header h1 {
|
|
904
|
-
font-size: 2.5em;
|
|
905
|
-
margin-bottom: 10px;
|
|
906
|
-
color: #333;
|
|
907
|
-
}
|
|
908
|
-
.directory-header p {
|
|
909
|
-
font-size: 1.2em;
|
|
910
|
-
color: #666;
|
|
911
|
-
}
|
|
912
|
-
.dashboard-grid {
|
|
913
|
-
display: grid;
|
|
914
|
-
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
915
|
-
gap: 30px;
|
|
916
|
-
margin-top: 30px;
|
|
917
|
-
}
|
|
918
|
-
.dashboard-card {
|
|
919
666
|
background: white;
|
|
667
|
+
padding: 50px;
|
|
920
668
|
border-radius: 8px;
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
}
|
|
928
|
-
.
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
}
|
|
932
|
-
.dashboard-card h2 {
|
|
933
|
-
margin: 0 0 10px 0;
|
|
934
|
-
font-size: 1.5em;
|
|
669
|
+
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
|
670
|
+
}}
|
|
671
|
+
.directory-header {{
|
|
672
|
+
margin-bottom: 40px;
|
|
673
|
+
border-bottom: 3px solid #16a085;
|
|
674
|
+
padding-bottom: 20px;
|
|
675
|
+
}}
|
|
676
|
+
.directory-header h1 {{
|
|
677
|
+
font-size: 2.5em;
|
|
678
|
+
margin: 0;
|
|
935
679
|
color: #2c3e50;
|
|
936
|
-
|
|
937
|
-
|
|
680
|
+
font-weight: 600;
|
|
681
|
+
}}
|
|
682
|
+
.dashboard-list {{
|
|
683
|
+
list-style: none;
|
|
684
|
+
padding: 0;
|
|
938
685
|
margin: 0;
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
}
|
|
947
|
-
.dashboard-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
686
|
+
}}
|
|
687
|
+
.dashboard-list li {{
|
|
688
|
+
margin: 0;
|
|
689
|
+
border-bottom: 1px solid #ecf0f1;
|
|
690
|
+
}}
|
|
691
|
+
.dashboard-list li:last-child {{
|
|
692
|
+
border-bottom: none;
|
|
693
|
+
}}
|
|
694
|
+
.dashboard-list a {{
|
|
695
|
+
display: block;
|
|
696
|
+
padding: 20px 10px;
|
|
697
|
+
font-weight: 600;
|
|
698
|
+
font-size: 1.2em;
|
|
699
|
+
color: #34495e;
|
|
700
|
+
text-decoration: none;
|
|
701
|
+
transition: all 0.2s ease;
|
|
702
|
+
}}
|
|
703
|
+
.dashboard-list a:hover {{
|
|
704
|
+
color: #16a085;
|
|
705
|
+
padding-left: 20px;
|
|
706
|
+
background: #f9f9f9;
|
|
707
|
+
}}
|
|
957
708
|
</style>
|
|
958
709
|
""")
|
|
959
710
|
|
|
@@ -961,19 +712,29 @@ class Directory:
|
|
|
961
712
|
with div(cls="directory-container"):
|
|
962
713
|
with div(cls="directory-header"):
|
|
963
714
|
h1(self.title)
|
|
964
|
-
p(f"Explore {len(self.dashboards)} dashboard{'s' if len(self.dashboards) != 1 else ''}")
|
|
965
715
|
|
|
966
|
-
with
|
|
716
|
+
with ul(cls="dashboard-list"):
|
|
967
717
|
for slug, dashboard in self.dashboards:
|
|
968
|
-
with
|
|
969
|
-
|
|
970
|
-
num_pages = len(dashboard.pages)
|
|
971
|
-
p(f"{num_pages} page{'s' if num_pages != 1 else ''} ")
|
|
972
|
-
span("→", cls="dashboard-arrow")
|
|
973
|
-
|
|
974
|
-
with div(cls="footer"):
|
|
975
|
-
p("Produced by staticdash")
|
|
718
|
+
with li():
|
|
719
|
+
a(dashboard.title, href=f"{slug}/index.html", target="_blank", rel="noopener noreferrer")
|
|
976
720
|
|
|
977
|
-
# Write the landing page
|
|
978
721
|
with open(os.path.join(output_dir, "index.html"), "w", encoding="utf-8") as f:
|
|
979
722
|
f.write(str(doc))
|
|
723
|
+
|
|
724
|
+
def publish(self, output_dir="output"):
|
|
725
|
+
"""Publish the directory landing page and all dashboards."""
|
|
726
|
+
output_dir = os.path.abspath(output_dir)
|
|
727
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
728
|
+
|
|
729
|
+
# Copy assets to root
|
|
730
|
+
assets_src = os.path.join(os.path.dirname(__file__), "assets")
|
|
731
|
+
assets_dst = os.path.join(output_dir, "assets")
|
|
732
|
+
shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
|
|
733
|
+
|
|
734
|
+
# Publish each dashboard independently (no modifications)
|
|
735
|
+
for slug, dashboard in self.dashboards:
|
|
736
|
+
dashboard_dir = os.path.join(output_dir, slug)
|
|
737
|
+
dashboard.publish(output_dir=dashboard_dir)
|
|
738
|
+
|
|
739
|
+
# Create the directory landing page
|
|
740
|
+
self._create_landing_page(output_dir)
|