104f84f252
As discussed with the product team we want to enforce kebab-case file names for all files, with the exception of files which export a single class, in which case they should be PascalCase and reflect the class which they export. This will help find classes faster, and should push better naming for them too. Some files and packages have been excluded from this linting, specifically when a library or framework depends on the naming of a file for the functionality e.g. Ember, knex-migrator, adapter-manager
268 lines
10 KiB
JavaScript
268 lines
10 KiB
JavaScript
const _ = require('lodash');
|
|
const BREAD = require('./CustomThemeSettingsBREADService');
|
|
const tpl = require('@tryghost/tpl');
|
|
const {ValidationError} = require('@tryghost/errors');
|
|
const debug = require('@tryghost/debug')('custom-theme-settings-service');
|
|
|
|
const messages = {
|
|
problemFindingSetting: 'Unknown setting: {key}.',
|
|
unallowedValueForSetting: 'Unallowed value for \'{key}\'. Allowed values: {allowedValues}.',
|
|
invalidValueForSetting: 'Invalid value for \'{key}\'. The value must follow this format: {format}.'
|
|
};
|
|
|
|
module.exports = class CustomThemeSettingsService {
|
|
/**
|
|
* @param {Object} options
|
|
* @param {any} options.model - Bookshelf-like model instance for storing theme setting key/value pairs
|
|
* @param {import('./CustomThemeSettingsCache')} options.cache - Instance of a custom key/value pair cache
|
|
*/
|
|
constructor({model, cache}) {
|
|
this.activeThemeName = null;
|
|
|
|
/** @private */
|
|
this._activatingPromise = null;
|
|
this._activatingName = null;
|
|
this._activatingSettings = null;
|
|
|
|
this._repository = new BREAD({model});
|
|
this._valueCache = cache;
|
|
this._activeThemeSettings = {};
|
|
}
|
|
|
|
/**
|
|
* The service only deals with one theme at a time,
|
|
* that theme is changed by calling this method with the output from gscan.
|
|
*
|
|
* To avoid syncing issues with activateTheme being called in quick succession,
|
|
* any previous/still-running activation promise is awaited before re-starting
|
|
* if necessary.
|
|
*
|
|
* @param {string} name - the name of the theme (Ghost has different names to themes with duplicate package.json names)
|
|
* @param {Object} theme - checked theme output from gscan
|
|
*/
|
|
async activateTheme(name, theme) {
|
|
const activate = async () => {
|
|
this.activeThemeName = name;
|
|
|
|
// add/remove/edit key/value records in the respository to match theme settings
|
|
const settings = await this._syncRepositoryWithTheme(name, theme);
|
|
|
|
// populate the shared cache with all key/value pairs for this theme
|
|
this._populateValueCacheForTheme(theme, settings);
|
|
// populate the cache used for exposing full setting details for editing
|
|
this._populateInternalCacheForTheme(theme, settings);
|
|
};
|
|
|
|
if (this._activatingPromise) {
|
|
// NOTE: must be calculated before awaiting promise as the promise finishing will clear the properties
|
|
const isSameName = name === this._activatingName;
|
|
const isSameSettings = JSON.stringify(theme.customSettings) === this._activatingSettings;
|
|
|
|
// wait for previous activation to finish
|
|
await this._activatingPromise;
|
|
|
|
// skip sync if we're re-activating exactly the same theme settings
|
|
if (isSameName && isSameSettings) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
this._activatingName = name;
|
|
this._activatingSettings = JSON.stringify(theme.customSettings);
|
|
this._activatingPromise = activate();
|
|
|
|
await this._activatingPromise;
|
|
} finally {
|
|
this._activatingPromise = null;
|
|
this._activatingName = null;
|
|
this._activatingSettings = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert the key'd internal cache object to an array suitable for use with Ghost's API
|
|
*/
|
|
listSettings() {
|
|
const settingObjects = Object.entries(this._activeThemeSettings).map(([key, setting]) => {
|
|
return Object.assign({}, setting, {key});
|
|
});
|
|
|
|
return settingObjects;
|
|
}
|
|
|
|
/**
|
|
* @param {Array} settings - array of setting objects with at least key and value properties
|
|
*/
|
|
async updateSettings(settings) {
|
|
// abort if any settings do not match known settings
|
|
const firstUnknownSetting = settings.find(setting => !this._activeThemeSettings[setting.key]);
|
|
|
|
if (firstUnknownSetting) {
|
|
throw new ValidationError({
|
|
message: tpl(messages.problemFindingSetting, {key: firstUnknownSetting.key})
|
|
});
|
|
}
|
|
|
|
settings.forEach((setting) => {
|
|
const definition = this._activeThemeSettings[setting.key];
|
|
switch (definition.type) {
|
|
case 'select':
|
|
if (!definition.options.includes(setting.value)) {
|
|
throw new ValidationError({
|
|
message: tpl(messages.unallowedValueForSetting, {key: setting.key, allowedValues: definition.options.join(', ')})
|
|
});
|
|
}
|
|
break;
|
|
case 'boolean':
|
|
if (![true, false].includes(setting.value)) {
|
|
throw new ValidationError({
|
|
message: tpl(messages.unallowedValueForSetting, {key: setting.key, allowedValues: [true, false].join(', ')})
|
|
});
|
|
}
|
|
break;
|
|
case 'color':
|
|
if (!/^#[0-9a-f]{6}$/i.test(setting.value)) {
|
|
throw new ValidationError({
|
|
message: tpl(messages.invalidValueForSetting, {key: setting.key, format: '#1234AF'})
|
|
});
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
// save the new values
|
|
for (const setting of settings) {
|
|
const theme = this.activeThemeName;
|
|
const {key, value} = setting;
|
|
|
|
const settingRecord = await this._repository.read({theme, key});
|
|
|
|
settingRecord.set('value', value);
|
|
|
|
if (settingRecord.hasChanged()) {
|
|
await settingRecord.save(null);
|
|
}
|
|
|
|
// update the internal cache
|
|
this._activeThemeSettings[setting.key].value = setting.value;
|
|
}
|
|
|
|
// update the public cache
|
|
this._valueCache.populate(this.listSettings());
|
|
|
|
// return full setting objects
|
|
return this.listSettings();
|
|
}
|
|
|
|
// Private -----------------------------------------------------------------
|
|
|
|
/**
|
|
* @param {Object} theme - checked theme output from gscan
|
|
* @returns {Array} - list of stored theme record objects
|
|
* @private
|
|
*/
|
|
async _syncRepositoryWithTheme(name, theme) {
|
|
const themeSettings = theme.customSettings || {};
|
|
|
|
const settingsCollection = await this._repository.browse({filter: `theme:'${name}'`});
|
|
let knownSettings = settingsCollection.toJSON();
|
|
|
|
// exit early if there's nothing to sync for this theme
|
|
if (knownSettings.length === 0 && _.isEmpty(themeSettings)) {
|
|
return [];
|
|
}
|
|
|
|
let removedIds = [];
|
|
|
|
// sync any knownSettings that have changed in the theme
|
|
for (const knownSetting of knownSettings) {
|
|
const themeSetting = themeSettings[knownSetting.key];
|
|
|
|
const hasBeenRemoved = !themeSetting;
|
|
const hasChangedType = themeSetting && themeSetting.type !== knownSetting.type;
|
|
|
|
if (hasBeenRemoved || hasChangedType) {
|
|
debug(`Removing custom theme setting '${name}.${knownSetting.key}' - ${hasBeenRemoved ? 'not found in theme' : 'type changed'}`);
|
|
await this._repository.destroy({id: knownSetting.id});
|
|
removedIds.push(knownSetting.id);
|
|
continue;
|
|
}
|
|
|
|
// replace value with default if it's not a valid select option
|
|
if (themeSetting.options && !themeSetting.options.includes(knownSetting.value)) {
|
|
debug(`Resetting custom theme setting value '${name}.${themeSetting.key}' - "${knownSetting.value}" is not a valid option`);
|
|
await this._repository.edit({value: themeSetting.default}, {id: knownSetting.id});
|
|
}
|
|
}
|
|
|
|
// clean up any removed knownSettings now that we've finished looping over them
|
|
knownSettings = knownSettings.filter(setting => !removedIds.includes(setting.id));
|
|
|
|
// add any new settings found in theme (or re-add settings that were removed due to type change)
|
|
const knownSettingsKeys = knownSettings.map(setting => setting.key);
|
|
|
|
for (const [key, setting] of Object.entries(themeSettings)) {
|
|
if (!knownSettingsKeys.includes(key)) {
|
|
const newSettingValues = {
|
|
theme: name,
|
|
key,
|
|
type: setting.type,
|
|
value: setting.default
|
|
};
|
|
|
|
debug(`Adding custom theme setting '${name}.${key}'`);
|
|
await this._repository.add(newSettingValues);
|
|
}
|
|
}
|
|
|
|
const updatedSettingsCollection = await this._repository.browse({filter: `theme:'${name}'`});
|
|
return updatedSettingsCollection.toJSON();
|
|
}
|
|
|
|
/**
|
|
* @param {Object} theme - checked theme output from gscan
|
|
* @param {Array} settings - theme settings fetched from repository
|
|
* @private
|
|
*/
|
|
_populateValueCacheForTheme(theme, settings) {
|
|
if (_.isEmpty(theme.customSettings)) {
|
|
this._valueCache.populate([]);
|
|
return;
|
|
}
|
|
|
|
this._valueCache.populate(settings);
|
|
}
|
|
|
|
/**
|
|
* @param {Object} theme - checked theme output from gscan
|
|
* @param {Array} settings - theme settings fetched from repository
|
|
* @private
|
|
*/
|
|
_populateInternalCacheForTheme(theme, settings) {
|
|
if (_.isEmpty(theme.customSettings)) {
|
|
this._activeThemeSettings = new Map();
|
|
return;
|
|
}
|
|
|
|
const settingValues = settings.reduce((acc, setting) => {
|
|
acc[setting.key] = setting;
|
|
return acc;
|
|
}, new Object());
|
|
|
|
const activeThemeSettings = new Object();
|
|
|
|
for (const [key, setting] of Object.entries(theme.customSettings)) {
|
|
// value comes from the stored key/value pairs rather than theme, we don't need the ID - theme name + key is enough
|
|
activeThemeSettings[key] = Object.assign({}, setting, {
|
|
id: settingValues[key].id,
|
|
value: settingValues[key].value
|
|
});
|
|
}
|
|
|
|
this._activeThemeSettings = activeThemeSettings;
|
|
}
|
|
};
|