Ghost/ghost/package-json/lib/package-json.js
Daniel Lockyer 8e83836de9 Fixed listing all themes when a theme path is invalid
- in the event one of the theme paths is invalid (a symlink that goes to
  a non-existent path), Ghost currently bails out of listing all themes
  because `fs.stat` throws an error
- in that case, we should just ignore the theme and continue
- this helps us prepare new symlinks whilst their source is not valid
- this commit wraps the code with try-catch to protect against that
- also adds a test
2023-09-26 13:33:39 +02:00

161 lines
5.0 KiB
JavaScript

/*
* # Package Utils
*
* Ghost has support for several different types of sub-packages:
* - Themes: have always been packages, but we're going to lean more heavily on npm & package.json in future
* - Adapters: replace fundamental pieces like storage, will become npm modules
*
* These utils facilitate loading, reading, managing etc, packages from the file system.
*
*/
const _ = require('lodash');
const fs = require('fs-extra');
const join = require('path').join;
const errors = require('@tryghost/errors');
const parse = require('./parse');
const notAPackageRegex = /^\.|_messages|README.md|node_modules|bower_components/i;
const packageJSONPath = 'package.json';
/**
* @typedef {object} PackageList
* @typedef {object} Package
*/
/**
* Recursively read directory and find the packages in it
*
* @param {string} absolutePath
* @param {string} packageName
* @returns {Promise<Package>}
*/
async function processPackage(absolutePath, packageName) {
const pkg = {
name: packageName,
path: absolutePath
};
try {
const packageJSON = await parse(join(absolutePath, packageJSONPath));
pkg['package.json'] = packageJSON;
} catch (err) {
// ignore invalid package.json for now,
// because Ghost does not rely/use them at the moment
// in the future, this .catch() will need to be removed,
// so that error is thrown on invalid json syntax
pkg['package.json'] = null;
}
return pkg;
}
/**
* ### Filter Packages
* Normalizes packages read by readPackages so that the themes module can use them.
* Iterates over each package and return an array of objects which are simplified representations of the package
* with 3 properties:
*
* @typedef {object} SimplePackage
* @prop {string} name - the package name
* @prop {object|boolean} package - contents of the package.json or false if there isn't one
* @prop {boolean} active - set to true if this package is active
*
* This data structure is used for listings of packages provided over the API and as such
* deliberately combines multiple sources of information in order to be efficient.
*
* TODO: simplify the package.json representation to contain only fields we use
*
* @param {PackageList} packages object made up of packages keyed by name as returned by readPackages
* @param {array|string} [active] optional set of names of packages that are active
* @returns {Array<SimplePackage>} array of objects with useful info about themes
*/
function filter(packages, active) {
// turn active into an array if it isn't one, so this function can deal with lists and one-offs
if (!Array.isArray(active)) {
active = [active];
}
return _.reduce(packages, function (result, pkg, key) {
let item = {};
if (!key.match(notAPackageRegex)) {
item = {
name: key,
package: pkg['package.json'] || false,
active: _.indexOf(active, key) !== -1
};
result.push(item);
}
return result;
}, []);
}
/**
* @param {string} packagePath
* @param {string} packageName
* @returns {Promise<Package>}
*/
async function readPackage(packagePath, packageName) {
const absolutePath = join(packagePath, packageName);
try {
const stat = await fs.stat(absolutePath);
if (!stat.isDirectory()) {
return {};
}
const pkg = await processPackage(absolutePath, packageName);
const res = {};
res[packageName] = pkg;
return res;
} catch (err) {
return Promise.reject(new errors.NotFoundError({
message: 'Package not found',
err: err,
help: 'path: ' + packagePath,
context: 'name: ' + packageName
}));
}
}
/**
* @param {string} packagePath
* @returns {Promise<PackageList>}
*/
async function readPackages(packagePath) {
const files = await fs.promises.readdir(packagePath, {withFileTypes: true});
const packages = await Promise.all(files.map(async (file) => {
// Filter out things which are not packages by regex
if (file.name.match(notAPackageRegex)) {
return false;
}
if (file.isSymbolicLink()) {
try {
const packageFileOrig = await fs.stat(join(packagePath, file.name));
return packageFileOrig.isDirectory();
} catch (err) {
// if there's an error reading the symlink, we should just return false
return false;
}
}
// Check the remaining items to ensure they are a directory
return file.isDirectory();
}))
.then(results => files.filter((_v, index) => results[index]))
.then(packageFiles => Promise.all(packageFiles.map((packageFile) => {
const absolutePath = join(packagePath, packageFile.name);
return processPackage(absolutePath, packageFile.name);
})));
return _.keyBy(packages, 'name');
}
module.exports = {
filter,
readPackage,
readPackages
};