ultimate-jekyll-manager
Version:
Ultimate Jekyll dependency manager
572 lines (482 loc) • 17.4 kB
JavaScript
// Libraries
const Manager = new (require('../../build.js'));
const logger = Manager.logger('audit');
const { series } = require('gulp');
const glob = require('glob').globSync;
const path = require('path');
const jetpack = require('fs-jetpack');
const spellchecker = require('spellchecker');
const cheerio = require('cheerio');
const { HtmlValidate } = require('html-validate');
const { XMLParser } = require('fast-xml-parser');
const chromeLauncher = require('chrome-launcher');
const lighthouse = require('lighthouse').default || require('lighthouse');
const { execute } = require('node-powertools');
// Utils
const collectTextNodes = require('./utils/collectTextNodes');
const dictionary = require('./utils/dictionary');
const formatDocument = require('./utils/formatDocument');
// Load package
const package = Manager.getPackage('main');
const project = Manager.getPackage('project');
const config = Manager.getConfig('project');
const rootPathPackage = Manager.getRootPath('main');
const rootPathProject = Manager.getRootPath('project');
// Glob
const input = [
// Files to include
'_site/**/*.{html,xml}',
];
const output = '';
const delay = 250;
// Task
async function audit(complete) {
// Log
logger.log('Starting...');
// Quit if NOT in build mode and UJ_AUDIT_FORCE is not true
if (!Manager.isBuildMode() && process.env.UJ_AUDIT_FORCE !== 'true') {
logger.log('Skipping audit in development mode');
return complete();
}
// Perform HTML/XML audit
await processAudit();
// Real quick, lets check if were in build mode and IF NOT then we run minifyHtml
if (!Manager.isBuildMode()) {
const minifyHtml = require('./minifyHtml');
await new Promise((resolve) => {
minifyHtml(resolve);
});
}
// Perform Lighthouse audit if URL is provided
if (process.env.UJ_AUDIT_LIGHTHOUSE_URL) {
await runLighthouseAudit();
}
// Log
logger.log('Finished!');
// Exit process if UJ_AUDIT_AUTOEXIT is set
if (process.env.UJ_AUDIT_AUTOEXIT === 'true') {
logger.log('Auto-exiting after audit completion...');
process.exit(0);
}
// Complete
return complete();
};
// Default Task
module.exports = series(audit);
async function validateFormat(file, content) {
// Log
// logger.log(`➡️ Validating HTML in ${file}`);
// Skip any file that is a blog post
// if (file.includes('/blog/')) {
// return { valid: true, messages: [] };
// }
// Initialize an array to hold formatted messages
let valid = true;
let formattedMessages = [];
// Get format
const format = file.endsWith('.html')
? 'html'
: 'xml';
// Run pretty validation and HTML/XML validation in parallel
const [prettyValidationResult, validationResult] = await Promise.all([
(async () => {
try {
// Format the content using Prettier
const formatted = await formatDocument(content, format);
// Save the formatted content back to the file
jetpack.write(file, formatted.content);
// Quit if there is an error
if (formatted.error) {
throw formatted.error;
}
return { valid: true, messages: [] };
} catch (e) {
return { valid: false, messages: [`[format] ${format.toUpperCase()} is not well-formatted @ ${file} \n${e.message}`] };
}
})(),
(async () => {
if (format === 'html') {
const validator = new HtmlValidate({
root: true,
extends: ['html-validate:recommended'],
rules: {
// Custom rules
'no-inline-style': 'error',
'void-style': ['error', { style: 'selfclosing' }],
'prefer-button': 'warn',
'doctype-style': 'error',
'no-dup-id': 'error',
// Ignore certain rules for this audit
'no-conditional-comment': 'off',
'no-trailing-whitespace': 'off',
'no-inline-style': 'off',
'script-type': 'off',
}
});
const report = await validator.validateString(content);
const results = report.results[0];
const messages = results?.messages || [];
return {
valid: report.valid,
messages: messages.map(msg => {
return `[${msg.ruleId}] ${msg.message} @ ${file}:${msg.line}:${msg.column} (${msg.ruleUrl})`;
})
};
} else if (format === 'xml') {
try {
const parser = new XMLParser({
ignoreAttributes: false,
allowBooleanAttributes: true
});
parser.parse(content);
return { valid: true, messages: [] };
} catch (e) {
return { valid: false, messages: [`[format] ${format.toUpperCase()} is not well-formatted @ ${file} \n${e.message}`] };
}
}
})()
]);
// Combine results
valid = prettyValidationResult.valid && validationResult.valid;
formattedMessages.push(...prettyValidationResult.messages, ...validationResult.messages);
// Return validation result
return {
valid,
messages: formattedMessages,
};
}
async function validateSpelling(file, content) {
// Log
// logger.log(`➡️ Validating spelling in ${file}`);
const $ = cheerio.load(content);
const textNodes = collectTextNodes($);
const brand = (config?.brand?.name || 'BrandName').toLowerCase();
const misspelledWords = textNodes.flatMap(({ text }) => {
// Split text into words using regex
const words = text.match(/\b[\w’']+\b/g) || [];
// Filter out words that are part of the brand name or are not misspelled
return words
.filter(word => {
const lowerWord = word.toLowerCase();
const baseWord = lowerWord.endsWith("'s") ? lowerWord.slice(0, -2) : lowerWord; // Remove possessive 's if present
if (
baseWord === brand ||
dictionary.includes(baseWord)
) {
return false;
}
return spellchecker.isMisspelled(word);
})
.map(word => {
// Find the sentence containing the word
const lines = content.split('\n');
let lineIndex = 0;
let column = 0;
// Iterate through lines to find the full text
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const textIndex = line.indexOf(text);
if (textIndex !== -1) {
lineIndex = i + 1; // Convert to 1-based index
column = textIndex + 1; // Convert to 1-based index
break;
}
}
return `[spelling] ${word} in "${text}" @ ${file}:${lineIndex}:${column}`;
});
});
return {
valid: misspelledWords.length === 0,
misspelledWords,
};
}
async function processAudit() {
const htmlFiles = glob(input, {
nodir: true,
ignore: [
// Auth files
'_site/__/auth/**/*',
// Sitemap
'**/sitemap.html',
]
});
// Run validations in parallel
const results = await Promise.all(
htmlFiles.map(async (file) => {
const content = jetpack.read(file);
// Run format and spellcheck in parallel
const [formatValidation, spellingValidation] = await Promise.all([
validateFormat(file, content),
validateSpelling(file, content)
]);
return {
file,
formatValidation,
spellingValidation
};
})
);
// Log results
const summary = {
totalFiles: htmlFiles.length,
validFiles: 0,
invalidFiles: 0,
formatErrors: [],
spellingErrors: []
};
results.forEach(({ file, formatValidation, spellingValidation }) => {
logger.log(`🔍 Results for file: ${file}`);
if (formatValidation.valid) {
logger.log(`✅ Format validation passed.`);
} else {
logger.log(`❌ Format validation failed:`);
console.log(format(formatValidation.messages));
summary.formatErrors.push({
file,
messages: formatValidation.messages
});
}
if (spellingValidation.valid) {
logger.log(`✅ Spelling validation passed.`);
} else {
logger.log(`❌ Spelling validation failed:`);
console.log(format(spellingValidation.misspelledWords));
summary.spellingErrors.push({
file,
misspelledWords: spellingValidation.misspelledWords
});
}
if (formatValidation.valid && spellingValidation.valid) {
summary.validFiles++;
} else {
summary.invalidFiles++;
}
});
// Save validation results to validator folder
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const validatorDir = `${rootPathProject}/.temp/audit/validator`;
// Ensure validator directory exists
jetpack.dir(validatorDir);
// Save validation summary
const summaryPath = `${validatorDir}/validation-${timestamp}.json`;
jetpack.write(summaryPath, JSON.stringify(summary, null, 2));
logger.log(`📄 Validation report saved to: ${summaryPath}`);
// Save latest validation
const latestPath = `${validatorDir}/latest-validation.json`;
jetpack.write(latestPath, JSON.stringify({
timestamp: new Date().toISOString(),
summary
}, null, 2));
// Log summary
logger.log('Audit Summary:', summary);
}
function format(messages) {
if (!Array.isArray(messages)) {
return messages;
}
return messages.map(msg => `- ${msg}`).join('\n');
}
// Lighthouse audit functionality
async function runLighthouseAudit() {
logger.log('📊 Starting Lighthouse audit...');
let serverStarted = false;
let auditUrl = null;
try {
// Check if index.html exists
const indexPath = path.join(rootPathProject, '_site', 'index.html');
if (!jetpack.exists(indexPath)) {
logger.error('❌ Could not find _site/index.html. Make sure Jekyll build completed successfully.');
return;
}
// Get the URL to test (default to homepage if not specified)
const customUrl = process.env.UJ_AUDIT_LIGHTHOUSE_URL || '/';
// Check if it's just a path (starts with /)
if (customUrl.startsWith('/')) {
// Try to get the working URL and append the path
try {
const baseUrl = Manager.getWorkingUrl();
if (baseUrl && baseUrl.includes(':')) {
auditUrl = new URL(customUrl, baseUrl).href;
logger.log(`Using path with working URL: ${auditUrl}`);
} else {
// No working server yet, will need to start one
auditUrl = null;
}
} catch (e) {
// No server running yet
auditUrl = null;
}
} else {
// It's a full URL
auditUrl = customUrl;
logger.log(`Using custom Lighthouse URL: ${auditUrl}`);
}
// If we couldn't set a URL from the custom path, try to get the working URL
if (!auditUrl) {
// Try to get the working URL (from BrowserSync if it's running)
try {
auditUrl = Manager.getWorkingUrl();
// Check if it's a local URL with a port (indicates server is running)
if (auditUrl && auditUrl.includes(':')) {
const urlObj = new URL(auditUrl);
if (urlObj.port) {
logger.log(`Using existing server at ${auditUrl}`);
}
}
} catch (e) {
// No server running yet
}
}
// If no working server and no custom URL, we need to ensure BrowserSync is running
if (!auditUrl || !auditUrl.includes(':')) {
logger.log('Ensuring BrowserSync server is running for Lighthouse audit...');
// Run serve task (it will check if already running)
const serve = require('./serve');
await new Promise((resolve) => {
serve(resolve);
});
// Wait for server to be ready and get the URL
const maxRetries = 30;
let retries = 0;
while (retries < maxRetries) {
try {
auditUrl = Manager.getWorkingUrl();
if (auditUrl && auditUrl.includes(':')) {
const urlObj = new URL(auditUrl);
if (urlObj.port) {
logger.log(`Server ready at ${auditUrl}`);
break;
}
}
} catch (e) {
// Continue waiting
}
retries++;
if (retries % 5 === 0) {
logger.log(`Waiting for server... (attempt ${retries}/${maxRetries})`);
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
if (!auditUrl || !auditUrl.includes(':')) {
throw new Error('Failed to get server URL for Lighthouse audit');
}
// Append the path now that the server is running
const pathToTest = process.env.UJ_AUDIT_LIGHTHOUSE_URL || '/';
if (pathToTest.startsWith('/')) {
auditUrl = new URL(pathToTest, auditUrl).href;
logger.log(`Using path with server: ${auditUrl}`);
}
}
logger.log(`Running Lighthouse on ${auditUrl}`);
// Lighthouse configuration
const lighthouseOptions = {
logLevel: 'info',
output: ['html', 'json'],
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
skipAudits: [
// Skip these audits since local doesn't support HTTP/2
'uses-http2',
],
port: 9222,
};
const chromeFlags = ['--headless', '--disable-gpu', '--no-sandbox'];
// Launch Chrome
const chrome = await chromeLauncher.launch({ chromeFlags });
lighthouseOptions.port = chrome.port;
// Run Lighthouse
const runnerResult = await lighthouse(auditUrl, lighthouseOptions);
// Kill Chrome
await chrome.kill();
// Process results
const { lhr, report } = runnerResult;
// Extract scores
const scores = {
performance: Math.round(lhr.categories.performance.score * 100),
accessibility: Math.round(lhr.categories.accessibility.score * 100),
bestPractices: Math.round(lhr.categories['best-practices'].score * 100),
seo: Math.round(lhr.categories.seo.score * 100),
};
// Log scores
logger.log('📊 Lighthouse Scores:');
logger.log(` 🚀 Performance: ${getScoreEmoji(scores.performance)} ${scores.performance}/100`);
logger.log(` ♿ Accessibility: ${getScoreEmoji(scores.accessibility)} ${scores.accessibility}/100`);
logger.log(` ✅ Best Practices: ${getScoreEmoji(scores.bestPractices)} ${scores.bestPractices}/100`);
logger.log(` 🔍 SEO: ${getScoreEmoji(scores.seo)} ${scores.seo}/100`);
// Calculate average
const average = Math.round((scores.performance + scores.accessibility + scores.bestPractices + scores.seo) / 4);
logger.log(` 📈 Average: ${getScoreEmoji(average)} ${average}/100`);
// Save reports
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const reportDir = `${rootPathProject}/.temp/audit/lighthouse`;
// Ensure report directory exists
jetpack.dir(reportDir);
// Save HTML report
let htmlPath = null;
if (report[0]) {
htmlPath = `${reportDir}/lighthouse-${timestamp}.html`;
jetpack.write(htmlPath, report[0]);
logger.log(`📄 HTML report saved to: ${htmlPath}`);
}
// Save JSON report
if (report[1]) {
const jsonPath = `${reportDir}/lighthouse-${timestamp}.json`;
jetpack.write(jsonPath, report[1]);
logger.log(`📄 JSON report saved to: ${jsonPath}`);
}
// Save latest scores
const scoresPath = `${reportDir}/latest-scores.json`;
jetpack.write(scoresPath, JSON.stringify({
timestamp: new Date().toISOString(),
url: auditUrl,
scores,
average
}, null, 2));
// Check if scores meet minimum thresholds
const minScores = {
performance: process.env.LIGHTHOUSE_MIN_PERFORMANCE || 70,
accessibility: process.env.LIGHTHOUSE_MIN_ACCESSIBILITY || 90,
bestPractices: process.env.LIGHTHOUSE_MIN_BEST_PRACTICES || 80,
seo: process.env.LIGHTHOUSE_MIN_SEO || 80,
};
let failed = false;
Object.keys(minScores).forEach(key => {
const scoreKey = key === 'bestPractices' ? 'bestPractices' : key;
if (scores[scoreKey] < minScores[key]) {
logger.error(`❌ ${key} score (${scores[scoreKey]}) is below minimum threshold (${minScores[key]})`);
failed = true;
}
});
if (failed && process.env.LIGHTHOUSE_STRICT === 'true') {
throw new Error('Lighthouse scores below minimum thresholds');
}
// Open the HTML report in the default browser
if (htmlPath && process.env.LIGHTHOUSE_OPEN_REPORT !== 'false') {
const openCommand = process.platform === 'darwin' ? 'open' :
process.platform === 'win32' ? 'start' : 'xdg-open';
try {
await execute(`${openCommand} "${htmlPath}"`, { log: false });
logger.log('📊 Opening Lighthouse report in browser...');
} catch (err) {
logger.log(`💡 To view the report, open: ${htmlPath}`);
}
}
} catch (error) {
logger.error('Lighthouse audit failed:', error);
if (process.env.LIGHTHOUSE_STRICT === 'true') {
throw error;
}
} finally {
// Note: We don't stop BrowserSync here since it might be used by other tasks
// or the user might want to keep it running
if (serverStarted) {
logger.log('Note: BrowserSync server is still running. Stop it manually if needed.');
}
}
logger.log('Lighthouse audit complete!');
}
// Helper function to get emoji based on score
function getScoreEmoji(score) {
if (score >= 90) return '🟢';
if (score >= 50) return '🟡';
return '🔴';
}