Skip to content

Commit 17fcd9d

Browse files
authored
Merge pull request #3 from pathsim/feature/example-thumbnails
example thumbnails
2 parents 505161b + ea0dcb7 commit 17fcd9d

13 files changed

Lines changed: 575 additions & 178 deletions

File tree

scripts/lib/notebooks.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def _process_notebook(nb_path: Path, output_dir: Path) -> dict | None:
7979
# Extract metadata
8080
title = _extract_title(notebook)
8181
description = _extract_description(notebook)
82+
thumbnail_path = _extract_thumbnail(notebook)
8283

8384
# Get category and tags from mapping
8485
category, tags = CATEGORY_MAPPINGS.get(stem, ("advanced", []))
@@ -92,7 +93,24 @@ def _process_notebook(nb_path: Path, output_dir: Path) -> dict | None:
9293
with open(output_path, "w", encoding="utf-8") as f:
9394
json.dump(clean_notebook, f, indent=1, ensure_ascii=False)
9495

95-
return {
96+
# Process thumbnail path - convert to figures-relative path
97+
thumbnail = None
98+
if thumbnail_path:
99+
# Handle relative paths like "../figures/image.png" or "figures/image.png"
100+
# Extract just the filename or subdirectory/filename
101+
path_parts = Path(thumbnail_path.replace("\\", "/"))
102+
# Get the path relative to figures directory
103+
# Common patterns: ../figures/foo.png, figures/foo.png, ./figures/foo.png
104+
parts = path_parts.parts
105+
if "figures" in parts:
106+
# Find index of 'figures' and take everything after
107+
fig_idx = parts.index("figures")
108+
thumbnail = "/".join(parts[fig_idx + 1:])
109+
else:
110+
# Just use the filename
111+
thumbnail = path_parts.name
112+
113+
result = {
96114
"slug": slug,
97115
"file": nb_path.name,
98116
"title": title,
@@ -102,6 +120,11 @@ def _process_notebook(nb_path: Path, output_dir: Path) -> dict | None:
102120
"executable": executable,
103121
}
104122

123+
if thumbnail:
124+
result["thumbnail"] = thumbnail
125+
126+
return result
127+
105128

106129
def _strip_outputs(notebook: dict) -> dict:
107130
"""Remove outputs from notebook cells (keep source only)."""
@@ -138,6 +161,49 @@ def _extract_title(notebook: dict) -> str:
138161
return "Untitled"
139162

140163

164+
def _extract_thumbnail(notebook: dict) -> str | None:
165+
"""Extract first image path from markdown cells.
166+
167+
Looks for images in markdown cells using:
168+
- Markdown syntax: ![alt](path)
169+
- HTML syntax: <img src="path">
170+
- RST syntax: .. image:: path
171+
172+
Returns the image path (relative to notebook) or None if not found.
173+
"""
174+
# Pattern for markdown images: ![alt](path)
175+
md_pattern = re.compile(r'!\[[^\]]*\]\(([^)]+)\)')
176+
# Pattern for HTML images: <img src="path"> or <img src='path'>
177+
html_pattern = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE)
178+
# Pattern for RST images: .. image:: path
179+
rst_pattern = re.compile(r'\.\.\s+image::\s*(\S+)')
180+
181+
for cell in notebook.get("cells", []):
182+
cell_type = cell.get("cell_type")
183+
# Check markdown and raw cells (RST is often in raw cells)
184+
if cell_type in ("markdown", "raw"):
185+
source = cell.get("source", [])
186+
if isinstance(source, list):
187+
source = "".join(source)
188+
189+
# Try markdown syntax first
190+
match = md_pattern.search(source)
191+
if match:
192+
return match.group(1)
193+
194+
# Try HTML syntax
195+
match = html_pattern.search(source)
196+
if match:
197+
return match.group(1)
198+
199+
# Try RST syntax
200+
match = rst_pattern.search(source)
201+
if match:
202+
return match.group(1)
203+
204+
return None
205+
206+
141207
def _extract_description(notebook: dict) -> str:
142208
"""Extract description from first paragraph after title.
143209

scripts/rebuild-manifests.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Rebuild manifest.json files with updated metadata (e.g., thumbnails).
4+
Does not re-execute notebooks - just re-extracts metadata from existing notebooks.
5+
"""
6+
7+
import json
8+
from pathlib import Path
9+
10+
from lib.notebooks import (
11+
_extract_title,
12+
_extract_description,
13+
_extract_thumbnail,
14+
_slugify,
15+
generate_version_manifest,
16+
)
17+
from lib.config import CATEGORY_MAPPINGS, NON_EXECUTABLE
18+
19+
20+
def rebuild_manifest(version_dir: Path) -> bool:
21+
"""Rebuild manifest.json for a version directory."""
22+
notebooks_dir = version_dir / "notebooks"
23+
manifest_path = version_dir / "manifest.json"
24+
25+
if not notebooks_dir.exists() or not manifest_path.exists():
26+
return False
27+
28+
# Load existing manifest to get package and tag
29+
with open(manifest_path, "r", encoding="utf-8") as f:
30+
existing = json.load(f)
31+
32+
package_id = existing.get("package", version_dir.parent.name)
33+
tag = existing.get("tag", version_dir.name)
34+
35+
print(f" Rebuilding {package_id}/{tag}...")
36+
37+
# Re-extract metadata from all notebooks
38+
notebooks = []
39+
for nb_path in sorted(notebooks_dir.glob("*.ipynb")):
40+
try:
41+
with open(nb_path, "r", encoding="utf-8") as f:
42+
notebook = json.load(f)
43+
44+
stem = nb_path.stem
45+
slug = _slugify(stem)
46+
47+
# Extract metadata
48+
title = _extract_title(notebook)
49+
description = _extract_description(notebook)
50+
thumbnail_path = _extract_thumbnail(notebook)
51+
52+
# Get category and tags from mapping
53+
category, tags = CATEGORY_MAPPINGS.get(stem, ("advanced", []))
54+
55+
# Check if executable
56+
executable = stem not in NON_EXECUTABLE
57+
58+
# Process thumbnail path
59+
thumbnail = None
60+
if thumbnail_path:
61+
path_parts = Path(thumbnail_path.replace("\\", "/"))
62+
parts = path_parts.parts
63+
if "figures" in parts:
64+
fig_idx = parts.index("figures")
65+
thumbnail = "/".join(parts[fig_idx + 1:])
66+
else:
67+
thumbnail = path_parts.name
68+
69+
meta = {
70+
"slug": slug,
71+
"file": nb_path.name,
72+
"title": title,
73+
"description": description,
74+
"category": category,
75+
"tags": tags,
76+
"executable": executable,
77+
}
78+
79+
if thumbnail:
80+
meta["thumbnail"] = thumbnail
81+
print(f" {nb_path.name}: thumbnail = {thumbnail}")
82+
83+
notebooks.append(meta)
84+
85+
except Exception as e:
86+
print(f" Warning: Failed to process {nb_path.name}: {e}")
87+
88+
# Generate and save new manifest
89+
manifest = generate_version_manifest(package_id, tag, notebooks)
90+
with open(manifest_path, "w", encoding="utf-8") as f:
91+
json.dump(manifest, f, indent=2, ensure_ascii=False)
92+
93+
return True
94+
95+
96+
def main():
97+
static_dir = Path(__file__).parent.parent / "static"
98+
99+
print("Rebuilding manifests...")
100+
101+
# Find all package directories
102+
for package_dir in static_dir.iterdir():
103+
if not package_dir.is_dir():
104+
continue
105+
106+
# Skip non-package directories
107+
if package_dir.name.startswith("."):
108+
continue
109+
110+
print(f"\nPackage: {package_dir.name}")
111+
112+
# Find all version directories
113+
for version_dir in package_dir.iterdir():
114+
if not version_dir.is_dir():
115+
continue
116+
if not version_dir.name.startswith("v"):
117+
continue
118+
119+
rebuild_manifest(version_dir)
120+
121+
print("\nDone!")
122+
123+
124+
if __name__ == "__main__":
125+
main()

src/lib/notebook/manifest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export interface NotebookMeta {
2424
tags: string[];
2525
/** Whether notebook can run in Pyodide */
2626
executable: boolean;
27+
/** Thumbnail image filename (relative to figures directory) */
28+
thumbnail?: string;
2729
}
2830

2931
export interface Category {

src/routes/+layout.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { onMount } from 'svelte';
44
import { base } from '$app/paths';
55
import { page } from '$app/stores';
6+
import { afterNavigate } from '$app/navigation';
67
import Tooltip from '$lib/components/common/Tooltip.svelte';
78
import { Header, MobileDrawer } from '$lib/components/layout';
89
import { packageOrder, type PackageId } from '$lib/config/packages';
@@ -85,6 +86,19 @@
8586
$page.url.pathname;
8687
mobileMenuOpen = false;
8788
});
89+
90+
// Scroll to top on navigation
91+
afterNavigate(() => {
92+
// Find scrollable content areas and reset their scroll position
93+
const scrollables = document.querySelectorAll('.doc-main, .page-wrapper');
94+
scrollables.forEach((el) => {
95+
if (el instanceof HTMLElement) {
96+
el.scrollTop = 0;
97+
}
98+
});
99+
// Also reset window scroll as fallback
100+
window.scrollTo(0, 0);
101+
});
88102
</script>
89103

90104
<svelte:head>

src/routes/[package]/[version]/examples/+page.svelte

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@
4343
function navigateToExample(slug: string) {
4444
goto(`${base}/${data.packageId}/${data.resolvedTag}/examples/${slug}`);
4545
}
46+
47+
function getThumbnailUrl(thumbnail: string): string {
48+
return `${base}/${data.packageId}/${data.resolvedTag}/figures/${thumbnail}`;
49+
}
4650
</script>
4751

4852
<svelte:head>
@@ -61,7 +65,7 @@
6165
{#each [...groupedNotebooks] as [category, notebooks]}
6266
<h2 id={category.id}>{category.title}</h2>
6367

64-
<div class="tile-grid cols-3">
68+
<div class="tile-grid cols-2">
6569
{#each notebooks as notebook}
6670
<div
6771
class="tile example-tile elevated"
@@ -71,7 +75,14 @@
7175
tabindex="0"
7276
>
7377
<div class="panel-header">{notebook.title}</div>
74-
<div class="panel-body tile-body">{notebook.description}</div>
78+
<div class="panel-body tile-body">
79+
<p class="tile-description">{notebook.description}</p>
80+
{#if notebook.thumbnail}
81+
<div class="thumbnail-wrapper">
82+
<img src={getThumbnailUrl(notebook.thumbnail)} alt={notebook.title} />
83+
</div>
84+
{/if}
85+
</div>
7586
</div>
7687
{/each}
7788
</div>
@@ -93,4 +104,29 @@
93104
.example-tile {
94105
cursor: pointer;
95106
}
107+
108+
/* Tile description text */
109+
.tile-description {
110+
margin: 0 0 var(--space-md) 0;
111+
color: var(--text-muted);
112+
font-size: var(--font-sm);
113+
line-height: 1.5;
114+
}
115+
116+
/* Thumbnail wrapper and image */
117+
.thumbnail-wrapper {
118+
display: flex;
119+
align-items: center;
120+
justify-content: center;
121+
overflow: hidden;
122+
}
123+
124+
.thumbnail-wrapper img {
125+
max-width: 100%;
126+
max-height: 180px;
127+
width: auto;
128+
height: auto;
129+
object-fit: contain;
130+
border-radius: var(--radius-sm);
131+
}
96132
</style>

0 commit comments

Comments
 (0)