js-build-tools 6.0.0 → 6.2.0
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 +134 -27
- package/dist/build.js +9 -3
- package/dist/build.min.js +1 -1
- package/dist/bundle.js +4 -3
- package/dist/bundle.min.js +1 -1
- package/dist/bundleMinify.js +2 -1
- package/dist/bundleMinify.min.js +1 -1
- package/dist/copyFiles.js +47 -0
- package/dist/copyFiles.min.js +1 -0
- package/dist/copyFonts.js +2 -1
- package/dist/copyFonts.min.js +1 -1
- package/dist/default.js +2 -1
- package/dist/default.min.js +1 -1
- package/dist/distMinify.js +2 -1
- package/dist/distMinify.min.js +1 -1
- package/dist/jsdocDocs.js +15 -0
- package/dist/jsdocDocs.min.js +1 -0
- package/dist/partials/addToReadme.js +3 -2
- package/dist/partials/beginWatcher.js +2 -1
- package/dist/partials/clean.js +2 -1
- package/dist/partials/copyFor.js +10 -2
- package/dist/partials/distFor.js +2 -1
- package/dist/partials/distForSrc.js +2 -1
- package/dist/partials/distSeries.js +2 -1
- package/dist/partials/imagesFor.js +2 -1
- package/dist/partials/jsdocDocsFor.js +155 -0
- package/dist/partials/neostandardLint.js +31 -12
- package/dist/partials/readmeTemplate.js +2 -1
- package/dist/partials/runOnChange.js +2 -1
- package/dist/partials/sassFor.js +2 -1
- package/dist/partials/tsFor.js +2 -1
- package/dist/partials/typeDocsFor.js +2 -1
- package/dist/sourceLint.js +4 -2
- package/dist/sourceLint.min.js +1 -1
- package/dist/test-helpers/neostandardLintWithRealNeostandard.js +5 -3
- package/dist/test-helpers/setUp.js +2 -1
- package/dist/testFull.js +2 -1
- package/dist/testFull.min.js +1 -1
- package/dist/testHelpers.js +2 -1
- package/dist/testHelpers.min.js +1 -1
- package/dist/testQuick.js +2 -1
- package/dist/testQuick.min.js +1 -1
- package/dist/utilities/dotNotate.js +3 -1
- package/dist/utilities/getBundleConfigs.js +3 -2
- package/dist/watchTest.js +2 -1
- package/dist/watchTest.min.js +1 -1
- package/functions/build.mjs +7 -2
- package/functions/build.test.mjs +50 -1
- package/functions/bundle.mjs +2 -2
- package/functions/copyFiles.mjs +32 -0
- package/functions/copyFiles.test.mjs +78 -0
- package/functions/jsdocDocs.mjs +10 -0
- package/functions/partials/addToReadme.mjs +1 -1
- package/functions/partials/copyFor.mjs +4 -1
- package/functions/partials/jsdocDocsFor.mjs +132 -0
- package/functions/partials/jsdocDocsFor.test.mjs +101 -0
- package/functions/partials/neostandardLint.mjs +19 -7
- package/functions/partials/neostandardLint.test.mjs +9 -1
- package/functions/sourceLint.mjs +1 -1
- package/functions/sourceLint.test.mjs +24 -0
- package/functions/test-helpers/neostandardLintWithRealNeostandard.mjs +7 -5
- package/functions/utilities/dotNotate.mjs +3 -1
- package/functions/utilities/dotNotate.test.mjs +34 -0
- package/functions/utilities/getBundleConfigs.mjs +1 -1
- package/gulp.config.mjs +42 -5
- package/gulpfile.base.mjs +2 -0
- package/package.json +11 -1
package/functions/build.test.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { build } from './build.mjs'
|
|
|
4
4
|
import { clean } from './partials/clean.mjs'
|
|
5
5
|
import { testFull } from './testFull.mjs'
|
|
6
6
|
import { typeDocs } from './typeDocs.mjs'
|
|
7
|
+
import { jsdocDocs } from './jsdocDocs.mjs'
|
|
7
8
|
import { countMatches } from 'test-filesystem'
|
|
8
9
|
|
|
9
10
|
// Run the real neostandard (in a subprocess) - Jest's CommonJS transform can't load the ESM-only package itself.
|
|
@@ -16,8 +17,11 @@ const genericFunction = () => Promise.resolve(true)
|
|
|
16
17
|
|
|
17
18
|
jest.mock('./partials/clean.mjs', () => ({ clean: jest.fn(genericFunction) }))
|
|
18
19
|
jest.mock('./testFull.mjs', () => ({ testFull: jest.fn(genericFunction) }))
|
|
19
|
-
// The documentation is generated by TypeDoc, whose own tests cover that (see typeDocsFor.test.mjs
|
|
20
|
+
// The documentation is generated by TypeDoc or jsdoc, whose own tests cover that (see typeDocsFor.test.mjs /
|
|
21
|
+
// jsdocDocsFor.test.mjs) - jsdocDocs in particular shells out to a real CLI, far too slow for a build.mjs unit
|
|
22
|
+
// test to actually run.
|
|
20
23
|
jest.mock('./typeDocs.mjs', () => ({ typeDocs: jest.fn(genericFunction) }))
|
|
24
|
+
jest.mock('./jsdocDocs.mjs', () => ({ jsdocDocs: jest.fn(genericFunction) }))
|
|
21
25
|
// tsFor's real implementation loads gulp-ts-compile (a genuine ESM-only package) via a dynamic import() hidden
|
|
22
26
|
// from babel's static analysis - necessary for production correctness (see tsFor.mjs's own comments), but this
|
|
23
27
|
// project's current CommonJS-transform-based Jest setup can't execute a real dynamic import at all: it doesn't
|
|
@@ -638,6 +642,7 @@ describe('build', () => {
|
|
|
638
642
|
expect.assertions(3)
|
|
639
643
|
build(() => {
|
|
640
644
|
gulpConfig.set('docs.enabled', false)
|
|
645
|
+
gulpConfig.set('typescript.enabled', false)
|
|
641
646
|
expect(typeDocs).toHaveBeenCalledTimes(1)
|
|
642
647
|
// The readme is not generated, it is written by hand
|
|
643
648
|
expect(fs.existsSync(`${readmePath}/${readmeFile}`)).toBe(false)
|
|
@@ -646,6 +651,50 @@ describe('build', () => {
|
|
|
646
651
|
})
|
|
647
652
|
}, 30000)
|
|
648
653
|
|
|
654
|
+
test('when docs.enabled is set without typescript.enabled: generates the documentation with jsdoc, inferred from docs.method being unset', done => {
|
|
655
|
+
const readmePath = gulpConfig.get('readme.to')
|
|
656
|
+
const readmeFile = gulpConfig.get('readme.file')
|
|
657
|
+
fs.rmSync(`${readmePath}/${readmeFile}`, { force: true })
|
|
658
|
+
jsdocDocs.mockClear()
|
|
659
|
+
gulpConfig.set('browser.enabled', false)
|
|
660
|
+
gulpConfig.set('typescript.enabled', false)
|
|
661
|
+
gulpConfig.set('docs.enabled', true)
|
|
662
|
+
expect.assertions(3)
|
|
663
|
+
build(() => {
|
|
664
|
+
gulpConfig.set('docs.enabled', false)
|
|
665
|
+
expect(jsdocDocs).toHaveBeenCalledTimes(1)
|
|
666
|
+
// The readme is not generated, it is written by hand - same as the TypeDoc path above
|
|
667
|
+
expect(fs.existsSync(`${readmePath}/${readmeFile}`)).toBe(false)
|
|
668
|
+
expect(testFull).toHaveBeenCalled()
|
|
669
|
+
done()
|
|
670
|
+
})
|
|
671
|
+
}, 30000)
|
|
672
|
+
|
|
673
|
+
test('docs.method overrides the typescript.enabled inference either way', done => {
|
|
674
|
+
const srcPath = gulpConfig.get('srcPath')
|
|
675
|
+
const rootPath = gulpConfig.get('rootPath')
|
|
676
|
+
const tsConfig = `${rootPath}/tsconfig.json`
|
|
677
|
+
fs.writeFileSync(`${srcPath}/main.ts`, mainTypescript)
|
|
678
|
+
fs.writeFileSync(tsConfig, tsConfigContents)
|
|
679
|
+
typeDocs.mockClear()
|
|
680
|
+
jsdocDocs.mockClear()
|
|
681
|
+
gulpConfig.set('browser.enabled', false)
|
|
682
|
+
gulpConfig.set('typescript.config', tsConfig)
|
|
683
|
+
gulpConfig.set('typescript.enabled', true)
|
|
684
|
+
gulpConfig.set('docs.enabled', true)
|
|
685
|
+
// typescript.enabled is true, which would normally infer 'typedoc' - method forces 'jsdoc' instead.
|
|
686
|
+
gulpConfig.set('docs.method', 'jsdoc')
|
|
687
|
+
expect.assertions(2)
|
|
688
|
+
build(() => {
|
|
689
|
+
gulpConfig.set('docs.enabled', false)
|
|
690
|
+
gulpConfig.set('docs.method', false)
|
|
691
|
+
gulpConfig.set('typescript.enabled', false)
|
|
692
|
+
expect(jsdocDocs).toHaveBeenCalledTimes(1)
|
|
693
|
+
expect(typeDocs).not.toHaveBeenCalled()
|
|
694
|
+
done()
|
|
695
|
+
})
|
|
696
|
+
}, 30000)
|
|
697
|
+
|
|
649
698
|
test('finishes clean before starting the tasks that write into the output folders', done => {
|
|
650
699
|
const srcPath = gulpConfig.get('srcPath')
|
|
651
700
|
const sourcesPath = `${srcPath}/sources`
|
package/functions/bundle.mjs
CHANGED
|
@@ -28,10 +28,10 @@ import { getBundleConfigs } from './utilities/getBundleConfigs.mjs'
|
|
|
28
28
|
export const bundle = () => mergeStream(
|
|
29
29
|
getBundleConfigs().map(({ name, main, ignore, exclude }) => {
|
|
30
30
|
const bundler = browserify(main, { standalone: name })
|
|
31
|
-
if (ignore
|
|
31
|
+
if (ignore.length) {
|
|
32
32
|
bundler.ignore(ignore)
|
|
33
33
|
}
|
|
34
|
-
if (exclude
|
|
34
|
+
if (exclude.length) {
|
|
35
35
|
bundler.exclude(exclude)
|
|
36
36
|
}
|
|
37
37
|
return bundler
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { copyFor } from './partials/copyFor.mjs'
|
|
2
|
+
import * as gulpConfig from '../gulp.config.mjs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Copy files from one place to another - any number of { from, to, base? } entries, configured via copyFiles.
|
|
6
|
+
* A project with no copyFiles entries gets a task that does nothing. Meant to be run explicitly (its own script,
|
|
7
|
+
* e.g. `gulp copyFiles`), not folded into the default build - a bundle (or any other file this copies) is often
|
|
8
|
+
* the live, publicly-reachable version of something, so publishing it elsewhere should be a deliberate step, not
|
|
9
|
+
* a side effect of every routine local build.
|
|
10
|
+
*
|
|
11
|
+
* Reuses copyFor (already shared with copyFonts) rather than a fresh src()/dest() pair - that keeps its existing
|
|
12
|
+
* encoding: false fix (Gulp 5's default utf8 handling corrupts binary files such as fonts or a minified bundle)
|
|
13
|
+
* instead of silently losing it in a parallel implementation.
|
|
14
|
+
*
|
|
15
|
+
* Each entry runs as its own independent copyFor(), awaited via Promise.all rather than merged into one combined
|
|
16
|
+
* stream (mergeStream, the pattern bundle.mjs uses) - that pattern assumes every merged stream ends up at the
|
|
17
|
+
* same shared destination; here, each entry can have a genuinely different one, and merging streams that each
|
|
18
|
+
* already terminate at their own separate dest() hit a real "write after end" error in practice.
|
|
19
|
+
* @memberOf module:js-build-tools
|
|
20
|
+
* @returns {Promise<void>}
|
|
21
|
+
*/
|
|
22
|
+
export const copyFiles = () => {
|
|
23
|
+
const copies = gulpConfig.get('copyFiles', [])
|
|
24
|
+
if (!copies.length) {
|
|
25
|
+
return Promise.resolve()
|
|
26
|
+
}
|
|
27
|
+
return Promise.all(copies.map(({ from, to, base }) => new Promise((resolve, reject) => {
|
|
28
|
+
copyFor(from, to, { base })
|
|
29
|
+
.on('finish', resolve)
|
|
30
|
+
.on('error', reject)
|
|
31
|
+
}))).then(() => {})
|
|
32
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import * as setUp from './test-helpers/setUp.mjs'
|
|
3
|
+
import { copyFiles } from './copyFiles.mjs'
|
|
4
|
+
|
|
5
|
+
setUp.setDefaults('test-copy-files')
|
|
6
|
+
const gulpConfig = setUp.gulpConfig
|
|
7
|
+
|
|
8
|
+
beforeEach(() => setUp.beforeEach())
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
gulpConfig.set('copyFiles', [])
|
|
11
|
+
return setUp.afterEach()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('copyFiles', () => {
|
|
15
|
+
test('with no entries configured, does nothing and resolves', async () => {
|
|
16
|
+
await expect(copyFiles()).resolves.toBeUndefined()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('copies a file from one place to another', async () => {
|
|
20
|
+
const sourceDir = `${gulpConfig.get('rootPath')}copy-source`
|
|
21
|
+
const destDir = `${gulpConfig.get('rootPath')}copy-dest`
|
|
22
|
+
fs.mkdirSync(sourceDir, { recursive: true })
|
|
23
|
+
fs.writeFileSync(`${sourceDir}/one.txt`, 'one file\n')
|
|
24
|
+
gulpConfig.set('copyFiles', [{ from: `${sourceDir}/*.txt`, to: destDir }])
|
|
25
|
+
|
|
26
|
+
await copyFiles()
|
|
27
|
+
|
|
28
|
+
expect(fs.readFileSync(`${destDir}/one.txt`).toString()).toBe('one file\n')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('copies several configured entries, each to its own destination', async () => {
|
|
32
|
+
const sourceDirA = `${gulpConfig.get('rootPath')}copy-source-a`
|
|
33
|
+
const sourceDirB = `${gulpConfig.get('rootPath')}copy-source-b`
|
|
34
|
+
const destDirA = `${gulpConfig.get('rootPath')}copy-dest-a`
|
|
35
|
+
const destDirB = `${gulpConfig.get('rootPath')}copy-dest-b`
|
|
36
|
+
fs.mkdirSync(sourceDirA, { recursive: true })
|
|
37
|
+
fs.mkdirSync(sourceDirB, { recursive: true })
|
|
38
|
+
fs.writeFileSync(`${sourceDirA}/a.txt`, 'from a\n')
|
|
39
|
+
fs.writeFileSync(`${sourceDirB}/b.txt`, 'from b\n')
|
|
40
|
+
gulpConfig.set('copyFiles', [
|
|
41
|
+
{ from: `${sourceDirA}/*.txt`, to: destDirA },
|
|
42
|
+
{ from: `${sourceDirB}/*.txt`, to: destDirB }
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
await copyFiles()
|
|
46
|
+
|
|
47
|
+
expect(fs.readFileSync(`${destDirA}/a.txt`).toString()).toBe('from a\n')
|
|
48
|
+
expect(fs.readFileSync(`${destDirB}/b.txt`).toString()).toBe('from b\n')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('base preserves the glob\'s own subdirectory structure in the destination', async () => {
|
|
52
|
+
const sourceDir = `${gulpConfig.get('rootPath')}copy-source-nested`
|
|
53
|
+
const destDir = `${gulpConfig.get('rootPath')}copy-dest-nested`
|
|
54
|
+
fs.mkdirSync(`${sourceDir}/inner`, { recursive: true })
|
|
55
|
+
fs.writeFileSync(`${sourceDir}/inner/deep.txt`, 'nested file\n')
|
|
56
|
+
gulpConfig.set('copyFiles', [{ from: `${sourceDir}/**/*.txt`, to: destDir, base: sourceDir }])
|
|
57
|
+
|
|
58
|
+
await copyFiles()
|
|
59
|
+
|
|
60
|
+
expect(fs.readFileSync(`${destDir}/inner/deep.txt`).toString()).toBe('nested file\n')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// The real bug this guards against: a fresh src()/dest() pair (what this used to do) loses copyFor.mjs's own
|
|
64
|
+
// encoding: false fix - Gulp 5's default utf8 handling corrupts binary files such as fonts or a minified
|
|
65
|
+
// bundle. Reusing copyFor instead of duplicating its logic keeps that fix automatically.
|
|
66
|
+
test('copies binary files byte for byte, including bytes above 0x7f', async () => {
|
|
67
|
+
const sourceDir = `${gulpConfig.get('rootPath')}copy-source-binary`
|
|
68
|
+
const destDir = `${gulpConfig.get('rootPath')}copy-dest-binary`
|
|
69
|
+
fs.mkdirSync(sourceDir, { recursive: true })
|
|
70
|
+
const original = Buffer.from(Array.from({ length: 512 }, (_, i) => i % 256))
|
|
71
|
+
fs.writeFileSync(`${sourceDir}/font.woff`, original)
|
|
72
|
+
gulpConfig.set('copyFiles', [{ from: `${sourceDir}/*.woff`, to: destDir }])
|
|
73
|
+
|
|
74
|
+
await copyFiles()
|
|
75
|
+
|
|
76
|
+
expect(fs.readFileSync(`${destDir}/font.woff`).equals(original)).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsdocDocsFor } from './partials/jsdocDocsFor.mjs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate the HTML documentation of a plain-JavaScript project from its source, with a modern jsdoc theme
|
|
5
|
+
* instead of jsdoc's own classic default. Turn it on with docs.enabled and docs.method: 'jsdoc' (the default
|
|
6
|
+
* when typescript.enabled is off), see {@link module:partials.jsdocDocsFor}.
|
|
7
|
+
* @memberOf module:js-build-tools
|
|
8
|
+
* @returns {Function}
|
|
9
|
+
*/
|
|
10
|
+
export const jsdocDocs = jsdocDocsFor()
|
|
@@ -15,7 +15,7 @@ import { convertMjsFilesForDocs } from '../utilities/convertMjsFilesForDocs.mjs'
|
|
|
15
15
|
* @returns {string|Uint8Array}
|
|
16
16
|
*/
|
|
17
17
|
export const addToReadme = () => {
|
|
18
|
-
const { filePaths: esmFilePaths, cleanUp } = convertMjsFilesForDocs(globSync(gulpConfig.get('readme.fromEsm')
|
|
18
|
+
const { filePaths: esmFilePaths, cleanUp } = convertMjsFilesForDocs(globSync(gulpConfig.get('readme.fromEsm')))
|
|
19
19
|
return jsdoc2md
|
|
20
20
|
.render({ files: [...globSync(gulpConfig.get('readme.from')), ...esmFilePaths] })
|
|
21
21
|
.finally(cleanUp)
|
|
@@ -5,8 +5,11 @@ import { dest, src } from 'gulp'
|
|
|
5
5
|
* @memberOf module:partials
|
|
6
6
|
* @param {string|array} srcPath
|
|
7
7
|
* @param {string} destPath
|
|
8
|
+
* @param {Object} [options]
|
|
9
|
+
* @param {string} [options.base] - Passed through to gulp's own src(pattern, {base}), to preserve srcPath's own
|
|
10
|
+
* subdirectory structure in destPath instead of flattening it.
|
|
8
11
|
* @returns {stream.Stream}
|
|
9
12
|
*/
|
|
10
13
|
// encoding: false copies the bytes untouched - Gulp 5's default utf8 handling corrupts binary files such as fonts.
|
|
11
|
-
export const copyFor = (srcPath, destPath) => src(srcPath, { encoding: false })
|
|
14
|
+
export const copyFor = (srcPath, destPath, { base } = {}) => src(srcPath, { encoding: false, ...(base ? { base } : {}) })
|
|
12
15
|
.pipe(dest(destPath))
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { execFile } from 'node:child_process'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
import * as gulpConfig from '../../gulp.config.mjs'
|
|
8
|
+
import { removeDirectory } from './removeDirectory.mjs'
|
|
9
|
+
// jsdoc.base.js is CommonJS (it's also required directly by consuming projects' own .jsdoc.conf.js, and by this
|
|
10
|
+
// project's own legacy htmldocs script) - Node's ESM loader imports a CJS module's module.exports as the default
|
|
11
|
+
// export directly, no createRequire/import.meta needed (import.meta.url tripped up this project's own Babel CJS
|
|
12
|
+
// transform, which Jest relies on to run .mjs test files - confirmed by removing it and nothing else changing).
|
|
13
|
+
import jsdocBase from '../../jsdoc.base.js'
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile)
|
|
16
|
+
|
|
17
|
+
const jsdocInstallHint = 'Install it in your project with: npm install --save-dev jsdoc'
|
|
18
|
+
const themeInstallHint = name => `Install it in your project with: npm install --save-dev ${name}`
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the installed directory of an optional peer dependency, from the project being built rather than from
|
|
22
|
+
* this package. Deliberately not requirePeer/importPeer's own `require(name + '/package.json')` approach - that
|
|
23
|
+
* breaks for any package whose own package.json restricts its exports map and does not expose that subpath
|
|
24
|
+
* (clean-jsdoc-theme does; confirmed live, not assumed - resolving its own package.json throws
|
|
25
|
+
* ERR_PACKAGE_PATH_NOT_EXPORTED in plain Node, and a differently-shaped MODULE_NOT_FOUND under Jest's own
|
|
26
|
+
* resolver, which still is not "not installed" either way). Walking Node's own real module-lookup directories
|
|
27
|
+
* (require.resolve.paths) and checking each on disk sidesteps the exports map entirely - this only ever reads
|
|
28
|
+
* whether `<dir>/<name>/package.json` exists, never asks the module system to resolve a restricted subpath.
|
|
29
|
+
* @memberOf module:partials
|
|
30
|
+
* @param {string} name - The package name.
|
|
31
|
+
* @param {Object} options
|
|
32
|
+
* @param {string} options.task
|
|
33
|
+
* @param {string} options.installHint
|
|
34
|
+
* @param {string} [options.projectPath=process.cwd()]
|
|
35
|
+
* @returns {string}
|
|
36
|
+
* @throws {Error} With install instructions when the package is not installed.
|
|
37
|
+
*/
|
|
38
|
+
const resolvePeerDir = (name, { task, installHint, projectPath = process.cwd() }) => {
|
|
39
|
+
const req = createRequire(path.join(projectPath, 'package.json'))
|
|
40
|
+
const found = (req.resolve.paths(name) || [])
|
|
41
|
+
.map(dir => path.join(dir, name))
|
|
42
|
+
.find(dir => fs.existsSync(path.join(dir, 'package.json')))
|
|
43
|
+
if (!found) {
|
|
44
|
+
throw new Error(`The ${task} task needs the optional peer dependency ${name}, which is not installed. ${installHint}`)
|
|
45
|
+
}
|
|
46
|
+
return found
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve jsdoc's own CLI entry point. jsdoc has no stable programmatic Node API for generating HTML docs (unlike
|
|
51
|
+
* TypeDoc's Application.bootstrap()/convert()/generateDocs()) - gulp-jsdoc3, the established gulp wrapper, is
|
|
52
|
+
* stale, and its own README admits jsdoc does not support piped input in the first place, a real anti-pattern for
|
|
53
|
+
* this project's own gulp-stream conventions. Running its real CLI via child_process is the portable way in -
|
|
54
|
+
* confirmed via `npm view jsdoc bin`, which reports "jsdoc.js" at the package root.
|
|
55
|
+
* @memberOf module:partials
|
|
56
|
+
* @param {string} [projectPath=process.cwd()]
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export const resolveJsdocBin = (projectPath = process.cwd()) =>
|
|
60
|
+
path.join(resolvePeerDir('jsdoc', { task: 'docs', installHint: jsdocInstallHint, projectPath }), 'jsdoc.js')
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a jsdoc theme package's own template directory, for jsdoc's own opts.template config key. Not
|
|
64
|
+
* necessarily the package's own root - clean-jsdoc-theme's own docs say to point opts.template at its `dist`
|
|
65
|
+
* subdirectory specifically, confirmed by reading its real README rather than assuming every theme follows the
|
|
66
|
+
* classic jsdoc template layout (a bare package root with its own tmpl/static folders) - clean-jsdoc-theme does
|
|
67
|
+
* not (it is a modern, self-contained, server-rendered theme with no such folders at its package root at all).
|
|
68
|
+
* @memberOf module:partials
|
|
69
|
+
* @param {string} name
|
|
70
|
+
* @param {string} [projectPath=process.cwd()]
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export const resolveJsdocTheme = (name, projectPath = process.cwd()) => {
|
|
74
|
+
const packageDir = resolvePeerDir(name, { task: 'docs', installHint: themeInstallHint(name), projectPath })
|
|
75
|
+
return name === 'clean-jsdoc-theme' ? path.join(packageDir, 'dist') : packageDir
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Generate the HTML documentation of a plain-JavaScript project with jsdoc, using jsdoc.base.js's own shared
|
|
80
|
+
* config (plugins, includePattern/excludePattern) as the starting point rather than a parallel one - the same
|
|
81
|
+
* base a project's own .jsdoc.conf.js has always been able to extend, just with source.include and the output
|
|
82
|
+
* options generated here instead of hand-maintained per project. Configure this with 'docs.from', 'docs.to',
|
|
83
|
+
* 'docs.index', 'docs.theme' and 'docs.title'.
|
|
84
|
+
* @memberOf module:partials
|
|
85
|
+
* @param {string} [srcPath=gulpConfig.get('docs.from')] - The directory (or glob) holding the JS source.
|
|
86
|
+
* @param {string} [outPath=gulpConfig.get('docs.to')] - The directory the documentation is generated into (cleared first).
|
|
87
|
+
* @param {function(string, Array<string>): Promise<Object>} [runJsdoc] - Runs the jsdoc CLI, tests can inject a stand-in.
|
|
88
|
+
* @returns {Function} A gulp task, it accepts an optional callback and returns a promise.
|
|
89
|
+
*/
|
|
90
|
+
export const jsdocDocsFor = (
|
|
91
|
+
srcPath = gulpConfig.get('docs.from'),
|
|
92
|
+
outPath = gulpConfig.get('docs.to'),
|
|
93
|
+
runJsdoc = (bin, args) => execFileAsync(process.execPath, [bin, ...args])
|
|
94
|
+
) => {
|
|
95
|
+
if (gulpConfig.get('docs.enabled') === false) {
|
|
96
|
+
return () => {}
|
|
97
|
+
}
|
|
98
|
+
return (done) => {
|
|
99
|
+
const generate = async () => {
|
|
100
|
+
const theme = gulpConfig.get('docs.theme') || 'clean-jsdoc-theme'
|
|
101
|
+
const themeDir = resolveJsdocTheme(theme)
|
|
102
|
+
const indexFile = gulpConfig.get('docs.index')
|
|
103
|
+
await removeDirectory(outPath)
|
|
104
|
+
const config = {
|
|
105
|
+
...jsdocBase,
|
|
106
|
+
source: {
|
|
107
|
+
...jsdocBase.source,
|
|
108
|
+
include: [path.resolve(srcPath)]
|
|
109
|
+
},
|
|
110
|
+
opts: {
|
|
111
|
+
destination: path.resolve(outPath),
|
|
112
|
+
// jsdoc does not recurse into source.include's own subdirectories by default - without this, only
|
|
113
|
+
// the top level of docs.from would ever be scanned. Confirmed from clean-jsdoc-theme's own README
|
|
114
|
+
// quick-start example, which sets this explicitly, not assumed from jsdoc's own classic defaults.
|
|
115
|
+
recurse: true,
|
|
116
|
+
template: themeDir,
|
|
117
|
+
...(indexFile && fs.existsSync(indexFile) ? { readme: path.resolve(indexFile) } : {})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsdocdocs-'))
|
|
121
|
+
try {
|
|
122
|
+
const configPath = path.join(workDir, 'jsdoc.conf.json')
|
|
123
|
+
fs.writeFileSync(configPath, JSON.stringify(config))
|
|
124
|
+
const bin = resolveJsdocBin()
|
|
125
|
+
await runJsdoc(bin, ['-c', configPath])
|
|
126
|
+
} finally {
|
|
127
|
+
await removeDirectory(workDir)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return typeof done === 'function' ? generate().then(() => done(), done) : generate()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import * as setUp from '../test-helpers/setUp.mjs'
|
|
4
|
+
import { jsdocDocsFor, resolveJsdocBin, resolveJsdocTheme } from './jsdocDocsFor.mjs'
|
|
5
|
+
|
|
6
|
+
setUp.setDefaults('test-jsdoc-docs-for')
|
|
7
|
+
const gulpConfig = setUp.gulpConfig
|
|
8
|
+
|
|
9
|
+
const write = (file, contents) => {
|
|
10
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
11
|
+
fs.writeFileSync(file, contents)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
beforeEach(
|
|
15
|
+
() => setUp.beforeEach().then(() => {
|
|
16
|
+
const srcPath = gulpConfig.get('srcPath')
|
|
17
|
+
write(`${srcPath}/add.js`, '/**\n * Add two numbers.\n * @param {number} a\n * @param {number} b\n * @returns {number}\n */\nconst add = (a, b) => a + b\n\nmodule.exports = add\n')
|
|
18
|
+
})
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
afterEach(setUp.afterEach)
|
|
22
|
+
|
|
23
|
+
describe('resolveJsdocBin / resolveJsdocTheme', () => {
|
|
24
|
+
test('resolves the real installed jsdoc CLI entry', () => {
|
|
25
|
+
const bin = resolveJsdocBin()
|
|
26
|
+
expect(fs.existsSync(bin)).toBe(true)
|
|
27
|
+
expect(path.basename(bin)).toBe('jsdoc.js')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('resolves the real installed theme directory', () => {
|
|
31
|
+
const themeDir = resolveJsdocTheme('clean-jsdoc-theme')
|
|
32
|
+
expect(fs.existsSync(themeDir)).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('throws a clear, install-hint error for a package that is not installed', () => {
|
|
36
|
+
expect(() => resolveJsdocTheme('not-a-real-jsdoc-theme-package')).toThrow(/npm install --save-dev/)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('jsdocDocsFor', () => {
|
|
41
|
+
const fakeRunJsdoc = calls => (bin, args) => {
|
|
42
|
+
calls.bin = bin
|
|
43
|
+
calls.args = args
|
|
44
|
+
calls.config = JSON.parse(fs.readFileSync(args[args.indexOf('-c') + 1], 'utf8'))
|
|
45
|
+
fs.mkdirSync(calls.config.opts.destination, { recursive: true })
|
|
46
|
+
fs.writeFileSync(`${calls.config.opts.destination}/index.html`, '<html></html>')
|
|
47
|
+
return Promise.resolve()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('generates the documentation into the output directory, clearing what was there', async () => {
|
|
51
|
+
gulpConfig.set('docs.enabled', true)
|
|
52
|
+
const outPath = `${gulpConfig.get('srcPath')}/../docs`
|
|
53
|
+
write(`${outPath}/stale.html`, 'old')
|
|
54
|
+
const calls = {}
|
|
55
|
+
await jsdocDocsFor(gulpConfig.get('srcPath'), outPath, fakeRunJsdoc(calls))()
|
|
56
|
+
expect(fs.existsSync(`${outPath}/stale.html`)).toBe(false)
|
|
57
|
+
expect(fs.existsSync(`${outPath}/index.html`)).toBe(true)
|
|
58
|
+
expect(calls.config.opts.destination).toBe(path.resolve(outPath))
|
|
59
|
+
expect(calls.config.source.include).toEqual([path.resolve(gulpConfig.get('srcPath'))])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('uses the configured theme as the template directory', async () => {
|
|
63
|
+
gulpConfig.set('docs.enabled', true)
|
|
64
|
+
gulpConfig.set('docs.theme', 'clean-jsdoc-theme')
|
|
65
|
+
const calls = {}
|
|
66
|
+
await jsdocDocsFor(gulpConfig.get('srcPath'), `${gulpConfig.get('srcPath')}/../docs`, fakeRunJsdoc(calls))()
|
|
67
|
+
expect(calls.config.opts.template).toBe(resolveJsdocTheme('clean-jsdoc-theme'))
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('uses the front page from docs.index when it exists, and leaves readme unset when it does not', async () => {
|
|
71
|
+
gulpConfig.set('docs.enabled', true)
|
|
72
|
+
gulpConfig.set('docs.index', `${gulpConfig.get('srcPath')}/../MAIN.md`)
|
|
73
|
+
write(`${gulpConfig.get('srcPath')}/../MAIN.md`, '# hello\n')
|
|
74
|
+
const calls = {}
|
|
75
|
+
await jsdocDocsFor(gulpConfig.get('srcPath'), `${gulpConfig.get('srcPath')}/../docs`, fakeRunJsdoc(calls))()
|
|
76
|
+
expect(calls.config.opts.readme).toBe(path.resolve(`${gulpConfig.get('srcPath')}/../MAIN.md`))
|
|
77
|
+
|
|
78
|
+
gulpConfig.set('docs.index', 'does-not-exist.md')
|
|
79
|
+
await jsdocDocsFor(gulpConfig.get('srcPath'), `${gulpConfig.get('srcPath')}/../docs`, fakeRunJsdoc(calls))()
|
|
80
|
+
expect(calls.config.opts.readme).toBeUndefined()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('skips if docs.enabled is false', () => {
|
|
84
|
+
gulpConfig.set('docs.enabled', false)
|
|
85
|
+
const result = jsdocDocsFor()
|
|
86
|
+
expect(result).toBeInstanceOf(Function)
|
|
87
|
+
expect(result.length).toBe(0)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('jsdocDocsFor, end to end with the real jsdoc CLI and clean-jsdoc-theme', () => {
|
|
92
|
+
test('produces a real browsable HTML page from real JSDoc comments', async () => {
|
|
93
|
+
gulpConfig.set('docs.enabled', true)
|
|
94
|
+
const outPath = `${gulpConfig.get('srcPath')}/../docs`
|
|
95
|
+
await jsdocDocsFor(gulpConfig.get('srcPath'), outPath)()
|
|
96
|
+
// clean-jsdoc-theme nests its real output under its own route folders (global/, source/...), not flat at
|
|
97
|
+
// outPath's own root - confirmed by actually running it, not assumed from the classic jsdoc template shape.
|
|
98
|
+
const files = fs.readdirSync(outPath, { recursive: true })
|
|
99
|
+
expect(files.some(file => file.endsWith('.html'))).toBe(true)
|
|
100
|
+
}, 30000)
|
|
101
|
+
})
|
|
@@ -6,7 +6,7 @@ import { importModule } from './loadPeer.mjs'
|
|
|
6
6
|
|
|
7
7
|
// neostandard (js-build-tools' chosen style - a maintained, TypeScript-aware successor to `standard`, see
|
|
8
8
|
// https://github.com/neostandard/neostandard) ships ESM-only, see importModule for how it is imported.
|
|
9
|
-
const importNeostandard = () => importModule('neostandard')
|
|
9
|
+
const importNeostandard = () => importModule('neostandard')
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Read the `env` a project already declared for Standard (the `standard` field in its package.json), so a project
|
|
@@ -23,20 +23,30 @@ const readEnv = cwd => {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Build an ESLint instance configured with neostandard, honoring the project's own package.json `standard.env
|
|
26
|
+
* Build an ESLint instance configured with neostandard, honoring the project's own package.json `standard.env`,
|
|
27
|
+
* plus an optional layer of rule overrides (see gulp.config.mjs's `lint.rules`) for a rule that is wrong for this
|
|
28
|
+
* project specifically rather than something to fix in its code - for example a test fixture whose content
|
|
29
|
+
* neostandard mistakes for a real syntax problem. Scoped to the same files neostandard itself lints (its own
|
|
30
|
+
* `defaultFilePatterns.all`), per neostandard's own guidance: an unscoped rule-tweak layer would otherwise apply
|
|
31
|
+
* to every lintable file, including ones from an unrelated plugin this project never defined.
|
|
27
32
|
* @param {Object} options
|
|
28
33
|
* @param {string} options.cwd
|
|
29
34
|
* @param {boolean} options.ts
|
|
30
35
|
* @param {boolean} options.fix
|
|
36
|
+
* @param {Object<string, *>} [options.rules={}]
|
|
31
37
|
* @returns {Promise<import('eslint').ESLint>}
|
|
32
38
|
*/
|
|
33
|
-
const buildEslint = async ({ cwd, ts, fix }) => {
|
|
34
|
-
const neostandard = await importNeostandard()
|
|
39
|
+
const buildEslint = async ({ cwd, ts, fix, rules = {} }) => {
|
|
40
|
+
const { neostandard, defaultFilePatterns } = await importNeostandard()
|
|
41
|
+
const baseConfig = neostandard({ ts, env: readEnv(cwd) })
|
|
42
|
+
if (Object.keys(rules).length) {
|
|
43
|
+
baseConfig.push({ files: defaultFilePatterns.all, rules })
|
|
44
|
+
}
|
|
35
45
|
return new ESLint({
|
|
36
46
|
cwd,
|
|
37
47
|
fix,
|
|
38
48
|
overrideConfigFile: true,
|
|
39
|
-
baseConfig
|
|
49
|
+
baseConfig
|
|
40
50
|
})
|
|
41
51
|
}
|
|
42
52
|
|
|
@@ -67,13 +77,15 @@ const printFileReport = (file, result) => {
|
|
|
67
77
|
* @param {Object} [options={}]
|
|
68
78
|
* @param {boolean} [options.fix=true] - Apply neostandard's automatic fixes to the file contents.
|
|
69
79
|
* @param {boolean} [options.ts=false] - Lint `.ts` files too, with type-aware rules (see neostandard's `ts` option).
|
|
80
|
+
* @param {Object<string, *>} [options.rules={}] - ESLint rule overrides layered on top of neostandard's own rules
|
|
81
|
+
* (see gulp.config.mjs's `lint.rules`).
|
|
70
82
|
* @param {function(Object): Promise<{lintText: Function}>} [loadEslint] - Resolves an ESLint-shaped linter. Real
|
|
71
83
|
* callers should never need to pass this - it exists so tests can supply a stand-in, since this project's
|
|
72
84
|
* CommonJS-transform-based Jest setup cannot execute a real dynamic import() of neostandard (ESM-only).
|
|
73
85
|
* @returns {import('stream').Transform}
|
|
74
86
|
*/
|
|
75
87
|
export const neostandardLint = (
|
|
76
|
-
{ fix = true, ts = false } = {},
|
|
88
|
+
{ fix = true, ts = false, rules = {} } = {},
|
|
77
89
|
loadEslint = buildEslint
|
|
78
90
|
) => {
|
|
79
91
|
const totals = { errors: 0, warnings: 0 }
|
|
@@ -89,7 +101,7 @@ export const neostandardLint = (
|
|
|
89
101
|
return
|
|
90
102
|
}
|
|
91
103
|
if (!eslintPromise) {
|
|
92
|
-
eslintPromise = loadEslint({ cwd: file.cwd, ts, fix })
|
|
104
|
+
eslintPromise = loadEslint({ cwd: file.cwd, ts, fix, rules })
|
|
93
105
|
}
|
|
94
106
|
eslintPromise
|
|
95
107
|
.then(eslint => eslint.lintText(file.contents.toString(), { filePath: file.path }))
|
|
@@ -61,7 +61,15 @@ describe('neostandardLint', () => {
|
|
|
61
61
|
expect.assertions(1)
|
|
62
62
|
const loadEslint = jest.fn(() => Promise.resolve(fakeEslint(() => [{ messages: [], errorCount: 0, warningCount: 0 }])))
|
|
63
63
|
await run(neostandardLint({ fix: false, ts: true }, loadEslint), [makeFile(Buffer.from('x'))])
|
|
64
|
-
expect(loadEslint).toHaveBeenCalledWith({ cwd: '/project', ts: true, fix: false })
|
|
64
|
+
expect(loadEslint).toHaveBeenCalledWith({ cwd: '/project', ts: true, fix: false, rules: {} })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('passes rule overrides through to the loader', async () => {
|
|
68
|
+
expect.assertions(1)
|
|
69
|
+
const loadEslint = jest.fn(() => Promise.resolve(fakeEslint(() => [{ messages: [], errorCount: 0, warningCount: 0 }])))
|
|
70
|
+
const rules = { 'no-template-curly-in-string': 'off' }
|
|
71
|
+
await run(neostandardLint({ rules }, loadEslint), [makeFile(Buffer.from('x'))])
|
|
72
|
+
expect(loadEslint).toHaveBeenCalledWith({ cwd: '/project', ts: false, fix: true, rules })
|
|
65
73
|
})
|
|
66
74
|
|
|
67
75
|
test('passes a file through untouched when the project ignores it (ESLint returns no results)', async () => {
|
package/functions/sourceLint.mjs
CHANGED
|
@@ -51,6 +51,30 @@ describe('sourceLint', () => {
|
|
|
51
51
|
})
|
|
52
52
|
}, 20000)
|
|
53
53
|
|
|
54
|
+
test('lint.rules actually suppresses a real neostandard finding', done => {
|
|
55
|
+
const srcPath = gulpConfig.get('srcPath')
|
|
56
|
+
// A fixture string containing literal ${...} text (simulating real module source, not an accidental
|
|
57
|
+
// non-template string) - neostandard's no-template-curly-in-string rule flags this as an error by default.
|
|
58
|
+
const fixtureContents = 'const fixture = \'import(`imagemin-${pluginName}`)\'\n\nmodule.exports = fixture\n'
|
|
59
|
+
fs.writeFileSync(`${srcPath}/fixture.js`, fixtureContents)
|
|
60
|
+
gulpConfig.set('lint.rules', { 'no-template-curly-in-string': 'off' })
|
|
61
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
62
|
+
expect.assertions(1)
|
|
63
|
+
sourceLint()
|
|
64
|
+
.on('finish', () => {
|
|
65
|
+
gulpConfig.set('lint.rules', {})
|
|
66
|
+
expect(logSpy.mock.calls.flat().join('\n')).not.toMatch(/no-template-curly-in-string|template string expression/)
|
|
67
|
+
logSpy.mockRestore()
|
|
68
|
+
done()
|
|
69
|
+
})
|
|
70
|
+
.on('error', error => {
|
|
71
|
+
gulpConfig.set('lint.rules', {})
|
|
72
|
+
logSpy.mockRestore()
|
|
73
|
+
console.error('Encountered error', error)
|
|
74
|
+
done(error)
|
|
75
|
+
})
|
|
76
|
+
}, 20000)
|
|
77
|
+
|
|
54
78
|
test('also lints .ts source when typescript.enabled', done => {
|
|
55
79
|
const srcPath = gulpConfig.get('srcPath')
|
|
56
80
|
gulpConfig.set('typescript.enabled', true)
|
|
@@ -6,9 +6,11 @@ import path from 'node:path'
|
|
|
6
6
|
// genuine neostandard behavior run the actual package in a plain Node subprocess instead of faking it.
|
|
7
7
|
const script = 'import { readFileSync } from \'node:fs\'\n' +
|
|
8
8
|
'import { ESLint } from \'eslint\'\n' +
|
|
9
|
-
'import { neostandard } from \'neostandard\'\n' +
|
|
10
|
-
'const { text, filePath, cwd, fix, ts, env } = JSON.parse(readFileSync(0, \'utf8\'))\n' +
|
|
11
|
-
'const
|
|
9
|
+
'import { neostandard, defaultFilePatterns } from \'neostandard\'\n' +
|
|
10
|
+
'const { text, filePath, cwd, fix, ts, env, rules } = JSON.parse(readFileSync(0, \'utf8\'))\n' +
|
|
11
|
+
'const baseConfig = neostandard({ ts, env })\n' +
|
|
12
|
+
'if (Object.keys(rules).length) { baseConfig.push({ files: defaultFilePatterns.all, rules }) }\n' +
|
|
13
|
+
'const eslint = new ESLint({ cwd, fix, overrideConfigFile: true, baseConfig })\n' +
|
|
12
14
|
'process.stdout.write(JSON.stringify(await eslint.lintText(text, { filePath })))\n'
|
|
13
15
|
|
|
14
16
|
const readEnv = cwd => {
|
|
@@ -19,12 +21,12 @@ const readEnv = cwd => {
|
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
export const loadEslintViaSubprocess = ({ cwd, ts, fix }) => Promise.resolve({
|
|
24
|
+
export const loadEslintViaSubprocess = ({ cwd, ts, fix, rules = {} }) => Promise.resolve({
|
|
23
25
|
lintText: (text, { filePath }) => Promise.resolve().then(() => JSON.parse(execFileSync(
|
|
24
26
|
process.execPath,
|
|
25
27
|
['--input-type=module', '--eval', script],
|
|
26
28
|
{
|
|
27
|
-
input: JSON.stringify({ text, filePath, cwd, fix, ts, env: readEnv(cwd) }),
|
|
29
|
+
input: JSON.stringify({ text, filePath, cwd, fix, ts, env: readEnv(cwd), rules }),
|
|
28
30
|
encoding: 'utf8',
|
|
29
31
|
cwd,
|
|
30
32
|
maxBuffer: 256 * 1024 * 1024
|
|
@@ -46,7 +46,9 @@ const performDotNotate = (arrayObject, didRetain, prepend = '', results = {}) =>
|
|
|
46
46
|
if (didRetain(currentKey, value, results)) {
|
|
47
47
|
continue
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
// An empty object/array has no keys to flatten into - recursing into one would silently drop currentKey
|
|
50
|
+
// from the result entirely, rather than keeping it as the empty value it actually is. Treat it as a leaf.
|
|
51
|
+
if (isObject(value) && Object.keys(value).length) {
|
|
50
52
|
performDotNotate(value, didRetain, `${currentKey}.`, results)
|
|
51
53
|
continue
|
|
52
54
|
}
|