Skip to content

Commit a659f06

Browse files
author
Daihyxsk (Dx)
committed
feat: add pipeline checks for i18n parity, internal links, accessibility
Add custom scripts in scripts/pipeline/: - check-i18n-parity.js: verify FR/EN quest parity and frontmatter keys - check-internal-links.js: verify all internal hrefs resolve after build - check-accessibility.js: check lang attr, img alt, heading hierarchy Add npm run check to run the full pipeline. Fix missing lang attribute on root redirect page.
1 parent 3cf4549 commit a659f06

File tree

5 files changed

+281
-3
lines changed

5 files changed

+281
-3
lines changed

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
"spell": "cspell \"src/**/*.njk\" \"README*.md\"",
99
"spell:fr": "cspell \"src/fr/**/*.njk\" \"README.fr.md\"",
1010
"spell:en": "cspell \"src/en/**/*.njk\" \"README.md\"",
11-
"check-links": "npx eleventy && lychee _site/**/*.html",
12-
"check-i18n": "node scripts/check-i18n.js",
11+
"check": "npm run check:i18n && npm run build && npm run check:links && npm run check:a11y",
12+
"check:i18n": "node scripts/pipeline/check-i18n-parity.js",
13+
"check:links": "node scripts/pipeline/check-internal-links.js",
14+
"check:a11y": "node scripts/pipeline/check-accessibility.js",
1315
"prepare": "husky"
1416
},
1517
"devDependencies": {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Basic accessibility checks on built HTML files:
4+
* - Images must have alt attributes
5+
* - Headings must not skip levels (h1 -> h3 without h2)
6+
* - Links must have discernible text
7+
* - Lang attribute must be present on <html>
8+
*
9+
* Lightweight alternative to pa11y (no browser needed).
10+
* Must run AFTER eleventy build.
11+
*/
12+
13+
const fs = require("fs");
14+
const path = require("path");
15+
16+
const SITE = path.resolve(__dirname, "../../_site");
17+
18+
if (!fs.existsSync(SITE)) {
19+
console.error("_site/ not found. Run 'npx eleventy' first.");
20+
process.exit(1);
21+
}
22+
23+
let errors = 0;
24+
let warnings = 0;
25+
let filesChecked = 0;
26+
27+
function getAllHtmlFiles(dir) {
28+
const results = [];
29+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
30+
const full = path.join(dir, entry.name);
31+
if (entry.isDirectory()) {
32+
results.push(...getAllHtmlFiles(full));
33+
} else if (entry.name.endsWith(".html")) {
34+
results.push(full);
35+
}
36+
}
37+
return results;
38+
}
39+
40+
function checkFile(filePath) {
41+
const content = fs.readFileSync(filePath, "utf8");
42+
const rel = path.relative(SITE, filePath);
43+
const issues = [];
44+
45+
// Check lang attribute on <html>
46+
if (content.includes("<html") && !/<html[^>]*\slang=/.test(content)) {
47+
issues.push({ level: "error", msg: "Missing lang attribute on <html>" });
48+
}
49+
50+
// Check images have alt
51+
const imgRegex = /<img\b([^>]*)>/g;
52+
let match;
53+
while ((match = imgRegex.exec(content)) !== null) {
54+
if (!/\balt\s*=/.test(match[1])) {
55+
issues.push({ level: "error", msg: `<img> missing alt attribute` });
56+
}
57+
}
58+
59+
// Check heading hierarchy
60+
const headingRegex = /<h([1-6])\b/g;
61+
let prevLevel = 0;
62+
while ((match = headingRegex.exec(content)) !== null) {
63+
const level = parseInt(match[1]);
64+
if (prevLevel > 0 && level > prevLevel + 1) {
65+
issues.push({ level: "warning", msg: `Heading skip: h${prevLevel} -> h${level}` });
66+
}
67+
prevLevel = level;
68+
}
69+
70+
// Check empty links
71+
const linkRegex = /<a\b[^>]*>([\s]*)<\/a>/g;
72+
while ((match = linkRegex.exec(content)) !== null) {
73+
issues.push({ level: "error", msg: "Empty link (no text content)" });
74+
}
75+
76+
for (const issue of issues) {
77+
const prefix = issue.level === "error" ? "ERROR" : "WARN";
78+
console.error(`${prefix}: ${rel} - ${issue.msg}`);
79+
if (issue.level === "error") errors++;
80+
else warnings++;
81+
}
82+
83+
return issues.length;
84+
}
85+
86+
const htmlFiles = getAllHtmlFiles(SITE);
87+
for (const file of htmlFiles) {
88+
filesChecked++;
89+
checkFile(file);
90+
}
91+
92+
console.log(`Accessibility: ${filesChecked} files checked, ${errors} error(s), ${warnings} warning(s)`);
93+
if (errors > 0) process.exit(1);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Check FR/EN parity: every French quest/cheatsheet must have
4+
* an English equivalent, and vice versa. Also checks that
5+
* frontmatter keys match between paired files.
6+
*/
7+
8+
const fs = require("fs");
9+
const path = require("path");
10+
11+
const SRC = path.resolve(__dirname, "../../src");
12+
const PAIRS = [
13+
{ fr: "fr/quetes", en: "en/quests" },
14+
{ fr: "fr/cheatsheets", en: "en/cheatsheets" },
15+
];
16+
17+
let errors = 0;
18+
19+
function getFrontmatter(filePath) {
20+
const content = fs.readFileSync(filePath, "utf8");
21+
const match = content.match(/^---\n([\s\S]*?)\n---/);
22+
if (!match) return {};
23+
const fm = {};
24+
for (const line of match[1].split("\n")) {
25+
const kv = line.match(/^(\w[\w_]*)\s*:/);
26+
if (kv) fm[kv[1]] = true;
27+
}
28+
return fm;
29+
}
30+
31+
function getSlugMap(dir) {
32+
if (!fs.existsSync(dir)) return new Map();
33+
const map = new Map();
34+
for (const file of fs.readdirSync(dir)) {
35+
if (!file.endsWith(".njk") || file === "index.njk") continue;
36+
const fm = getFrontmatter(path.join(dir, file));
37+
const questNum = file.match(/^(\d+|A\d+)-/);
38+
if (questNum) {
39+
map.set(questNum[1], { file, keys: Object.keys(fm) });
40+
}
41+
}
42+
return map;
43+
}
44+
45+
for (const pair of PAIRS) {
46+
const frDir = path.join(SRC, pair.fr);
47+
const enDir = path.join(SRC, pair.en);
48+
const frMap = getSlugMap(frDir);
49+
const enMap = getSlugMap(enDir);
50+
51+
// Check FR files have EN equivalent
52+
for (const [num, info] of frMap) {
53+
if (!enMap.has(num)) {
54+
console.error(`MISSING EN: ${pair.fr}/${info.file} has no English equivalent (quest ${num})`);
55+
errors++;
56+
}
57+
}
58+
59+
// Check EN files have FR equivalent
60+
for (const [num, info] of enMap) {
61+
if (!frMap.has(num)) {
62+
console.error(`MISSING FR: ${pair.en}/${info.file} has no French equivalent (quest ${num})`);
63+
errors++;
64+
}
65+
}
66+
67+
// Check frontmatter key parity for matched pairs
68+
for (const [num, frInfo] of frMap) {
69+
const enInfo = enMap.get(num);
70+
if (!enInfo) continue;
71+
72+
const frKeys = new Set(frInfo.keys);
73+
const enKeys = new Set(enInfo.keys);
74+
const requiredKeys = ["layout", "lang", "title", "arc", "quest_number", "permalink"];
75+
76+
for (const key of requiredKeys) {
77+
if (frKeys.has(key) && !enKeys.has(key)) {
78+
console.error(`KEY MISMATCH: quest ${num} - "${key}" in FR but missing in EN`);
79+
errors++;
80+
}
81+
if (enKeys.has(key) && !frKeys.has(key)) {
82+
console.error(`KEY MISMATCH: quest ${num} - "${key}" in EN but missing in FR`);
83+
errors++;
84+
}
85+
}
86+
}
87+
}
88+
89+
if (errors > 0) {
90+
console.error(`\ni18n parity: ${errors} issue(s) found`);
91+
process.exit(1);
92+
} else {
93+
console.log(`i18n parity: all ${PAIRS.map(p => getSlugMap(path.join(SRC, p.fr)).size).reduce((a, b) => a + b, 0)} pairs matched`);
94+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Check that all internal href links in the built _site/ resolve
4+
* to actual files. Catches broken links before deployment.
5+
*
6+
* Must run AFTER eleventy build (_site/ must exist).
7+
*/
8+
9+
const fs = require("fs");
10+
const path = require("path");
11+
12+
const SITE = path.resolve(__dirname, "../../_site");
13+
14+
if (!fs.existsSync(SITE)) {
15+
console.error("_site/ not found. Run 'npx eleventy' first.");
16+
process.exit(1);
17+
}
18+
19+
let errors = 0;
20+
let linksChecked = 0;
21+
let filesChecked = 0;
22+
23+
function getAllHtmlFiles(dir) {
24+
const results = [];
25+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
26+
const full = path.join(dir, entry.name);
27+
if (entry.isDirectory()) {
28+
results.push(...getAllHtmlFiles(full));
29+
} else if (entry.name.endsWith(".html")) {
30+
results.push(full);
31+
}
32+
}
33+
return results;
34+
}
35+
36+
function extractInternalLinks(content) {
37+
const links = [];
38+
const regex = /href="(\/[^"#?]*)/g;
39+
let match;
40+
while ((match = regex.exec(content)) !== null) {
41+
links.push(match[1]);
42+
}
43+
return links;
44+
}
45+
46+
function linkResolves(href) {
47+
// /fr/quetes/01-la-guilde-des-archivistes/ -> _site/fr/quetes/01-.../index.html
48+
let target = path.join(SITE, href);
49+
50+
// If it ends with /, look for index.html
51+
if (href.endsWith("/")) {
52+
target = path.join(target, "index.html");
53+
}
54+
55+
// Check exact file or directory with index.html
56+
if (fs.existsSync(target)) return true;
57+
58+
// Try adding .html
59+
if (fs.existsSync(target + ".html")) return true;
60+
61+
// Try as directory with index.html
62+
if (fs.existsSync(path.join(target, "index.html"))) return true;
63+
64+
return false;
65+
}
66+
67+
const htmlFiles = getAllHtmlFiles(SITE);
68+
69+
for (const file of htmlFiles) {
70+
const content = fs.readFileSync(file, "utf8");
71+
const links = extractInternalLinks(content);
72+
const relFile = path.relative(SITE, file);
73+
filesChecked++;
74+
75+
for (const href of links) {
76+
linksChecked++;
77+
if (!linkResolves(href)) {
78+
console.error(`BROKEN: ${relFile} -> ${href}`);
79+
errors++;
80+
}
81+
}
82+
}
83+
84+
if (errors > 0) {
85+
console.error(`\nInternal links: ${errors} broken link(s) found (${linksChecked} checked in ${filesChecked} files)`);
86+
process.exit(1);
87+
} else {
88+
console.log(`Internal links: ${linksChecked} links OK in ${filesChecked} files`);
89+
}

src/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
<!DOCTYPE html>
2-
<html><head><meta http-equiv="refresh" content="0;url=/fr/"></head><body></body></html>
2+
<html lang="fr"><head><meta http-equiv="refresh" content="0;url=/fr/"></head><body></body></html>

0 commit comments

Comments
 (0)