Sync Hugo static site files to Canvas LMS with SSO authentication
Project description
hugo-canvas-sync
Seamless Canvas LMS file protection for Hugo static sites
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
<!-- In your Hugo markdown -->
{{< canvas-file "static/slides/lecture1.pdf" "Lecture 1 Slides" >}}
That's it.
When you git push:
- GitHub Actions scans your Hugo source for
canvas-fileshortcodes - Uploads new or changed files to Canvas (hash-based — unchanged files are skipped)
- Commits
data/canvas_urls.jsonback to your repo - Rebuilds Hugo with the authenticated Canvas URLs injected
- 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 — a real course site with three Canvas-protected files, deployed via this Action.
Clicking any 🔒 link redirects to Penn WebLogin before serving the file:
Quick Start
1. Add the shortcode templates to your Hugo site
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
# .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
<!-- 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)
{{< 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)
{{< 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:
pip install hugo-canvas-sync
# or: pip install git+https://github.com/jlumbroso/hugo-canvas-sync
# 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:
# 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:
- 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
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
Architecture Decisions
All non-trivial design decisions are recorded as ADRs in docs/adr/:
| ADR | Topic |
|---|---|
| 0001 | Overall architecture |
| 0002 | Toolchain (uv, hatchling, semantic-release) |
| 0003 | Shortcode parser (lark PEG grammar) |
| 0004 | canvas_urls.json persistence strategies |
| 0005 | 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.jsonpersistence with three storage strategiessync/validate/status/pruneCLI commandscanvas-fileandcanvas-file-extendedHugo 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
- Install and forget — after initial setup, instructors never think about file protection
- Fail-safe, not silent — build fails if Canvas upload fails; no broken links deployed
- Development-friendly —
hugo serverworks locally with no Canvas credentials - Transparent — clear per-file logs and GitHub Actions step summary
- Configurable without complexity — sensible defaults, graduated override options
License
MIT — see 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 and the pain of manual Canvas file management.
"Install and forget" — because instructors should focus on teaching, not file management.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file hugo_canvas_sync-1.0.1.tar.gz.
File metadata
- Download URL: hugo_canvas_sync-1.0.1.tar.gz
- Upload date:
- Size: 129.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1853efb469eadda7b6b38428ac670b2241539839c3030e0c3f2f180b31d2c909
|
|
| MD5 |
dd07681bccc5c85371fe81d71b434cf3
|
|
| BLAKE2b-256 |
d339d729beb772d9a307151e030194efd3e2ad480662af0490ce4e48a263d407
|
Provenance
The following attestation bundles were made for hugo_canvas_sync-1.0.1.tar.gz:
Publisher:
release.yml on jlumbroso/hugo-canvas-sync
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hugo_canvas_sync-1.0.1.tar.gz -
Subject digest:
1853efb469eadda7b6b38428ac670b2241539839c3030e0c3f2f180b31d2c909 - Sigstore transparency entry: 1188943814
- Sigstore integration time:
-
Permalink:
jlumbroso/hugo-canvas-sync@1ec05cfbc209cd51e820d42aa042aa95a7ac9773 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/jlumbroso
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@1ec05cfbc209cd51e820d42aa042aa95a7ac9773 -
Trigger Event:
push
-
Statement type:
File details
Details for the file hugo_canvas_sync-1.0.1-py3-none-any.whl.
File metadata
- Download URL: hugo_canvas_sync-1.0.1-py3-none-any.whl
- Upload date:
- Size: 24.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4abb0ba07c836068cbcb9bf7ded89c51ac5b5ae47c454b6aa67bd1578fe5ff54
|
|
| MD5 |
475650a3892538135a2bc81f9bcf0a6f
|
|
| BLAKE2b-256 |
5900cf802e79888ef9f9c55911910a0b33acca8eebbbca95df3f9d62f1552510
|
Provenance
The following attestation bundles were made for hugo_canvas_sync-1.0.1-py3-none-any.whl:
Publisher:
release.yml on jlumbroso/hugo-canvas-sync
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hugo_canvas_sync-1.0.1-py3-none-any.whl -
Subject digest:
4abb0ba07c836068cbcb9bf7ded89c51ac5b5ae47c454b6aa67bd1578fe5ff54 - Sigstore transparency entry: 1188943819
- Sigstore integration time:
-
Permalink:
jlumbroso/hugo-canvas-sync@1ec05cfbc209cd51e820d42aa042aa95a7ac9773 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/jlumbroso
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@1ec05cfbc209cd51e820d42aa042aa95a7ac9773 -
Trigger Event:
push
-
Statement type: