From 41b11d6f2671beffca00808e87a74983118a94b9 Mon Sep 17 00:00:00 2001 From: Connor Anderson Date: Mon, 15 Mar 2021 12:03:36 -0400 Subject: [PATCH] Add custom card commands and the add vertical command for themes v1.15 and earlier --- .../commands/addvertical.js | 148 ++++++++++++++++ .../commands/cardcreator.js | 160 +++++++++++++++++ .../commands/directanswercardcreator.js | 165 ++++++++++++++++++ .../commands/helpers/errors/usererror.js | 20 +++ .../helpers/utils/argumentmetadata.js | 52 ++++++ .../helpers/utils/jamboconfigutils.js | 69 ++++++++ 6 files changed, 614 insertions(+) create mode 100644 themes/answers-hitchhiker-theme/commands/addvertical.js create mode 100644 themes/answers-hitchhiker-theme/commands/cardcreator.js create mode 100644 themes/answers-hitchhiker-theme/commands/directanswercardcreator.js create mode 100644 themes/answers-hitchhiker-theme/commands/helpers/errors/usererror.js create mode 100644 themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js create mode 100644 themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js diff --git a/themes/answers-hitchhiker-theme/commands/addvertical.js b/themes/answers-hitchhiker-theme/commands/addvertical.js new file mode 100644 index 0000000..7343cb6 --- /dev/null +++ b/themes/answers-hitchhiker-theme/commands/addvertical.js @@ -0,0 +1,148 @@ +const fs = require('fs-extra'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const UserError = require('./helpers/errors/usererror'); +const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); + +/** + * VerticalAdder represents the `vertical` custom jambo command. The command adds + * a new page for the given Vertical and associates a card type with it. + */ +class VerticalAdder { + constructor(jamboConfig) { + this.config = jamboConfig; + } + + /** + * @returns {string} the alias for the add vertical command. + */ + static getAlias() { + return 'vertical'; + } + + /** + * @returns {string} a short description of the add vertical command. + */ + static getShortDescription() { + return 'create the page for a vertical'; + } + + /** + * @returns {Object} description of each argument for + * the add vertical command, keyed by name + */ + static args() { + return { + name: new ArgumentMetadata(ArgumentType.STRING, 'name of the vertical\'s page', true), + verticalKey: new ArgumentMetadata(ArgumentType.STRING, 'the vertical\'s key', true), + cardName: new ArgumentMetadata( + ArgumentType.STRING, 'card to use with vertical', false, 'standard'), + template: new ArgumentMetadata( + ArgumentType.STRING, 'page template to use within theme', true) + }; + } + + /** + * @returns {Object} description of the vertical command and its parameters. + */ + static describe(jamboConfig) { + return { + displayName: 'Add Vertical', + params: { + name: { + displayName: 'Page Name', + required: true, + type: 'string' + }, + verticalKey: { + displayName: 'Vertical Key', + required: true, + type: 'string', + }, + cardName: { + displayName: 'Card Name', + type: 'singleoption', + options: this._getAvailableCards(jamboConfig) + }, + template: { + displayName: 'Page Template', + required: true, + type: 'singleoption', + options: this._getPageTemplates(jamboConfig) + } + } + }; + } + + /** + * @returns {Array} the names of the available cards in the Theme + */ + static _getAvailableCards(jamboConfig) { + const defaultTheme = jamboConfig.defaultTheme; + const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; + if (!defaultTheme || !themesDir) { + return []; + } + const cardsDir = path.join(themesDir, defaultTheme, 'cards'); + return fs.readdirSync(cardsDir, { withFileTypes: true }) + .filter(dirent => !dirent.isFile()) + .map(dirent => dirent.name); + } + + /** + * @returns {Array} The page templates available in the current theme + */ + static _getPageTemplates(jamboConfig) { + const defaultTheme = jamboConfig.defaultTheme; + const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; + if (!defaultTheme || !themesDir) { + return []; + } + const pageTemplatesDir = path.resolve(themesDir, defaultTheme, 'templates'); + return fs.readdirSync(pageTemplatesDir); + } + + /** + * Executes the add vertical command with the provided arguments. + * + * @param {Object} args The arguments, keyed by name + */ + execute(args) { + if (!VerticalAdder._getAvailableCards(this.config).includes(args.cardName)) { + throw new UserError(`${args.cardName} is not a valid card`); + } + + this._createVerticalPage(args.name, args.template); + this._configureVerticalPage(args.name, args.verticalKey, args.cardName); + } + + /** + * Creates a page for the vertical using the provided name and template. Any output from + * the `jambo page` command is piped through. + * + * @param {string} name The name of the vertical's page. + * @param {string} template The template to use. + */ + _createVerticalPage(name, template) { + const args = ['--name', name, '--template', template]; + spawnSync('npx jambo page', args, { shell: true, stdio: 'inherit' }); + } + + /** + * Updates the vertical page's configuration file. Specifically, placeholders for + * vertical key and card type are replaced with the provided values. + * + * @param {string} name The page name. + * @param {string} verticalKey The vertical's key. + * @param {string} cardName The card to be used with the vertical. + */ + _configureVerticalPage(name, verticalKey, cardName) { + const configFile = `config/${name}.json`; + let parsedConfig = fs.readFileSync(configFile, { encoding: 'utf-8' }); + parsedConfig = parsedConfig.replace(/\/g, verticalKey); + parsedConfig = parsedConfig.replace(/"cardType": "[^,]+"/, `"cardType": "${cardName}"`); + fs.writeFileSync(configFile, parsedConfig); + } +} +module.exports = VerticalAdder; diff --git a/themes/answers-hitchhiker-theme/commands/cardcreator.js b/themes/answers-hitchhiker-theme/commands/cardcreator.js new file mode 100644 index 0000000..33c1c6a --- /dev/null +++ b/themes/answers-hitchhiker-theme/commands/cardcreator.js @@ -0,0 +1,160 @@ +const fs = require('fs-extra'); +const { containsPartial, addToPartials } = require('./helpers/utils/jamboconfigutils'); +const path = require('path'); +const UserError = require('./helpers/errors/usererror'); +const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); + +/** + * CardCreator represents the `card` custom jambo command. + * The command creates a new, custom card in the top-level 'cards' directory + * of a jambo repo. + */ +class CardCreator { + constructor(jamboConfig) { + this.config = jamboConfig; + this._customCardsDir = 'cards'; + } + + /** + * @returns {string} the alias for the create card command. + */ + static getAlias() { + return 'card'; + } + + /** + * @returns {string} a short description of the create card command. + */ + static getShortDescription() { + return 'add a new card for use in the site'; + } + + /** + * @returns {Object} description of each argument for + * the create card command, keyed by name + */ + static args() { + return { + 'name': new ArgumentMetadata(ArgumentType.STRING, 'name for the new card', true), + 'templateCardFolder': new ArgumentMetadata(ArgumentType.STRING, 'folder of card to fork', true) + }; + } + + /** + * @returns {Object} description of the card command, including paths to + * all available cards + */ + static describe(jamboConfig) { + const cardPaths = this._getCardPaths(jamboConfig); + return { + displayName: 'Add Card', + params: { + name: { + displayName: 'Card Name', + required: true, + type: 'string' + }, + templateCardFolder: { + displayName: 'Template Card Folder', + required: true, + type: 'singleoption', + options: cardPaths + } + } + }; + } + + /** + * @returns {Array} the paths of the available cards + */ + static _getCardPaths(jamboConfig) { + const defaultTheme = jamboConfig.defaultTheme; + const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; + if (!defaultTheme || !themesDir) { + return []; + } + const cardsDir = path.join(themesDir, defaultTheme, 'cards'); + return fs.readdirSync(cardsDir, { withFileTypes: true }) + .filter(dirent => !dirent.isFile()) + .map(dirent => path.join(cardsDir, dirent.name)); + } + + /** + * Executes the create card command with the provided arguments. + * + * @param {Object} args The arguments, keyed by name + */ + execute(args) { + this._create(args.name, args.templateCardFolder); + } + + /** + * Creates a new, custom card in the top-level 'Cards' directory. This card + * will be based off either an existing custom card or one supplied by the + * Theme. + * + * @param {string} cardName The name of the new card. A folder with a + * lowercased version of this name will be + * created. + * @param {string} templateCardFolder The folder of the existing card on which + * the new one will be based. + */ + _create(cardName, templateCardFolder) { + const defaultTheme = this.config.defaultTheme; + const themeCardsDir = + `${this.config.dirs.themes}/${defaultTheme}/${this._customCardsDir}`; + + const cardFolderName = cardName.toLowerCase(); + const isFolderInUse = + fs.existsSync(`${themeCardsDir}/${cardFolderName}`) || + fs.existsSync(`${this._customCardsDir}/${cardFolderName}`); + if (isFolderInUse) { + throw new UserError(`A folder with name ${cardFolderName} already exists`); + } + + const cardFolder = `${this._customCardsDir}/${cardFolderName}`; + if (fs.existsSync(templateCardFolder)) { + !fs.existsSync(this._customCardsDir) && fs.mkdirSync(this._customCardsDir); + !containsPartial(this._customCardsDir) && addToPartials(this._customCardsDir); + fs.copySync(templateCardFolder, cardFolder); + this._renameCardComponent(cardFolderName, cardFolder); + } else { + throw new UserError(`The folder ${templateCardFolder} does not exist`); + } + } + + _renameCardComponent(customCardName, cardFolder) { + const cardComponentPath = path.resolve(cardFolder, 'component.js'); + const originalComponent = fs.readFileSync(cardComponentPath).toString(); + const renamedComponent = + this._getRenamedCardComponent(originalComponent, customCardName); + fs.writeFileSync(cardComponentPath, renamedComponent); + } + + /** + * Returns the internal contents for a newly-created card, updated based on + * the given customCardName. (e.g. StandardCardComponent -> [CustomName]CardComponent) + * @param {string} content + * @param {string} customCardName + * @returns {string} + */ + _getRenamedCardComponent(content, customCardName) { + const cardNameSuffix = 'CardComponent'; + const registerComponentTypeRegex = /\([\w_]+CardComponent\)/g; + const regexArray = [...content.matchAll(/componentName\s*=\s*'(.*)'/g)]; + if (regexArray.length === 0 || regexArray[0].length < 2) { + return content; + } + const originalComponentName = regexArray[0][1]; + + const customComponentClassName = + customCardName.replace(/-/g, '_') + cardNameSuffix; + + return content + .replace(/class (.*) extends/g, `class ${customComponentClassName} extends`) + .replace(registerComponentTypeRegex, `(${customComponentClassName})`) + .replace(new RegExp(originalComponentName, 'g'), customCardName) + .replace(/cards[/_](.*)[/_]template/g, `cards/${customCardName}/template`); + } +} +module.exports = CardCreator; diff --git a/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js b/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js new file mode 100644 index 0000000..7658ca6 --- /dev/null +++ b/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js @@ -0,0 +1,165 @@ +const fs = require('fs-extra'); +const { containsPartial, addToPartials } = require('./helpers/utils/jamboconfigutils'); +const path = require('path'); +const UserError = require('./helpers/errors/usererror'); +const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); + +/** + * DirectAnswerCardCreator represents the `directanswercard` custom jambo command. + * The command creates a new, custom direct answer card in the top-level + * 'directanswercards' directory of a jambo repo. + */ +class DirectAnswerCardCreator { + constructor(jamboConfig) { + this.config = jamboConfig; + this._customCardsDir = 'directanswercards'; + } + + /** + * @returns {string} the alias for the create direct answer card command. + */ + static getAlias() { + return 'directanswercard'; + } + + /** + * @returns {string} a short description of the create direct answer card command. + */ + static getShortDescription() { + return 'add a new direct answer card for use in the site'; + } + + /** + * @returns {Object} description of each argument for + * the create direct answer card command, keyed by name + */ + static args() { + return { + 'name': new ArgumentMetadata(ArgumentType.STRING, 'name for the new direct answer card', true), + 'templateCardFolder': new ArgumentMetadata(ArgumentType.STRING, 'folder of direct answer card to fork', true) + }; + } + + /** + * @returns {Object} description of the direct answer card command, including paths to + * all available direct answer cards + */ + static describe(jamboConfig) { + const directAnswerCardPaths = this._getDirectAnswerCardPaths(jamboConfig); + return { + displayName: 'Add Direct Answer Card', + params: { + name: { + displayName: 'Direct Answer Card Name', + required: true, + type: 'string' + }, + templateCardFolder: { + displayName: 'Template Card Folder', + required: true, + type: 'singleoption', + options: directAnswerCardPaths + } + } + }; + } + + /** + * @returns {Array} the paths of the available direct answer cards + */ + static _getDirectAnswerCardPaths(jamboConfig) { + const defaultTheme = jamboConfig.defaultTheme; + const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; + if (!defaultTheme || !themesDir) { + return []; + } + const daCardsDir = path.join(themesDir, defaultTheme, 'directanswercards'); + return fs.readdirSync(daCardsDir, { withFileTypes: true }) + .filter(dirent => !dirent.isFile()) + .map(dirent => path.join(daCardsDir, dirent.name)); + } + + /** + * Executes the create direct answer card command with the provided arguments. + * + * @param {Object + * [CustomName]Component) + * + * @param {string} content + * @param {string} customCardName + * @returns {string} + */ + _getRenamedCardComponent(content, customCardName) { + const cardNameSuffix = 'Component'; + const registerComponentTypeRegex = /\([\w_]+Component\)/g; + const regexArray = [...content.matchAll(/componentName\s*=\s*'(.*)'/g)]; + if (regexArray.length === 0 || regexArray[0].length < 2) { + return content; + } + const originalComponentName = regexArray[0][1]; + + const customComponentClassName = + customCardName.replace(/-/g, '_') + cardNameSuffix; + + return content + .replace(/class (.*) extends/g, `class ${customComponentClassName} extends`) + .replace(registerComponentTypeRegex, `(${customComponentClassName})`) + .replace(new RegExp(originalComponentName, 'g'), customCardName) + .replace( + /directanswercards[/_](.*)[/_]template/g, + `directanswercards/${customCardName}/template`); + } +} + +module.exports = DirectAnswerCardCreator; diff --git a/themes/answers-hitchhiker-theme/commands/helpers/errors/usererror.js b/themes/answers-hitchhiker-theme/commands/helpers/errors/usererror.js new file mode 100644 index 0000000..d815b29 --- /dev/null +++ b/themes/answers-hitchhiker-theme/commands/helpers/errors/usererror.js @@ -0,0 +1,20 @@ +/** + * Represents errors that we may reasonably expect a user to make + */ +class UserError extends Error { + constructor(message, stack) { + super(message); + + if (stack) { + this.stack = stack; + this.message = message; + } else { + Error.captureStackTrace(this, this.constructor); + } + + this.name = 'UserError' + this.exitCode = 13; + } +} + +module.exports = UserError; \ No newline at end of file diff --git a/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js b/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js new file mode 100644 index 0000000..2047695 --- /dev/null +++ b/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js @@ -0,0 +1,52 @@ +/** + * An enum describing the different kinds of argument that are supported. + */ +const ArgumentType = { + STRING: 'string', + NUMBER: 'number', + BOOLEAN: 'boolean' +} +Object.freeze(ArgumentType); + +/** + * A class outlining the metadata for a {@link Command}'s argument. This includes + * the type of the argument's values, if it is required, and an optional default. + */ +class ArgumentMetadata { + constructor(type, description, isRequired, defaultValue) { + this._type = type; + this._isRequired = isRequired; + this._defaultValue = defaultValue; + this._description = description; + } + + /** + * @returns {ArgumentType} The type of the argument, e.g. STRING, BOOLEAN, etc. + */ + getType() { + return this._type; + } + + /** + * @returns {string} The description of the argument. + */ + getDescription() { + return this._description + } + + /** + * @returns {boolean} A boolean indicating if the argument is required. + */ + isRequired() { + return !!this._isRequired; + } + + /** + * @returns {string|boolean|number} Optional, a default value for the argument. + */ + defaultValue() { + return this._defaultValue; + } + +} +module.exports = { ArgumentMetadata, ArgumentType }; \ No newline at end of file diff --git a/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js b/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js new file mode 100644 index 0000000..c356cba --- /dev/null +++ b/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js @@ -0,0 +1,69 @@ +const fs = require('file-system'); +const path = require('path'); +const mergeOptions = require('merge-options'); +const { + parse, + stringify +} = require('comment-json'); +const UserError = require('../errors/usererror'); + +/** + * Parses the repository's Jambo config file. If certain attributes are not + * present, defaults will be applied. + * + * @returns {Object} The parsed Jambo configuration, as an {@link Object}. + */ +exports.parseJamboConfig = function () { + try { + let config = mergeOptions( + { + dirs: { + themes: 'themes', + config: 'config', + output: 'public', + pages: 'pages', + partials: ['partials'], + } + }, + parse(fs.readFileSync('jambo.json', 'utf8')) + ); + return config; + } catch (err) { + throw new UserError('Error parsing jambo.json', err.stack); + } +} + +/** + * Registers a new set of Handlebars partials in the Jambo configuration + * file. The set will not be registered if it has been already or if it + * comes from a Theme's 'static' directory. + * + * @param {string} partialsPath The local path to the set of partials. + */ +exports.addToPartials = function (partialsPath) { + const jamboConfig = parseJamboConfig(); + const existingPartials = jamboConfig.dirs.partials; + + const shouldAddNewPartialsPath = + !existingPartials.includes(partialsPath) && + partialsPath.split(path.sep)[0] !== 'static'; + + if (shouldAddNewPartialsPath) { + existingPartials.push(partialsPath); + fs.writeFileSync('jambo.json', stringify(jamboConfig, null, 2)); + } +} + +/** + * Returns whether or not the partialsPath exists in the partials object in the + * Jambo config + * + * @param {Object} jamboConfig The parsed jambo config + * @param {string} partialsPath The local path to the set of partials. + * @returns {boolean} + */ +exports.containsPartial = function (jamboConfig, partialsPath) { + return jamboConfig.dirs + && jamboConfig.dirs.partials + && jamboConfig.dirs.partials.includes(partialsPath); +} -- 2.30.2