Files
configure-pages/src/config-parser.js

437 lines
15 KiB
JavaScript

const fs = require('fs')
const espree = require('espree')
const core = require('@actions/core')
/*
Parse a JavaScript based configuration file and inject arbitrary key/value in it.
This is used to make sure most static site generators can automatically handle
Pages's path based routing (and work).
Supported configuration initializations:
(1) Direct default export:
export default {
// configuration object here
}
(2) Direct module export:
module.exports = {
// configuration object here
}
(3) Indirect default export:
const config = {
// configuration object here
}
export default config
(4) Indirect module export:
const config = {
// configuration object here
}
module.exports = config
(5) Direct default export with wrapping call:
export default defineConfig({
// configuration object here
})
(6) Direct module export with wrapping call:
module.exports = defineConfig({
// configuration object here
})
(7) Indirect default export with wrapping call at the definition:
const config = defineConfig({
// configuration object here
})
export default config
(8) Indirect default export with wrapping call at the export:
const config = {
// configuration object here
}
export default defineConfig(config)
(9) Indirect module export with wrapping call at the definition:
const config = defineConfig({
// configuration object here
})
module.exports = config
(10) Indirect module export with wrapping call at the export:
const config = {
// configuration object here
}
module.exports = defineConfig(config)
*/
class ConfigParser {
// Ctor
// - configurationFile: path to the configuration file
// - blankConfigurationFile: a blank configuration file to use if non was previously found
constructor({ configurationFile, blankConfigurationFile, allowWrappingCall = false, properties }) {
// Save field
this.configurationFile = configurationFile
this.allowWrappingCall = allowWrappingCall === true
this.properties = properties
// If the configuration file does not exist, initialize it with the blank configuration file
if (!fs.existsSync(this.configurationFile)) {
core.info('Using default blank configuration')
const blankConfiguration = fs.readFileSync(blankConfigurationFile, 'utf8')
fs.writeFileSync(this.configurationFile, blankConfiguration, {
encoding: 'utf8'
})
}
// Read the configuration file
this.configuration = fs.readFileSync(this.configurationFile, 'utf8')
}
findTopLevelVariableDeclarator(ast, identifierName) {
let targetDeclarator
ast.body.find(
node =>
node.type === 'VariableDeclaration' &&
node.declarations &&
node.declarations.length > 0 &&
node.declarations.find(declarator => {
if (
declarator.type === 'VariableDeclarator' &&
declarator.id &&
declarator.id.type === 'Identifier' &&
declarator.id.name === identifierName
) {
targetDeclarator = declarator
return true
}
})
)
return targetDeclarator
}
// Find the configuration object in an AST.
// Look for, in order:
// - a direct default export
// - a direct default export with a wrapping call
// - an indirect default export
// - an indirect default export with a wrapping call at the definition
// - an indirect default export with a wrapping call at the export
// - a direct module export
// - a direct module export with a wrapping call
// - an indirect module export
// - an indirect module export with a wrapping call at the definition
// - an indirect module export with a wrapping call at the export
//
// Return the configuration object or null.
findConfigurationObject(ast, allowWrappingCall = false) {
// Try to find a default export
var defaultExport = ast.body.find(node => node.type === 'ExportDefaultDeclaration')
// Direct default export
if (defaultExport && defaultExport.declaration.type === 'ObjectExpression') {
core.info('Found configuration object in direct default export declaration')
return defaultExport.declaration
}
// Direct default export with a wrapping call
else if (
allowWrappingCall &&
defaultExport &&
defaultExport.declaration.type === 'CallExpression' &&
defaultExport.declaration.arguments.length > 0 &&
defaultExport.declaration.arguments[0] &&
defaultExport.declaration.arguments[0].type === 'ObjectExpression'
) {
core.info('Found configuration object in direct default export declaration with a wrapping call')
return defaultExport.declaration.arguments[0]
}
// Indirect default export
else if (defaultExport && defaultExport.declaration.type === 'Identifier') {
const identifierName = defaultExport.declaration.name
const identifierDeclarator = this.findTopLevelVariableDeclarator(ast, identifierName)
const identifierInitialization = identifierDeclarator && identifierDeclarator.init
if (identifierInitialization && identifierInitialization.type === 'ObjectExpression') {
core.info('Found configuration object in indirect default export declaration')
return identifierInitialization
}
// Indirect default export with a wrapping call at the definition
else if (
allowWrappingCall &&
identifierInitialization &&
identifierInitialization.type === 'CallExpression' &&
identifierInitialization.arguments.length > 0 &&
identifierInitialization.arguments[0] &&
identifierInitialization.arguments[0].type === 'ObjectExpression'
) {
core.info(
'Found configuration object in indirect default export declaration with a wrapping call at the definition'
)
return identifierInitialization.arguments[0]
}
}
// Indirect default export with a wrapping call at the export
else if (
allowWrappingCall &&
defaultExport &&
defaultExport.declaration.type === 'CallExpression' &&
defaultExport.declaration.arguments.length > 0 &&
defaultExport.declaration.arguments[0] &&
defaultExport.declaration.arguments[0].type === 'Identifier'
) {
const identifierName = defaultExport.declaration.arguments[0].name
const identifierDeclarator = this.findTopLevelVariableDeclarator(ast, identifierName)
const identifierInitialization = identifierDeclarator && identifierDeclarator.init
if (identifierInitialization && identifierInitialization.type === 'ObjectExpression') {
core.info(
'Found configuration object in indirect default export declaration with a wrapping call at the export'
)
return identifierInitialization
}
}
// 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'
)
// Direct module export
if (moduleExport && moduleExport.expression.right.type === 'ObjectExpression') {
core.info('Found configuration object in direct module export')
return moduleExport.expression.right
}
// Direct default export with a wrapping call
else if (
allowWrappingCall &&
moduleExport &&
moduleExport.expression.right.type === 'CallExpression' &&
moduleExport.expression.right.arguments.length > 0 &&
moduleExport.expression.right.arguments[0] &&
moduleExport.expression.right.arguments[0].type === 'ObjectExpression'
) {
core.info('Found configuration object in direct module export with a wrapping call')
return moduleExport.expression.right.arguments[0]
}
// Indirect module export
else if (moduleExport && moduleExport.expression.right.type === 'Identifier') {
const identifierName = moduleExport && moduleExport.expression.right.name
const identifierDeclarator = this.findTopLevelVariableDeclarator(ast, identifierName)
const identifierInitialization = identifierDeclarator && identifierDeclarator.init
if (identifierInitialization && identifierInitialization.type === 'ObjectExpression') {
core.info('Found configuration object in indirect module export')
return identifierInitialization
}
// Indirect module export with a wrapping call at the definition
else if (
allowWrappingCall &&
identifierInitialization &&
identifierInitialization.type === 'CallExpression' &&
identifierInitialization.arguments.length > 0 &&
identifierInitialization.arguments[0] &&
identifierInitialization.arguments[0].type === 'ObjectExpression'
) {
core.info('Found configuration object in indirect module export with a wrapping call at the definition')
return identifierInitialization.arguments[0]
}
}
// Indirect module export with a wrapping call at the export
else if (
allowWrappingCall &&
moduleExport &&
moduleExport.expression.right.type === 'CallExpression' &&
moduleExport.expression.right.arguments.length > 0 &&
moduleExport.expression.right.arguments[0] &&
moduleExport.expression.right.arguments[0].type === 'Identifier'
) {
const identifierName = moduleExport.expression.right.arguments[0].name
const identifierDeclarator = this.findTopLevelVariableDeclarator(ast, identifierName)
const identifierInitialization = identifierDeclarator && identifierDeclarator.init
if (identifierInitialization && identifierInitialization.type === 'ObjectExpression') {
core.info('Found configuration object in indirect module export declaration with a wrapping call at the export')
return identifierInitialization
}
}
// No configuration object found
return null
}
// 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 property's value (if found) or null
if (property) {
return property.value
}
return null
}
// Generate a (nested) property declaration.
// - properties: list of properties to generate
// - startIndex: the index at which to start in the declaration
// - propertyValue: the value of the property
//
// Return a nested property declaration as a string.
getPropertyDeclaration(properties, startIndex, propertyValue) {
if (startIndex === properties.length - 1) {
return `${properties[startIndex]}: ${JSON.stringify(propertyValue)}`
} else {
return (
`${properties[startIndex]}: {` + this.getPropertyDeclaration(properties, startIndex + 1, propertyValue) + '}'
)
}
}
// Inject all properties into the configuration
injectAll() {
for (var [propertyName, propertyValue] of Object.entries(this.properties)) {
this.inject(propertyName, propertyValue)
}
}
// Inject an arbitrary property into the configuration
// - propertyName: the name of the property (may use . to target nested objects)
// - propertyValue: the value of the property
inject(propertyName, propertyValue) {
// Logging
core.info(`Injecting property=${propertyName} and value=${propertyValue} in:`)
core.info(this.configuration)
// Parse the AST out of the configuration file
const espreeOptions = {
ecmaVersion: 'latest',
sourceType: 'module',
range: true
}
const ast = espree.parse(this.configuration, espreeOptions)
// Find the configuration object
var configurationObject = this.findConfigurationObject(ast, this.allowWrappingCall)
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 = propertyName.split('.')
var lastNode = configurationObject
// eslint-disable-next-line no-constant-condition
while (true) {
// 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
}
}
// 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.range[0]) +
JSON.stringify(propertyValue) +
this.configuration.slice(lastNode.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]) +
JSON.stringify(propertyValue) +
this.configuration.slice(lastNode.range[1])
}
}
// Create nested properties in the configuration file
else {
// Build the declaration to inject
const declaration = this.getPropertyDeclaration(properties, depth, propertyValue)
// 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])
}
}
// Logging
core.info('Injection successful, new configuration:')
core.info(this.configuration)
// Finally write the new configuration in the file
fs.writeFileSync(this.configurationFile, this.configuration, {
encoding: 'utf8'
})
}
}
module.exports = { ConfigParser }