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