ultimate-jekyll-manager
Version:
Ultimate Jekyll dependency manager
583 lines (491 loc) • 18.1 kB
JavaScript
// Libraries
const Manager = new (require('../../build.js'));
const logger = Manager.logger('defaults');
const { src, dest, watch, series } = require('gulp');
const through2 = require('through2');
const jetpack = require('fs-jetpack');
const path = require('path');
const { minimatch } = require('minimatch');
const { template } = require('node-powertools');
const createTemplateTransform = require('./utils/template-transform');
const argv = require('yargs').argv;
const JSON5 = require('json5');
// 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');
// Constants
const LOUD = process.env.UJ_LOUD_LOGS === 'true';
// Load ultimate-jekyll-manager.json config
const ujConfigPath = path.join(rootPathPackage, 'dist/defaults/config/ultimate-jekyll-manager.json');
const ujConfig = jetpack.exists(ujConfigPath) ? JSON5.parse(jetpack.read(ujConfigPath)) : {};
// Get clean versions
// const cleanVersions = { versions: Manager.getCleanVersions()};
const cleanVersions = { versions: package.engines };
// File MAP
const FILE_MAP = {
// Files to skip overwrite
'**/*.md': {
overwrite: false,
},
'hooks/**/*': {
overwrite: false,
},
'src/**/*': {
overwrite: false,
},
'src/**/*.{html,md,json}': {
skip: (file) => {
// Get the name
const name = path.basename(file.name, path.extname(file.name));
// file.destination is relative to project root (e.g., "src/pages")
// Check if consuming project has equivalent file
const htmlFilePath = path.join(rootPathProject, file.destination, `${name}.html`);
const mdFilePath = path.join(rootPathProject, file.destination, `${name}.md`);
const jsonFilePath = path.join(rootPathProject, file.destination, `${name}.json`);
const htmlFileExists = jetpack.exists(htmlFilePath);
const mdFileExists = jetpack.exists(mdFilePath);
const jsonFileExists = jetpack.exists(jsonFilePath);
const anyExists = htmlFileExists || mdFileExists || jsonFileExists;
// Skip if consuming project has an equivalent file
return anyExists;
},
},
'dist/**/*.{html,md,json}': {
skip: (file) => {
// Get the name and relative path within dist/
const name = path.basename(file.name, path.extname(file.name));
// file.destination is relative to project root (e.g., "dist/pages")
// We need to check if consuming project has equivalent in src/
// e.g., "dist/pages" -> "src/pages"
const srcPath = file.destination.replace(/^dist\//, 'src/');
const htmlFilePath = path.join(rootPathProject, srcPath, `${name}.html`);
const mdFilePath = path.join(rootPathProject, srcPath, `${name}.md`);
const jsonFilePath = path.join(rootPathProject, srcPath, `${name}.json`);
const htmlFileExists = jetpack.exists(htmlFilePath);
const mdFileExists = jetpack.exists(mdFilePath);
const jsonFileExists = jetpack.exists(jsonFilePath);
const anyExists = htmlFileExists || mdFileExists || jsonFileExists;
// Skip if consuming project has an equivalent file in src/
return anyExists;
},
},
// Files to rewrite path
// Removed because getting too confusing
// 'dist/pages/**/*': {
// path: (file) => file.source.replace('dist/pages', 'dist'),
// },
// Files to rename and merge
'_.gitignore': {
name: (file) => file.name.replace('_.gitignore', '.gitignore'),
mergeLines: true, // Merge line-by-line instead of overwriting
},
'_.env': {
name: (file) => file.name.replace('_.env', '.env'),
mergeLines: true, // Merge line-by-line instead of overwriting
},
// Config file with smart merging
'config/ultimate-jekyll-manager.json': {
overwrite: true,
merge: true,
},
// Files to run templating on
'.github/workflows/build.yml': {
template: { ...cleanVersions, ...ujConfig },
},
'.nvmrc': {
template: cleanVersions,
},
'Gemfile': {
template: {
ujPowertoolsVersion: argv.ujPluginDevMode === 'true'
? `path: File.expand_path('~/Developer/Repositories/ITW-Creative-Works/jekyll-uj-powertools')`
: '"~> 1.0"'
},
},
// Always overwrite team images
'src/assets/images/team/**/*': {
overwrite: true,
},
// Files to skip
'**/.DS_Store': {
skip: true,
},
'**/__temp/**/*': {
skip: true,
},
}
// Glob
const input = [
// Files to include
`${rootPathPackage}/dist/defaults/**/*`,
];
const output = './';
const delay = 250;
// Index
let index = -1;
// Helper function to merge line-based files (.gitignore, .env)
function mergeLineBasedFiles(existingContent, newContent, fileName) {
// Parse existing content into lines
const existingLines = existingContent.split('\n');
const newLines = newContent.split('\n');
// For .env files, we track keys; for .gitignore, we track the full line
const isEnvFile = fileName === '.env';
// Markers for separating default values from user custom values
const DEFAULT_SECTION_MARKER = '# ========== Default Values ==========';
const CUSTOM_SECTION_MARKER = '# ========== Custom Values ==========';
// Parse existing file into default section and custom section
let defaultSection = [];
let customSection = [];
let inCustomSection = false;
let inDefaultSection = false;
const existingDefaultKeys = new Set();
const existingCustomKeys = new Set();
for (const line of existingLines) {
const trimmed = line.trim();
// Check for section markers
if (trimmed === DEFAULT_SECTION_MARKER) {
inDefaultSection = true;
inCustomSection = false;
continue;
}
if (trimmed === CUSTOM_SECTION_MARKER) {
inCustomSection = true;
inDefaultSection = false;
continue;
}
// Add to appropriate section
if (inCustomSection) {
customSection.push(line);
// Track custom keys
if (isEnvFile && trimmed && !trimmed.startsWith('#')) {
const key = trimmed.split('=')[0].trim();
if (key) {
existingCustomKeys.add(key);
}
}
} else if (inDefaultSection) {
defaultSection.push(line);
// Track default keys
if (isEnvFile && trimmed && !trimmed.startsWith('#')) {
const key = trimmed.split('=')[0].trim();
if (key) {
existingDefaultKeys.add(key);
}
}
}
}
// Parse new content to build complete default section in order
const newDefaultSection = [];
const newDefaultKeys = new Set();
let inNewDefaultSection = false;
let inNewCustomSection = false;
for (const line of newLines) {
const trimmed = line.trim();
// Check for section markers
if (trimmed === DEFAULT_SECTION_MARKER) {
inNewDefaultSection = true;
inNewCustomSection = false;
continue;
}
if (trimmed === CUSTOM_SECTION_MARKER) {
inNewCustomSection = true;
inNewDefaultSection = false;
continue;
}
// Only process default section from new file
if (inNewDefaultSection) {
// For env files, check if key exists
if (isEnvFile && trimmed && !trimmed.startsWith('#')) {
const key = trimmed.split('=')[0].trim();
if (key) {
newDefaultKeys.add(key);
// If key exists in user's file (either section), skip the default value
if (!existingDefaultKeys.has(key) && !existingCustomKeys.has(key)) {
// New key - add it
newDefaultSection.push(line);
} else {
// Key exists - we'll add user's value later in order
newDefaultSection.push(null); // Placeholder to maintain order
}
} else {
newDefaultSection.push(line);
}
} else {
// Comments and empty lines
newDefaultSection.push(line);
}
}
}
// Now merge user's existing default values in the correct order
const mergedDefaultSection = [];
let defaultSectionIndex = 0;
for (const line of newDefaultSection) {
if (line === null) {
// Placeholder - insert corresponding user value
// Find the next user value
while (defaultSectionIndex < defaultSection.length) {
const userLine = defaultSection[defaultSectionIndex++];
const trimmed = userLine.trim();
if (trimmed && !trimmed.startsWith('#')) {
mergedDefaultSection.push(userLine);
break;
}
}
} else {
mergedDefaultSection.push(line);
}
}
// Find any user-added lines in default section that aren't in new defaults
// These should be moved to custom section
const userAddedToDefaults = [];
for (const line of defaultSection) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
if (isEnvFile) {
// For .env, check if key exists in new defaults
const key = trimmed.split('=')[0].trim();
if (key && !newDefaultKeys.has(key) && !existingCustomKeys.has(key)) {
// User added this key to defaults section - move to custom
userAddedToDefaults.push(line);
}
} else {
// For .gitignore, check if line exists in new defaults
// We need to check if this exact line appears in the new default section
const lineExistsInNewDefaults = newLines.some(newLine => {
return newLine.trim() === trimmed;
});
if (!lineExistsInNewDefaults) {
// User added this line to defaults section - move to custom
userAddedToDefaults.push(line);
}
}
}
// Build final result
const result = [];
// Add default section
result.push(DEFAULT_SECTION_MARKER);
result.push(...mergedDefaultSection);
// Add custom section
result.push('');
result.push(CUSTOM_SECTION_MARKER);
// First add any user lines that were in default section (moved to custom)
if (userAddedToDefaults.length > 0) {
result.push(...userAddedToDefaults);
}
// Then add existing custom section
result.push(...customSection);
return result.join('\n');
}
// Helper function to merge configs intelligently
function mergeConfigs(existingConfig, newConfig) {
const merged = { ...newConfig };
// Always update version to new version
merged.version = newConfig.version;
// Recursively merge nested objects
function mergeNested(target, source, newDefaults) {
for (const key in newDefaults) {
if (Object.prototype.hasOwnProperty.call(newDefaults, key)) {
const newValue = newDefaults[key];
const existingValue = source[key];
const isNewDefault = newValue === 'default' || (typeof newValue === 'object' && newValue !== null);
if (typeof newValue === 'object' && newValue !== null && !Array.isArray(newValue)) {
// Handle nested objects
target[key] = target[key] || {};
mergeNested(target[key], existingValue || {}, newValue);
} else if (Object.prototype.hasOwnProperty.call(source, key) && existingValue !== 'default') {
// User has a custom value, keep it
target[key] = existingValue;
} else {
// User doesn't have this option or has 'default', use new default
target[key] = newValue;
}
}
}
}
mergeNested(merged, existingConfig, newConfig);
return merged;
}
// Main task
function defaults(complete, changedFile) {
// Increment index
index++;
// Log
logger.log('Starting...');
Manager.logMemory(logger, 'Start');
// Use changedFile if provided, otherwise use all inputs
const filesToProcess = changedFile ? [changedFile] : input;
logger.log('input', filesToProcess)
// Log files being used
logger.log('Files being used:');
// Complete
// return src(input, { base: 'src' })
return src(filesToProcess, { base: `${rootPathPackage}/dist/defaults`, dot: true, encoding: false }) // Add base to preserve directory structure
// .pipe(through2.obj(function (file, _, callback) {
// // Log each file being processed
// logger.log(` - ${file.path}`);
// callback(null, file);
// }))
.pipe(customTransform())
.pipe(createTemplateTransform({site: config}))
.pipe(dest(output, { encoding: false }))
.on('finish', () => {
// Log
logger.log('Finished!');
// Complete
return complete();
});
}
function customTransform() {
return through2.obj(function (file, _, callback) {
// Skip if it's a directory
if (file.isDirectory()) {
return callback(null, file);
}
// If the file is named .gitkeep, create the directory but don't copy the file
if (path.basename(file.path) === '.gitkeep') {
jetpack.dir(path.dirname(path.join(output, path.relative(file.base, file.path))));
return callback();
}
// Get relative path
const relativePath = path.relative(file.base, file.path).replace(/\\/g, '/');
// Check if this is a binary file BEFORE any processing
const isBinaryFile = /\.(jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|otf|eot|pdf|zip|tar|gz|mp3|mp4|avi|mov)$/i.test(file.path);
// Build item
const item = {
source: path.dirname(file.path),
name: path.basename(file.path),
destination: path.dirname(relativePath),
};
const options = getFileOptions(relativePath);
// Handle dynamic rename
if (typeof options.name === 'function') {
item.name = options.name(item);
}
// Handle dynamic path
if (typeof options.path === 'function') {
item.destination = options.path(item);
}
// Handle overwrite/skip as functions
if (typeof options.overwrite === 'function') {
options.overwrite = options.overwrite(item);
}
if (typeof options.skip === 'function') {
options.skip = options.skip(item);
}
// Final relative path
const finalRelativePath = path.join(item.destination, item.name);
const fullOutputPath = path.join(output, finalRelativePath);
// Check existence
const exists = jetpack.exists(fullOutputPath);
// Handle config merging
if (options.merge && exists && !isBinaryFile) {
try {
const existingContent = jetpack.read(fullOutputPath);
const newContent = file.contents.toString();
const existingConfig = JSON5.parse(existingContent);
const newConfig = JSON5.parse(newContent);
// Merge configs, preserving user's non-default values
const mergedConfig = mergeConfigs(existingConfig, newConfig);
// Update file contents with merged config
file.contents = Buffer.from(JSON5.stringify(mergedConfig, null, 2));
logger.log(`Merged config file: ${relativePath}`);
} catch (error) {
logger.error(`Error merging config file ${relativePath}:`, error);
// Fall through to normal processing if merge fails
}
}
// Handle line-based merging (.gitignore, .env)
if (options.mergeLines && exists && !isBinaryFile) {
try {
const existingContent = jetpack.read(fullOutputPath);
const newContent = file.contents.toString();
// Merge line-by-line, passing the filename to handle .env differently
const mergedContent = mergeLineBasedFiles(existingContent, newContent, item.name);
// Update file contents
file.contents = Buffer.from(mergedContent);
logger.log(`Merged line-based file: ${relativePath}`);
} catch (error) {
logger.error(`Error merging line-based file ${relativePath}:`, error);
// Fall through to normal processing if merge fails
}
}
// Skip if instructed
if (options.skip || (!options.overwrite && exists && !options.merge && !options.mergeLines)) {
// Log if loud is enabled
if (LOUD) {
logger.log(`Skipping file: ${relativePath}`);
}
return callback();
}
// Log
// logger.log(`Processing file: ${relativePath}`);
// logger.log(` _ORIG: ${file.path}`);
// logger.log(` name: ${item.name}`);
// logger.log(` destination: ${item.destination}`);
// logger.log(` overwrite: ${options.overwrite}`);
// logger.log(` skip: ${options.skip}`);
// logger.log(` rule: ${options.rule}`);
// logger.log(` _FINAL: ${fullOutputPath}`);
// logger.log(` _TEST_TEMP: ${minimatch(relativePath, 'dist/__temp/**/*')}`);
// logger.log(` _TEST_DS: ${minimatch(relativePath, '.DS_Store')}`);
// Run template if required (skip for binary files)
if (options.template && !isBinaryFile) {
const contents = file.contents.toString();
const templated = template(contents, options.template);
// Update file contents
file.contents = Buffer.from(templated);
}
// Update path
file.path = path.join(file.base, finalRelativePath);
// Push transformed file
this.push(file);
// Complete
return callback();
});
}
function defaultsWatcher(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(input, { delay: delay, dot: true })
.on('change', (changedPath) => {
logger.log(`[watcher] File changed (${changedPath})`);
// Call defaults with just the changed file
defaults(() => {}, changedPath);
});
// Complete
return complete();
}
// Default Task
module.exports = series(defaults, defaultsWatcher);
function getFileOptions(filePath) {
const defaults = {
overwrite: true,
name: null,
path: null,
template: null,
skip: false,
merge: false,
mergeLines: false,
rule: null,
};
let options = { ...defaults };
for (const pattern in FILE_MAP) {
if (minimatch(filePath, pattern)) {
options = { ...options, ...FILE_MAP[pattern] };
options.rule = pattern;
}
}
return options;
}