staticdash 2026.6__py3-none-any.whl → 2026.7__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/dashboard.py CHANGED
@@ -2,6 +2,7 @@ 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
@@ -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 = text.split('\n\n')
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
- restored_paragraphs.append(para.strip())
94
+ if para.strip():
95
+ paragraphs.append(para.strip())
96
+
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')
42
134
 
43
- # Filter out empty paragraphs
44
- return [para for para in restored_paragraphs if para]
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
- # `el_width` is the fractional width (0..1) used for layout columns.
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 render(
96
- self,
97
- index,
98
- downloads_dir=None,
99
- relative_prefix="",
100
- inherited_width=None,
101
- inherited_marking=None,
102
- inherited_distribution=None
103
- ):
104
- effective_width = self.page_width or inherited_width
105
- effective_marking = self.marking if (self.marking is not None) else inherited_marking
106
- effective_distribution = getattr(self, "distribution", None) or inherited_distribution
107
-
108
- elements = []
109
-
110
- # Only show bars if a marking is present (no default text at all).
111
- if effective_marking:
112
- # Center within the MAIN CONTENT AREA (exclude sidebar and #content padding):
113
- # left = sidebar_width + content_padding_x
114
- # width = viewport - sidebar_width - 2*content_padding_x
115
- shared_pos = (
116
- "position: fixed; "
117
- "left: calc(var(--sidebar-width, 240px) + var(--content-padding-x, 20px)); "
118
- "width: calc(100vw - var(--sidebar-width, 240px) - 2*var(--content-padding-x, 20px)); "
119
- "text-align: center; "
120
- "background-color: #f8f9fa; "
121
- "padding: 10px; "
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
- footer_block = []
133
- if effective_distribution:
134
- footer_block.append(div(effective_distribution, style="margin-bottom: 4px; font-size: 10pt;"))
135
- footer_block.append(div(effective_marking))
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
- elements.append(div(
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
- style = ""
146
- outer_style = ""
147
- if el_width is not None:
148
- style = f"width: {el_width * 100}%;"
149
- outer_style = "display: flex; justify-content: center; margin: 0 auto;"
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=style)
365
- elem = div(elem, style=outer_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
- "margin: 0 auto; width: 100%; "
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
- wrapper = div(*elements, style=wrapper_style)
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="", inherited_width=None, inherited_marking=None, inherited_distribution=None):
385
- effective_width = self.page_width or inherited_width
386
- row_div = div(cls="minipage-row", style=f"max-width: {effective_width}px; margin: 0 auto; width: 100%;")
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
- style = ""
389
- outer_style = ""
390
- if el_width is not None:
391
- style = f"width: {el_width * 100}%;"
392
- outer_style = "display: flex; justify-content: center; margin: 0 auto;"
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=style)
595
- elem = div(elem, style=outer_style)
596
- cell = div(elem, cls="minipage-cell")
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=900, marking=None, distribution=None):
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,258 @@ class Dashboard:
609
451
  def add_page(self, page):
610
452
  self.pages.append(page)
611
453
 
612
- def _render_sidebar(self, pages, prefix="", current_slug=None):
613
- # Structure preserved for your JS/CSS:
614
- # <div class="sidebar-group [open]">
615
- # <a class="nav-link sidebar-parent" href="...">
616
- # <span class="sidebar-arrow"></span>Title
617
- # </a>
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
- def has_active_child(pg):
625
- return any(
626
- child.slug == current_slug or has_active_child(child)
627
- for child in getattr(pg, "children", [])
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 getattr(page, "children", []):
491
+ if has_children:
633
492
  link_classes += " sidebar-parent"
634
493
  if is_active:
635
494
  link_classes += " active"
636
495
 
637
- if getattr(page, "children", []):
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
- self._render_sidebar(page.children, prefix, current_slug)
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 publish(self, output_dir="output"):
652
- output_dir = os.path.abspath(output_dir)
653
- pages_dir = os.path.join(output_dir, "pages")
654
- downloads_dir = os.path.join(output_dir, "downloads")
655
- assets_src = os.path.join(os.path.dirname(__file__), "assets")
656
- assets_dst = os.path.join(output_dir, "assets")
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
- os.makedirs(pages_dir, exist_ok=True)
659
- os.makedirs(downloads_dir, exist_ok=True)
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
- # Index page (first page)
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
- self._render_sidebar(self.pages, prefix="pages/", current_slug=self.pages[0].slug)
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 self.pages[0].render(
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
- with open(os.path.join(output_dir, "index.html"), "w", encoding="utf-8") as f:
756
- f.write(str(index_doc))
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. Each dashboard is published
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=900):
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
- # Generate slug from dashboard title
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
- def publish(self, output_dir="output"):
800
- """
801
- Publish the directory landing page and all dashboards.
802
-
803
- Creates a landing page (index.html) that links to each dashboard,
804
- and publishes each dashboard into its own subfolder.
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: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
890
- margin: 0;
891
- padding: 0;
892
- background-color: #f5f5f5;
893
- }
894
- .directory-container {
895
- max-width: """ + str(self.page_width) + """px;
896
- 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 {
657
+ body {{
658
+ font-family: {DEFAULT_FONT_FAMILY};
659
+ margin: 0; padding: 0; background-color: #f5f5f5;
660
+ }}
661
+ .directory-container {{
662
+ max-width: {self.page_width}px;
663
+ margin: 0 auto; padding: 40px 20px;
664
+ }}
665
+ .directory-header {{
666
+ text-align: center; margin-bottom: 50px;
667
+ }}
668
+ .directory-header h1 {{
669
+ font-size: 2.5em; margin-bottom: 10px; color: #333;
670
+ }}
671
+ .directory-header p {{
672
+ font-size: 1.2em; color: #666;
673
+ }}
674
+ .dashboard-grid {{
913
675
  display: grid;
914
676
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
915
- gap: 30px;
916
- margin-top: 30px;
917
- }
918
- .dashboard-card {
919
- background: white;
920
- border-radius: 8px;
921
- padding: 30px;
677
+ gap: 30px; margin-top: 30px;
678
+ }}
679
+ .dashboard-card {{
680
+ background: white; border-radius: 8px; padding: 30px;
922
681
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
923
682
  transition: transform 0.2s, box-shadow 0.2s;
924
- text-decoration: none;
925
- color: inherit;
926
- display: block;
927
- }
928
- .dashboard-card:hover {
683
+ text-decoration: none; color: inherit; display: block;
684
+ }}
685
+ .dashboard-card:hover {{
929
686
  transform: translateY(-4px);
930
687
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
931
- }
932
- .dashboard-card h2 {
933
- margin: 0 0 10px 0;
934
- font-size: 1.5em;
935
- color: #2c3e50;
936
- }
937
- .dashboard-card p {
938
- margin: 0;
939
- color: #7f8c8d;
940
- font-size: 0.95em;
941
- }
942
- .dashboard-arrow {
943
- display: inline-block;
944
- margin-left: 5px;
688
+ }}
689
+ .dashboard-card h2 {{
690
+ margin: 0 0 10px 0; font-size: 1.5em; color: #2c3e50;
691
+ }}
692
+ .dashboard-card p {{
693
+ margin: 0; color: #7f8c8d; font-size: 0.95em;
694
+ }}
695
+ .dashboard-arrow {{
696
+ display: inline-block; margin-left: 5px;
945
697
  transition: transform 0.2s;
946
- }
947
- .dashboard-card:hover .dashboard-arrow {
698
+ }}
699
+ .dashboard-card:hover .dashboard-arrow {{
948
700
  transform: translateX(5px);
949
- }
950
- .footer {
951
- text-align: center;
952
- margin-top: 60px;
953
- padding: 20px;
954
- color: #999;
955
- font-size: 0.9em;
956
- }
701
+ }}
702
+ .footer {{
703
+ text-align: center; margin-top: 60px; padding: 20px;
704
+ color: #999; font-size: 0.9em;
705
+ }}
957
706
  </style>
958
707
  """)
959
708
 
@@ -961,19 +710,38 @@ class Directory:
961
710
  with div(cls="directory-container"):
962
711
  with div(cls="directory-header"):
963
712
  h1(self.title)
964
- p(f"Explore {len(self.dashboards)} dashboard{'s' if len(self.dashboards) != 1 else ''}")
713
+ plural = 's' if len(self.dashboards) != 1 else ''
714
+ p(f"Explore {len(self.dashboards)} dashboard{plural}")
965
715
 
966
716
  with div(cls="dashboard-grid"):
967
717
  for slug, dashboard in self.dashboards:
968
- with a(href=f"{slug}/index.html", cls="dashboard-card"):
718
+ with a(href=f"{slug}/index.html", cls="dashboard-card", target="_blank", rel="noopener noreferrer"):
969
719
  h2(dashboard.title)
970
720
  num_pages = len(dashboard.pages)
971
- p(f"{num_pages} page{'s' if num_pages != 1 else ''} ")
721
+ plural = 's' if num_pages != 1 else ''
722
+ p(f"{num_pages} page{plural} ")
972
723
  span("→", cls="dashboard-arrow")
973
724
 
974
725
  with div(cls="footer"):
975
726
  p("Produced by staticdash")
976
727
 
977
- # Write the landing page
978
728
  with open(os.path.join(output_dir, "index.html"), "w", encoding="utf-8") as f:
979
729
  f.write(str(doc))
730
+
731
+ def publish(self, output_dir="output"):
732
+ """Publish the directory landing page and all dashboards."""
733
+ output_dir = os.path.abspath(output_dir)
734
+ os.makedirs(output_dir, exist_ok=True)
735
+
736
+ # Copy assets to root
737
+ assets_src = os.path.join(os.path.dirname(__file__), "assets")
738
+ assets_dst = os.path.join(output_dir, "assets")
739
+ shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
740
+
741
+ # Publish each dashboard independently (no modifications)
742
+ for slug, dashboard in self.dashboards:
743
+ dashboard_dir = os.path.join(output_dir, slug)
744
+ dashboard.publish(output_dir=dashboard_dir)
745
+
746
+ # Create the directory landing page
747
+ self._create_landing_page(output_dir)