diff --git a/src/blank-configurations/gatsby.js b/src/blank-configurations/gatsby.js new file mode 100644 index 0000000..9e32e30 --- /dev/null +++ b/src/blank-configurations/gatsby.js @@ -0,0 +1,2 @@ +// Default Pages configuration for Gatsby +module.exports = {} diff --git a/src/blank-configurations/next.js b/src/blank-configurations/next.js new file mode 100644 index 0000000..ab2254a --- /dev/null +++ b/src/blank-configurations/next.js @@ -0,0 +1,3 @@ +// Default Pages configuration for Next +const nextConfig = {} +module.exports = nextConfig diff --git a/src/blank-configurations/nuxt.js b/src/blank-configurations/nuxt.js new file mode 100644 index 0000000..f6d70b9 --- /dev/null +++ b/src/blank-configurations/nuxt.js @@ -0,0 +1,2 @@ +// Default Pages configuration for Nuxt +export default {} diff --git a/src/config-parser.js b/src/config-parser.js index 58f89e5..404cefb 100644 --- a/src/config-parser.js +++ b/src/config-parser.js @@ -1,209 +1,281 @@ const fs = require('fs') const espree = require('espree') -const format = require('string-format') const prettier = require('prettier') const core = require('@actions/core') -// Parse the AST -const espreeOptions = { - ecmaVersion: 6, - sourceType: 'module', - range: true -} +/* +Parse a JavaScript based configuration file and initialize or update a given property. +This is used to make sure most static site generators can automatically handle +Pages's path based routing. -class ConfigParser { - constructor(staticSiteConfig) { - this.pathPropertyNuxt = `router: { base: '{0}' }` - this.pathPropertyNext = `basePath: '{0}'` - this.pathPropertyGatsby = `pathPrefix: '{0}'` - this.configskeleton = `export default { {0} }` - this.staticSiteConfig = staticSiteConfig - this.config = fs.existsSync(this.staticSiteConfig.filePath) - ? fs.readFileSync(this.staticSiteConfig.filePath, 'utf8') - : null - this.validate() - } +Supported configuration initializations: - validate() { - if (!this.config) { - core.info(`original raw configuration was empty:\n${this.config}`) - core.info('Generating a default configuration to start from...') +(1) Default export: - // Update the `config` property with a default configuration file - this.config = this.generateConfigFile() - } + export default { + // configuration object here } - generateConfigFile() { - switch (this.staticSiteConfig.type) { - case 'nuxt': - return format( - this.configskeleton, - format(this.pathPropertyNuxt, this.staticSiteConfig.newPath) - ) - case 'next': - return format( - this.configskeleton, - format(this.pathPropertyNext, this.staticSiteConfig.newPath) - ) - case 'gatsby': - return format( - this.configskeleton, - format(this.pathPropertyGatsby, this.staticSiteConfig.newPath) - ) - default: - throw 'Unknown config type' - } +(2) Direct module export: + + module.exports = { + // configuration object here } - generateConfigProperty() { - switch (this.staticSiteConfig.type) { - case 'nuxt': - return format(this.pathPropertyNuxt, this.staticSiteConfig.newPath) - case 'next': - return format(this.pathPropertyNext, this.staticSiteConfig.newPath) - case 'gatsby': - return format(this.pathPropertyGatsby, this.staticSiteConfig.newPath) - default: - throw 'Unknown config type' +(3) Indirect module export: + + const config = // configuration object here + module.exports = config +*/ + +class ConfigParser { + // Ctor + // - configurationFile: path to the configuration file + // - propertyName: name of the property to update (or set) + // - propertyValue: value of the property to update (or set) + // - blankConfigurationFile: a blank configuration file to use if non was previously found + constructor({ + configurationFile, + propertyName, + propertyValue, + blankConfigurationFile + }) { + // Save fields + this.configurationFile = configurationFile + this.propertyName = propertyName + this.propertyValue = propertyValue + + // If the configuration file does not exist, initialize it with the blank configuration file + if (!fs.existsSync(this.configurationFile)) { + core.info('Use default blank configuration') + const blankConfiguration = fs.readFileSync(blankConfigurationFile, 'utf8') + fs.writeFileSync(this.configurationFile, blankConfiguration, { + encoding: 'utf8' + }) } + + // Read the configuration file + core.info('Read existing configuration') + this.configuration = fs.readFileSync(this.configurationFile, 'utf8') } - parse() { - // Print current configuration - core.info(`original configuration:\n${this.config}`) + // Find the configuration object in an AST. + // Look for a default export, a direct module export or an indirect module + // export (in that order). + // + // Return the configuration object or null. + findConfigurationObject(ast) { + // Try to find a default export + var defaultExport = ast.body.find( + node => + node.type === 'ExportDefaultDeclaration' && + node.declaration.type === 'ObjectExpression' + ) + if (defaultExport) { + core.info('Found configuration object in default export declaration') + return defaultExport.declaration + } - // Parse the AST - const ast = espree.parse(this.config, espreeOptions) + // Try to find a module export + var moduleExport = ast.body.find( + node => + node.type === 'ExpressionStatement' && + node.expression.type === 'AssignmentExpression' && + node.expression.operator === '=' && + node.expression.left.type === 'MemberExpression' && + node.expression.left.object.type === 'Identifier' && + node.expression.left.object.name === 'module' && + node.expression.left.property.type === 'Identifier' && + node.expression.left.property.name === 'exports' + ) - // Find the default export declaration node - var exportNode = ast.body.find(node => node.type === 'ExpressionStatement') - if (exportNode) { - var property = this.getPropertyModuleExport(exportNode) - } else { - exportNode = ast.body.find( - node => node.type === 'ExportDefaultDeclaration' - ) - if (!exportNode) throw 'Unable to find default export' - var property = this.getPropertyExportDefault(exportNode) + // Direct module export + if ( + moduleExport && + moduleExport.expression.right.type === 'ObjectExpression' + ) { + core.info('Found configuration object in direct module export') + return moduleExport.expression.right } - if (property) { - switch (this.staticSiteConfig.type) { - case 'nuxt': - this.parseNuxt(property) - break - case 'next': - case 'gatsby': - this.parseNextGatsby(property) - break - default: - throw 'Unknown config type' + // Indirect module export + else if ( + moduleExport && + moduleExport.expression.right.type === 'Identifier' + ) { + const identifierName = moduleExport && moduleExport.expression.right.name + const identifierDefinition = ast.body.find( + node => + node.type === 'VariableDeclaration' && + node.declarations.length == 1 && + node.declarations[0].type === 'VariableDeclarator' && + node.declarations[0].id.type === 'Identifier' && + node.declarations[0].id.name === identifierName && + node.declarations[0].init.type === 'ObjectExpression' + ) + if (identifierDefinition) { + core.info('Found configuration object in indirect module export') + return identifierDefinition.declarations[0].init } } - // Write down the updated configuration - core.info(`parsed configuration:\n${this.config}`) - fs.writeFileSync(this.staticSiteConfig.filePath, this.config) + // No configuration object found + return null + } - // Format the updated configuration with prettier's default settings - this.config = prettier.format(this.config, { - filePath: this.staticSiteConfig.filePath, - parser: 'babel' /* default ot javascript for when filePath is nil */ - }) + // Find a property with a given name on a given object. + // + // Return the matching property or null. + findProperty(object, name) { + // Try to find a property matching a given name + const property = + object.type === 'ObjectExpression' && + object.properties.find( + node => node.key.type === 'Identifier' && node.key.name === name + ) - // Return the new configuration - return this.config + // Return the property's value (if found) or null + if (property) { + return property.value + } + return null } - getPropertyModuleExport(exportNode) { - var propertyNode = exportNode.expression.right.properties.find( - node => - node.key.type === 'Identifier' && - node.key.name === this.staticSiteConfig.pathName - ) - - if (!propertyNode) { - core.info( - 'Unable to find property, insert it : ' + - this.staticSiteConfig.pathName + // Generate a (nested) property declaration. + // - properties: list of properties to generate + // - startIndex: the index at which to start in the declaration + // + // Return a nested property declaration as a string. + getPropertyDeclaration(properties, startIndex) { + if (startIndex === properties.length - 1) { + return `${properties[startIndex]}: "${this.propertyValue}"` + } else { + return ( + `${properties[startIndex]}: {` + + this.getPropertyDeclaration(properties, startIndex + 1) + + '}' ) - if (exportNode.expression.right.properties.length > 0) { - this.config = - this.config.slice( - 0, - exportNode.expression.right.properties[0].range[0] - ) + - this.generateConfigProperty() + - ',' + - this.config.slice(exportNode.expression.right.properties[0].range[0]) - core.info('new config = \n' + this.config) - } else { - this.config = - this.config.slice(0, exportNode.expression.right.range[0] + 1) + - this.generateConfigProperty() + - this.config.slice(exportNode.expression.right.range[1] - 1) - core.info('new config = \n' + this.config) - } } - return propertyNode } - getPropertyExportDefault(exportNode) { - var propertyNode = exportNode.declaration.properties.find( - node => - node.key.type === 'Identifier' && - node.key.name === this.staticSiteConfig.pathName - ) + parse() { + // Logging + core.info(`Parsing configuration:\n${this.configuration}`) - if (!propertyNode) { - core.info( - 'Unable to find property, insert it ' + this.staticSiteConfig.pathName - ) - if (exportNode.declaration.properties.length > 0) { - this.config = - this.config.slice(0, exportNode.declaration.properties[0].range[0]) + - this.generateConfigProperty() + - ',' + - this.config.slice(exportNode.declaration.properties[0].range[0]) - core.info('new config = \n' + this.config) - } else { - this.config = - this.config.slice(0, exportNode.declaration.range[0] + 1) + - this.generateConfigProperty() + - this.config.slice(exportNode.declaration.range[1] - 1) - core.info('new config = \n' + this.config) + // Parse the AST out of the configuration file + const espreeOptions = { + ecmaVersion: 6, + sourceType: 'module', + range: true + } + const ast = espree.parse(this.configuration, espreeOptions) + + // Find the configuration object + var configurationObject = this.findConfigurationObject(ast) + if (!configurationObject) { + throw 'Could not find a configuration object in the configuration file' + } + + // A property may be nested in the configuration file. Split the property name with `.` + // then walk the configuration object one property at a time. + var depth = 0 + const properties = this.propertyName.split('.') + var lastNode = configurationObject + while (1) { + // Find the node for the current property + var propertyNode = this.findProperty(lastNode, properties[depth]) + + // Update last node + if (propertyNode != null) { + lastNode = propertyNode + depth++ + } + + // Exit when exiting the current configuration object + if (propertyNode == null || depth >= properties.length) { + break } } - return propertyNode - } + // If the configuration file is defining the property we are after, update it. + if (depth == properties.length) { + // The last node identified is an object expression, so do the assignment + if (lastNode.type === 'ObjectExpression') { + this.configuration = + this.configuration.slice(0, lastNode.value.range[0]) + + `"${this.propertyValue}"` + + this.configuration.slice(lastNode.value.range[1]) + } - parseNuxt(propertyNode) { - // Find the base node - if (propertyNode && propertyNode.value.type === 'ObjectExpression') { - var baseNode = propertyNode.value.properties.find( - node => - node.key.type === 'Identifier' && - node.key.name === this.staticSiteConfig.subPathName - ) //'base') - if (baseNode) { - // Swap the base value by a hardcoded string and print it - this.config = - this.config.slice(0, baseNode.value.range[0]) + - `'${this.staticSiteConfig.newPath}'` + - this.config.slice(baseNode.value.range[1]) + // A misc object was found in the configuration file (e.g. an array, a string, a boolean, + // a number, etc.), just replace the whole range by our declaration + else { + this.configuration = + this.configuration.slice(0, lastNode.range[0]) + + `"${this.propertyValue}"` + + this.configuration.slice(lastNode.range[1]) } } - } - parseNextGatsby(pathNode) { - if (pathNode) { - this.config = - this.config.slice(0, pathNode.value.range[0]) + - `'${this.staticSiteConfig.newPath}'` + - this.config.slice(pathNode.value.range[1]) + // Create nested properties in the configuration file + else { + // Build the declaration to inject + const declaration = this.getPropertyDeclaration(properties, depth) + + // The last node identified is an object expression, so do the assignment + if (lastNode.type === 'ObjectExpression') { + // The object is blank (no properties) so replace the whole range by a new object containing the declaration + if (lastNode.properties.length === 0) { + this.configuration = + this.configuration.slice(0, lastNode.range[0]) + + '{' + + declaration + + '}' + + this.configuration.slice(lastNode.range[1]) + } + + // The object contains other properties, prepend our new one at the beginning + else { + this.configuration = + this.configuration.slice(0, lastNode.properties[0].range[0]) + + declaration + + ',' + + this.configuration.slice(lastNode.properties[0].range[0]) + } + } + + // A misc object was found in the configuration file (e.g. an array, a string, a boolean, + // a number, etc.), just replace the whole range by our declaration + else { + this.configuration = + this.configuration.slice(0, lastNode.range[0]) + + declaration + + this.configuration.slice(lastNode.range[1]) + } } + + // Format the updated configuration with prettier's default settings + this.configuration = prettier.format(this.configuration, { + parser: 'espree', + + // Matching this repo's prettier configuration + printWidth: 80, + tabWidth: 2, + useTabs: false, + semi: false, + singleQuote: true, + trailingComma: 'none', + bracketSpacing: false, + arrowParens: 'avoid' + }) + + // Logging + core.info(`Parsing configuration:\n${this.configuration}`) + + // Finally write the new configuration in the file + fs.writeFileSync(this.configurationFile, this.configuration, { + encoding: 'utf8' + }) } } diff --git a/src/config-parser.test.js b/src/config-parser.test.js index 6000e5b..e12631d 100644 --- a/src/config-parser.test.js +++ b/src/config-parser.test.js @@ -12,76 +12,73 @@ const cases = [ [ 'next.config.js', { - filePath: `${tmpFolder}/next.config.js`, - type: 'next', - pathName: 'basePath', - newPath: repoPath + configurationFile: `${tmpFolder}/next.config.js`, + propertyName: 'basePath', + propertyValue: repoPath, + blankConfigurationFile: `${process.cwd()}/src/blank-configurations/next.js` } ], [ 'next.config.old.js', { - filePath: `${tmpFolder}/next.config.old.js`, - type: 'next', - pathName: 'basePath', - newPath: repoPath + configurationFile: `${tmpFolder}/next.config.old.js`, + propertyName: 'basePath', + propertyValue: repoPath, + blankConfigurationFile: `${process.cwd()}/src/blank-configurations/next.js` } ], [ 'next.config.old.missing.js', { - filePath: `${tmpFolder}/next.config.old.missing.js`, - type: 'next', - pathName: 'basePath', - newPath: repoPath + configurationFile: `${tmpFolder}/next.config.old.missing.js`, + propertyName: 'basePath', + propertyValue: repoPath, + blankConfigurationFile: `${process.cwd()}/src/blank-configurations/next.js` } ], [ 'gatsby-config.js', { - filePath: `${tmpFolder}/gatsby-config.js`, - type: 'gatsby', - pathName: 'pathPrefix', - newPath: repoPath + configurationFile: `${tmpFolder}/gatsby-config.js`, + propertyName: 'pathPrefix', + propertyValue: repoPath, + blankConfigurationFile: `${process.cwd()}/src/blank-configurations/gatsby.js` } ], [ 'gatsby-config.old.js', { - filePath: `${tmpFolder}/gatsby-config.old.js`, - type: 'gatsby', - pathName: 'pathPrefix', - newPath: repoPath + configurationFile: `${tmpFolder}/gatsby-config.old.js`, + propertyName: 'pathPrefix', + propertyValue: repoPath, + blankConfigurationFile: `${process.cwd()}/src/blank-configurations/gatsby.js` } ], [ 'nuxt.config.js', { - filePath: `${tmpFolder}/nuxt.config.js`, - type: 'nuxt', - pathName: 'router', - subPathName: 'base', - newPath: repoPath + configurationFile: `${tmpFolder}/nuxt.config.js`, + propertyName: 'router.base', + propertyValue: repoPath, + blankConfigurationFile: `${process.cwd()}/src/blank-configurations/nuxt.js` } ], [ 'nuxt.config.missing.js', { - filePath: `${tmpFolder}/nuxt.config.missing.js`, - type: 'nuxt', - pathName: 'router', - subPathName: 'base', - newPath: repoPath + configurationFile: `${tmpFolder}/nuxt.config.missing.js`, + propertyName: 'router.base', + propertyValue: repoPath, + blankConfigurationFile: `${process.cwd()}/src/blank-configurations/nuxt.js` } ], [ 'nuxt.config.old.js', { - filePath: `${tmpFolder}/nuxt.config.old.js`, - type: 'nuxt', - pathName: 'router', - subPathName: 'base', - newPath: repoPath + configurationFile: `${tmpFolder}/nuxt.config.old.js`, + propertyName: 'router.base', + propertyValue: repoPath, + blankConfigurationFile: `${process.cwd()}/src/blank-configurations/nuxt.js` } ] ] diff --git a/src/fixtures/expected/nuxt.config.missing.js b/src/fixtures/expected/nuxt.config.missing.js index 4658555..d0e0e81 100644 --- a/src/fixtures/expected/nuxt.config.missing.js +++ b/src/fixtures/expected/nuxt.config.missing.js @@ -1,9 +1,7 @@ import {resolve} from 'path' export default { - router: { - base: '/amazing-new-repo/' - }, + router: {base: '/amazing-new-repo/'}, alias: { style: resolve(__dirname, './assets/style') }, diff --git a/src/fixtures/x.js b/src/fixtures/x.js new file mode 100644 index 0000000..2e5375a --- /dev/null +++ b/src/fixtures/x.js @@ -0,0 +1,3 @@ +module.exports = { + pathPrefix: "hello", +}; diff --git a/src/set-pages-path.js b/src/set-pages-path.js index 8a159a6..eae20a5 100644 --- a/src/set-pages-path.js +++ b/src/set-pages-path.js @@ -1,41 +1,38 @@ const core = require('@actions/core') -const axios = require('axios') const {ConfigParser} = require('./config-parser') +function getParserConfiguration(staticSiteGenerator, path) { + switch (staticSiteGenerator) { + case 'nuxt': + return { + configurationFile: './nuxt.config.js', + propertyName: 'router.base', + propertyValue: path, + blankConfigurationFile: `${process.cwd()}/blank-configurations/nuxt.js` + } + case 'next': + return { + configurationFile: './next.config.js', + propertyName: 'basePath', + propertyValue: path, + blankConfigurationFile: `${process.cwd()}/blank-configurations/next.js` + } + case 'gatsby': + return { + configurationFile: './gatsby-config.js', + propertyName: 'pathPrefix', + propertyValue: path, + blankConfigurationFile: `${process.cwd()}/blank-configurations/gatsby.js` + } + default: + throw `Unsupported static site generator: ${staticSiteGenerator}` + } +} + async function setPagesPath({staticSiteGenerator, path}) { try { - switch (staticSiteGenerator) { - case 'nuxt': - var ssConfig = { - filePath: './nuxt.config.js', - type: 'nuxt', - pathName: 'router', - subPathName: 'base', - newPath: path - } - break - case 'next': - var ssConfig = { - filePath: './next.config.js', - type: 'next', - pathName: 'basePath', - newPath: path - } - break - case 'gatsby': - var ssConfig = { - filePath: './gatsby-config.js', - type: 'gatsby', - pathName: 'pathPrefix', - newPath: path - } - break - default: - throw 'Unknown config type' - } - - let configParser = new ConfigParser(ssConfig) - configParser.parse() + // Parse/mutate the configuration file + new ConfigParser(getParserConfiguration(staticSiteGenerator, path)).parse() } catch (error) { core.warning( `We were unable to determine how to inject the site metadata into your config. Generated URLs may be incorrect. The base URL for this site should be ${path}. Please ensure your framework is configured to generate relative links appropriately.`,