Skip to content
Merged
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
92 changes: 92 additions & 0 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# PR Validation Scripts

These scripts are used by the `pr-check.yml` workflow to validate PRs before merging.

## Scripts

### `validate-data.mjs`
Validates the Google Sheets data source that powers the lessons.

**Checks:**
- CSV is accessible and fetchable
- CSV parses correctly
- At least some lessons have required fields (name, description, url)
- URL formats are valid

**Exit codes:**
- 0: Success (CSV accessible, data usable)
- 1: Failure (CSV unreachable, no valid lessons)

**Usage:**
```bash
node .github/scripts/validate-data.mjs
```

### `validate-build.mjs`
Validates the build output to ensure critical pages and assets exist.

**Checks:**
- dist/ directory exists
- Critical pages exist (index, lessons, pathways, etc.)
- Critical assets exist (CSS, logo)
- Reasonable number of HTML pages generated
- Files are not suspiciously small (empty or error pages)

**Exit codes:**
- 0: Success
- 1: Failure (missing critical files)

**Usage:**
```bash
npm run build
node .github/scripts/validate-build.mjs
```

### `check-links.mjs`
Checks for broken internal links in the built site.

**Checks:**
- All internal href links resolve to real files
- Handles relative and absolute paths
- Handles directory index.html files

**Exit codes:**
- 0: Success (all links valid)
- 1: Failure (broken links found)

**Usage:**
```bash
npm run build
node .github/scripts/check-links.mjs
```

## Running All Checks Locally

To run all PR checks locally before pushing:

```bash
# 1. Validate data source
node .github/scripts/validate-data.mjs

# 2. Type check
npx astro check

# 3. Build
npm run build

# 4. Validate build
node .github/scripts/validate-build.mjs

# 5. Check links
node .github/scripts/check-links.mjs
```

## Adding New Checks

When adding new validation scripts:

1. Create the script in `.github/scripts/`
2. Make it executable: `chmod +x .github/scripts/your-script.mjs`
3. Add it to `.github/workflows/pr-check.yml`
4. Document it in this README
5. Test locally before committing
172 changes: 172 additions & 0 deletions .github/scripts/check-links.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#!/usr/bin/env node
// Link validation script for PR checks
// Checks for broken internal links in the built site

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const DIST_DIR = path.resolve(process.cwd(), 'dist');

// Collect all HTML files
function collectHTMLFiles(dir, files = []) {
const entries = fs.readdirSync(dir, { withFileTypes: true });

for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
collectHTMLFiles(fullPath, files);
} else if (entry.isFile() && entry.name.endsWith('.html')) {
files.push(fullPath);
}
}

return files;
}

// Extract internal links from HTML
function extractInternalLinks(html, filePath) {
const links = [];

// Match href attributes
const hrefRegex = /href=["']([^"']+)["']/g;
let match;

while ((match = hrefRegex.exec(html)) !== null) {
const href = match[1];

// Skip external links, anchors, mailto, tel, etc.
if (href.startsWith('http://') ||
href.startsWith('https://') ||
href.startsWith('mailto:') ||
href.startsWith('tel:') ||
href.startsWith('#')) {
continue;
}

links.push({ href, file: filePath });
}

return links;
}

// Check if a link resolves to a real file
function checkLink(link, baseDir) {
let targetPath = link.href;

// Remove query strings and anchors
targetPath = targetPath.split('?')[0].split('#')[0];

// Handle absolute paths
if (targetPath.startsWith('/')) {
targetPath = path.join(baseDir, targetPath);
} else {
// Handle relative paths
const linkDir = path.dirname(link.file);
targetPath = path.join(linkDir, targetPath);
}

// Normalize path
targetPath = path.normalize(targetPath);

// Check if file exists
if (fs.existsSync(targetPath)) {
return { valid: true };
}

// Check with .html extension
if (fs.existsSync(targetPath + '.html')) {
return { valid: true };
}

// Check if it's a directory with index.html
if (fs.existsSync(path.join(targetPath, 'index.html'))) {
return { valid: true };
}

return {
valid: false,
error: `Link points to non-existent path: ${targetPath}`
};
}

async function main() {
console.log('🔗 Checking internal links...\n');

if (!fs.existsSync(DIST_DIR)) {
console.error('❌ dist/ directory not found. Build the site first.');
process.exit(1);
}

// Collect all HTML files
const htmlFiles = collectHTMLFiles(DIST_DIR);
console.log(`Found ${htmlFiles.length} HTML files\n`);

if (htmlFiles.length === 0) {
console.error('❌ No HTML files found in dist/');
process.exit(1);
}

// Extract and check all links
const allLinks = [];
const brokenLinks = [];

for (const file of htmlFiles) {
const html = fs.readFileSync(file, 'utf-8');
const links = extractInternalLinks(html, file);
allLinks.push(...links);
}

console.log(`Checking ${allLinks.length} internal links...\n`);

// Check each unique link
const checkedLinks = new Set();

for (const link of allLinks) {
const linkKey = `${link.file}::${link.href}`;

// Skip if already checked
if (checkedLinks.has(linkKey)) {
continue;
}
checkedLinks.add(linkKey);

const result = checkLink(link, DIST_DIR);

if (!result.valid) {
brokenLinks.push({
file: path.relative(DIST_DIR, link.file),
href: link.href,
error: result.error
});
}
}

// Report results
if (brokenLinks.length > 0) {
console.error(`❌ Found ${brokenLinks.length} broken internal links:\n`);

// Group by file
const byFile = {};
brokenLinks.forEach(link => {
if (!byFile[link.file]) byFile[link.file] = [];
byFile[link.file].push(link.href);
});

Object.entries(byFile).forEach(([file, links]) => {
console.error(`📄 ${file}`);
links.forEach(link => console.error(` ❌ ${link}`));
console.error('');
});

process.exit(1);
} else {
console.log(`✅ All ${checkedLinks.size} internal links are valid!`);
process.exit(0);
}
}

main();
Loading
Loading