diff --git a/assets/logicFunction/@types/particle_core.d.ts b/assets/logicFunction/@types/particle_core.d.ts new file mode 100644 index 000000000..0cd38fa31 --- /dev/null +++ b/assets/logicFunction/@types/particle_core.d.ts @@ -0,0 +1,109 @@ +declare module 'particle:core' { + namespace Particle { + /***********************************************LEDGER**************************************************************/ + export const MERGE: "Merge"; + export const REPLACE: "Replace"; + export interface Ledger { + /** + * Gets the current value of the ledger. + * Empty ledger returns {}, error returns null + * @returns {Object} the current value of the ledger + * @throws {Error} if the ledger doesn't exist or there was an error getting the ledger instance + */ + get(): { updatedAt: string, data: Record }, + + /** + * Sets the value of the ledger. If the ledger does not exist, it will be created. + * + * @param data The data you would like to put into the ledger + * @param setMode Whether to replace or merge the new data into the ledger. Defaults to Replace. You can use the constants MERGE and REPLACE here + * @throws {Error} if the ledger doesn't exist or there was an error setting the ledger instance + */ + set(data: Record, setMode: "Replace" | "Merge"): null, + + /** + * Deletes the ledger data + * @throws {Error} if there was an error deleting the ledger instance + */ + delete(): null + } + /** + * Gets a ledger object which allows you to store data in the cloud. The scope value will automatically be picked + * based on the trigger's context. If you want to use a different scope value, you can provide a deviceId or productId + * depending on your needs in the second param. + * + * If the trigger contains a deviceId and productId, we will pass those to ledger and use what the ledger definition requires + * + * @param name a unique name regardless of the definition's scope + * @param scopeValues the override scope values if you want to ignore the logic trigger or are using a scheduled logic trigger + */ + export function ledger(name: string, scopeValues?: { deviceId?: string, productId?: number }): Ledger; + + /***********************************************CONTEXT*************************************************************/ + export interface FunctionInfo { + ownerId: string; + logicFunctionId: string; + } + + export interface TriggerInfo { + triggerEventId?: string; + triggeredAt: string; + } + + export interface EventInfo { + publishedAt: string; + eventName: string; + eventData?: string; + deviceId: string; + productId?: number; + userId?: string; + } + + export interface ScheduledInfo { + scheduledAt: string; + startAt: string; + endAt?: string; + } + + export interface LedgerChangeInfo { + changedAt: string; + changeType: 'set' | 'delete'; + name: string; + scope: string; + data: Record + } + + /** + * The context object that is passed to the function when it is invoked. + * The event, scheduled and ledgerChange properties will only be present if the function was invoked by that type of trigger + * and only one of them will be present at a time (logic functions can only have one type of trigger). + */ + export interface FunctionContext { + functionInfo: FunctionInfo; + trigger: TriggerInfo; + event?: EventInfo; + scheduled?: ScheduledInfo; + ledgerChange?: LedgerChangeInfo; + } + + /** + * Returns an object containing the raw context, trigger and its details. + */ + export function getContext(): FunctionContext; + + /***********************************************PUBLISH*************************************************************/ + /** + * Synchronously emits a Particle event message. Note that non-string values for `data` will be stringified + * + * @param name The name of the event + * @param data The particle publish data + * @param options Allows specifying how the data is published + * @param options.productId Publish to the event stream of this product + * @param options.asDeviceId Publish the event as if it came from this device. + * @throws {Error} if there was an error publishing the event + */ + export function publish(name: string, data: any | undefined, options: { productId: number, asDeviceId: string }): null; + } + + export = Particle; +} diff --git a/assets/logicFunction/@types/particle_encoding.d.ts b/assets/logicFunction/@types/particle_encoding.d.ts new file mode 100644 index 000000000..6537ac5f4 --- /dev/null +++ b/assets/logicFunction/@types/particle_encoding.d.ts @@ -0,0 +1,42 @@ +declare module 'particle:encoding' { + namespace Encoding { + /** + * Converts a byte array to a UTF-8 string + * @param input The byte array to convert + * @returns {string} The converted string + */ + export function bytesToString(input: number[]): string; + /** + * Converts a UTF-8 string to a byte array + * @param input The string to convert + * @returns {number[]} The byte array representing this string + */ + export function stringToBytes(input: string): number[]; + /** + * Encodes a string or byte array to base64 (RFC 3548) + * @param input The string or byte array to encode + * @returns {string} The base64 encoded string + */ + export function base64Encode(input: string | number[]): string; + /** + * Decodes a base64 (RFC 3548) string to a byte array + * @param input The base64 string to decode + * @returns {number[]} The decoded byte array + */ + export function base64Decode(input: string): number[]; + /** + * Encodes a string or byte array to base85 (RFC1924) + * @param input The string or byte array to encode + * @returns {string} The base85 encoded string + */ + export function base85Encode(input: string | number[]): string; + /** + * Decodes a base85 (RFC1924) string to a byte array + * @param input The base85 string to decode + * @returns {number[]} The decoded byte array + */ + export function base85Decode(input: string): number[]; + } + + export = Encoding; +} diff --git a/assets/logicFunction/logic_function_name.js.template b/assets/logicFunction/logic_function_name.js.template new file mode 100644 index 000000000..474d1b04a --- /dev/null +++ b/assets/logicFunction/logic_function_name.js.template @@ -0,0 +1,5 @@ +import Particle from 'particle:core'; + +export default function process({ functionInfo, trigger, event }) { + // Add your code here +} diff --git a/assets/logicFunction/logic_function_name.logic.json.template b/assets/logicFunction/logic_function_name.logic.json.template new file mode 100644 index 000000000..7e8bf2fea --- /dev/null +++ b/assets/logicFunction/logic_function_name.logic.json.template @@ -0,0 +1,12 @@ +{ + "$schema": "schemas/logic_function.schema.json", + "logic_function": { + "name": "${name}", + "description": "${description}", + "source": { + "type": "JavaScript" + }, + "enabled": true, + "logic_triggers": [] + } +} diff --git a/src/cli/logic-function.js b/src/cli/logic-function.js index 78dc9c8e7..02f810321 100644 --- a/src/cli/logic-function.js +++ b/src/cli/logic-function.js @@ -36,7 +36,10 @@ module.exports = ({ commandProcessor, root }) => { options: { 'org': { description: 'Specify the organization' - } + }, + 'name': { + description: 'Name of the logic function' + }, }, handler: (args) => { const LogicFunctionsCmd = require('../cmd/logic-function'); diff --git a/src/cmd/logic-function.js b/src/cmd/logic-function.js index 31687154e..c3759549a 100644 --- a/src/cmd/logic-function.js +++ b/src/cmd/logic-function.js @@ -1,9 +1,14 @@ +const os = require('os'); +const path = require('path'); const ParticleAPI = require('./api'); +const CLICommandBase = require('./base'); const VError = require('verror'); const settings = require('../../settings'); const { normalizedApiError } = require('../lib/api-client'); -const CLICommandBase = require('./base'); -const os = require('os'); +const templateProcessor = require('../lib/template-processor'); +const fs = require('fs-extra'); +const { slugify } = require('../lib/utilities'); +const logicFunctionTemplatePath = path.join(__dirname, '/../../assets/logicFunction'); /** * Commands for managing encryption keys. @@ -19,7 +24,7 @@ module.exports = class LogicFunctionsCommand extends CLICommandBase { try { const logicFunctions = await api.getLogicFunctionList({ org }); const orgName = getOrgName(org); - const list = logicFunctions['logic_functions']; + const list = logicFunctions.logic_functions; if (list.length === 0) { this.ui.stdout.write(`No Logic Functions currently deployed in your ${orgName}.`); this.ui.stdout.write(`To create a Logic Function, see \`particle logic-function create\`.${os.EOL} @@ -35,6 +40,7 @@ module.exports = class LogicFunctionsCommand extends CLICommandBase { }); this.ui.stdout.write(`${os.EOL}To view a Logic Function's code, see \`particle lf get.\`${os.EOL}`); } + return list; } catch (e) { throw createAPIErrorResult({ error: e, message: 'Error listing logic functions' }); } @@ -46,9 +52,129 @@ module.exports = class LogicFunctionsCommand extends CLICommandBase { console.log(org, name, id); } - async create({ org, params: { filepath } }) { - // TODO - console.log(org, filepath); + async create({ org, name, params : { filepath } } = { params: { } }) { + const orgName = getOrgName(org); + const api = createAPI(); + // get name from filepath + if (!filepath) { + // use default directory + filepath = '.'; + } + if (!name) { + const question = { + type: 'input', + name: 'name', + message: 'What would you like to call your Function?' + }; + const result = await this.ui.prompt([question]); + name = result.name; + } + // trim name + name = name.trim(); + // ask for description + const question = { + type: 'input', + name: 'description', + message: 'Add a description for your Function (optional)' + }; + const result = await this.ui.prompt([question]); + const description = result.description; + const slugName = slugify(name); + const destinationPath = path.join(filepath, slugName); + + this.ui.stdout.write(`Creating Logic Function ${this.ui.chalk.bold(name)} for ${orgName}...${os.EOL}`); + await this._validateExistingName({ api, org, name }); + await this._validateExistingFiles({ templatePath: logicFunctionTemplatePath, destinationPath }); + const createdFiles = await this._copyAndReplaceLogicFunction({ + logicFunctionName: name, + logicFunctionSlugName: slugName, + description, + templatePath: logicFunctionTemplatePath, + destinationPath: path.join(filepath, slugName) + }); + this.ui.stdout.write(`Successfully created ${this.ui.chalk.bold(name)} in ${this.ui.chalk.bold(filepath)}${os.EOL}`); + this.ui.stdout.write(`Files created:${os.EOL}`); + createdFiles.forEach((file) => { + this.ui.stdout.write(`- ${file}${os.EOL}`); + }); + this.ui.stdout.write(`${os.EOL}Guidelines for creating your Logic Function can be found .${os.EOL}`); + this.ui.stdout.write(`Once you have written your Logic Function, run${os.EOL}`); + this.ui.stdout.write(`- \`particle logic execute\` to run your Function${os.EOL}`); + this.ui.stdout.write(`- \`particle logic deploy\` to deploy your new changes${os.EOL}`); + return createdFiles; + } + + async _validateExistingName({ api, org, name }) { + // TODO (hmontero): request for a getLogicFunctionByName() method in the API + let existingLogicFunction; + try { + const logicFunctionsResponse = await api.getLogicFunctionList({ org }); + const existingLogicFunctions = logicFunctionsResponse.logic_functions; + existingLogicFunction = existingLogicFunctions.find((item) => item.name === name); + } catch (error) { + this.ui.stdout.write(this.ui.chalk.yellow(`Warn: We were unable to check if a Logic Function with name ${name} already exists.${os.EOL}`)); + } + if (existingLogicFunction) { + throw new Error(`Error: Logic Function with name ${name} already exists.`); + } + } + + async _validateExistingFiles({ templatePath, destinationPath }){ + const filesAlreadyExist = await templateProcessor.hasTemplateFiles({ + templatePath, + destinationPath + }); + if (filesAlreadyExist) { + const question = { + type: 'confirm', + name: 'overwrite', + message: `We found existing files in ${this.ui.chalk.bold(destinationPath)}. Would you like to overwrite them?` + }; + const { overwrite } = await this.ui.prompt([question]); + if (!overwrite) { + this.ui.stdout.write(`Aborted.${os.EOL}`); + process.exit(0); + } + } + } + + /** Recursively copy and replace template files */ + async _copyAndReplaceLogicFunction({ logicFunctionName, logicFunctionSlugName, description, templatePath, destinationPath }){ + const files = await fs.readdir(templatePath); + const createdFiles = []; + + for (const file of files){ + //createdFiles.push(destinationFile); + // check if file is a dir + const stat = await fs.stat(path.join(templatePath, file)); + if (stat.isDirectory()) { + const subFiles = await this._copyAndReplaceLogicFunction({ + logicFunctionName, + logicFunctionSlugName, + description, + templatePath: path.join(templatePath, file), + destinationPath: path.join(destinationPath, file) + }); + createdFiles.push(...subFiles); + } else { + const fileReplacements = { + 'logic_function_name': logicFunctionSlugName, + }; + const destinationFile = await templateProcessor.copyAndReplaceTemplate({ + fileNameReplacements: fileReplacements, + file, + templatePath, + destinationPath, + replacements: { + name: logicFunctionName, + description: description || '' + } + }); + createdFiles.push(destinationFile); + } + } + // return file name created + return createdFiles; } async execute({ org, data, params: { filepath } }) { @@ -92,7 +218,7 @@ function createAPIErrorResult({ error: e, message, json }){ // get org name from org slug function getOrgName(org) { - return org || 'Staging'; + return org || 'Sandbox'; } // TODO (mirande): reconcile this w/ `normalizedApiError()` and `ensureError()` diff --git a/src/cmd/logic-function.test.js b/src/cmd/logic-function.test.js index 40291f2fd..4f3c64c13 100644 --- a/src/cmd/logic-function.test.js +++ b/src/cmd/logic-function.test.js @@ -1,14 +1,18 @@ -const { expect } = require('../../test/setup'); -const LogicFunctionCommands = require('./logic-function'); -const fs = require('fs'); +const os = require('os'); +const fs = require('fs-extra'); const path = require('path'); const nock = require('nock'); -const { PATH_FIXTURES_LOGIC_FUNCTIONS } = require('../../test/lib/env'); +const { expect, sinon } = require('../../test/setup'); +const LogicFunctionCommands = require('./logic-function'); +const { PATH_FIXTURES_LOGIC_FUNCTIONS, PATH_TMP_DIR } = require('../../test/lib/env'); +const templateProcessor = require('../lib/template-processor'); + describe('LogicFunctionCommands', () => { let logicFunctionCommands; + let originalUi = new LogicFunctionCommands().ui; let logicFunc1 = fs.readFileSync(path.join(PATH_FIXTURES_LOGIC_FUNCTIONS, 'logicFunc1.json'), 'utf-8'); logicFunc1 = JSON.parse(logicFunc1); let logicFunc2 = fs.readFileSync(path.join(PATH_FIXTURES_LOGIC_FUNCTIONS, 'logicFunc2.json'), 'utf-8'); @@ -16,10 +20,28 @@ describe('LogicFunctionCommands', () => { beforeEach(async () => { logicFunctionCommands = new LogicFunctionCommands(); + logicFunctionCommands.ui = { + stdout: { + write: sinon.stub() + }, + stderr: { + write: sinon.stub() + }, + chalk: { + bold: sinon.stub(), + cyanBright: sinon.stub(), + yellow: sinon.stub(), + grey: sinon.stub(), + }, + }; }); afterEach(async () => { - // TODO: Fill this out? + sinon.restore(); + logicFunctionCommands.ui = originalUi; + // remove tmp dir + fs.emptyDirSync(PATH_TMP_DIR); + }); describe('list', () => { @@ -27,10 +49,7 @@ describe('LogicFunctionCommands', () => { const stub = nock('https://api.particle.io/v1', ) .intercept('/logic/functions', 'GET') .reply(200, logicFunc1); - const expectedResp = { - body: logicFunc1, - statusCode: 200 - }; + const expectedResp = logicFunc1.logic_functions; const res = await logicFunctionCommands.list({}); expect(res).to.eql(expectedResp); @@ -41,10 +60,7 @@ describe('LogicFunctionCommands', () => { const stub = nock('https://api.particle.io/v1/orgs/particle') .intercept('/logic/functions', 'GET') .reply(200, logicFunc2); - const expectedResp = { - body: logicFunc2, - statusCode: 200 - }; + const expectedResp = logicFunc2.logic_functions; const res = await logicFunctionCommands.list({ org: 'particle' }); expect(res).to.eql(expectedResp); @@ -55,13 +71,12 @@ describe('LogicFunctionCommands', () => { const stub = nock('https://api.particle.io/v1', ) .intercept('/logic/functions', 'GET') .reply(200, { logic_functions: [] }); - const expectedResp = { - body: { logic_functions: [] }, - statusCode: 200 - }; + const expectedResp = []; const res = await logicFunctionCommands.list({}); expect(res).to.eql(expectedResp); + expect(logicFunctionCommands.ui.stdout.write.callCount).to.equal(2); + expect(logicFunctionCommands.ui.stdout.write.firstCall.args[0]).to.equal('No Logic Functions currently deployed in your Sandbox.'); expect (stub.isDone()).to.be.true; }); @@ -98,4 +113,80 @@ describe('LogicFunctionCommands', () => { expect(error.message).to.equal('Error listing logic functions: Organization Not Found'); }); }); + + describe('create', () => { + it('creates a logic function locally for Sandbox account', async () => { + nock('https://api.particle.io/v1', ) + .intercept('/logic/functions', 'GET') + .reply(200, { logic_functions: [] }); + logicFunctionCommands.ui.prompt = sinon.stub(); + logicFunctionCommands.ui.prompt.onCall(0).resolves({ name: 'logic func 1' }); + logicFunctionCommands.ui.prompt.onCall(1).resolves({ description: 'Logic Function 1' }); + const filePaths = await logicFunctionCommands.create({ + params: { filepath: PATH_TMP_DIR } + }); + expect(filePaths.length).to.equal(4); + const expectedFiles = [ + path.join('logic-func-1', 'logic-func-1.js'), + path.join('logic-func-1', 'logic-func-1.logic.json'), + path.join('logic-func-1', '@types', 'particle_core.d.ts'), + path.join('logic-func-1', '@types', 'particle_encoding.d.ts') + ]; + for (const expectedFile of expectedFiles) { + const includesExpected = filePaths.some(value => value.includes(expectedFile)); + expect(includesExpected, `File path "${expectedFile}" does not include expected values`).to.be.true; + } + }); + + it('shows warning if a logic function cannot be looked up in the cloud', async () => { + nock('https://api.particle.io/v1', ) + .intercept('/logic/functions', 'GET') + .reply(403); + logicFunctionCommands.ui.prompt = sinon.stub(); + logicFunctionCommands.ui.prompt.onCall(0).resolves({ name: 'logicFunc1' }); + logicFunctionCommands.ui.prompt.onCall(1).resolves({ description: 'Logic Function 1' }); + await logicFunctionCommands.create({ + params: { filepath: PATH_TMP_DIR } + }); + expect(logicFunctionCommands.ui.chalk.yellow.callCount).to.equal(1); + expect(logicFunctionCommands.ui.chalk.yellow.firstCall.args[0]).to.equal(`Warn: We were unable to check if a Logic Function with name logicFunc1 already exists.${os.EOL}`); + }); + + it('ask to overwrite if files already exist', async () => { + nock('https://api.particle.io/v1', ) + .intercept('/logic/functions', 'GET') + .reply(200, { logic_functions: [] }); + sinon.stub(templateProcessor, 'hasTemplateFiles').resolves(true); + logicFunctionCommands.ui.prompt = sinon.stub(); + logicFunctionCommands.ui.prompt.onCall(0).resolves({ name: 'logicFunc1' }); + logicFunctionCommands.ui.prompt.onCall(1).resolves({ description: 'Logic Function 1' }); + logicFunctionCommands.ui.prompt.onCall(2).resolves({ overwrite: true }); + await logicFunctionCommands.create({ + params: { filepath: PATH_TMP_DIR } + }); + expect(logicFunctionCommands.ui.prompt.callCount).to.equal(3); + expect(logicFunctionCommands.ui.prompt.thirdCall.lastArg[0].message).to.contain('We found existing files in'); + }); + + it('throws an error if logic function already exists', async () => { + nock('https://api.particle.io/v1', ) + .intercept('/logic/functions', 'GET') + .reply(200, logicFunc1); + logicFunctionCommands.ui.prompt = sinon.stub(); + logicFunctionCommands.ui.prompt.onCall(0).resolves({ name: 'LF1' }); + logicFunctionCommands.ui.prompt.onCall(1).resolves({ description: 'Logic Function 1' }); + let error; + try { + const files = await logicFunctionCommands.create({ + params: { filepath: PATH_TMP_DIR } + }); + console.log(files); + } catch (e) { + error = e; + } + expect(error).to.be.an.instanceOf(Error); + expect(error.message).to.equal('Error: Logic Function with name LF1 already exists.'); + }); + + }); }); diff --git a/src/lib/template-processor.js b/src/lib/template-processor.js new file mode 100644 index 000000000..22c736349 --- /dev/null +++ b/src/lib/template-processor.js @@ -0,0 +1,50 @@ +const fs = require('fs-extra'); +const path = require('path'); + + + +async function copyAndReplaceTemplate({ fileNameReplacements, file, templatePath, destinationPath, replacements }) { + // ensure destination path exists + await fs.ensureDir(destinationPath); + const templateFile = path.join(templatePath, file); + const fileName = replace(file, fileNameReplacements, { stringMatch: true }).replace('.template', ''); + const destinationFile = path.join(destinationPath, fileName); + const templateContent = await fs.readFile(templateFile, 'utf8'); + const destinationContent = replace(templateContent, replacements); + await fs.writeFile(destinationFile, destinationContent); + return destinationFile; +} + +// FIXME (hmontero): Stop working after file naming changes +async function hasTemplateFiles({ templatePath, destinationPath }){ + const files = await fs.readdir(templatePath); + for (const file of files){ + const fileName = file.replace('.template', ''); + const destinationFile = path.join(destinationPath, fileName); + try { + await fs.stat(destinationFile); + return true; // File exists in the destination path + } catch (error) { + // File doesn't exist, continue checking other files + } + } + return false; +} + +function replace(content, replacements, options = { stringMatch: false }){ + let result = content; + for (const key in replacements){ + const value = replacements[key]; + if (options.stringMatch){ + result = result.replace(key, value); + } else { + result = result.replace(new RegExp(`\\$\{${key}}`, 'g'), value); + } + } + return result; +} + +module.exports = { + copyAndReplaceTemplate, + hasTemplateFiles +}; diff --git a/src/lib/template-processor.test.js b/src/lib/template-processor.test.js new file mode 100644 index 000000000..cdfe8a65c --- /dev/null +++ b/src/lib/template-processor.test.js @@ -0,0 +1,80 @@ +const { expect } = require('../../test/setup'); +const { copyAndReplaceTemplate, hasTemplateFiles } = require('./template-processor'); +const { PATH_TMP_DIR } = require('../../test/lib/env'); +const fs = require('fs-extra'); +const path = require('path'); + +describe('template-processor', () => { + afterEach(async () => { + await fs.emptyDir(PATH_TMP_DIR); + }); + describe('copyAndReplaceTemplate', () => { + it('copies template files to destination', async () => { + const templatePath = path.join(__dirname, '..', '..', 'assets', 'logicFunction'); + const destinationPath = path.join(PATH_TMP_DIR, 'tmp-logic-function'); + const replacements = { + name: 'My Logic Function', + description: 'My Logic Function Description', + }; + let templates = await fs.readdir(templatePath); + // filter out @types from templates + templates = templates.filter((template) => !template.includes('@types')); + const createdFiles = []; + for (const template of templates){ + const createdFile = await copyAndReplaceTemplate({ + file: template, + templatePath, + destinationPath, + replacements + }); + createdFiles.push(createdFile); + } + // check files were copied + const files = await fs.readdir(destinationPath); + expect(files).to.have.lengthOf(templates.length); + expect(createdFiles).to.have.lengthOf(templates.length); + const templateFileNames = templates.map((file) => file.replace('.template', '')); + expect(files).to.have.all.members(templateFileNames); + // check content was replaced + const codeContent = await fs.readFile(path.join(destinationPath, 'logic_function_name.logic.json'), 'utf8'); + expect(codeContent).to.include(replacements.name); + expect(codeContent).to.include(replacements.description); + }); + }); + + describe('hasTemplateFiles', () => { + const logicFunctionPath = 'tmp-logic-function'; + beforeEach(async () => { + await fs.emptyDir(path.join(PATH_TMP_DIR, logicFunctionPath)); + }); + + it('returns true if template files exist in destination', async () => { + const templatePath = path.join(__dirname, '..', '..', 'assets', 'logicFunction'); + const destinationPath = path.join(PATH_TMP_DIR, logicFunctionPath); + const replacements = { + name: 'My Logic Function', + description: 'My Logic Function Description', + }; + let templates = await fs.readdir(templatePath); + // filter out @types from templates + templates = templates.filter((template) => !template.includes('@types')); + for (const template of templates){ + await copyAndReplaceTemplate({ + file: template, + templatePath, + destinationPath, + replacements + }); + } + const hasFiles = await hasTemplateFiles({ templatePath, destinationPath }); + expect(hasFiles).to.be.true; + }); + it('returns false if template files do not exist in destination', async () => { + const templatePath = path.join(__dirname, '..', '..', 'assets', 'logicFunction'); + const destinationPath = path.join(PATH_TMP_DIR, logicFunctionPath); + const hasFiles = await hasTemplateFiles({ templatePath, destinationPath }); + expect(hasFiles).to.be.false; + }); + }); + +}); diff --git a/src/lib/utilities.js b/src/lib/utilities.js index 824b8d686..f446a2845 100644 --- a/src/lib/utilities.js +++ b/src/lib/utilities.js @@ -389,6 +389,23 @@ module.exports = { const savedProp = fs.readFileSync(propPath, 'utf8'); const parsedPropFile = propertiesParser.parse(savedProp); return parsedPropFile; - } + }, + + /** + * Converts a string to the slug version by replacing + * spaces and underscores with dashes, changing + * to lowercase, and removing anything other than + * numbers, letters, and dashes + * @param {String} str string to slugify + * @return {String} slugified version of str + */ + slugify(str) { + const slug = str.trim().toLowerCase() + // replace every group of spaces and underscores with a single hyphen + .replace(/[ _]+/g, '-') + // delete everything other than letters, numbers and hyphens + .replace(/[^a-z0-9-]/g, ''); + return slug; + }, };