@antora/playbook-builder
Version:
Builds a playbook object from user input for configuring successive documentation components in an Antora pipeline.
173 lines (166 loc) • 5.5 kB
JavaScript
const convict = require('convict')
const json = require('json5')
const toml = require('@iarna/toml')
const yaml = require('js-yaml')
const ARGS_SCANNER_RX = /(?:([^=,]+)|(?==))(?:,|$|=(|("|').*?\3|[^,]+)(?:,|$))/g
const PRIMITIVE_TYPES = [Boolean, Number, String]
const YAML_SCHEMA = yaml.CORE_SCHEMA.extend({ implicit: [yaml.types.merge] })
/**
* A convict function wrapper that registers custom formats and parsers and
* isolates the configuration from the process environment by default.
*/
function solitaryConvict (schema, opts) {
registerFormats(convict)
registerParsers(convict)
return convict(schema, opts || { args: [], env: {} })
}
function registerParsers (convict) {
convict.addParser([
{ extension: 'json', parse: json.parse },
{ extension: 'toml', parse: toml.parse },
{ extension: 'yaml', parse: (source) => yaml.load(source, { schema: YAML_SCHEMA }) },
{ extension: 'yml', parse: (source) => yaml.load(source, { schema: YAML_SCHEMA }) },
{
extension: '*',
parse: () => {
throw new Error('Unknown playbook file extension: must be .yml (or .yaml), .json, or .toml')
},
},
])
}
function registerFormats (convict) {
convict.addFormat({
name: 'array-or-string',
validate: (val) => {
if (!(val == null || val.constructor === String || Array.isArray(val))) {
throw new Error('must be an array, string, or null')
}
},
})
convict.addFormat({
name: 'map',
validate: (val) => {
if (!(val == null || val.constructor === Object)) throw new Error('must be a map (i.e., key/value pairs)')
},
coerce: (val, config, name) => {
if (config == null) return val
const accum = config.has(name) ? config.get(name) : {}
let match
ARGS_SCANNER_RX.lastIndex = 0
while ((match = ARGS_SCANNER_RX.exec(val))) {
const [, k, v = ''] = match
if (!k) continue
let parsed = v
if (parsed && parsed !== '-') {
try {
parsed = yaml.load(v, { schema: yaml.CORE_SCHEMA })
} catch {}
}
accum[k] = parsed
}
return accum
},
})
convict.addFormat({
name: 'primitive-map',
validate: (val) => {
if (val == null) return
if (
!(
val.constructor === Object &&
Object.entries(val).every(([k, v]) => k && (!v || ~PRIMITIVE_TYPES.indexOf(v.constructor)))
)
) {
throw new Error('must be a primitive map (i.e., key/value pairs, primitive values only)')
}
},
coerce: (val, config, name) => {
if (config == null) return val
const accum = config.has(name) ? config.get(name) : {}
let match
ARGS_SCANNER_RX.lastIndex = 0
while ((match = ARGS_SCANNER_RX.exec(val))) {
const [, k, v] = match
if (k) {
let parsed
if (v && v !== '-') {
parsed = yaml.load(v, { schema: yaml.CORE_SCHEMA })
if (parsed && PRIMITIVE_TYPES.indexOf(parsed.constructor) < 0) parsed = v
} else {
parsed = v || ''
}
accum[~k.indexOf('-') ? k.replace(/-/g, '_') : k] = parsed
}
}
return accum
},
})
convict.addFormat({
name: 'require-array',
validate: (val) => {
if (!Array.isArray(val)) throw new Error('must be of type Array')
},
coerce: (val, config, name) => {
const accum = config?.has(name) ? config.get(name) : []
val.split(',').forEach((v) => {
if (~accum.indexOf(v)) return
const match = accum.find((it) => it.constructor === Object && it.id === v)
if (match) {
if (match.enabled === false) match.enabled = true
} else {
accum.push(v)
}
})
return accum
},
})
convict.addFormat({
name: 'boolean-or-string',
validate: (val) => {
if (!(val == null || val.constructor === String || val.constructor === Boolean)) {
throw new Error('must be a boolean or string')
}
},
})
convict.addFormat({
name: 'dir-or-virtual-files',
validate: (val) => {
if (!(val == null || val.constructor === String || Array.isArray(val))) {
throw new Error('must be a directory path or list of virtual files')
}
},
})
convict.addFormat({
name: 'url',
validate: (val) => {
if (val == null) return
if (val.constructor !== String) throw new Error('must be a string')
let protocol
try {
protocol = new URL(val).protocol
} catch {
throw new Error('must be a valid URL')
}
if (!(protocol === 'https:' || protocol === 'http:')) throw new Error('must be an HTTP or HTTPS URL')
},
})
convict.addFormat({
name: 'url-or-pathname',
validate: (val) => {
if (val == null) return
if (val.constructor !== String) throw new Error('must be a string')
let parsedUrl
try {
parsedUrl = new URL((val.charAt() === '/' ? 'https://example.org' : '') + val)
} catch {
throw new Error('must be a valid URL or a pathname (i.e., root-relative path)')
}
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
throw new Error('must be an HTTP or HTTPS URL or a pathname (i.e., root-relative path)')
}
if (~parsedUrl.pathname.indexOf('%20')) throw new Error('pathname segment must not contain spaces')
},
})
}
module.exports = solitaryConvict