diff --git a/CONTRIBUTORS_FILTERING.md b/CONTRIBUTORS_FILTERING.md
new file mode 100644
index 00000000000..5d8b0a27eef
--- /dev/null
+++ b/CONTRIBUTORS_FILTERING.md
@@ -0,0 +1,142 @@
+# Contributors Page Filtering
+
+This document describes the filtering functionality added to the Contributors page.
+
+## Overview
+
+The Contributors page now supports dynamic filtering by project and role via URL parameters. This allows users to view specific subsets of contributors while maintaining full SEO visibility of all contributor data.
+
+## Usage
+
+### Filtering by Project
+
+To view contributors for a specific project, add the `project` parameter to the URL:
+
+```
+/contributors?project=glossary
+/contributors?project=replications-and-reversals
+/contributors?project=impact-on-students
+```
+
+Project names should be:
+- Lowercase
+- Spaces replaced with hyphens
+- Ampersands replaced with "and"
+
+Examples of project name normalization:
+- "Glossary" → `glossary`
+- "Replications & Reversals" → `replications-and-reversals`
+- "Impact on students" → `impact-on-students`
+
+### Filtering by Role
+
+To view contributors by their role, add the `role` parameter to the URL:
+
+```
+/contributors?role=project-manager
+/contributors?role=writing---original-draft
+/contributors?role=investigation
+```
+
+Role names follow the same normalization rules as projects.
+
+Examples of role name normalization:
+- "Project Manager" → `project-manager`
+- "Writing - original draft" → `writing---original-draft`
+- "Investigation" → `investigation`
+
+### Combined Filtering
+
+You can filter by both project and role simultaneously:
+
+```
+/contributors?project=glossary&role=writing---review-and-editing
+```
+
+This will show only contributors who worked on the Glossary project with the "Writing - review & editing" role.
+
+### Viewing All Contributors
+
+To view all contributors (default view), simply visit:
+
+```
+/contributors
+```
+
+Or click the "Show All Contributors" button that appears when filters are active.
+
+## Technical Details
+
+### Data Generation (tenzing.py)
+
+The `tenzing.py` script has been updated to:
+
+1. Add a `normalize_for_attribute()` function that converts project and role names to a consistent format
+2. Extract all projects and roles for each contributor
+3. Generate HTML list items with `data-projects` and `data-roles` attributes
+4. Wrap contributor entries in proper HTML structure with `
` and `
` tags
+
+### Template (tenzing_template.md)
+
+The template now includes:
+
+1. Filter control UI (hidden by default)
+2. A container for displaying filtered results
+3. A wrapper `
` tag for the contributor list
+4. Script tag to load the filtering JavaScript
+
+### JavaScript (contributor-filter.js)
+
+The JavaScript file provides:
+
+1. URL parameter parsing to detect filter requests
+2. Contributor filtering based on data attributes
+3. Dynamic display of filtered results
+4. UI controls for clearing filters and returning to the full list
+
+### SEO Considerations
+
+All contributor data remains in the static HTML as `
` elements with data attributes. Search engines can index:
+- All contributor names
+- All project names
+- All role descriptions
+
+The filtering is purely client-side and doesn't prevent search engine crawling.
+
+## Regenerating the Contributors Page
+
+After updating contributor data in the source spreadsheets, regenerate the page by running:
+
+```bash
+cd scripts/forrt_contribs
+python3 tenzing.py
+```
+
+Then copy the generated `tenzing.md` file to the appropriate location:
+
+```bash
+cp scripts/forrt_contribs/tenzing.md content/contributors/tenzing.md
+```
+
+## Examples
+
+### Example 1: Find all Glossary contributors
+```
+https://forrt.org/contributors?project=glossary
+```
+
+This will display all contributors who worked on the Glossary project with their specific roles.
+
+### Example 2: Find all project managers
+```
+https://forrt.org/contributors?role=project-manager
+```
+
+This will show everyone who served as a Project Manager across all projects.
+
+### Example 3: Find writing contributors to Impact on Students
+```
+https://forrt.org/contributors?project=impact-on-students&role=writing---original-draft
+```
+
+This shows contributors who did original draft writing for the Impact on Students project.
diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md
new file mode 100644
index 00000000000..7f7b8b53234
--- /dev/null
+++ b/IMPLEMENTATION_NOTES.md
@@ -0,0 +1,177 @@
+# Contributors Page Filtering - Implementation Notes
+
+## Overview
+
+This document provides technical implementation notes for the contributors page filtering feature added to the FORRT website.
+
+## Implementation Date
+
+November 2025
+
+## Problem Statement
+
+The contributors page previously displayed a static list of all contributors. The goal was to add dynamic filtering by project and role via URL parameters while maintaining full SEO visibility of all contributor names and data.
+
+## Solution Architecture
+
+### Three-Layer Approach
+
+1. **Data Generation Layer** (`tenzing.py`)
+ - Fetches contributor data from Google Sheets
+ - Processes and normalizes project/role names
+ - Generates static HTML with structured data attributes
+ - Ensures proper HTML escaping for security
+
+2. **Static HTML Layer** (`tenzing_template.md` → generated `tenzing.md`)
+ - Contains all contributor data in semantic HTML (`
` and `
` elements)
+ - Includes data attributes for filtering (`data-projects`, `data-roles`)
+ - Maintains full SEO visibility (all text in HTML)
+ - Includes filter UI structure (hidden by default)
+
+3. **Client-Side Enhancement Layer** (`contributor-filter.js`)
+ - Parses URL parameters for filtering requests
+ - Filters and displays matching contributors dynamically
+ - Provides UI controls for managing filters
+ - Uses progressive enhancement (works without JS)
+
+## Technical Decisions
+
+### Why Client-Side Filtering?
+
+- **SEO Preservation**: All contributor data remains in static HTML
+- **No Backend Required**: Hugo is a static site generator
+- **Fast Performance**: No server round-trips needed
+- **Simple Deployment**: No API or database changes required
+
+### Why Data Attributes?
+
+- **Standard HTML5**: Native browser support
+- **Non-Intrusive**: Doesn't affect visual presentation
+- **Parseable**: Easy to query with JavaScript
+- **SEO-Friendly**: Doesn't hide content from search engines
+
+### Normalization Strategy
+
+Project and role names are normalized to ensure consistent matching:
+
+```
+Original: "Replications & Reversals"
+Normalized: "replications-and-reversals"
+
+Original: "Writing - original draft"
+Normalized: "writing---original-draft"
+```
+
+Rules:
+1. Convert to lowercase
+2. Replace whitespace sequences with single hyphens
+3. Replace `&` with `and`
+4. Trim leading/trailing whitespace
+
+This normalization is applied consistently in both Python and JavaScript.
+
+## Security Considerations
+
+### Implemented Protections
+
+1. **HTML Attribute Escaping**: All data attribute values are escaped using `html.escape()` in Python
+2. **XSS Prevention**: URL parameters are sanitized before display using custom `escapeHtml()` function
+3. **Safe DOM Manipulation**: Filtered results are created using DOM methods, not `innerHTML`
+4. **Input Validation**: Null checks and empty string handling for all user inputs
+5. **CodeQL Analysis**: Passed with 0 vulnerabilities
+
+### Attack Vectors Considered
+
+- ✅ Malicious project/role names in source data
+- ✅ XSS via URL parameters
+- ✅ HTML injection through data attributes
+- ✅ JavaScript injection in filter display
+
+## Testing
+
+### Manual Testing Performed
+
+1. ✅ Filter by project only
+2. ✅ Filter by role only
+3. ✅ Combined project + role filtering
+4. ✅ Clear filters button
+5. ✅ Empty results handling
+6. ✅ Multiple projects per contributor
+7. ✅ Multiple roles per contributor
+8. ✅ Special characters in names
+9. ✅ Page without filters (full list)
+
+### Browser Testing
+
+Tested in Chromium-based browser via Playwright automation.
+
+## Maintenance
+
+### When to Regenerate
+
+The contributors page should be regenerated whenever:
+- New contributors are added to the source spreadsheets
+- Contributor roles are updated
+- Project names change
+- The filtering logic needs updates
+
+### Regeneration Process
+
+```bash
+cd scripts/forrt_contribs
+python3 tenzing.py
+cp tenzing.md ../../content/contributors/tenzing.md
+git add content/contributors/tenzing.md
+git commit -m "Update contributors data"
+git push
+```
+
+### Monitoring
+
+No special monitoring required. The feature is entirely client-side and uses static data.
+
+## Performance
+
+- **Initial Page Load**: No performance impact (HTML is pre-rendered)
+- **Filtering Operation**: O(n) where n = number of contributors (~900)
+- **Memory Usage**: Minimal (only clones matching DOM nodes)
+- **Browser Compatibility**: Works in all modern browsers (ES6+)
+
+## Known Limitations
+
+1. **No Fuzzy Matching**: Filter values must match exactly (after normalization)
+2. **Case-Insensitive Only**: All matching is lowercase
+3. **No Search Within Text**: Filters match entire project/role names only
+4. **Client-Side Only**: Requires JavaScript enabled for filtering
+
+## Future Enhancements
+
+Potential improvements for future consideration:
+
+1. **Filter UI**: Add dropdown selects for easier filter selection
+2. **Multi-Select**: Allow filtering by multiple projects/roles simultaneously
+3. **Search Box**: Add text search within contributor names
+4. **Filter History**: Remember last used filters in localStorage
+5. **Share Links**: Generate shareable filtered view URLs
+6. **Analytics**: Track which filters are most commonly used
+
+## Related Documentation
+
+- `CONTRIBUTORS_FILTERING.md` - User-facing documentation
+- `scripts/forrt_contribs/README.md` - Script usage guide
+- Code comments in `tenzing.py` and `contributor-filter.js`
+
+## Success Criteria Met
+
+✅ URL parameter filtering works for project and role
+✅ All contributor data remains SEO-visible
+✅ No server-side changes required
+✅ Security best practices followed
+✅ Comprehensive documentation provided
+✅ Code review comments addressed
+✅ No CodeQL security vulnerabilities
+✅ Functional testing completed successfully
+
+## Support
+
+For questions or issues, refer to the main FORRT repository documentation or contact the development team.
diff --git a/scripts/forrt_contribs/README.md b/scripts/forrt_contribs/README.md
new file mode 100644
index 00000000000..784226b01ef
--- /dev/null
+++ b/scripts/forrt_contribs/README.md
@@ -0,0 +1,79 @@
+# FORRT Contributors Data Generation
+
+This directory contains the script and template for generating the Contributors page.
+
+## Files
+
+- `tenzing.py` - Python script that fetches contributor data from Google Sheets and generates the tenzing.md file
+- `tenzing_template.md` - Template file with frontmatter and structure
+- `tenzing.md` - Generated output file (copy to `content/contributors/tenzing.md` after generation)
+
+## Generating the Contributors Page
+
+### Prerequisites
+
+Install required Python packages:
+
+```bash
+pip install pandas
+```
+
+### Running the Script
+
+```bash
+cd scripts/forrt_contribs
+python3 tenzing.py
+```
+
+This will:
+1. Fetch contributor data from Google Sheets
+2. Process and deduplicate contributors
+3. Generate HTML with data attributes for filtering
+4. Save the output to `tenzing.md`
+
+### Deploying the Updates
+
+After running the script, copy the generated file to the content directory:
+
+```bash
+cp scripts/forrt_contribs/tenzing.md content/contributors/tenzing.md
+```
+
+Then commit and push the changes.
+
+## New Filtering Feature
+
+The generated page now includes:
+
+- **Data attributes**: Each contributor entry has `data-projects` and `data-roles` attributes
+- **Filter UI**: Hidden by default, appears when URL parameters are present
+- **JavaScript filtering**: Client-side filtering via `/js/contributor-filter.js`
+
+### How Filtering Works
+
+1. The script normalizes project and role names (lowercase, hyphens, etc.)
+2. Each `
` element gets data attributes with comma-separated normalized values
+3. JavaScript parses URL parameters and filters based on these attributes
+4. All data remains in the HTML for SEO purposes
+
+### Example URLs
+
+- View all Glossary contributors: `/contributors?project=glossary`
+- View all project managers: `/contributors?role=project-manager`
+- Combined filter: `/contributors?project=glossary&role=writing---original-draft`
+
+See `CONTRIBUTORS_FILTERING.md` in the root directory for complete documentation.
+
+## Data Structure
+
+The script processes data from multiple sources:
+
+1. **Main CSV**: Project list with Tenzing CSV links
+2. **Project CSVs**: Individual contributor data per project
+3. **Extra Roles CSV**: Additional roles not captured in Tenzing format
+
+The output includes:
+- Contributor name (with ORCID link if available)
+- Projects contributed to
+- Roles/contributions for each project
+- Data attributes for filtering
diff --git a/scripts/forrt_contribs/tenzing.py b/scripts/forrt_contribs/tenzing.py
index 0491a313ba4..76ce074a2af 100644
--- a/scripts/forrt_contribs/tenzing.py
+++ b/scripts/forrt_contribs/tenzing.py
@@ -1,5 +1,7 @@
import pandas as pd
import os
+import re
+import html
# Tenzing directory
csv_export_url = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vT_IaXiYtB3iAmtDZ_XiQKrToRkxOlkXNAeNU2SIT_J9PxvsQyptga6Gg9c8mSvDZpwY6d8skswIQYh/pub?output=csv&gid=0'
@@ -118,6 +120,14 @@ def format_name(row):
# Propagate ORCID iD within each contributor's grouping
merged_data['ORCID iD'] = merged_data.groupby('full_name')['ORCID iD'].transform(lambda x: x.ffill().bfill())
+# Helper function to normalize project/role names for data attributes
+def normalize_for_attribute(text):
+ """Convert text to lowercase and replace spaces with hyphens for HTML attributes"""
+ if pd.isna(text) or text == '':
+ return ''
+ # Collapse multiple spaces into single hyphens and replace & with 'and'
+ return re.sub(r'\s+', '-', text.lower().strip()).replace('&', 'and')
+
# Group by 'ORCID iD' and concatenate the contributions
def concatenate_contributions(group):
@@ -128,6 +138,34 @@ def concatenate_contributions(group):
full_name = format_name(group.iloc[0])
group = group.sort_values(by='special_role', ascending=False)
+ # Collect all projects and roles for data attributes
+ projects = []
+ roles = []
+
+ for _, row in group.iterrows():
+ # Normalize project name
+ project_name = row['Project Name']
+ if pd.notna(project_name) and project_name != '':
+ normalized_project = normalize_for_attribute(project_name)
+ if normalized_project not in projects:
+ projects.append(normalized_project)
+
+ # Parse and normalize roles from Contributions
+ contributions_text = row['Contributions']
+ if pd.notna(contributions_text):
+ # Check if it contains 'as Project Manager' (special role)
+ if row['special_role'] and 'Project Manager' in contributions_text:
+ if 'project-manager' not in roles:
+ roles.append('project-manager')
+
+ # Extract roles from the contributions text
+ # Roles are marked with asterisks like *Writing - original draft*
+ role_matches = re.findall(r'\*([^*]+)\*', contributions_text)
+ for role_match in role_matches:
+ normalized_role = normalize_for_attribute(role_match)
+ if normalized_role not in roles:
+ roles.append(normalized_role)
+
# Create the contributions string for each project
contributions = [
f"{row['Project Name']} {('as' if row['special_role'] else '')} {row['Contributions']}" if pd.isna(row['Project URL']) or row['Project URL'] == ''
@@ -142,11 +180,23 @@ def concatenate_contributions(group):
# Turn contributions into multiline list or single line
contributions_str = contributions[0] if len(contributions) == 1 else '\n ' + '\n '.join(contributions) + '\n' + '{{}}
{{}}'
+ # Create data attributes with proper escaping
+ projects_attr = ','.join(projects) if projects else ''
+ roles_attr = ','.join(roles) if roles else ''
+
+ # Escape attribute values to prevent HTML injection
+ projects_attr_escaped = html.escape(projects_attr, quote=True)
+ roles_attr_escaped = html.escape(roles_attr, quote=True)
+
orcid_id = group.iloc[0]['ORCID iD']
+
+ # Build the list item with data attributes
+ data_attrs = f'data-projects="{projects_attr_escaped}" data-roles="{roles_attr_escaped}"'
+
if orcid_id:
- return min_order, f"- **[{full_name}]({'https://orcid.org/' + orcid_id.strip()})** contributed to {contributions_str}"
+ return min_order, f"- {{{{}}}}
{{{{}}}}"
def extract_orcid_id(value):
if not isinstance(value, str) or len(value) < 5:
@@ -184,6 +234,13 @@ def extract_orcid_id(value):
summary = summary.reset_index(drop=True)
summary_string = '\n\n'.join(summary['Contributions'])
+# Add closing tags and JavaScript include
+footer_content = """
+
+
+{{}}
+"""
+
# --- LOGGING ADDED HERE ---
# Log the final deduplicated number of contributors
print("\n--- Processing Complete ---")
@@ -204,8 +261,8 @@ def extract_orcid_id(value):
with open(template_path, 'r') as file:
template_content = file.read()
-# Combine the template content with the new summary string
-combined_content = template_content + summary_string
+# Combine the template content with the new summary string and footer
+combined_content = template_content + summary_string + footer_content
# Save the combined content to 'tenzing.md'
with open(output_path, 'w') as file:
diff --git a/scripts/forrt_contribs/tenzing_template.md b/scripts/forrt_contribs/tenzing_template.md
index 749ed9bbaee..6762e5fed75 100644
--- a/scripts/forrt_contribs/tenzing_template.md
+++ b/scripts/forrt_contribs/tenzing_template.md
@@ -79,3 +79,13 @@ FORRT is driven by a **large and diverse community of contributors** that shape
## **Contributions**
+{{}}
+
+
Filtered View:
+
+
+
+
+
+
+
diff --git a/static/js/contributor-filter.js b/static/js/contributor-filter.js
new file mode 100644
index 00000000000..9067aa5d900
--- /dev/null
+++ b/static/js/contributor-filter.js
@@ -0,0 +1,143 @@
+(function() {
+ 'use strict';
+
+ // Parse URL parameters
+ function getURLParams() {
+ const params = new URLSearchParams(window.location.search);
+ return {
+ project: params.get('project'),
+ role: params.get('role')
+ };
+ }
+
+ // Normalize text for comparison (lowercase, hyphenated)
+ function normalize(text) {
+ if (!text) return '';
+ return text.toLowerCase().trim().replace(/\s+/g, '-').replace(/&/g, 'and');
+ }
+
+ // Format text for display (capitalize, spaces)
+ function formatForDisplay(text) {
+ if (!text) return '';
+ return text
+ .split('-')
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ')
+ .replace(/\band\b/i, '&');
+ }
+
+ // Escape HTML to prevent XSS
+ function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ // Filter contributors based on URL parameters
+ function filterContributors() {
+ const params = getURLParams();
+ const contributorList = document.getElementById('contributor-list');
+ const filterControls = document.getElementById('filter-controls');
+ const filterInfo = document.getElementById('filter-info');
+ const filteredView = document.getElementById('filtered-view');
+
+ // If no filters, show the full list
+ if (!params.project && !params.role) {
+ contributorList.style.display = 'block';
+ filterControls.style.display = 'none';
+ filteredView.style.display = 'none';
+ return;
+ }
+
+ // Hide the default list and show filtered view
+ contributorList.style.display = 'none';
+ filterControls.style.display = 'block';
+ filteredView.style.display = 'block';
+
+ // Build filter info text with escaped values
+ let filterText = [];
+ if (params.project) {
+ filterText.push(`Project: ${escapeHtml(formatForDisplay(params.project))}`);
+ }
+ if (params.role) {
+ filterText.push(`Role: ${escapeHtml(formatForDisplay(params.role))}`);
+ }
+ filterInfo.innerHTML = filterText.join(' ');
+
+ // Get all contributor list items
+ const allItems = contributorList.querySelectorAll('li[data-projects]');
+ const matchedContributors = [];
+
+ allItems.forEach(item => {
+ // Safely get and parse data attributes with null checks
+ const projectsAttr = item.getAttribute('data-projects') || '';
+ const rolesAttr = item.getAttribute('data-roles') || '';
+ const projects = projectsAttr.split(',').filter(p => p.trim()).map(p => p.trim());
+ const roles = rolesAttr.split(',').filter(r => r.trim()).map(r => r.trim());
+
+ let matches = true;
+
+ // Check project filter
+ if (params.project) {
+ const normalizedProject = normalize(params.project);
+ if (!projects.includes(normalizedProject)) {
+ matches = false;
+ }
+ }
+
+ // Check role filter
+ if (params.role && matches) {
+ const normalizedRole = normalize(params.role);
+ if (!roles.includes(normalizedRole)) {
+ matches = false;
+ }
+ }
+
+ if (matches) {
+ matchedContributors.push({
+ item: item.cloneNode(true)
+ });
+ }
+ });
+
+ // Display matched contributors
+ if (matchedContributors.length === 0) {
+ filteredView.innerHTML = '
No contributors found matching the specified filters.
';
+ } else {
+ // Create a formatted list using DOM manipulation for safety
+ filteredView.innerHTML = '';
+
+ const heading = document.createElement('h3');
+ heading.textContent = `Matching Contributors (${matchedContributors.length})`;
+ filteredView.appendChild(heading);
+
+ const ul = document.createElement('ul');
+ ul.style.cssText = 'list-style-type: none; padding-left: 0;';
+
+ matchedContributors.forEach(c => {
+ // Apply styling to the cloned item and append directly to ul
+ c.item.style.cssText = 'margin-bottom: 1em;';
+ ul.appendChild(c.item);
+ });
+
+ filteredView.appendChild(ul);
+ }
+ }
+
+ // Clear filters and show all contributors
+ function clearFilters() {
+ window.location.href = window.location.pathname;
+ }
+
+ // Initialize on page load
+ document.addEventListener('DOMContentLoaded', function() {
+ // Add event listener to clear button
+ const clearButton = document.getElementById('clear-filters');
+ if (clearButton) {
+ clearButton.addEventListener('click', clearFilters);
+ }
+
+ // Apply filters
+ filterContributors();
+ });
+})();