Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Keystatic storage mode:
# - unset or "local": edits write to your local filesystem
# - "github": edits authenticate and write commits/PRs via GitHub
PUBLIC_KEYSTATIC_STORAGE=local

# Required only when PUBLIC_KEYSTATIC_STORAGE=github
KEYSTATIC_GITHUB_CLIENT_ID=
KEYSTATIC_GITHUB_CLIENT_SECRET=

# Secret used to encrypt auth session cookies (required for GitHub auth)
# Use a long random string (for example: openssl rand -base64 48)
KEYSTATIC_SECRET=

14 changes: 4 additions & 10 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
run: npm ci

# ============================================
# Step 1: Validate Google Sheets data source
# Step 1: Validate data source
# ============================================
- name: Validate data source
run: node .github/scripts/validate-data.mjs
Expand All @@ -52,7 +52,7 @@ jobs:
run: npx astro check

# ============================================
# Step 3: Build the site
# Step 2: Build the site
# ============================================
- name: Build site
run: npm run build
Expand Down Expand Up @@ -81,13 +81,10 @@ jobs:

| Check | Status |
|-------|--------|
| 📊 Data validation | ✅ Pass |
| 🔍 TypeScript check | ✅ Pass |
| 🏗️ Build | ✅ Pass |
| 📄 Critical pages | ✅ Pass |
| 🔗 Internal links | ✅ Pass |

The site builds successfully and all validation checks passed.`;
The site builds successfully and all active validation checks passed.`;

github.rest.issues.createComment({
issue_number: context.issue.number,
Expand All @@ -106,11 +103,8 @@ jobs:
One or more validation checks failed. Please review the [workflow logs](${context.payload.repository.html_url}/actions/runs/${context.runId}) to see what went wrong.

Common issues:
- 📊 **Data validation**: Google Sheets CSV is unreachable or has invalid data
- 🔍 **TypeScript check**: Type errors in code
- 🏗️ **Build**: Build process failed
- 📄 **Critical pages**: Missing required pages (index, lessons, pathways)
- 🔗 **Internal links**: Broken links detected`;
- 🏗️ **Build**: Build process failed`;

github.rest.issues.createComment({
issue_number: context.issue.number,
Expand Down
30 changes: 16 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ Work regarding the education subgroup. This repository hosts the Astro-based web

- **Comprehensive Lesson Library**: Browse open source education lessons from The Carpentries, CodeRefinery, and other sources
- **Learning Pathways**: Curated sequences of lessons organized by topic and skill level
- **Dynamic Data Integration**: Content automatically synced from Google Sheets for easy updates
- **Keystatic Integration**: Built-in Admin UI for managing lessons and pathways content
- **Legacy Data Import**: Migration scripts available to import content from Google Sheets
- **Advanced Search & Filtering**: Find lessons by topic, source, duration, and difficulty level
- **Responsive Design**: Optimized experience across desktop, tablet, and mobile devices
- **Automated Quality Checks**: Built-in validation for data quality, links, and build integrity
Expand Down Expand Up @@ -49,20 +50,11 @@ This project was built with [Astro](https://astro.build/).
The project includes automated validation scripts to ensure data quality and build integrity:

```bash
# Validate data source from Google Sheets
node .github/scripts/validate-data.mjs

# Run TypeScript type checking
npx astro check

# Build the site
npm run build

# Validate build output (critical pages)
node .github/scripts/validate-build.mjs

# Check for broken internal links
node .github/scripts/check-links.mjs
```

These checks run automatically on pull requests via GitHub Actions.
Expand Down Expand Up @@ -97,10 +89,20 @@ Visit the live site to explore the lesson library:

### Updating Content

Lesson data is managed through Google Sheets:
1. Update the [lesson inventory spreadsheet](https://docs.google.com/spreadsheets/d/1JqM5OYX4f-T0jR-GJ5UeI7PnGJP6o4jtPRNtDJUGPmI/edit?gid=1792935546#gid=1792935546)
2. Changes are automatically pulled during the build process
3. The site rebuilds and deploys when changes are pushed to the main branch
### Updating Content

**1. Keystatic (Primary Source)**
- This project uses [Keystatic](https://keystatic.com/) for managing rich markdown content like lessons and pathways.
- **Local Access**: When running the dev server (`npm run dev`), visit `http://localhost:4321/keystatic` to access the Admin UI.
- **GitHub Integration**: In production, content is managed directly via GitHub PRs, but you can use the local UI to generate the commits.

**2. Google Sheets (Legacy/Migration)**
- The [lesson inventory spreadsheet](https://docs.google.com/spreadsheets/d/1JqM5OYX4f-T0jR-GJ5UeI7PnGJP6o4jtPRNtDJUGPmI/edit?gid=1792935546#gid=1792935546) was used for initial content population.
- **Note**: This sheet is **NOT** automatically synced during the build. It serves as a historical reference.
- **Manual Import**: If you need to re-import from Sheets (warning: overwrites local changes), run:
```bash
npm run migrate:lessons
```

## 🗺️ Roadmap (Optional)

Expand Down
68 changes: 48 additions & 20 deletions STUDENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,22 @@ This website helps learners explore open-source learning materials through six m
## 📁 Project Structure

```
keystatic.config.ts # Keystatic CMS configuration (Git-backed editing)
src/
├── components/
│ ├── Card.astro # Basic card component
│ ├── Folder.astro # Deprecated folder component
│ ├── LessonFilter.tsx # React component for filtering lessons
│ ├── PathwayCard.astro # Pathway card component
│ └── StackedPathways.jsx # Interactive stacked pathways (NEW)
├── content/
│ ├── config.ts # Astro Content Collections schema
│ └── lessons/ # Lesson data files (edited via Keystatic)
├── layouts/
│ └── BaseLayout.astro # Main layout with header/footer
├── lib/
│ └── getSheetData.ts # Fetches lesson data from Google Sheets
│ ├── lessons.ts # Loads lessons from Astro content collection (primary)
│ └── getSheetData.ts # Fetches from Google Sheets (optional migration source)
├── pages/
│ ├── index.astro # Homepage with stacked pathways
│ ├── lessons.astro # Filterable lessons library
Expand Down Expand Up @@ -75,31 +80,53 @@ npm run build
npm run preview
```

The site will be available at `http://localhost:4321`
The site will be available at `http://localhost:4321/education`

## 📊 Data Management

### Lesson Data Source
Lessons are stored in a Google Sheet and fetched via a published CSV:
- **File**: `src/lib/getSheetData.ts`
- **Sheet URL**: Already configured
- **Format**: Bioschemas Training schema
Lessons are stored as files in the repo and edited via Keystatic:
- **Keystatic admin UI (local dev only)**: `http://127.0.0.1:4321/keystatic`
- **Content location**: `src/content/lessons/*.json`
- **Schema**: `src/content/config.ts`
- **Keystatic config**: `keystatic.config.ts`
- **Optional**: `getSheetData.ts` can fetch from Google Sheets for one-time migration

### Key Metadata Fields (Simplified for MVP)
Essential fields to display:
- `name` - Lesson title
- `description` - Lesson description
- `url` - Link to the lesson
- `learnerCategory` - Which pathway(s) the lesson belongs to
- `learnerCategory` - Which pathway the lesson belongs to (can be left **Unassigned**)
- `educationalLevel` - Beginner/Intermediate/Advanced
- `oss_role` - OSS role (Contributor, Maintainer, etc.)
- `subTopic` - Grouping within a pathway
- `Keep?` - Filter to only show lessons marked "Keep candidate" or "Keep"
- `keepStatus` - Filter to only show lessons marked "keep" or "keepCandidate"

### Adding/Updating Lessons
1. Edit the Google Sheet (ask Tim for access)
2. Mark lessons with `Keep?` = "Keep candidate" or "Keep"
3. The website automatically fetches new data on each build
1. Run the site locally: `npm run dev`
2. Open Keystatic: `http://127.0.0.1:4321/keystatic`
3. Edit existing lessons or create new ones under the **Lessons** collection
4. Commit the changed files in `src/content/lessons/`

### GitHub-backed Editing (Auth)
Keystatic supports GitHub-backed edits (creates commits/PRs). To enable this locally:
1. Create a GitHub OAuth App for this repo/org
2. Add callback URLs:
- `http://127.0.0.1:4321/api/keystatic/github/oauth/callback`
3. Create a `.env` file (copy from `.env.example`) and set:
- `PUBLIC_KEYSTATIC_STORAGE=github`
- `KEYSTATIC_GITHUB_CLIENT_ID=...`
- `KEYSTATIC_GITHUB_CLIENT_SECRET=...`
- `KEYSTATIC_SECRET=...` (random long string)

If you don’t need GitHub auth locally, leave `PUBLIC_KEYSTATIC_STORAGE` unset and Keystatic will use local storage.

### One-time Migration From Google Sheets (Optional)
If you need to import the current Google Sheets data into files once:
- Run: `npm run migrate:lessons`
- This writes `src/content/lessons/*.json`
- After that, Google Sheets changes will **not** automatically update the site anymore.

## 📄 Pages Overview

Expand Down Expand Up @@ -214,17 +241,12 @@ This project uses strict TypeScript checks via Astro’s strict tsconfig.
Strict typing helps catch errors early and improves maintainability.


### Working with the CSV Data
### Working with Lesson Data
```javascript
import { getSheetData } from '../lib/getSheetData.ts';
import { getActiveLessons } from '../lib/lessons';

// In Astro component
const lessons = await getSheetData();

// Filter active lessons
const activeLessons = lessons.filter(lesson =>
lesson['Keep?']?.includes('Keep')
);
const activeLessons = await getActiveLessons();
```

### Adding a New Page
Expand All @@ -239,7 +261,13 @@ const activeLessons = lessons.filter(lesson =>

## 🐛 Common Issues

**Issue**: CSV data not loading
**Issue**: Keystatic page not loading
- **Solution**: Ensure `@keystatic/astro` is in `astro.config.mjs` and visit `http://127.0.0.1:4321/keystatic`

**Issue**: GitHub sign-in fails (redirect mismatch)
- **Solution**: Confirm your OAuth callback URL is exactly `http://127.0.0.1:4321/api/keystatic/github/oauth/callback`

**Issue**: CSV data not loading (migration)
- **Solution**: Check the published CSV URL in `getSheetData.ts`

**Issue**: React component not interactive
Expand Down
11 changes: 9 additions & 2 deletions astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
// @ts-check
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import keystatic from '@keystatic/astro';

// https://astro.build/config
const isDev = !process.argv.some(arg => arg === 'build' || arg.includes('build'));

export default defineConfig({
site: 'https://UC-OSPO-Network.github.io',
base: '/education',
integrations: [react()],
// Align local dev with GitHub Pages (served under /education).
// Keystatic requires root access in dev mode for its API to work correctly.
base: isDev ? undefined : '/education/',
// Keystatic injects non-prerendered routes, which require a server adapter in production builds.
// This site deploys as a static build (GitHub Pages), so we only enable Keystatic in dev.
integrations: [react(), ...(isDev ? [keystatic()] : [])],
});
130 changes: 130 additions & 0 deletions keystatic.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { collection, config, fields } from '@keystatic/core';
import { PATHWAYS } from './src/types/lesson';

const learnerCategoryOptions = PATHWAYS.flatMap((pathway) => pathway.learnerCategories).map(
(category) => ({
label: category,
value: category,
})
);

const uniqueLearnerCategoryOptions = Array.from(
new Map(learnerCategoryOptions.map((option) => [option.value, option])).values()
).sort((a, b) => a.label.localeCompare(b.label));

const learnerCategorySelectOptions = [
{ label: 'Unassigned', value: '' },
...uniqueLearnerCategoryOptions,
];

const educationalLevelOptions = [
{ label: 'Beginner', value: 'Beginner' },
{ label: 'Intermediate', value: 'Intermediate' },
{ label: 'Advanced', value: 'Advanced' },
{ label: 'Mixed', value: 'Mixed' },
{ label: 'Unknown', value: 'Unknown' },
] as const;

const keepStatusOptions = [
{ label: 'Keep', value: 'keep' },
{ label: 'Keep candidate', value: 'keepCandidate' },
{ label: 'Drop', value: 'drop' },
] as const;

const useGitHubStorage =
import.meta.env.PUBLIC_KEYSTATIC_STORAGE === 'github' && import.meta.env.DEV;

export default config({
storage: useGitHubStorage
? { kind: 'github', repo: 'UC-OSPO-Network/education' }
: { kind: 'local' },
ui: {
brand: { name: 'UC OSPO Education' },
navigation: {
Content: ['lessons'],
},
},
collections: {
lessons: collection({
label: 'Lessons',
path: 'src/content/lessons/*',
format: { data: 'json' },
slugField: 'slug',
schema: {
name: fields.text({
label: 'Name',
validation: { isRequired: true },
description: 'The title of the lesson',
}),
slug: fields.text({
label: 'Slug',
validation: { isRequired: true },
description: 'The filename and URL part for this lesson',
}),
keepStatus: fields.select({
label: 'Keep status',
options: keepStatusOptions,
defaultValue: 'keepCandidate',
}),
description: fields.text({ label: 'Description', multiline: true }),
url: fields.url({
label: 'Lesson URL',
validation: { isRequired: true },
}),
author: fields.text({ label: 'Author' }),
license: fields.text({ label: 'License' }),
learnerCategory: fields.select({
label: 'Pathway (learnerCategory)',
options: learnerCategorySelectOptions,
defaultValue: '',
description: 'Pick the primary pathway for this lesson (or leave Unassigned).',
}),
educationalLevel: fields.select({
label: 'Skill level (educationalLevel)',
options: educationalLevelOptions,
defaultValue: 'Unknown',
}),
ossRole: fields.text({
label: 'OSS role(s)',
description: 'Used for filtering and tag pills. Example: Contributor, Maintainer',
}),
subTopic: fields.text({ label: 'Sub-topic' }),
timeRequired: fields.text({ label: 'Time Required', description: 'e.g. 30 minutes, 2 hours' }),
learningResourceType: fields.text({ label: 'Learning resource type' }),
inLanguage: fields.array(fields.text({ label: 'Language' }), {
label: 'Languages',
itemLabel: (props) => props.value,
}),
keywords: fields.array(fields.text({ label: 'Keyword' }), {
label: 'Keywords',
itemLabel: (props) => props.value,
}),
// --- Additional Metadata Fields ---
topic: fields.text({ label: 'Topic' }),
sortingId: fields.text({ label: 'Sorting ID' }),
dependsOn: fields.text({ label: 'Depends On' }),
learningObjectives: fields.text({ label: 'Learning Objectives', multiline: true }),
ospoRelevance: fields.text({ label: 'OSPO Relevance', multiline: true }),
about: fields.text({ label: 'About' }),
abstract: fields.text({ label: 'Abstract', multiline: true }),
accessibilitySummary: fields.text({ label: 'Accessibility Summary', multiline: true }),
audience: fields.text({ label: 'Audience' }),
competencyRequired: fields.text({ label: 'Competency Required' }),
contributor: fields.text({ label: 'Contributor' }),
creativeWorkStatus: fields.text({ label: 'Creative Work Status' }),
dateCreated: fields.text({ label: 'Date Created' }),
dateModified: fields.text({ label: 'Date Modified' }),
datePublished: fields.text({ label: 'Date Published' }),
hasPart: fields.text({ label: 'Has Part' }),
identifier: fields.text({ label: 'Identifier' }),
isPartOf: fields.text({ label: 'Is Part Of' }),
notes: fields.text({ label: 'Notes', multiline: true }),
mentions: fields.text({ label: 'Mentions' }),
recordedAt: fields.text({ label: 'Recorded At' }),
teaches: fields.text({ label: 'Teaches' }),
version: fields.text({ label: 'Version' }),
workTranslation: fields.text({ label: 'Work Translation' }),
},
}),
},
});
Loading