ultimate-jekyll-manager
Version:
Ultimate Jekyll dependency manager
605 lines (470 loc) • 18.4 kB
JavaScript
// Libraries
const Manager = new (require('../build.js'));
const logger = Manager.logger('setup');
const path = require('path');
const jetpack = require('fs-jetpack');
const version = require('wonderful-version');
const fetch = require('wonderful-fetch');
const { execute } = require('node-powertools');
const NPM = require('npm-api');
const glob = require('glob').globSync;
const detectGitHubRepository = require('../gulp/tasks/utils/detect-github-repo');
const { Octokit } = require('@octokit/rest');
const sodium = require('libsodium-wrappers');
// Load package
const package = Manager.getPackage('main');
const project = Manager.getPackage('project');
let config = Manager.getConfig('project');
const rootPathPackage = Manager.getRootPath('main');
const rootPathProject = Manager.getRootPath('project');
// Map of dependencies that should be installed as devDependencies
const DEPENDENCY_MAP = {
'gulp': 'dev',
}
module.exports = async function (options) {
// Fix options - handle string 'false' values
options = options || {};
options.checkManager = options.checkManager !== 'false';
options.checkNode = options.checkNode !== 'false';
options.checkRuby = options.checkRuby !== 'false';
options.checkBundle = options.checkBundle !== 'false';
options.checkPeerDependencies = options.checkPeerDependencies !== 'false';
options.setupScripts = options.setupScripts !== 'false';
options.ensureCoreFiles = options.ensureCoreFiles !== 'false';
options.createCname = options.createCname !== 'false';
options.fetchFirebaseAuth = options.fetchFirebaseAuth !== 'false';
options.checkLocality = options.checkLocality !== 'false';
options.publishGitHubToken = options.publishGitHubToken !== 'false';
options.deduplicatePosts = options.deduplicatePosts !== 'false';
options.migrate = options.migrate !== 'false';
// Quick mode: skip slow/network operations
if (Manager.isQuickMode()) {
logger.log('Quick mode: Skipping slow setup operations');
options.checkManager = false;
options.checkNode = false;
options.checkRuby = false;
options.checkBundle = false;
options.checkPeerDependencies = false;
options.fetchFirebaseAuth = false;
options.publishGitHubToken = false;
options.deduplicatePosts = false;
}
// Log
logger.log(`Welcome to ${package.name} v${package.version}!`);
logger.log(`options`, options);
// Prefix project
project.dependencies = project.dependencies || {};
project.devDependencies = project.devDependencies || {};
// Log current working directory
logger.log('Current working directory:', process.cwd());
// Run migrations
if (options.migrate) {
await migrate();
}
// Detect GitHub repository early so it's available to all tasks/functions
await detectGitHubRepository(logger);
// --- Version checks ---
// Ensure this package is up-to-date
if (options.checkManager) {
await ensureManagerVersion();
}
// Ensure proper node version
if (options.checkNode) {
await ensureNodeVersion();
}
// Ensure proper ruby version
if (options.checkRuby) {
await ensureRubyVersion();
}
// Ensure peer dependencies are installed
if (options.checkPeerDependencies) {
await ensurePeerDependencies();
}
// --- Project setup ---
// Setup scripts in package.json
if (options.setupScripts) {
setupScripts();
}
// Ensure _config.yml + scaffolded files (Gemfile, etc.) exist.
// Must run BEFORE ensureBundle so `bundle install` has a Gemfile to read on first setup.
if (options.ensureCoreFiles) {
await ensureCoreFiles();
}
// Ensure proper bundler version + install/update gems
if (options.checkBundle) {
await ensureBundle();
}
// Create CNAME file
if (options.createCname) {
createCname();
}
// Fetch firebase-auth files
if (options.fetchFirebaseAuth) {
await fetchFirebaseAuth();
}
// Warn if using local version
if (options.checkLocality) {
checkLocality();
}
// Publish GH_TOKEN as repository secret
if (options.publishGitHubToken) {
await publishGitHubToken();
}
// Deduplicate posts (remove duplicate posts with same slug but different dates)
if (options.deduplicatePosts) {
await deduplicatePosts();
}
};
// --- Version check functions ---
async function ensureManagerVersion() {
const npm = new NPM();
const installedVersion = project.devDependencies[package.name];
if (!installedVersion) {
throw new Error(`No installed version of ${package.name} found in devDependencies.`);
}
// Skip if local
if (installedVersion.startsWith('file:')) {
logVersionCheck(package.name, installedVersion, installedVersion, true);
return;
}
const latestVersion = await npm.repo(package.name)
.package()
.then((pkg) => pkg.version, () => '0.0.0');
const isUpToDate = version.is(installedVersion, '>=', latestVersion);
logVersionCheck(package.name, installedVersion, latestVersion, isUpToDate);
if (!isUpToDate) {
const levelDifference = version.levelDifference(installedVersion, latestVersion);
if (levelDifference === 'major' && installedVersion !== 'latest') {
return logger.error(`Major version difference detected. Please update to ${latestVersion} manually.`);
}
await npmInstall(package.name, latestVersion);
}
}
async function ensureNodeVersion() {
const installedVersion = version.clean(process.version);
const requiredVersion = version.clean(package.engines.node);
const isUpToDate = version.is(installedVersion, '>=', requiredVersion);
logVersionCheck('Node.js', installedVersion, requiredVersion, isUpToDate);
if (!isUpToDate) {
throw new Error(`Node version is out-of-date. Required version is ${requiredVersion}.`);
}
}
async function ensureRubyVersion() {
const installedVersion = version.clean(
(await execute('ruby -v', { log: false })).match(/(\d+\.\d+\.\d+)/)[0]
);
const requiredVersion = version.clean(package.engines.ruby);
const isUpToDate = version.is(installedVersion, '>=', requiredVersion);
logVersionCheck('Ruby', installedVersion, requiredVersion, isUpToDate);
if (!isUpToDate) {
throw new Error(`Ruby version is out-of-date. Required version is ${requiredVersion}.`);
}
}
async function ensureBundle() {
// Check bundler gem version
const installedVersion = version.clean(
(await execute('bundler -v', { log: false })).match(/(\d+\.\d+\.\d+)/)[0]
);
const requiredVersion = version.clean(package.engines.bundler);
const isUpToDate = version.is(installedVersion, '>=', requiredVersion);
logVersionCheck('Bundler', installedVersion, requiredVersion, isUpToDate);
// Install bundler gem + update Gemfile.lock if needed
if (!isUpToDate) {
logger.log(`Bundler is out-of-date. Installing version ${requiredVersion}...`);
await execute(`gem install bundler -v ${requiredVersion}`, { log: true });
await execute(`bundle update --bundler`, { log: true });
}
// Skip bundle install/update on server
if (Manager.isServer()) {
return;
}
// Install and update gems
logger.log('Running bundle install...');
await execute('bundle install', { log: true });
logger.log('Running bundle update...');
await execute('bundle update --all', { log: true });
}
async function ensurePeerDependencies() {
const requiredPeerDependencies = package.peerDependencies || {};
for (let [dependency, ver] of Object.entries(requiredPeerDependencies)) {
const projectDependencyVersion = version.clean(project?.dependencies?.[dependency] || project?.devDependencies?.[dependency]);
const location = DEPENDENCY_MAP[dependency] === 'dev' ? '--save-dev' : '';
ver = version.clean(ver);
const isUpToDate = version.is(projectDependencyVersion, '>=', ver);
logVersionCheck(dependency, projectDependencyVersion, ver, isUpToDate);
if (!projectDependencyVersion || !isUpToDate) {
await npmInstall(dependency, ver, location);
}
}
}
// --- Project setup functions ---
function setupScripts() {
project.scripts = project.scripts || {};
project.private = true;
Object.keys(package.projectScripts).forEach((key) => {
project.scripts[key] = package.projectScripts[key];
});
jetpack.write(path.join(process.cwd(), 'package.json'), project);
}
async function ensureCoreFiles() {
if (jetpack.exists('src/_config.yml')) {
return;
}
logger.log('No src/_config.yml found. Creating default config file...');
// Files that must exist BEFORE the gulpfile loads. Several task modules
// (sass/distribute/imagemin) read these at module top-level, so a fresh
// consumer can't even invoke `gulp defaults` without them.
const coreFiles = [
'src/_config.yml',
'config/ultimate-jekyll-manager.json',
];
coreFiles.forEach((relPath) => {
const sourcePath = path.join(rootPathPackage, 'dist/defaults', relPath);
const targetPath = path.join(rootPathProject, relPath);
jetpack.copy(sourcePath, targetPath, { overwrite: false });
logger.log(`Copied default ${relPath}`);
});
// Inject new config into config variable
config = Manager.getConfig('project');
// Run gulp defaults task since this is likely the first run
await execute('UJ_BUILD_MODE=true npm run gulp -- defaults', { log: true });
}
function createCname() {
const url = config.url || 'https://ultimate-jekyll.itwcreativeworks.com';
const host = new URL(url).host;
jetpack.write('dist/CNAME', host);
logger.log('Created CNAME');
}
async function fetchFirebaseAuth() {
const app = config.web_manager.firebase.app.config || {};
if (!app.projectId) {
logger.warn('Skipping fetchFirebaseAuth due to missing Firebase project ID.');
return;
}
const base = `https://${app.projectId}.firebaseapp.com`;
const output = './dist';
const files = [
{ remote: '__/auth/handler', filename: 'handler.html' },
{ remote: '__/auth/handler.js' },
{ remote: '__/auth/experiments.js' },
{
remote: '__/auth/iframe',
filename: 'iframe.html',
replace: (content) => content.replace('src="iframe.js"', 'src="iframe.js?cb={{ site.uj.cache_breaker }}"'),
},
{ remote: '__/auth/iframe.js' },
{ remote: '__/firebase/init.json' },
];
logger.log('Fetching firebase-auth files...');
const promises = files.map((file) => {
const url = `${base}/${file.remote}`;
const fileName = file.filename
? path.basename(file.filename)
: path.basename(file.remote);
const filePath = path.join(path.dirname(file.remote), fileName);
const finalPath = path.join(output, filePath);
return fetch(url, { response: 'text', tries: 3 })
.then((r) => {
if (file.replace) {
r = file.replace(r);
}
logger.log(`Fetched: ${file.remote}`);
jetpack.write(finalPath,
'---\n'
+ `permalink: /${file.remote}\n`
+ '---\n'
+ '\n'
+ r
);
})
.catch((error) => {
logger.error(`Failed to fetch: ${file.remote}`);
logger.error(` URL: ${url}`);
const htmlPattern = / a.date - b.date);
const [original, ...newer] = posts;
logger.log(`Found ${posts.length} posts with slug "${slug}":`);
logger.log(` Keeping original: ${original.filename} (${original.dateStr})`);
for (const post of newer) {
logger.log(` Removing duplicate: ${post.filename} (${post.dateStr})`);
try {
jetpack.remove(post.filePath);
removedCount++;
const imageFolder = `src/assets/images/blog/post-${post.slug}`;
if (jetpack.exists(imageFolder)) {
jetpack.remove(imageFolder);
logger.log(` Removed image folder: ${imageFolder}`);
}
duplicates.push({
kept: original.filename,
removed: post.filename,
removedPath: post.filePath,
slug,
});
} catch (error) {
logger.error(`Failed to remove ${post.filePath}:`, error);
}
}
}
if (removedCount > 0) {
logger.log(`Removed ${removedCount} duplicate post(s)`);
const reportDir = path.join(rootPathProject, '.temp/deduplicate');
jetpack.dir(reportDir);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const reportPath = path.join(reportDir, `duplicates-${timestamp}.json`);
jetpack.write(reportPath, JSON.stringify({
timestamp: new Date().toISOString(),
removedCount,
duplicates,
}, null, 2));
logger.log(`Report saved to: ${reportPath}`);
} else {
logger.log('No duplicate posts found');
}
}
// --- Migration functions ---
async function migrate() {
const installedVersion = project.devDependencies[package.name] || '0.0.0';
if (installedVersion.startsWith('file:')) {
return;
}
if (version.is(installedVersion, '<=', '1.0.0')) {
await migrateHooksToNestedStructure();
}
}
async function migrateHooksToNestedStructure() {
const hooksDir = path.join(rootPathProject, 'hooks');
const migrations = [
{ old: 'build:post.js', new: 'build/post.js' },
{ old: 'build:pre.js', new: 'build/pre.js' },
{ old: 'middleware:request.js', new: 'middleware/request.js' },
];
let migratedCount = 0;
for (const migration of migrations) {
const oldPath = path.join(hooksDir, migration.old);
const newPath = path.join(hooksDir, migration.new);
if (!jetpack.exists(oldPath)) {
continue;
}
if (jetpack.exists(newPath)) {
logger.warn(`Migrate ${migration.old}: ${migration.new} already exists`);
}
jetpack.move(oldPath, newPath, { overwrite: true });
logger.log(`Migrated hook: ${migration.old} -> ${migration.new}`);
migratedCount++;
}
if (migratedCount > 0) {
logger.log(`Migrated ${migratedCount} hook file(s) to new nested structure`);
}
}
// --- Utility functions ---
function npmInstall(pkg, ver, location) {
ver = ver || 'latest';
ver = ver === 'latest' ? ver : version.clean(ver);
const command = `npm install ${pkg}@${ver} ${location || '--save'}`;
logger.log('Installing:', command);
return execute(command, { log: true })
.then(() => {
const projectUpdated = jetpack.read(path.join(process.cwd(), 'package.json'), 'json');
logger.log('Installed:', pkg, ver);
project.dependencies = projectUpdated.dependencies;
project.devDependencies = projectUpdated.devDependencies;
});
}
function logVersionCheck(name, installedVersion, latestVersion, isUpToDate) {
if (installedVersion.startsWith('file:')) {
isUpToDate = true;
}
logger.log(`Checking if ${name} is up to date (${logger.format.bold(installedVersion)} >= ${logger.format.bold(latestVersion)}): ${isUpToDate ? logger.format.green('Yes') : logger.format.red('No')}`);
}