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.
Metadata
Release files for hugo-canvas-sync 1.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| hugo_canvas_sync-1.0.1.tar.gz | 129.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| hugo_canvas_sync-1.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 154.6 kB
Release files / hugo_canvas_sync-1.0.1.tar.gz
| Download URL | hugo_canvas_sync-1.0.1.tar.gz |
|---|---|
| Size | 129.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1853efb469eadda7b6b38428ac670b2241539839c3030e0c3f2f180b31d2c909
|
|
BLAKE2b-256 checksum How to use checksums |
d339d729beb772d9a307151e030194efd3e2ad480662af0490ce4e48a263d407
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Mar 27, 2026.
Transparency logRelease files / hugo_canvas_sync-1.0.1-py3-none-any.whl
| Download URL | hugo_canvas_sync-1.0.1-py3-none-any.whl |
|---|---|
| Size | 24.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4abb0ba07c836068cbcb9bf7ded89c51ac5b5ae47c454b6aa67bd1578fe5ff54
|
|
BLAKE2b-256 checksum How to use checksums |
5900cf802e79888ef9f9c55911910a0b33acca8eebbbca95df3f9d62f1552510
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Mar 27, 2026.
Transparency log