Metadata-Version: 2.4
Name: hugo-canvas-sync
Version: 1.0.1
Summary: Sync Hugo static site files to Canvas LMS with SSO authentication
Project-URL: Homepage, https://github.com/jlumbroso/hugo-canvas-sync
Project-URL: Documentation, https://github.com/jlumbroso/hugo-canvas-sync/blob/main/docs/INSTALLATION.md
Project-URL: Repository, https://github.com/jlumbroso/hugo-canvas-sync
Project-URL: Bug Tracker, https://github.com/jlumbroso/hugo-canvas-sync/issues
Project-URL: Changelog, https://github.com/jlumbroso/hugo-canvas-sync/releases
License: MIT
Requires-Python: >=3.9
Requires-Dist: canvasapi>=3.3.0
Requires-Dist: click>=8.1.0
Requires-Dist: lark>=1.1
Requires-Dist: pyyaml>=6.0
Requires-Dist: requests>=2.31.0
Requires-Dist: structlog>=24.0.0
Requires-Dist: tomli>=2.0; python_version < '3.11'
Provides-Extra: dev
Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
Requires-Dist: pytest-mock>=3.12.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: python-semantic-release>=9.0.0; extra == 'dev'
Requires-Dist: responses>=0.25.0; extra == 'dev'
Description-Content-Type: text/markdown

# hugo-canvas-sync

**Seamless Canvas LMS file protection for Hugo static sites**

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python ≥ 3.9](https://img.shields.io/badge/python-%E2%89%A53.9-blue.svg)](https://pypi.org/project/hugo-canvas-sync/)
[![Tests](https://github.com/jlumbroso/hugo-canvas-sync/actions/workflows/tests.yml/badge.svg)](https://github.com/jlumbroso/hugo-canvas-sync/actions/workflows/tests.yml)

---

## The Problem

You're teaching a course. You've built a beautiful Hugo static site. But some materials—slides, videos, problem sets—need to be behind institutional authentication. Canvas has SSO. Hugo doesn't.

**Current solution**: Manually upload files to Canvas, copy URLs, paste into Hugo source, hope links don't break. Repeat for every file update.

**This is tedious, error-prone, and doesn't scale.**

---

## The Solution

```markdown
<!-- In your Hugo markdown -->
{{< canvas-file "static/slides/lecture1.pdf" "Lecture 1 Slides" >}}
```

**That's it.**

When you `git push`:
1. GitHub Actions scans your Hugo source for `canvas-file` shortcodes
2. Uploads new or changed files to Canvas (hash-based — unchanged files are skipped)
3. Commits `data/canvas_urls.json` back to your repo
4. Rebuilds Hugo with the authenticated Canvas URLs injected
5. Deploys your site

**Students click a link → redirect through institutional SSO → download file.**
Zero manual file management after initial setup.

---

## Live Demo

**[jlumbroso.github.io/hugo-canvas-sync-test](https://jlumbroso.github.io/hugo-canvas-sync-test/lectures/week1/)** — a real course site with three Canvas-protected files, deployed via this Action.

![Course page with Canvas-protected links](docs/screenshots/week1-page-with-robot.png)

Clicking any 🔒 link redirects to Penn WebLogin before serving the file:

![Penn SSO login gate](docs/screenshots/sso-login-verified-2026-03-17.png)

---

## Quick Start

### 1. Add the shortcode templates to your Hugo site

```bash
mkdir -p layouts/shortcodes

# Simple shortcode
curl -o layouts/shortcodes/canvas-file.html \
  https://raw.githubusercontent.com/jlumbroso/hugo-canvas-sync/main/layouts/shortcodes/canvas-file.html

# Extended shortcode (with icon, description, custom folder)
curl -o layouts/shortcodes/canvas-file-extended.html \
  https://raw.githubusercontent.com/jlumbroso/hugo-canvas-sync/main/layouts/shortcodes/canvas-file-extended.html
```

### 2. Add GitHub Secrets

In your repo → **Settings → Secrets → Actions**, add:

| Secret | Value |
|--------|-------|
| `CANVAS_API_URL` | `https://canvas.yourinstitution.edu` |
| `CANVAS_API_KEY` | Your Canvas API token *(Account → Settings → New Access Token)* |
| `CANVAS_COURSE_ID` | Integer from your Canvas course URL `/courses/<id>` |

### 3. Add to your GitHub Actions workflow

```yaml
# .github/workflows/deploy.yml
name: Deploy to GitHub Pages

permissions:
  contents: write   # needed for auto-commit of canvas_urls.json
  pages: write
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: 'latest'
          extended: true

      - name: Hugo build (development pass)
        run: hugo --gc

      - name: Sync files to Canvas
        uses: jlumbroso/hugo-canvas-sync@main
        with:
          canvas_api_url: ${{ secrets.CANVAS_API_URL }}
          canvas_api_key: ${{ secrets.CANVAS_API_KEY }}
          canvas_course_id: ${{ secrets.CANVAS_COURSE_ID }}
          # run_hugo_rebuild: true  ← default, builds production Hugo after sync
          # persistence_strategy: main  ← default, commits canvas_urls.json here

      - name: Deploy
        uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./public
```

### 4. Mark files as protected

```markdown
<!-- Simple: path + title -->
{{< canvas-file "static/slides/lecture1.pdf" "Lecture 1 Slides" >}}

<!-- Extended: with icon, description, custom CSS class -->
{{< canvas-file-extended
    path="static/videos/demo.mp4"
    title="Demo Recording"
    description="Week 3 live demo — requires PennKey login"
    icon="video" >}}
```

**Commit and push. The rest is automatic.**

---

## How It Works

```
git push
  │
  ├─ Hugo build (development mode)
  │    shortcodes render as local file links / DEV MODE badges
  │
  ├─ hugo-canvas-sync sync
  │    ├─ Scans .md files for {{< canvas-file >}} shortcodes (lark PEG parser)
  │    ├─ Computes SHA-256 hash of each file
  │    ├─ Skips files whose hash matches data/canvas_urls.json
  │    ├─ Uploads new/changed files to Canvas (course_files/ mirroring Hugo layout)
  │    ├─ Writes data/canvas_urls.json with SSO-protected URLs
  │    ├─ Commits canvas_urls.json back to repo [skip ci]
  │    └─ Posts file-by-file summary to GitHub Actions step summary
  │
  ├─ Hugo build (production mode)
  │    shortcodes look up canvas_urls.json → render as 🔒 Canvas links
  │    build FAILS if any shortcode has no Canvas URL
  │
  └─ Deploy to GitHub Pages
```

---

## Shortcodes

### `canvas-file` (simple)

```markdown
{{< canvas-file "PATH" "TITLE" >}}
```

| Parameter | Required | Description |
|-----------|----------|-------------|
| `PATH` | Yes | Path to file relative to Hugo site root (e.g. `static/slides/lec1.pdf`) |
| `TITLE` | No | Link text (defaults to filename) |

**Development mode** (no `canvas_urls.json` entry): renders as local file link with 🔓 DEV MODE badge.
**Production mode** (entry exists): renders as Canvas SSO link with 🔒.
**Production mode** (entry missing): **build fails** — no silent broken links.

### `canvas-file-extended` (full-featured)

```markdown
{{< canvas-file-extended
    path="static/slides/lec1.pdf"
    title="Lecture 1"
    description="Download before class"
    icon="pdf"
    class="my-custom-class" >}}
```

| Parameter | Required | Description |
|-----------|----------|-------------|
| `path` | Yes | File path |
| `title` | No | Link text |
| `description` | No | Accessible tooltip / `aria-label` |
| `icon` | No | `pdf`, `video`, `zip`, `doc`, `file` |
| `class` | No | Extra CSS class on `<a>` |

---

## CLI Reference

Install locally for testing and validation:

```bash
pip install hugo-canvas-sync
# or: pip install git+https://github.com/jlumbroso/hugo-canvas-sync
```

```bash
# Upload new/changed files, update canvas_urls.json
hugo-canvas-sync sync \
  --canvas-url https://canvas.upenn.edu \
  --canvas-key $CANVAS_API_KEY \
  --course-id 12345

# Show what would be uploaded without uploading
hugo-canvas-sync sync --dry-run ...

# HEAD-check all Canvas URLs are still accessible
hugo-canvas-sync validate

# Show sync status of all protected files
hugo-canvas-sync status

# Remove stale records for deleted files
hugo-canvas-sync prune
```

Environment variables (`CANVAS_API_URL`, `CANVAS_API_KEY`, `CANVAS_COURSE_ID`) are read automatically — no need to pass flags in CI.

---

## Configuration

Create `hugo-canvas-sync.yaml` (or `.toml`) at your Hugo site root to override defaults:

```yaml
# hugo-canvas-sync.yaml
data_file: data/canvas_urls.json   # where to write/read URL records
content_dir: content               # Hugo contentDir (auto-detected from hugo.toml)
static_dir: static                 # Hugo staticDir
prune_strategy: auto               # auto | mark | off
```

---

## GitHub Action Inputs

| Input | Default | Description |
|-------|---------|-------------|
| `canvas_api_url` | *(required)* | Canvas base URL |
| `canvas_api_key` | *(required)* | Canvas API token |
| `canvas_course_id` | *(required)* | Course ID integer |
| `hugo_root` | `.` | Path to Hugo site root |
| `data_file` | `data/canvas_urls.json` | Path to URL data file |
| `persistence_strategy` | `main` | `main` / `branch` / `cache` — where canvas_urls.json lives |
| `canvas_data_branch` | `canvas-data` | Branch name for `branch` strategy |
| `auto_commit` | `true` | Commit canvas_urls.json after sync |
| `run_hugo_rebuild` | `true` | Run `hugo --environment production` after sync |
| `hugo_build_flags` | `--gc --minify` | Flags for the production Hugo build |
| `install_hugo` | `false` | Install Hugo via `peaceiris/actions-hugo` |
| `hugo_version` | `latest` | Hugo version to install |
| `hugo_binary` | *(PATH lookup)* | Path to hugo binary override |
| `dry_run` | `false` | Scan without uploading |
| `package_version` | *(Action tag)* | Override hugo-canvas-sync version |

### Persistence strategies

| Strategy | Git noise | Complexity | When to use |
|----------|-----------|------------|-------------|
| `main` *(default)* | O(1) — one commit per sync with changes | Lowest | Most courses; noise is minimal |
| `branch` | None on main | Moderate | Commit-history perfectionists |
| `cache` | None | Low (fragile: eviction) | Fastest builds; re-uploads on cache miss |

**Recommendation**: Start with `main`. Switch to `branch` if you want a pristine commit history.

---

## Manual Double-Pass Workflow

If you prefer to run Hugo yourself instead of `run_hugo_rebuild: true`:

```yaml
- name: Hugo build (development pass)
  run: hugo --gc

- name: Sync files to Canvas
  uses: jlumbroso/hugo-canvas-sync@main
  with:
    canvas_api_url: ${{ secrets.CANVAS_API_URL }}
    canvas_api_key: ${{ secrets.CANVAS_API_KEY }}
    canvas_course_id: ${{ secrets.CANVAS_COURSE_ID }}
    run_hugo_rebuild: false   # I'll handle the second build

- name: Hugo build (production pass — Canvas URLs now available)
  run: hugo --environment production --gc --minify
```

---

## Development

```bash
git clone https://github.com/jlumbroso/hugo-canvas-sync
cd hugo-canvas-sync
uv pip install -e ".[dev]"
uv run pytest          # 83 tests, 85% coverage
```

**Requires**: Python ≥ 3.9, [uv](https://docs.astral.sh/uv/)

---

## Architecture Decisions

All non-trivial design decisions are recorded as ADRs in [`docs/adr/`](docs/adr/):

| ADR | Topic |
|-----|-------|
| [0001](docs/adr/0001-hugo-canvas-sync-integration.md) | Overall architecture |
| [0002](docs/adr/0002-python-package-toolchain.md) | Toolchain (uv, hatchling, semantic-release) |
| [0003](docs/adr/0003-shortcode-parsing-strategy.md) | Shortcode parser (lark PEG grammar) |
| [0004](docs/adr/0004-canvas-url-persistence.md) | canvas_urls.json persistence strategies |
| [0005](docs/adr/0005-github-action-design.md) | GitHub Action design |

---

## Development Status

**Phase 1–3: Complete ✅**
- Canvas API client with retry logic
- Lark PEG-based shortcode scanner (derived from Hugo v0.159 source)
- Hash-based sync engine (skip unchanged files)
- `data/canvas_urls.json` persistence with three storage strategies
- `sync` / `validate` / `status` / `prune` CLI commands
- `canvas-file` and `canvas-file-extended` Hugo shortcodes
- GitHub Action (composite) with step summary, Hugo rebuild, auto-commit
- 83 tests, 85% coverage, CI matrix across Python 3.9 / 3.11 / 3.12

**Phase 4: Upcoming**
- TUI with Textual (debugging dashboard)
- MCP server for student document access via Claude

**Phase 5+: Future**
- `hugo-canvas-sync import` — reconstruct canvas_urls.json from existing Canvas course
- Multi-course support

---

## Design Philosophy

1. **Install and forget** — after initial setup, instructors never think about file protection
2. **Fail-safe, not silent** — build fails if Canvas upload fails; no broken links deployed
3. **Development-friendly** — `hugo server` works locally with no Canvas credentials
4. **Transparent** — clear per-file logs and GitHub Actions step summary
5. **Configurable without complexity** — sensible defaults, graduated override options

---

## License

MIT — see [LICENSE](LICENSE).

---

## Acknowledgments

Created through human-AI collaboration between:
- **Jérémie Lumbroso** (University of Pennsylvania) — vision, design, course context
- **Claude Sonnet 4.6** (Anthropic) — architecture, implementation, ADR methodology

Inspired by [hugo-encrypt](https://github.com/Izumiko/hugo-encrypt) and the pain of manual Canvas file management.

---

*"Install and forget" — because instructors should focus on teaching, not file management.*
