@antora/assembler 1.0.0-alpha.9 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,13 @@
1
1
  # Antora Assembler
2
2
 
3
- A library for writing Antora extensions that combine multiple pages into one.
3
+ Antora Assembler provides a JavaScript library and a set of Antora extensions for merging content in an Antora site into assembly files and converting those files to another format, such as PDF.
4
4
 
5
- Assembler works by constructing aggregate AsciiDoc documents from the pages based on the navigation tree per component version.
6
- It then invokes the specified callback to convert those documents to an output format.
7
- If a catalog is specified, Assembler pushes the converted files back into the catalog to be published as attachments alongside the other files in the site.
5
+ The core of Assembler (this package) handles merging AsciiDoc content from multiple pages into an assembly.
6
+ It repeats this process for each selected component version.
7
+ It then delegates to a function or command (e.g., asciidoctor-pdf) designated by an exporter extension (a separate package) to convert the assembly files to an export format, such as PDF, resulting in an export.
8
+ Assembler then publishes the export files along with the other files in the site.
9
+
10
+ The exports can then be viewed offline, either by the browser or a reader application.
8
11
 
9
12
  [Antora](https://antora.org) is a modular static site generator designed for creating documentation sites from AsciiDoc documents.
10
13
  The Assembler extends the feature set of Antora by providing the foundation for page aggregation.
@@ -1,46 +1,234 @@
1
1
  'use strict'
2
2
 
3
+ const computeOut = require('./util/compute-out')
4
+ const fs = require('node:fs')
5
+ const { promises: fsp } = fs
6
+ const LazyReadable = require('./util/lazy-readable')
3
7
  const loadConfig = require('./load-config')
4
- const produceAggregateDocuments = require('./produce-aggregate-documents')
8
+ const ospath = require('node:path')
9
+ const { posix: path } = ospath
10
+ const produceAssemblyFiles = require('./produce-assembly-files')
5
11
  const PromiseQueue = require('./util/promise-queue')
6
- const runCommand = require('./util/run-command')
12
+ const { stringify: toJSON } = JSON
7
13
 
8
- async function assembleContent (playbook, contentCatalog, convertDocument, { siteCatalog, configSource }) {
9
- // Q: could we get ContentCatalog#getComponentVersionStartPage() in Antora core?
10
- if (typeof contentCatalog.getComponentVersionStartPage !== 'function') {
11
- contentCatalog.getComponentVersionStartPage = function (component, version) {
12
- return this.resolvePage('index.adoc', { component, version })
13
- }
14
- }
14
+ const invariably = { new: () => ({}), void: () => undefined }
15
+ const PACKAGE_NAME = require('../package.json').name
16
+
17
+ async function assembleContent (playbook, contentCatalog, converter, { configSource, navigationCatalog }) {
15
18
  const assemblerConfig = await loadConfig(playbook, configSource)
16
19
  if (!assemblerConfig) return [] // TODO consider removing and doing this another way
17
- const generatorFunctions = this ? this.getFunctions() : {}
20
+ const context = isBound(this)
21
+ ? this
22
+ : {
23
+ getFunctions: invariably.new,
24
+ getLogger: invariably.void,
25
+ getVariables: invariably.new,
26
+ }
27
+ const generatorFunctions = context.getFunctions()
18
28
  const { loadAsciiDoc = require('@antora/asciidoc-loader') } = generatorFunctions
19
- const aggregateDocuments = produceAggregateDocuments(loadAsciiDoc, contentCatalog, assemblerConfig)
20
- if (!convertDocument) return aggregateDocuments
29
+ const targetBackend = converter == null ? undefined : (converter.backend ?? converter.extname?.slice(1))
30
+ const { assembly: assemblyConfig, build: buildConfig } = assemblerConfig
31
+ const { profile = targetBackend } = assemblyConfig
32
+ const intrinsicAttributes = { 'loader-assembler': '' }
33
+ if (profile) {
34
+ buildConfig.dir ??= ospath.join(playbook.dir ?? process.cwd(), `build/assembler-${profile}`)
35
+ intrinsicAttributes[`assembler-profile-${profile}`] = ''
36
+ intrinsicAttributes['assembler-profile'] = profile
37
+ if (targetBackend) {
38
+ intrinsicAttributes[`assembler-backend-${targetBackend}`] = ''
39
+ intrinsicAttributes['assembler-backend'] = targetBackend
40
+ }
41
+ } else {
42
+ buildConfig.dir ??= ospath.join(playbook.dir ?? process.cwd(), 'build/assembler')
43
+ }
44
+ Object.assign(assemblerConfig.asciidoc.attributes, intrinsicAttributes)
45
+ const assemblyFiles = produceAssemblyFiles(
46
+ loadAsciiDoc,
47
+ contentCatalog,
48
+ assemblerConfig,
49
+ createResolveAssemblyModel(context, contentCatalog, assemblyConfig, intrinsicAttributes, navigationCatalog)
50
+ )
51
+ const { convert = converter, extname: targetExtname, mediaType: targetMediaType } = converter ?? {}
52
+ if (!(convert && assemblyFiles.length)) return assemblyFiles
21
53
  const { publishSite: publishFiles = require('@antora/site-publisher') } = generatorFunctions
22
- const buildConfig = assemblerConfig.build
23
- await prepareWorkspace(publishFiles, aggregateDocuments, contentCatalog, buildConfig)
24
- // TODO: pass more information to convertDocument so it doesn't have to compute internal stuff
25
- // Q: don't we need to pass in the combined/resolved AsciiDoc attributes per file or component version?
54
+ await prepareWorkspace(publishFiles, assemblyFiles, contentCatalog, buildConfig)
55
+ const boundConvert = convert.bind(context)
26
56
  return new PromiseQueue({ concurrency: buildConfig.processLimit })
27
- .add(aggregateDocuments.map((doc) => () => convertDocument.call(this, doc, buildConfig, runCommand)))
57
+ .add(
58
+ assemblyFiles.map((doc) => async () => {
59
+ const convertAttributes = prepareConvertAttributes(doc, targetExtname, buildConfig)
60
+ if (buildConfig.mkdirs) await fsp.mkdir(convertAttributes.outdir, { recursive: true, force: true })
61
+ return boundConvert(doc, convertAttributes, buildConfig).then(
62
+ (fileOrContents = new LazyReadable(() => fs.createReadStream(convertAttributes.outfile))) => {
63
+ return coerceToExportFormat(doc, targetBackend, targetExtname, targetMediaType, fileOrContents)
64
+ }
65
+ )
66
+ })
67
+ )
28
68
  .toPromise()
29
69
  .then((files) => {
30
- if (buildConfig.publish && siteCatalog) siteCatalog.addFiles(files)
31
- return files
70
+ if (!buildConfig.publish) {
71
+ for (const file of files) delete file.assembler.assembled
72
+ return files
73
+ }
74
+ const qualifyExports = buildConfig.qualifyExports
75
+ return files.map((file) => {
76
+ const pages = file.assembler.assembled.pages
77
+ delete file.assembler.assembled
78
+ file = contentCatalog.addFile(Object.assign(file, { out: computeOut.call(contentCatalog, file.src) }))
79
+ const extname = file.extname
80
+ const download = file.assembler.downloadStem + extname
81
+ if (qualifyExports) {
82
+ file.pub.url = '/' + (file.out.path = path.join(file.out.dirname, (file.out.basename = download)))
83
+ } else {
84
+ file.pub.download = download
85
+ }
86
+ pages.forEach((fragment, page) => {
87
+ const assemblerMeta = (page.assembler ??= {})
88
+ ;(assemblerMeta.exports ??= []).push({ fragment, file })
89
+ const extnameProp = extname.slice(1)
90
+ if (extnameProp in assemblerMeta) return
91
+ Object.defineProperty(assemblerMeta, extnameProp, {
92
+ configurable: true,
93
+ enumerable: true,
94
+ get: findFirstExportWithExtname.bind(assemblerMeta, extname),
95
+ })
96
+ })
97
+ return file
98
+ })
32
99
  })
33
100
  }
34
101
 
102
+ function createResolveAssemblyModel (context, contentCatalog, shared, intrinsicAttributes, navigationCatalog) {
103
+ const { assemblerProfiles } = context.getVariables()
104
+ if (!assemblerProfiles) {
105
+ return (componentVersion) => {
106
+ const navigation =
107
+ navigationCatalog?.getNavigation(componentVersion.name, componentVersion.version) ?? componentVersion.navigation
108
+ return Object.assign({}, shared, { navigation })
109
+ }
110
+ }
111
+ const boundSendToLog = sendToLog.bind(context.getLogger ? context.getLogger(PACKAGE_NAME) : undefined)
112
+ const { buildNavigation = require('@antora/navigation-builder') } = context.getFunctions()
113
+ return (componentVersion) => {
114
+ const componentVersionProfiles = assemblerProfiles.get(componentVersion.version + '@' + componentVersion.name)
115
+ const overrides =
116
+ componentVersionProfiles?.get(intrinsicAttributes['assembler-profile']) ?? componentVersionProfiles?.get()
117
+ const { navFiles, messages } = overrides
118
+ const model = Object.assign({}, shared, overrides)
119
+ delete model.navFiles
120
+ delete model.messages
121
+ const navigationOverride = navigationCatalog?.getNavigation(componentVersion.name, componentVersion.version)
122
+ if (navigationOverride) return Object.assign(model, { navigation: navigationOverride })
123
+ messages?.forEach(boundSendToLog)
124
+ model.navigation = componentVersion.navigation
125
+ if (!navFiles) return model
126
+ if (!navFiles.length) return Object.assign(model, { navigation: [] })
127
+ const navigation_ = model.navigation
128
+ const asciidoc_ = componentVersion.asciidoc
129
+ componentVersion.asciidoc = Object.assign({}, asciidoc_, {
130
+ attributes: Object.assign({}, asciidoc_.attributes, intrinsicAttributes),
131
+ })
132
+ buildNavigation(
133
+ new Proxy(contentCatalog, {
134
+ get (target, property) {
135
+ const method = target[property]
136
+ if (property !== 'findBy') return method
137
+ return (criteria) => (toJSON(criteria) === '{"family":"nav"}' ? navFiles : method.call(target, criteria))
138
+ },
139
+ })
140
+ )
141
+ model.navigation = componentVersion.navigation
142
+ Object.assign(componentVersion, { asciidoc: asciidoc_, navigation: navigation_ })
143
+ return model
144
+ }
145
+ }
146
+
147
+ function prepareConvertAttributes (doc, targetExtname, buildConfig) {
148
+ const {
149
+ asciidoc: { attributes: docAttributes } = { attributes: {} },
150
+ extname: docfilesuffix,
151
+ path: reldocfile,
152
+ src: { family, relative },
153
+ } = doc
154
+ const { cwd = process.cwd(), dir = cwd } = buildConfig
155
+ const docname = family + '$' + relative.slice(0, relative.length - docfilesuffix.length)
156
+ const docfile = ospath.join(dir, reldocfile)
157
+ const docdir = dir
158
+ const imagesdir = ''
159
+ const outfile = docfile.slice(0, docfile.length - docfilesuffix.length) + targetExtname
160
+ const attributes = Object.assign({}, docAttributes, {
161
+ docdir,
162
+ docfile,
163
+ docfilesuffix,
164
+ 'docname@': docname,
165
+ imagesdir,
166
+ outdir: ospath.dirname(docfile),
167
+ outfile,
168
+ outfilesuffix: targetExtname,
169
+ toArgs (optionFlag, command) {
170
+ const padCharRef = process.platform === 'win32' && command.startsWith('bundle exec ')
171
+ const args = []
172
+ for (let [name, val] of Object.entries(this)) {
173
+ if (val) {
174
+ val = `${name}=${padCharRef && typeof val.charAt === 'function' && val.charAt() === '&' ? ' ' : ''}${val}`
175
+ } else if (val === '') {
176
+ val = name
177
+ } else {
178
+ val = `!${name}${val === false ? '@' : ''}`
179
+ }
180
+ args.push('-a', val)
181
+ }
182
+ return args
183
+ },
184
+ })
185
+ return Object.defineProperty(attributes, 'toArgs', { enumerable: false })
186
+ }
187
+
188
+ function coerceToExportFormat (originalFile, targetBackend, targetExtname, targetMediaType, fileOrContents) {
189
+ const file =
190
+ fileOrContents == null || Buffer.isBuffer(fileOrContents) || typeof fileOrContents.pipe === 'function'
191
+ ? Object.assign(originalFile, { contents: fileOrContents })
192
+ : fileOrContents
193
+ ;(file.assembler ??= {}).backend = targetBackend
194
+ if (file.extname === targetExtname) return file
195
+ const sourcePath = file.path
196
+ const sourceExtname = file.extname
197
+ const relativeWithoutExtname = file.src.relative.slice(0, file.src.relative.length - sourceExtname.length)
198
+ const newPath = sourcePath.slice(0, sourcePath.length - sourceExtname.length) + targetExtname
199
+ Object.assign(file, { mediaType: (file.src.mediaType = targetMediaType), path: newPath })
200
+ file.src.basename = path.basename((file.src.relative = relativeWithoutExtname + (file.src.extname = targetExtname)))
201
+ return file
202
+ }
203
+
204
+ function findFirstExportWithExtname (extname) {
205
+ return this.exports.find((it) => it.file.extname === extname)
206
+ }
207
+
35
208
  // TODO: if no workspace dir is defined, we shouldn't continue
36
- function prepareWorkspace (publishFiles, aggregateDocuments, contentCatalog, buildConfig) {
37
- const { dir, clean, keepAggregateSource } = buildConfig
38
- const files = contentCatalog.findBy({ family: 'image' }).filter(({ out }) => out?.assembled)
39
- if (keepAggregateSource) {
40
- files.push(...aggregateDocuments.map((file) => Object.assign(file, { out: { path: file.path } })))
209
+ function prepareWorkspace (publishFiles, assemblyFiles, contentCatalog, buildConfig) {
210
+ const { dir, clean, keepSource } = buildConfig
211
+ const files = []
212
+ const outPaths = new Set()
213
+ for (const file of assemblyFiles) {
214
+ for (const asset of file.assembler.assembled.assets) {
215
+ if (outPaths.has(asset.out.path)) continue
216
+ files.push(asset)
217
+ outPaths.add(asset.out.path)
218
+ }
41
219
  }
42
- // TODO: site publisher should accept a single catalog
43
- return publishFiles({ output: { clean, dir } }, [{ getFiles: () => files }])
220
+ if (keepSource) files.push(...assemblyFiles.map((file) => Object.assign(file, { out: { path: file.path } })))
221
+ return publishFiles({ output: { clean, dir } }, { getFiles: () => files })
222
+ }
223
+
224
+ function isBound (obj) {
225
+ if (obj == null) return false
226
+ for (const p in obj) return true
227
+ return false
228
+ }
229
+
230
+ function sendToLog (levelAndArgs) {
231
+ if (this) this[levelAndArgs[0]].apply(this, levelAndArgs.slice(1))
44
232
  }
45
233
 
46
234
  module.exports = assembleContent
@@ -0,0 +1,96 @@
1
+ 'use strict'
2
+
3
+ function configure (context, ...args) {
4
+ internalConfigure.apply(context, args)
5
+ }
6
+
7
+ function internalConfigure (converter, config = {}, providers = {}) {
8
+ this.once('componentsRegistered', ({ contentCatalog, assemblerProfiles }) => {
9
+ this.updateVariables(assemblerProfiles ? undefined : { assemblerProfiles: getAssemblerProfiles(contentCatalog) })
10
+ })
11
+
12
+ this.once('beforeProcess', ({ siteAsciiDocConfig }) => {
13
+ siteAsciiDocConfig.keepSource = true
14
+ })
15
+
16
+ this.once('navigationBuilt', async ({ playbook, contentCatalog }) => {
17
+ const { assembleContent = require('./assemble-content'), ...assembleContentConfig } = providers
18
+ assembleContentConfig.configSource ??= config.configFile
19
+ await assembleContent.call(this, playbook, contentCatalog, converter, assembleContentConfig)
20
+ })
21
+ }
22
+
23
+ function getAssemblerProfiles (contentCatalog, assemblerProfiles = new Map()) {
24
+ contentCatalog.getComponents().forEach((component) => {
25
+ component.versions.forEach((componentVersion) => {
26
+ const source = componentVersion.nav?.origin
27
+ const assemblerConfig = getAssemblerConfigFromDescriptor(source?.descriptor)
28
+ if (!assemblerConfig) return
29
+ const componentVersionRef = `${componentVersion.version}@${componentVersion.name}`
30
+ const filesByPath = componentVersion.files.reduce((accum, it) => accum.set(it.path, it), new Map())
31
+ const componentVersionProfiles = assemblerConfig.reduce((accum, entry) => {
32
+ const data = {}
33
+ const profile = entry.profile ?? undefined
34
+ if (accum.has(profile)) return accum
35
+ const nav = entry.nav
36
+ Object.entries(entry).forEach(([key, val]) => {
37
+ if (key === 'profile' || key === 'nav') return
38
+ const camelKey = key.toLowerCase().replace(/[_-]([a-z0-9])/g, (_, l, idx) => (idx ? l.toUpperCase() : l))
39
+ data[camelKey] = val
40
+ })
41
+ if (nav) {
42
+ data.navFiles = (nav.length ? [...new Set(nav)] : nav).reduce((navFiles, path_) => {
43
+ const navFile = filesByPath.get(path_)
44
+ if (navFile) {
45
+ navFiles.push(initNavFile(navFile, component.name, componentVersion.version, navFiles.length))
46
+ } else {
47
+ ;(data.messages ??= []).push([
48
+ 'warn',
49
+ { source },
50
+ `Could not resolve nav file for ${profile || '<default>'} profile in ${componentVersionRef}: ${path_}`,
51
+ ])
52
+ }
53
+ return navFiles
54
+ }, [])
55
+ } else if (!Object.keys(data).length) {
56
+ return accum
57
+ }
58
+ return accum.set(profile, data)
59
+ }, new Map())
60
+ assemblerProfiles.set(componentVersionRef, componentVersionProfiles)
61
+ })
62
+ })
63
+ return assemblerProfiles
64
+ }
65
+
66
+ function getAssemblerConfigFromDescriptor (descriptor = {}) {
67
+ let assemblerConfig = descriptor.ext?.assembler
68
+ if (!assemblerConfig) return
69
+ if (Array.isArray(assemblerConfig)) {
70
+ if (!assemblerConfig.length) return
71
+ } else {
72
+ assemblerConfig = [assemblerConfig]
73
+ }
74
+ return assemblerConfig
75
+ }
76
+
77
+ function initNavFile (file, component, version, index) {
78
+ const src = Object.assign({}, file.src, { component, version, module: 'ROOT', family: 'nav' })
79
+ const filePathSegments = file.path.split('/')
80
+ let relativeStartIdx = 0
81
+ if (filePathSegments[0] === 'modules') {
82
+ relativeStartIdx += 1
83
+ if (filePathSegments.length > 2) {
84
+ relativeStartIdx += 1
85
+ src.module = filePathSegments[1]
86
+ if (filePathSegments.length > 3 && filePathSegments[2] === 'partials') {
87
+ relativeStartIdx += 1
88
+ src.family = 'partial'
89
+ }
90
+ }
91
+ }
92
+ src.relative = filePathSegments.slice(relativeStartIdx).join('/')
93
+ return Object.assign(new file.constructor(file), { nav: { index }, src })
94
+ }
95
+
96
+ module.exports = configure
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
3
  const assembleContent = require('./assemble-content')
4
- const runCommand = require('./util/run-command')
4
+ const configure = require('./configure')
5
5
 
6
- module.exports = { assembleContent, runCommand }
6
+ module.exports = { assembleContent, configure, configureAssembler: configure }
@@ -10,14 +10,14 @@ function loadConfig (playbook, configSource = './antora-assembler.yml') {
10
10
  configSource.constructor === Object
11
11
  ? Promise.resolve(configSource)
12
12
  : fsp
13
- .access((configSource = expandPath(configSource, { dot: playbook.dir })))
14
- .then(
15
- () => true,
16
- () => false
17
- )
18
- .then((exists) =>
19
- exists ? fsp.readFile(configSource).then((data) => camelCaseKeys(yaml.load(data), ['asciidoc'])) : {}
20
- )
13
+ .access((configSource = expandPath(configSource, { dot: playbook.dir })))
14
+ .then(
15
+ () => true,
16
+ () => false
17
+ )
18
+ .then((exists) =>
19
+ exists ? fsp.readFile(configSource).then((data) => camelCaseKeys(yaml.load(data), ['asciidoc'])) : {}
20
+ )
21
21
  ).then((config) => {
22
22
  if (config.enabled === false) return undefined
23
23
  let asciidocAttrs
@@ -26,30 +26,47 @@ function loadConfig (playbook, configSource = './antora-assembler.yml') {
26
26
  } else if (!(asciidocAttrs = config.asciidoc.attributes)) {
27
27
  config.asciidoc.attributes = asciidocAttrs = {}
28
28
  }
29
- if (!('doctype' in asciidocAttrs)) asciidocAttrs.doctype = 'book'
30
29
  if (!('revdate' in asciidocAttrs)) asciidocAttrs.revdate = getLocalDate()
31
30
  asciidocAttrs['page-partial'] = null
32
- asciidocAttrs['loader-assembler'] = ''
33
- if (config.componentVersions == null) {
34
- config.componentVersions = ['*']
35
- } else if (typeof config.componentVersions === 'string') {
36
- config.componentVersions = config.componentVersions.split(', ')
31
+ const remapComponentVersionsKey = !('componentVersionFilter' in config)
32
+ const componentVersionFilter = (config.componentVersionFilter ??= {})
33
+ if (remapComponentVersionsKey && 'componentVersions' in config) {
34
+ componentVersionFilter.names = config.componentVersions
35
+ delete config.componentVersions
37
36
  }
38
- if (!('rootLevel' in config)) config.rootLevel = 0
39
- if (!('insertStartPage' in config)) config.insertStartPage = true
40
- if (['discrete', 'fuse', 'enclose'].indexOf(config.sectionMergeStrategy) < 0) {
41
- config.sectionMergeStrategy = 'discrete'
37
+ if (componentVersionFilter.names == null) {
38
+ componentVersionFilter.names = ['*']
39
+ } else if (typeof componentVersionFilter.names === 'string') {
40
+ componentVersionFilter.names = componentVersionFilter.names.split(', ')
42
41
  }
43
- const build = config.build || (config.build = {})
42
+ const remapAssemblyKeys = !('assembly' in config)
43
+ const assembly = (config.assembly ??= {})
44
+ if (remapAssemblyKeys) {
45
+ for (const key of ['rootLevel', 'insertStartPage', 'sectionMergeStrategy']) {
46
+ if (!(key in config)) continue
47
+ assembly[key] = config[key]
48
+ delete config[key]
49
+ }
50
+ }
51
+ if (!('doctype' in assembly)) assembly.doctype = 'doctype' in asciidocAttrs ? asciidocAttrs.doctype : 'book'
52
+ delete asciidocAttrs.doctype
53
+ if (!('rootLevel' in assembly)) assembly.rootLevel = 0
54
+ if (!('insertStartPage' in assembly)) assembly.insertStartPage = true
55
+ if (['discrete', 'fuse', 'enclose'].indexOf(assembly.sectionMergeStrategy) < 0) {
56
+ assembly.sectionMergeStrategy = 'discrete'
57
+ }
58
+ const build = (config.build ??= {})
44
59
  if (build.dir === '$' + '{playbook.output.dir}') {
45
- //build.dir = playbook.output.dir
46
60
  throw new Error('Not implemented')
47
- } else {
48
- build.dir = expandPath(build.dir || './build/assembler', { dot: playbook.dir })
49
61
  }
50
- build.cwd = playbook.dir // use playbook.dir for the purpose of finding and loading require scripts
62
+ build.dir &&= expandPath(build.dir, { dot: playbook.dir })
63
+ build.cwd = playbook.dir // use playbook.dir for finding and loading require scripts
51
64
  if (!('clean' in build) && 'output' in playbook) build.clean = playbook.output.clean
52
65
  if (!('publish' in build)) build.publish = true
66
+ if ('keepAggregateSource' in build) {
67
+ build.keepSource = build.keepAggregateSource
68
+ delete build.keepAggregateSource
69
+ }
53
70
  if (!build.processLimit) {
54
71
  build.processLimit = 'processLimit' in build ? Infinity : Math.round(os.cpus().length * 0.5)
55
72
  }
@@ -57,7 +74,7 @@ function loadConfig (playbook, configSource = './antora-assembler.yml') {
57
74
  })
58
75
  }
59
76
 
60
- function camelCaseKeys (o, stopPaths = [], p) {
77
+ function camelCaseKeys (o, stopPaths = [], p = undefined) {
61
78
  if (Array.isArray(o)) return o.map((it) => camelCaseKeys(it, stopPaths, p))
62
79
  if (o == null || o.constructor !== Object) return o
63
80
  const pathPrefix = p ? p + '.' : ''