diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
index 3f7d15277..f3a31e394 100644
--- a/.github/workflows/preview.yml
+++ b/.github/workflows/preview.yml
@@ -53,6 +53,14 @@ jobs:
- name: Render Quarto site
run: quarto render
+ - name: Generate Jupyter notebooks
+ run: sh assets/scripts/generate_notebooks.sh
+
+ - name: Add notebook download links to HTML
+ run: sh assets/scripts/add_notebook_links.sh
+ env:
+ COLAB_PATH_PREFIX: pr-previews/${{ github.event.pull_request.number }}
+
- name: Save _freeze folder
id: cache-save
if: ${{ !cancelled() }}
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 5cb81b4f0..012e27c4a 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -85,6 +85,14 @@ jobs:
- name: Render Quarto site
run: quarto render
+ - name: Generate Jupyter notebooks
+ run: sh assets/scripts/generate_notebooks.sh
+
+ - name: Add notebook download links to HTML
+ run: sh assets/scripts/add_notebook_links.sh
+ env:
+ COLAB_PATH_PREFIX: versions/${{ env.version }}
+
- name: Rename original search index
run: mv _site/search.json _site/search_original.json
diff --git a/.gitignore b/.gitignore
index 3c097ca05..c50c42e25 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,4 +28,5 @@ venv
site_libs
.DS_Store
index_files
-digest.txt
\ No newline at end of file
+digest.txt
+**/*.quarto_ipynb
diff --git a/assets/scripts/add_notebook_links.sh b/assets/scripts/add_notebook_links.sh
new file mode 100755
index 000000000..07a46b847
--- /dev/null
+++ b/assets/scripts/add_notebook_links.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+# Add Jupyter notebook download links to rendered HTML files
+# This adds a download link to the toc-actions section (next to "Edit this page" and "Report an issue")
+
+set -e
+
+echo "Adding notebook download links to HTML pages..."
+
+# Link text variables
+DOWNLOAD_TEXT="Download notebook"
+COLAB_TEXT="Open in Colab"
+
+# Colab URL configuration (can be overridden via environment variables)
+COLAB_REPO="${COLAB_REPO:-TuringLang/docs}"
+COLAB_BRANCH="${COLAB_BRANCH:-gh-pages}"
+COLAB_PATH_PREFIX="${COLAB_PATH_PREFIX:-}"
+
+# Find all HTML files that have corresponding .ipynb files
+find _site/tutorials _site/usage _site/developers -name "index.html" 2>/dev/null | while read html_file; do
+ dir=$(dirname "$html_file")
+ ipynb_file="${dir}/index.ipynb"
+
+ # Check if the corresponding .ipynb file exists
+ if [ -f "$ipynb_file" ]; then
+ # Check if link is already present
+ if ! grep -q "$DOWNLOAD_TEXT" "$html_file"; then
+ # Get relative path from _site/ directory
+ relative_path="${html_file#_site/}"
+ relative_path="${relative_path%/index.html}"
+
+ # Extract notebook name from parent folder
+ notebook_name=$(basename "$relative_path")
+
+ # Construct Colab URL
+ if [ -n "$COLAB_PATH_PREFIX" ]; then
+ colab_url="https://colab.research.google.com/github/${COLAB_REPO}/blob/${COLAB_BRANCH}/${COLAB_PATH_PREFIX}/${relative_path}/index.ipynb"
+ else
+ colab_url="https://colab.research.google.com/github/${COLAB_REPO}/blob/${COLAB_BRANCH}/${relative_path}/index.ipynb"
+ fi
+
+ # Insert both download and Colab links BEFORE the "Edit this page" link
+ # The download attribute forces browser to download with custom filename instead of navigate
+ perl -i -pe 's|(
]*>Edit this page)|'"$DOWNLOAD_TEXT"''"$COLAB_TEXT"'$1|g' "$html_file"
+ echo " ✓ Added notebook links to $html_file"
+ fi
+ fi
+done
+
+echo "Notebook links added successfully!"
\ No newline at end of file
diff --git a/assets/scripts/generate_notebooks.sh b/assets/scripts/generate_notebooks.sh
new file mode 100755
index 000000000..8d2e5885c
--- /dev/null
+++ b/assets/scripts/generate_notebooks.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+# Generate Jupyter notebooks from .qmd files without re-executing code
+# This script converts .qmd files to .ipynb format with proper cell structure
+
+set -e
+
+echo "Generating Jupyter notebooks from .qmd files..."
+
+# Find all .qmd files in tutorials, usage, and developers directories
+find tutorials usage developers -name "index.qmd" | while read qmd_file; do
+ dir=$(dirname "$qmd_file")
+ ipynb_file="${dir}/index.ipynb"
+
+ echo "Converting $qmd_file to $ipynb_file"
+
+ # Convert qmd to ipynb using our custom Python script
+ # Use relative path from repo root (assets/scripts/qmd_to_ipynb.py)
+ python3 assets/scripts/qmd_to_ipynb.py "$qmd_file" "$ipynb_file"
+
+ # Check if conversion was successful
+ if [ -f "$ipynb_file" ]; then
+ # Move the notebook to the _site directory
+ mkdir -p "_site/${dir}"
+ cp "$ipynb_file" "_site/${ipynb_file}"
+ echo " ✓ Generated _site/${ipynb_file}"
+ else
+ echo " ✗ Failed to generate $ipynb_file"
+ fi
+done
+
+echo "Notebook generation complete!"
+echo "Generated notebooks are in _site/ directory alongside HTML files"
\ No newline at end of file
diff --git a/assets/scripts/qmd_to_ipynb.py b/assets/scripts/qmd_to_ipynb.py
new file mode 100755
index 000000000..9faac6515
--- /dev/null
+++ b/assets/scripts/qmd_to_ipynb.py
@@ -0,0 +1,235 @@
+#!/usr/bin/env python3
+"""
+Convert Quarto .qmd files to Jupyter .ipynb notebooks with proper cell structure.
+Each code block becomes a code cell, and markdown content becomes markdown cells.
+"""
+
+import sys
+import json
+import re
+from pathlib import Path
+from typing import List, Dict, Any, Optional
+
+
+class QmdToIpynb:
+ def __init__(self, qmd_path: str):
+ self.qmd_path = Path(qmd_path)
+ self.cells: List[Dict[str, Any]] = []
+ self.kernel_name = "julia" # Default kernel
+ self.packages: set = set() # Track packages found in using statements
+
+ def _extract_packages_from_line(self, line: str) -> None:
+ """Extract package names from a 'using' statement and add to self.packages."""
+ line = line.strip()
+ if not line.startswith('using '):
+ return
+
+ # Remove 'using ' prefix and any trailing semicolon/whitespace
+ remainder = line[6:].rstrip(';').strip()
+
+ # Handle 'using Package: item1, item2' format - extract just the package name
+ if ':' in remainder:
+ package = remainder.split(':')[0].strip()
+ if package and package != 'Pkg':
+ self.packages.add(package)
+ else:
+ # Handle 'using Package1, Package2, ...' format
+ packages = [pkg.strip() for pkg in remainder.split(',')]
+ for pkg in packages:
+ if pkg and pkg != 'Pkg':
+ self.packages.add(pkg)
+
+ def parse(self) -> None:
+ """Parse the .qmd file and extract cells."""
+ with open(self.qmd_path, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ lines = content.split('\n')
+ i = 0
+
+ # Skip YAML frontmatter
+ if lines[0].strip() == '---':
+ i = 1
+ while i < len(lines) and lines[i].strip() != '---':
+ # Check for engine specification
+ if lines[i].strip().startswith('engine:'):
+ engine = lines[i].split(':', 1)[1].strip()
+ if engine == 'julia':
+ self.kernel_name = "julia"
+ elif engine == 'python':
+ self.kernel_name = "python3"
+ i += 1
+ i += 1 # Skip the closing ---
+
+ # Parse the rest of the document
+ current_markdown = []
+
+ while i < len(lines):
+ line = lines[i]
+
+ # Check for code block start
+ code_block_match = re.match(r'^```\{(\w+)\}', line)
+ if code_block_match:
+ # Save any accumulated markdown
+ if current_markdown:
+ self._add_markdown_cell(current_markdown)
+ current_markdown = []
+
+ # Extract code block
+ lang = code_block_match.group(1)
+ i += 1
+ code_lines = []
+ cell_options = []
+
+ # Collect code and options
+ while i < len(lines) and not lines[i].startswith('```'):
+ if lines[i].startswith('#|'):
+ cell_options.append(lines[i])
+ else:
+ code_lines.append(lines[i])
+ i += 1
+
+ # Check if this is the Pkg.instantiate() cell that we want to skip
+ code_content = '\n'.join(code_lines).strip()
+ is_pkg_instantiate = (
+ 'using Pkg' in code_content and
+ 'Pkg.instantiate()' in code_content and
+ len(code_content.split('\n')) <= 3 # Only skip if it's just these lines
+ )
+
+ # Add code cell (with options as comments at the top) unless it's the Pkg.instantiate cell
+ if not is_pkg_instantiate:
+ full_code = cell_options + code_lines
+ self._add_code_cell(full_code, lang)
+
+ i += 1 # Skip closing ```
+ else:
+ # Accumulate markdown
+ current_markdown.append(line)
+ i += 1
+
+ # Add any remaining markdown
+ if current_markdown:
+ self._add_markdown_cell(current_markdown)
+
+ def _add_markdown_cell(self, lines: List[str]) -> None:
+ """Add a markdown cell, stripping leading/trailing empty lines."""
+ # Strip leading empty lines
+ while lines and not lines[0].strip():
+ lines.pop(0)
+
+ # Strip trailing empty lines
+ while lines and not lines[-1].strip():
+ lines.pop()
+
+ if not lines:
+ return
+
+ content = '\n'.join(lines)
+ cell = {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": content
+ }
+ self.cells.append(cell)
+
+ def _add_code_cell(self, lines: List[str], lang: str) -> None:
+ """Add a code cell."""
+ # Extract packages from Julia code cells
+ if lang == 'julia':
+ for line in lines:
+ self._extract_packages_from_line(line)
+
+ content = '\n'.join(lines)
+
+ # For non-Julia code blocks (like dot/graphviz), add as markdown with code formatting
+ # since Jupyter notebooks typically use Julia kernel for these docs
+ if lang != 'julia' and lang != 'python':
+ # Convert to markdown with code fence
+ markdown_content = f"```{lang}\n{content}\n```"
+ cell = {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": markdown_content
+ }
+ else:
+ cell = {
+ "cell_type": "code",
+ "execution_count": None,
+ "metadata": {},
+ "outputs": [],
+ "source": content
+ }
+
+ self.cells.append(cell)
+
+ def to_notebook(self) -> Dict[str, Any]:
+ """Convert parsed cells to Jupyter notebook format."""
+ # Add package activation cell at the top for Julia notebooks
+ cells = self.cells
+ if self.kernel_name.startswith("julia"):
+ # Build the source code for the setup cell
+ pkg_source_lines = ["using Pkg; Pkg.activate(; temp=true)"]
+
+ # Add Pkg.add() calls for each package found in the document
+ for package in sorted(self.packages):
+ pkg_source_lines.append(f'Pkg.add("{package}")')
+
+ pkg_cell = {
+ "cell_type": "code",
+ "execution_count": None,
+ "metadata": {},
+ "outputs": [],
+ "source": "\n".join(pkg_source_lines)
+ }
+ cells = [pkg_cell] + self.cells
+
+ notebook = {
+ "cells": cells,
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Julia",
+ "language": "julia",
+ "name": self.kernel_name
+ },
+ "language_info": {
+ "file_extension": ".jl",
+ "mimetype": "application/julia",
+ "name": "julia"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+ }
+ return notebook
+
+ def write(self, output_path: str) -> None:
+ """Write the notebook to a file."""
+ notebook = self.to_notebook()
+ with open(output_path, 'w', encoding='utf-8') as f:
+ json.dump(notebook, f, indent=2, ensure_ascii=False)
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("Usage: qmd_to_ipynb.py [output.ipynb]")
+ sys.exit(1)
+
+ qmd_path = sys.argv[1]
+
+ # Determine output path
+ if len(sys.argv) >= 3:
+ ipynb_path = sys.argv[2]
+ else:
+ ipynb_path = Path(qmd_path).with_suffix('.ipynb')
+
+ # Convert
+ converter = QmdToIpynb(qmd_path)
+ converter.parse()
+ converter.write(ipynb_path)
+
+ print(f"Converted {qmd_path} -> {ipynb_path}")
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/tutorials/coin-flipping/index.qmd b/tutorials/coin-flipping/index.qmd
index 757525249..f39e5aced 100755
--- a/tutorials/coin-flipping/index.qmd
+++ b/tutorials/coin-flipping/index.qmd
@@ -1,7 +1,7 @@
---
title: "Introduction: Coin Flipping"
engine: julia
-aliases:
+aliases:
- ../00-introduction/index.html
- ../00-introduction/
---