ultimate-jekyll-manager
Version:
Ultimate Jekyll dependency manager
486 lines (401 loc) • 14.6 kB
JavaScript
// Libraries
const Manager = new (require('../../build.js'));
const logger = Manager.logger('sass');
const { src, dest, watch, series } = require('gulp');
const glob = require('glob').globSync;
const path = require('path');
const jetpack = require('fs-jetpack');
const compiler = require('gulp-sass')(require('sass'));
const cleanCSS = require('gulp-clean-css');
const rename = require('gulp-rename');
const filter = require('gulp-filter').default;
const { template } = require('node-powertools');
const yaml = require('js-yaml');
const postcss = require('gulp-postcss');
const purgeCss = require('@fullhuman/postcss-purgecss').default;
// 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');
// Const
const PAGE_IMPORT = `
/* Main CSS for { pagePath } */
html[data-page-path="{ pagePath }"] {
{ mainImport }
}
/* Project CSS for { pagePath } */
html[data-page-path="{ pagePath }"] {
{ projectImport }
}
`
// Glob
const input = [
// Project entry point
'src/assets/css/main.scss',
// Page-specific CSS
`${rootPathPackage}/dist/assets/css/pages/**/*.scss`,
'src/assets/css/pages/**/*.scss',
// Files to exclude
// '!dist/**',
];
// Additional files to watch (but not compile as entry points)
const watchInput = [
// Watch the paths we're compiling
...input,
// Core CSS - watch for changes but don't compile as entry points
`${rootPathPackage}/dist/assets/css/**/*.scss`,
// Theme CSS - watch for changes but don't compile as entry points
`${rootPathPackage}/dist/assets/themes/**/*.scss`,
'src/assets/themes/**/*.scss',
];
const output = 'dist/assets/css';
const delay = 250;
const compiled = {};
// Configuration
const MAIN_BUNDLE_PAGE_PARTIALS = false; // Set to true to merge pages into _page-specific.scss, false to compile separately
// Enable PurgeCSS via environment variable or in production mode
const ENABLE_PURGECSS = Manager.isBuildMode() || process.env.UJ_PURGECSS === 'true';
// SASS Compilation Task
function sass(complete) {
// Log
logger.log('Starting...');
// Generate pages scss
generatePageScss();
// Compile
let stream = src(input, { sourcemaps: true })
// Skip files based on configuration
.pipe(filter(file => !shouldSkip(file.path), { restore: true }))
// Compile SASS
.pipe(compiler({
loadPaths: [
// So we can use "@use 'ultimate-jekyll-manager' as *;"
path.resolve(rootPathPackage, 'dist/assets/css'),
// So we can use "@use 'themes/{theme}' as *;" in the project
path.resolve(rootPathPackage, 'dist/assets/themes', config.theme.id),
// So we can load _pages.scss from the project's dist
path.resolve(rootPathProject, 'dist/assets/css'),
// TODO: Add more load paths like node_modules for things like fontawesome
// path.resolve(rootPathProject, 'node_modules'),
],
// Suppress deprecation warnings from Bootstrap
quietDeps: true,
// Only show warnings once
verbose: false
})
.on('error', (error) => Manager.reportBuildError(Object.assign(error, { plugin: 'SASS' }), complete)));
// Apply PurgeCSS if enabled
if (ENABLE_PURGECSS) {
logger.log('PurgeCSS enabled - removing unused CSS');
// Define content patterns for PurgeCSS
const contentPatterns = [
// All Ultimate Jekyll defaults EXCEPT themes subdirectories
`${rootPathPackage}/dist/defaults/**/*.{html,liquid,md}`,
// Explicitly exclude ALL theme directories, then include only the active theme
`!${rootPathPackage}/dist/defaults/**/_includes/themes/**`,
`!${rootPathPackage}/dist/defaults/**/_layouts/themes/**`,
// Exclude test pages that include components we don't normally use (would prevent PurgeCSS from working)
`!${rootPathPackage}/dist/defaults/**/pages/test/**/*.{html,liquid,md}`,
// Include ONLY the active theme's files
`${rootPathPackage}/dist/defaults/**/_includes/themes/${config.theme.id}/**/*.{html,liquid,md}`,
`${rootPathPackage}/dist/defaults/**/_layouts/themes/${config.theme.id}/**/*.{html,liquid,md}`,
// Project HTML
'src/**/*.{html,liquid,md}',
'dist/**/*.{html,liquid,md}',
// Main JS
`${rootPathPackage}/dist/assets/js/**/*.js`,
`${rootPathPackage}/node_modules/web-manager/**/*.js`,
// Theme JS
`${rootPathPackage}/dist/assets/themes/${config.theme.id}/**/*.js`,
// Project JS
'src/assets/js/**/*.js',
];
// // Log the files that will be analyzed
// logger.log('PurgeCSS content patterns:', contentPatterns);
// // Separate inclusion and exclusion patterns for glob
// const includePatterns = contentPatterns.filter(p => !p.startsWith('!'));
// const excludePatterns = contentPatterns.filter(p => p.startsWith('!')).map(p => p.substring(1));
// // Use glob to get the actual files (respecting exclusions)
// const allFiles = glob(includePatterns, { ignore: excludePatterns });
// logger.log(`PurgeCSS will analyze ${allFiles.length} total files:`);
// // Group files by type for better readability
// const fileGroups = {
// 'HTML/Liquid/MD files': allFiles.filter(f => /\.(html|liquid|md)$/.test(f)),
// 'JavaScript files': allFiles.filter(f => /\.js$/.test(f))
// };
// Object.entries(fileGroups).forEach(([groupName, files]) => {
// if (files.length > 0) {
// logger.log(` ${groupName}: ${files.length} files`);
// // Show first 5 files as examples
// files.forEach(file => {
// logger.log(` - ${file}`);
// });
// }
// });
// Apply PurgeCSS
stream = stream.pipe(postcss([
purgeCss({
content: contentPatterns,
// Safelist patterns for dynamic classes
safelist: {
standard: [
// Bootstrap JavaScript components
/^modal-/,
/^bs-/,
/^data-bs-/,
/^carousel-/,
/^collapse/,
/^dropdown-/,
/^offcanvas-/,
/^tooltip-/,
/^popover-/,
/^toast-/,
/^show$/,
/^showing$/,
/^hide$/,
/^fade$/,
/^active$/,
/^disabled$/,
// Accordion specific
/^accordion/,
/^collapsed$/,
/^collapsing$/,
// Common dynamic classes
/^is-/,
/^has-/,
/^was-/,
// Animations
/^animation-/,
// Utilities that might be added dynamically
/^[mp][trblxy]?-[0-9]+$/,
/^text-/,
/^bg-/,
/^border-/,
/^rounded-/,
/^shadow-/,
/^d-/,
/^flex-/,
/^justify-/,
/^align-/,
/^order-/,
/^overflow-/,
/^position-/,
/^w-/,
/^h-/,
/^mw-/,
/^mh-/,
/^min-/,
/^max-/,
// Libraries
// Cookies
/^cookie-consent-/,
// Font Awesome
/^fa-/,
// Lazy
/^lazy-/,
// Social
/^social-share-/,
],
deep: [],
greedy: [],
// Preserve keyframe animations
keyframes: [
/^spinner-/,
// /^accordion/,
// /^fade-/,
// /^slide-/,
// /^collapse/
]
},
// Don't remove CSS variables
variables: true,
// Keep keyframes
keyframes: true,
// Keep font-face rules
fontFace: true
})
]));
}
// Process
return stream
.pipe(cleanCSS({
format: Manager.actLikeProduction() ? 'compressed' : 'beautify',
}))
.pipe(rename((file) => {
// Add bundle to the name
file.basename += '.bundle';
// If its NOT basename === main.bundle, add pages/ before dirname
if (file.basename !== 'main.bundle') {
file.dirname = `pages/${file.dirname}`;
}
// Track the full output path
const fullPath = path.resolve(output, file.dirname, `${file.basename}${file.extname}`);
compiled[fullPath] = true;
}))
.pipe(dest(output, { sourcemaps: '.' }))
// .pipe(dest(output))
.on('finish', () => {
// Log
logger.log('Finished!');
// Trigger rebuild
Manager.triggerRebuild(compiled);
// Complete
return complete();
});
}
// Watcher Task
function sassWatcher(complete) {
// Quit if in build mode
if (Manager.isBuildMode()) {
logger.log('[watcher] Skipping watcher in build mode');
return complete();
}
// Log
logger.log('[watcher] Watching for changes...');
// Watch for changes
watch(watchInput, { delay: delay, dot: true }, sass)
.on('change', (path) => {
logger.log(`[watcher] File changed (${path})`);
});
// Complete
return complete();
}
function parseFrontmatter(filePath) {
try {
const content = jetpack.read(filePath);
if (!content) return null;
// Check if file has frontmatter
if (!content.startsWith('---')) return null;
// Find the end of frontmatter
const frontmatterEnd = content.indexOf('---', 3);
if (frontmatterEnd === -1) return null;
// Extract frontmatter content
const frontmatterContent = content.substring(3, frontmatterEnd).trim();
// Parse YAML frontmatter
const frontmatter = yaml.load(frontmatterContent);
return frontmatter || {};
} catch (error) {
logger.log(`Error parsing frontmatter for ${filePath}:`, error.message);
return null;
}
}
function getPagePermalink(pageFilePath) {
const frontmatter = parseFrontmatter(pageFilePath);
if (frontmatter && frontmatter.permalink) {
return frontmatter.permalink;
}
// Fallback to path-based permalink
const relativePath = pageFilePath.replace(/.*\/pages\//, '/').replace(/\.(md|html)$/, '');
return relativePath === '/index' ? '/' : relativePath;
}
function findPageFiles() {
const pagePatterns = [
// Main package pages
`${rootPathPackage}/dist/defaults/dist/pages/**/*.{md,html}`,
// Project package pages
`${rootPathProject}/src/pages/**/*.{md,html}`,
];
const pageFiles = [];
pagePatterns.forEach(pattern => {
const files = glob(pattern);
pageFiles.push(...files.map(f => path.resolve(f)));
});
return pageFiles;
}
function generatePageScss() {
// Only generate _page-specific.scss if we're skipping page partials
if (!MAIN_BUNDLE_PAGE_PARTIALS) {
// When compiling pages separately, we can either:
// 1. Create an empty file with a comment
// 2. Create imports to the compiled page bundles (for backwards compatibility)
const outputPath = path.resolve(rootPathProject, 'dist/assets/css/_page-specific.scss');
const content = '/*\n AUTO-GENERATED PAGE-SPECIFIC SCSS\n Pages are now compiled separately when MAIN_BUNDLE_PAGE_PARTIALS = false\n Find compiled page CSS in dist/assets/css/pages/\n*/\n\n';
jetpack.write(outputPath, content);
Manager.triggerRebuild(outputPath);
return;
}
// Original behavior when MAIN_BUNDLE_PAGE_PARTIALS is true
// Get all page files to extract permalinks
const pageFiles = findPageFiles();
// Create a map to store the pages based on their permalinks
const pagesMap = {};
// Process each page file to find its permalink and corresponding SCSS files
pageFiles.forEach((pageFile) => {
const permalink = getPagePermalink(pageFile);
// Skip if we already processed this permalink
if (pagesMap[permalink]) {
return;
}
// Look for corresponding SCSS files based on the permalink
const scssBasePath = permalink === '/' ? '/index' : permalink;
// Construct potential SCSS file paths
const mainScssPath = path.resolve(rootPathPackage, `dist/assets/css/pages${scssBasePath}/index.scss`);
const projectScssPath = path.resolve(rootPathProject, `src/assets/css/pages${scssBasePath}/index.scss`);
// Check if SCSS files exist
const mainExists = jetpack.exists(mainScssPath);
const projectExists = jetpack.exists(projectScssPath);
// Only add to map if at least one SCSS file exists
if (mainExists || projectExists) {
pagesMap[permalink] = {
main: mainExists ? mainScssPath : null,
project: projectExists ? projectScssPath : null
};
}
});
// Set the output file
let content = '/*\n AUTO-GENERATED PAGE-SPECIFIC SCSS\n*/\n\n';
// Loop through each page and generate the import statement
Object.entries(pagesMap).forEach(([page, paths]) => {
// Skip if both paths are not found
if (!paths.main && !paths.project) {
return
};
const mainContent = paths.main ? jetpack.read(paths.main) : null;
const projectContent = paths.project ? jetpack.read(paths.project) : null;
// Update the file with the correct import paths
content += template(PAGE_IMPORT, {
pagePath: page,
// mainImport: paths.main ? `@use '${paths.main}';` : '// Not found',
// projectImport: paths.project ? `@use '${paths.project}';` : '// Not found',
mainImport: mainContent ? indent(mainContent, 2) : '// Not found',
projectImport: projectContent ? indent(projectContent, 2) : '// Not found',
});
});
// Get the output path
const outputPath = path.resolve(rootPathProject, 'dist/assets/css/_page-specific.scss');
// Log
logger.log('Found pages:', Object.keys(pagesMap));
// logger.log('Generated content:', content);
// logger.log('Output path:', outputPath);
// Write the content to the output file
jetpack.write(outputPath, content);
// Trigger a rebuild
Manager.triggerRebuild(outputPath);
}
function isPagePartial(file) {
return file.includes('/assets/css/pages/') && file.endsWith('index.scss');
}
function shouldSkip(file) {
// Skip page partials only if MAIN_BUNDLE_PAGE_PARTIALS is true
if (MAIN_BUNDLE_PAGE_PARTIALS && isPagePartial(file)) {
return true;
}
return false;
}
// Indentify the output
function indent(str, spaces) {
const indent = ' '.repeat(spaces);
return str
.trim()
.split('\n')
.map(line => line.trim() ? `${indent}${line}` : '')
.join('\n');
}
// Default Task
// Export
module.exports = series(
// Manager.wrapTask('sass', sass),
sass,
sassWatcher
);