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..757fa9b2a --- /dev/null +++ b/assets/scripts/add_notebook_links.sh @@ -0,0 +1,46 @@ +#!/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}" + + # 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 AFTER the "Report an issue" link + # The download="index.ipynb" attribute forces browser to download instead of navigate + perl -i -pe "s/(]*><\/i>Report an issue<\/a><\/li>)/\$1
  • <\/i>$DOWNLOAD_TEXT<\/a><\/li>
  • <\/i>$COLAB_TEXT<\/a><\/li>/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..52c66ec8a --- /dev/null +++ b/assets/scripts/qmd_to_ipynb.py @@ -0,0 +1,201 @@ +#!/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 + + 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.""" + 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"): + pkg_cell = { + "cell_type": "code", + "execution_count": None, + "metadata": {}, + "outputs": [], + "source": "using Pkg; Pkg.activate(; temp=true)" + } + 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/ ---