This repository contains a linter for PL (PrairieLearn) HTML files.
The linter checks HTML files for:
-
HTML/Template Syntax Validation: Uses djlint with the mustache profile to validate HTML structure while correctly handling
{{ mustache }}template expressions found in PrairieLearn question files. This includes:- Properly formatted tags
- Properly nested elements
- Correct attribute syntax
-
PrairieLearn-Specific Rules:
<pl-multiple-choice>elements must not be nested inside any element whose tag starts withpl-(e.g. they must not appear inside<pl-question-panel>or similar PrairieLearn elements)
-
Extensible Framework: Additional custom validation rules can be easily added
This repository provides a reusable GitHub Actions workflow that can be called from other repositories containing PrairieLearn content.
To use the linter in your own repository, create a workflow file (e.g., .github/workflows/lint-html.yml):
name: Lint HTML Files
'on':
workflow_dispatch:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint:
uses: ucsb-cs/pl-linter/.github/workflows/lint-html.yml@mainThis will automatically lint all HTML files in your repository and fail the workflow if any errors are found.
This repository uses a special test mode to validate that the linter correctly detects errors. The workflow in this repo expects certain files to fail:
name: Test Linter
'on':
workflow_dispatch:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test-lint:
uses: ./.github/workflows/lint-html.yml
with:
test-mode: true
expected-failures: 'example_invalid.html,example_pl_invalid.html'In test mode:
- Files listed in
expected-failuresmust fail linting (to verify error detection works) - All other files must pass linting
- The workflow succeeds only if this behavior is correct
To run the linter locally in normal mode (all files must pass):
# Lint the questions/ subdirectory (default when no arguments given)
python3 lint_html.py
# Lint one or more specific directories
python3 lint_html.py questions/
python3 lint_html.py questions/q1 questions/q2When no command-line arguments are provided, the script scans the questions/ subdirectory of the current directory (or of GITHUB_WORKSPACE when running in a GitHub Actions workflow). When one or more directory paths are provided as command-line arguments, those directories are scanned instead. This allows you to lint a subset of a PrairieLearn repository (e.g., a single question folder) without scanning the entire repo.
To run the linter in test mode (for testing the linter itself):
TEST_MODE=true EXPECTED_FAILURES="example_invalid.html,example_pl_invalid.html" python3 lint_html.pyThe script will:
- Find all
.htmland.HTMLfiles in the specified directories (or thequestions/subdirectory if none are given), excluding the.gitdirectory - Validate each file using djlint (mustache template aware)
- Apply any custom PL-specific rules
- Report errors with line numbers and descriptions
Normal Mode:
0: All files passed linting1: One or more files failed linting
Test Mode:
0: Files that should fail did fail, and files that should pass did pass1: Unexpected pass/fail results
The repository includes example HTML files to demonstrate the linter's functionality:
example.html- A valid HTML file that passes all checksexample_invalid.html- An invalid HTML file with mismatched tags (structural error)example_pl_valid.html- A valid PrairieLearn file with<pl-multiple-choice>as root elementexample_pl_invalid.html- An invalid PrairieLearn file with<pl-multiple-choice>nested inside a<pl-question-panel>elementexample_question.html- A valid PrairieLearn question fragmentexample_pl_invalid_mustache.html- An invalid PrairieLearn file that uses mustache templates ({{ params.x }}) and has a nested<pl-multiple-choice>element; demonstrates that the linter catches custom-rule violations even in template-heavy files
- Python 3.x
- djlint (
pip install djlint, orpip install -r requirements.txt)
To add custom validation rules, modify the check_custom_rules() function in lint_html.py.
To add a new rule (e.g., checking for specific attributes or element patterns), edit the check_custom_rules() function:
def check_custom_rules(file_path):
"""Check custom PL-specific rules."""
errors = []
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Example: Check for specific pattern
# if 'pattern' not in content:
# errors.append("Missing required pattern")
except Exception as e:
errors.append(f"Error checking custom rules: {str(e)}")
return errorsThe linter will automatically run your custom rules on all HTML files.