js-build-tools 6.2.2 → 6.3.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.
Files changed (52) hide show
  1. package/README.md +180 -9
  2. package/dist/build.js +66 -66
  3. package/dist/bundle.js +1 -1
  4. package/dist/docs.js +11 -11
  5. package/dist/partials/jsdocDocsFor.js +31 -13
  6. package/dist/partials/loadPlaywright.js +34 -0
  7. package/dist/partials/neostandardLint.js +5 -3
  8. package/dist/partials/resolveDocsMethod.js +12 -12
  9. package/dist/partials/serveStatic.js +57 -0
  10. package/dist/partials/tsFor.js +2 -2
  11. package/dist/partials/typeDocsFor.js +28 -22
  12. package/dist/partials.js +169 -149
  13. package/dist/partials.min.js +1 -1
  14. package/dist/test-helpers/setUp.js +6 -0
  15. package/dist/utilities/convertMjsFilesForDocs.js +1 -0
  16. package/dist/utilities/dotGet.js +1 -0
  17. package/dist/utilities/dotNotate.js +5 -0
  18. package/dist/utilities/dotSet.js +1 -0
  19. package/dist/utilities/esmNamedExportsToCjs.js +1 -0
  20. package/dist/utilities/getBundleConfigs.js +1 -0
  21. package/dist/utilities/isObject.js +1 -0
  22. package/dist/utilities/strAfter.js +1 -0
  23. package/dist/utilities/strBefore.js +1 -0
  24. package/functions/bundle.mjs +1 -1
  25. package/functions/jsdocEsmExports.test.mjs +24 -0
  26. package/functions/partials/jsdocDocsFor.mjs +25 -13
  27. package/functions/partials/jsdocDocsFor.test.mjs +48 -0
  28. package/functions/partials/loadPlaywright.mjs +23 -0
  29. package/functions/partials/loadPlaywright.test.mjs +71 -0
  30. package/functions/partials/neostandardLint.mjs +5 -3
  31. package/functions/partials/serveStatic.mjs +47 -0
  32. package/functions/partials/serveStatic.test.mjs +71 -0
  33. package/functions/partials/tsFor.mjs +2 -2
  34. package/functions/partials/typeDocsFor.mjs +24 -20
  35. package/functions/partials/typeDocsFor.test.mjs +23 -0
  36. package/functions/partials.mjs +2 -0
  37. package/functions/partials.test.mjs +1 -1
  38. package/functions/test-helpers/setUp.mjs +6 -0
  39. package/functions/utilities/convertMjsFilesForDocs.mjs +1 -0
  40. package/functions/utilities/dotGet.mjs +1 -0
  41. package/functions/utilities/dotNotate.mjs +5 -0
  42. package/functions/utilities/dotSet.mjs +1 -0
  43. package/functions/utilities/esmNamedExportsToCjs.mjs +1 -0
  44. package/functions/utilities/getBundleConfigs.mjs +1 -0
  45. package/functions/utilities/isObject.mjs +1 -0
  46. package/functions/utilities/strAfter.mjs +1 -0
  47. package/functions/utilities/strBefore.mjs +1 -0
  48. package/gulp.config.mjs +2 -2
  49. package/gulpfile.base.mjs +1 -3
  50. package/jsdoc.base.js +9 -3
  51. package/jsdoc.esmExports.js +17 -0
  52. package/package.json +16 -10
@@ -9,6 +9,7 @@ exports.isObject = void 0;
9
9
  * Original source concepts from {@link https://github.com/jheagle/si-funciona/blob/main/src/helpers/objects/isObject.ts Sí, funciona}
10
10
  * @param {*} object
11
11
  * @returns {boolean}
12
+ * @private
12
13
  */
13
14
  const isObject = object => typeof object === 'object' && object !== null;
14
15
  exports.isObject = isObject;
@@ -10,6 +10,7 @@ exports.strAfter = void 0;
10
10
  * @param {string} str
11
11
  * @param {string} search
12
12
  * @returns {string}
13
+ * @private
13
14
  */
14
15
  const strAfter = (str, search) => {
15
16
  const index = str.indexOf(search);
@@ -10,6 +10,7 @@ exports.strBefore = void 0;
10
10
  * @param {string} str
11
11
  * @param {string} search
12
12
  * @returns {string}
13
+ * @private
13
14
  */
14
15
  const strBefore = (str, search) => {
15
16
  const index = str.indexOf(search);
@@ -14,7 +14,7 @@ import { getBundleConfigs } from './utilities/getBundleConfigs.mjs'
14
14
  *
15
15
  * A bundle's name does double duty: it names the output file (name.js) and, via browserify's
16
16
  * `standalone` option, is also the name its exports are exposed under - as a global when loaded
17
- * with a bare <script> tag, or as the module when required via CommonJS/AMD. Without this, a
17
+ * with a bare `<script>` tag, or as the module when required via CommonJS/AMD. Without this, a
18
18
  * bundle built from a real entry point (one that actually exports something) builds successfully
19
19
  * but exposes nothing a consumer can reach.
20
20
  *
@@ -0,0 +1,24 @@
1
+ import path from 'node:path'
2
+ import jsdocBase from '../jsdoc.base.js'
3
+ import { handlers } from '../jsdoc.esmExports.js'
4
+
5
+ describe('jsdoc.esmExports', () => {
6
+ test('is one of the plugins in jsdoc.base', () => {
7
+ expect(jsdocBase.plugins).toContain(path.resolve('jsdoc.esmExports.js'))
8
+ })
9
+
10
+ test('drops the exports. jsdoc gives a documented export of a file without its own @module tag', () => {
11
+ const doclet = { name: 'exports.build', longname: 'module:js-build-tools.exports.build' }
12
+ handlers.newDoclet({ doclet })
13
+ expect(doclet).toEqual({ name: 'build', longname: 'module:js-build-tools.build' })
14
+ })
15
+
16
+ test('leaves every other name as it is', () => {
17
+ const doclet = { name: 'build', longname: 'module:js-build-tools.build' }
18
+ handlers.newDoclet({ doclet })
19
+ expect(doclet).toEqual({ name: 'build', longname: 'module:js-build-tools.build' })
20
+ const exportsless = { name: 'exportsThing', longname: 'exportsThing' }
21
+ handlers.newDoclet({ doclet: exportsless })
22
+ expect(exportsless).toEqual({ name: 'exportsThing', longname: 'exportsThing' })
23
+ })
24
+ })
@@ -6,14 +6,16 @@ import { execFile } from 'node:child_process'
6
6
  import { promisify } from 'node:util'
7
7
  import * as gulpConfig from '../../gulp.config.mjs'
8
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).
9
+ // jsdoc.base.js is CommonJS (it's also required directly by consuming projects' own .jsdoc.conf.js) - Node's ESM
10
+ // loader imports a CJS module's module.exports as the default export directly, no createRequire/import.meta needed
11
+ // (import.meta.url tripped up this project's own Babel CJS transform, which Jest relies on to run .mjs test files -
12
+ // confirmed by removing it and nothing else changing).
13
13
  import jsdocBase from '../../jsdoc.base.js'
14
14
 
15
15
  const execFileAsync = promisify(execFile)
16
16
 
17
+ const escapeHtml = text => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
18
+
17
19
  const jsdocInstallHint = 'Install it in your project with: npm install --save-dev jsdoc'
18
20
  const themeInstallHint = name => `Install it in your project with: npm install --save-dev ${name}`
19
21
 
@@ -60,11 +62,9 @@ export const resolveJsdocBin = (projectPath = process.cwd()) =>
60
62
  path.join(resolvePeerDir('jsdoc', { task: 'docs', installHint: jsdocInstallHint, projectPath }), 'jsdoc.js')
61
63
 
62
64
  /**
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).
65
+ * Resolve a jsdoc theme package's own template directory, for jsdoc's own opts.template config key: the folder
66
+ * holding its publish.js. That is the package root for a classic jsdoc template (clean-jsdoc-theme 4.x included),
67
+ * but `dist` for a bundled one (clean-jsdoc-theme 5.x).
68
68
  * @memberOf module:partials
69
69
  * @param {string} name
70
70
  * @param {string} [projectPath=process.cwd()]
@@ -72,7 +72,8 @@ export const resolveJsdocBin = (projectPath = process.cwd()) =>
72
72
  */
73
73
  export const resolveJsdocTheme = (name, projectPath = process.cwd()) => {
74
74
  const packageDir = resolvePeerDir(name, { task: 'docs', installHint: themeInstallHint(name), projectPath })
75
- return name === 'clean-jsdoc-theme' ? path.join(packageDir, 'dist') : packageDir
75
+ const distDir = path.join(packageDir, 'dist')
76
+ return fs.existsSync(path.join(distDir, 'publish.js')) ? distDir : packageDir
76
77
  }
77
78
 
78
79
  /**
@@ -82,7 +83,8 @@ export const resolveJsdocTheme = (name, projectPath = process.cwd()) => {
82
83
  * options generated here instead of hand-maintained per project. Configure this with 'docs.from', 'docs.to',
83
84
  * 'docs.index', 'docs.theme' and 'docs.title'.
84
85
  * @memberOf module:partials
85
- * @param {string} [srcPath=gulpConfig.get('docs.from')] - The directory (or glob) holding the JS source.
86
+ * @param {string|Array<string>} [srcPath=gulpConfig.get('docs.from')] - The directory (or directories and files) holding
87
+ * the JS source.
86
88
  * @param {string} [outPath=gulpConfig.get('docs.to')] - The directory the documentation is generated into (cleared first).
87
89
  * @param {function(string, Array<string>): Promise<Object>} [runJsdoc] - Runs the jsdoc CLI, tests can inject a stand-in.
88
90
  * @returns {Function} A gulp task, it accepts an optional callback and returns a promise.
@@ -100,12 +102,13 @@ export const jsdocDocsFor = (
100
102
  const theme = gulpConfig.get('docs.theme') || 'clean-jsdoc-theme'
101
103
  const themeDir = resolveJsdocTheme(theme)
102
104
  const indexFile = gulpConfig.get('docs.index')
105
+ const title = gulpConfig.get('docs.title')
103
106
  await removeDirectory(outPath)
104
107
  const config = {
105
108
  ...jsdocBase,
106
109
  source: {
107
110
  ...jsdocBase.source,
108
- include: [path.resolve(srcPath)]
111
+ include: [].concat(srcPath).map(includePath => path.resolve(includePath))
109
112
  },
110
113
  opts: {
111
114
  destination: path.resolve(outPath),
@@ -114,6 +117,9 @@ export const jsdocDocsFor = (
114
117
  // quick-start example, which sets this explicitly, not assumed from jsdoc's own classic defaults.
115
118
  recurse: true,
116
119
  template: themeDir,
120
+ // clean-jsdoc-theme's own option for the name in its header. Given as a link: a plain-text title links to the
121
+ // site root ("/"), while every page is in one folder, so index.html is the home page wherever they are served
122
+ ...(title ? { theme_opts: { title: `<a href="index.html" class="sidebar-title sidebar-title-anchor">${escapeHtml(title)}</a>` } } : {}),
117
123
  ...(indexFile && fs.existsSync(indexFile) ? { readme: path.resolve(indexFile) } : {})
118
124
  }
119
125
  }
@@ -122,7 +128,13 @@ export const jsdocDocsFor = (
122
128
  const configPath = path.join(workDir, 'jsdoc.conf.json')
123
129
  fs.writeFileSync(configPath, JSON.stringify(config))
124
130
  const bin = resolveJsdocBin()
125
- await runJsdoc(bin, ['-c', configPath])
131
+ // Pass on what jsdoc and the theme report (warnings such as a page which failed to render), which would
132
+ // otherwise be lost with the captured output
133
+ const { stdout = '', stderr = '' } = (await runJsdoc(bin, ['-c', configPath])) || {}
134
+ const report = `${stdout}${stderr}`.trim()
135
+ if (report) {
136
+ console.warn(report)
137
+ }
126
138
  } finally {
127
139
  await removeDirectory(workDir)
128
140
  }
@@ -2,6 +2,7 @@ import fs from 'fs'
2
2
  import path from 'node:path'
3
3
  import * as setUp from '../test-helpers/setUp.mjs'
4
4
  import { jsdocDocsFor, resolveJsdocBin, resolveJsdocTheme } from './jsdocDocsFor.mjs'
5
+ import jsdocBase from '../../jsdoc.base.js'
5
6
 
6
7
  setUp.setDefaults('test-jsdoc-docs-for')
7
8
  const gulpConfig = setUp.gulpConfig
@@ -57,6 +58,53 @@ describe('jsdocDocsFor', () => {
57
58
  expect(fs.existsSync(`${outPath}/index.html`)).toBe(true)
58
59
  expect(calls.config.opts.destination).toBe(path.resolve(outPath))
59
60
  expect(calls.config.source.include).toEqual([path.resolve(gulpConfig.get('srcPath'))])
61
+ // Headings of the front page get ids, so the theme's table of contents can follow them
62
+ expect(calls.config.markdown).toEqual({ idInHeadings: true })
63
+ })
64
+
65
+ test('passes on what jsdoc reports', async () => {
66
+ gulpConfig.set('docs.enabled', true)
67
+ const srcPath = gulpConfig.get('srcPath')
68
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
69
+ const calls = {}
70
+ const reporting = (bin, args) => fakeRunJsdoc(calls)(bin, args).then(() => ({ stdout: '', stderr: 'WARNING: a page failed\n' }))
71
+ await jsdocDocsFor(srcPath, `${srcPath}/../docs`, reporting)()
72
+ expect(warn).toHaveBeenCalledWith('WARNING: a page failed')
73
+ warn.mockClear()
74
+ await jsdocDocsFor(srcPath, `${srcPath}/../docs`, fakeRunJsdoc(calls))()
75
+ expect(warn).not.toHaveBeenCalled()
76
+ warn.mockRestore()
77
+ })
78
+
79
+ test('takes a list of directories and files to document', async () => {
80
+ gulpConfig.set('docs.enabled', true)
81
+ const srcPath = gulpConfig.get('srcPath')
82
+ const calls = {}
83
+ await jsdocDocsFor([srcPath, `${srcPath}/../gulp.config.mjs`], `${srcPath}/../docs`, fakeRunJsdoc(calls))()
84
+ expect(calls.config.source.include).toEqual([path.resolve(srcPath), path.resolve(`${srcPath}/../gulp.config.mjs`)])
85
+ })
86
+
87
+ test('documents .js, .mjs and .cjs files, but not tests or minified copies', () => {
88
+ const include = new RegExp(jsdocBase.source.includePattern)
89
+ const exclude = new RegExp(jsdocBase.source.excludePattern)
90
+ const documented = file => include.test(file) && !exclude.test(file)
91
+ expect(['a.js', 'a.mjs', 'a.cjs', 'dir/a.mjs'].filter(documented)).toEqual(['a.js', 'a.mjs', 'a.cjs', 'dir/a.mjs'])
92
+ expect(['a.test.mjs', 'a.min.js', 'a.min.mjs', 'dir/_private.mjs', 'a.ts'].filter(documented)).toEqual([])
93
+ })
94
+
95
+ test('passes docs.title to the theme as a link to the home page of the docs, and leaves it out when unset', async () => {
96
+ gulpConfig.set('docs.enabled', true)
97
+ const srcPath = gulpConfig.get('srcPath')
98
+ const calls = {}
99
+ gulpConfig.set('docs.title', 'Tom & Jerry <Tools>')
100
+ await jsdocDocsFor(srcPath, `${srcPath}/../docs`, fakeRunJsdoc(calls))()
101
+ expect(calls.config.opts.theme_opts).toEqual({
102
+ title: '<a href="index.html" class="sidebar-title sidebar-title-anchor">Tom &amp; Jerry &lt;Tools&gt;</a>'
103
+ })
104
+
105
+ gulpConfig.set('docs.title', '')
106
+ await jsdocDocsFor(srcPath, `${srcPath}/../docs`, fakeRunJsdoc(calls))()
107
+ expect(calls.config.opts.theme_opts).toBeUndefined()
60
108
  })
61
109
 
62
110
  test('uses the configured theme as the template directory', async () => {
@@ -0,0 +1,23 @@
1
+ import { requirePeer } from './loadPeer.mjs'
2
+
3
+ const installHint = 'Install it in your project with: npm install --save-dev playwright'
4
+
5
+ /**
6
+ * Find Playwright (headless-browser automation) of the project being built, an optional peer dependency only
7
+ * needed by whatever actually drives a real browser - projects which never do never need it installed.
8
+ * @memberOf module:partials
9
+ * @param {string} [projectPath=process.cwd()] The folder of the project (which has the package.json) being built.
10
+ * @returns {Object} The Playwright module.
11
+ * @throws {Error} With install instructions when playwright is not installed.
12
+ */
13
+ export const loadPlaywright = (projectPath = process.cwd()) => requirePeer('playwright', { task: 'browser automation', installHint, projectPath })
14
+
15
+ /**
16
+ * Launch a real headless Chromium browser via Playwright. See {@link loadPlaywright}.
17
+ * @memberOf module:partials
18
+ * @param {Function} [load=loadPlaywright] Which Playwright to launch - swappable in tests.
19
+ * @param {string} [projectPath=process.cwd()] The folder of the project (which has the package.json) being built.
20
+ * @returns {Promise<Browser>} The running Playwright browser - close it yourself (browser.close()) once done with it.
21
+ * @throws {Error} With install instructions when playwright is not installed.
22
+ */
23
+ export const launchBrowser = (load = loadPlaywright, projectPath = process.cwd()) => load(projectPath).chromium.launch()
@@ -0,0 +1,71 @@
1
+ import fs from 'fs'
2
+ import path from 'node:path'
3
+ import * as setUp from '../test-helpers/setUp.mjs'
4
+ import { loadPlaywright, launchBrowser } from './loadPlaywright.mjs'
5
+ import { serveStatic } from './serveStatic.mjs'
6
+
7
+ setUp.setDefaults('test-load-playwright')
8
+ const gulpConfig = setUp.gulpConfig
9
+
10
+ beforeEach(setUp.beforeEach)
11
+ afterEach(setUp.afterEach)
12
+
13
+ describe('loadPlaywright', () => {
14
+ test('explains how to install playwright when the project does not have it', () => {
15
+ expect(() => loadPlaywright('/')).toThrow(/optional peer dependency playwright.*npm install --save-dev playwright/)
16
+ })
17
+
18
+ // Unlike typedoc (ESM-only, see typeDocsFor.test.mjs), playwright is CommonJS-compatible, so its real loading can
19
+ // be tested directly here rather than only end to end.
20
+ test('loads the real playwright module from this project (its own devDependency)', () => {
21
+ const playwright = loadPlaywright()
22
+ expect(typeof playwright.chromium.launch).toBe('function')
23
+ })
24
+ })
25
+
26
+ describe('launchBrowser', () => {
27
+ test('resolves playwright (via the given loader) and launches its chromium', async () => {
28
+ const launch = jest.fn(() => Promise.resolve('a-real-browser'))
29
+ const load = jest.fn(() => ({ chromium: { launch } }))
30
+ await expect(launchBrowser(load, '/some/project')).resolves.toBe('a-real-browser')
31
+ expect(load).toHaveBeenCalledWith('/some/project')
32
+ expect(launch).toHaveBeenCalledTimes(1)
33
+ })
34
+
35
+ test('defaults to the real loadPlaywright and launches a real, working browser', async () => {
36
+ const browser = await launchBrowser()
37
+ expect(browser.isConnected()).toBe(true)
38
+ await browser.close()
39
+ }, 30000)
40
+ })
41
+
42
+ describe('a real page, served and loaded end to end', () => {
43
+ // The exact real-world case that motivated this partial: a page using a root-relative asset path
44
+ // (/style.css), which a bare file:// load resolves against the filesystem root instead of the page's own
45
+ // directory - serveStatic + a real browser is what actually proves this resolves correctly, not jsdom
46
+ // (which cannot execute <script type="module"> at all, the limitation this whole capability exists to close).
47
+ test('a page with a root-relative asset path loads correctly through a real server and a real browser', async () => {
48
+ const srcPath = gulpConfig.get('srcPath')
49
+ fs.mkdirSync(`${srcPath}/browser`, { recursive: true })
50
+ fs.writeFileSync(`${srcPath}/browser/style.css`, 'body { background-color: rgb(1, 2, 3); }')
51
+ fs.writeFileSync(`${srcPath}/index.html`, [
52
+ '<!doctype html><html><head>',
53
+ '<link rel="stylesheet" href="/browser/style.css">',
54
+ '<script type="module">document.title = \'loaded via a real module script\'</script>',
55
+ '</head><body></body></html>'
56
+ ].join(''))
57
+
58
+ const { server, url } = await serveStatic(srcPath)
59
+ const browser = await launchBrowser()
60
+ try {
61
+ const page = await browser.newPage()
62
+ await page.goto(`${url}/index.html`)
63
+ expect(await page.title()).toBe('loaded via a real module script')
64
+ const backgroundColor = await page.evaluate(() => getComputedStyle(document.body).backgroundColor)
65
+ expect(backgroundColor).toBe('rgb(1, 2, 3)')
66
+ } finally {
67
+ await browser.close()
68
+ server.close()
69
+ }
70
+ }, 30000)
71
+ })
@@ -13,6 +13,7 @@ const importNeostandard = () => importModule('neostandard')
13
13
  * migrating from `standard` keeps its globals (jest, browser, ...) without touching package.json.
14
14
  * @param {string} cwd
15
15
  * @returns {Array<string>}
16
+ * @private
16
17
  */
17
18
  const readEnv = cwd => {
18
19
  try {
@@ -34,7 +35,8 @@ const readEnv = cwd => {
34
35
  * @param {boolean} options.ts
35
36
  * @param {boolean} options.fix
36
37
  * @param {Object<string, *>} [options.rules={}]
37
- * @returns {Promise<import('eslint').ESLint>}
38
+ * @returns {Promise<ESLint>} eslint's ESLint instance
39
+ * @private
38
40
  */
39
41
  const buildEslint = async ({ cwd, ts, fix, rules = {} }) => {
40
42
  const { neostandard, defaultFilePatterns } = await importNeostandard()
@@ -53,7 +55,7 @@ const buildEslint = async ({ cwd, ts, fix, rules = {} }) => {
53
55
  /**
54
56
  * Print a file's lint messages, if it has any.
55
57
  * @memberOf module:partials
56
- * @param {import('vinyl')} file
58
+ * @param {Vinyl} file
57
59
  * @param {{messages: Array<{line: number, column: number, message: string}>, errorCount: number, warningCount: number}} result
58
60
  * @returns {undefined}
59
61
  */
@@ -82,7 +84,7 @@ const printFileReport = (file, result) => {
82
84
  * @param {function(Object): Promise<{lintText: Function}>} [loadEslint] - Resolves an ESLint-shaped linter. Real
83
85
  * callers should never need to pass this - it exists so tests can supply a stand-in, since this project's
84
86
  * CommonJS-transform-based Jest setup cannot execute a real dynamic import() of neostandard (ESM-only).
85
- * @returns {import('stream').Transform}
87
+ * @returns {stream.Transform}
86
88
  */
87
89
  export const neostandardLint = (
88
90
  { fix = true, ts = false, rules = {} } = {},
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs'
2
+ import http from 'node:http'
3
+ import path from 'node:path'
4
+
5
+ // A real, previously-undiscovered bug fixed here: without a Content-Type header, a browser enforces strict MIME
6
+ // checking on <script type="module"> and refuses to load it at all - exactly the case this partial exists for.
7
+ const contentTypes = {
8
+ '.css': 'text/css',
9
+ '.gif': 'image/gif',
10
+ '.html': 'text/html',
11
+ '.ico': 'image/x-icon',
12
+ '.jpeg': 'image/jpeg',
13
+ '.jpg': 'image/jpeg',
14
+ '.js': 'text/javascript',
15
+ '.json': 'application/json',
16
+ '.mjs': 'text/javascript',
17
+ '.png': 'image/png',
18
+ '.svg': 'image/svg+xml',
19
+ '.woff': 'font/woff',
20
+ '.woff2': 'font/woff2'
21
+ }
22
+
23
+ /**
24
+ * Serve a directory's real files over plain HTTP, for anything that needs a page loaded from a real URL rather
25
+ * than a bare file:// path - most commonly, a page using root-relative asset paths (/browser/...), which a
26
+ * file:// load resolves against the filesystem root instead of the page's own directory.
27
+ * @memberOf module:partials
28
+ * @param {string} rootDir - The directory to serve.
29
+ * @param {number} [port=0] - The port to listen on; 0 picks any free port.
30
+ * @returns {Promise<{server: http.Server, url: string}>} The running server, and its base URL - close the
31
+ * server yourself (server.close()) once done with it.
32
+ */
33
+ export const serveStatic = (rootDir, port = 0) => new Promise((resolve, reject) => {
34
+ const server = http.createServer((req, res) => {
35
+ const filePath = path.join(rootDir, decodeURIComponent(req.url.split('?')[0]))
36
+ fs.readFile(filePath, (error, data) => {
37
+ if (error) {
38
+ res.statusCode = 404
39
+ return res.end(`Not found: ${req.url}`)
40
+ }
41
+ res.setHeader('Content-Type', contentTypes[path.extname(filePath)] || 'application/octet-stream')
42
+ res.end(data)
43
+ })
44
+ })
45
+ server.once('error', reject)
46
+ server.listen(port, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}` }))
47
+ })
@@ -0,0 +1,71 @@
1
+ import fs from 'fs'
2
+ import path from 'node:path'
3
+ import * as setUp from '../test-helpers/setUp.mjs'
4
+ import { serveStatic } from './serveStatic.mjs'
5
+
6
+ setUp.setDefaults('test-serve-static')
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}/index.html`, '<!doctype html><html><body>hello</body></html>')
18
+ write(`${srcPath}/sub/page.html`, '<!doctype html><html><body>nested</body></html>')
19
+ write(`${srcPath}/module.js`, 'export default 1')
20
+ })
21
+ )
22
+
23
+ afterEach(setUp.afterEach)
24
+
25
+ describe('serveStatic', () => {
26
+ test('serves real files from the given directory over a real local HTTP server', async () => {
27
+ const { server, url } = await serveStatic(gulpConfig.get('srcPath'))
28
+ try {
29
+ expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/)
30
+ const root = await fetch(`${url}/index.html`)
31
+ expect(await root.text()).toBe('<!doctype html><html><body>hello</body></html>')
32
+ const nested = await fetch(`${url}/sub/page.html`)
33
+ expect(await nested.text()).toBe('<!doctype html><html><body>nested</body></html>')
34
+ } finally {
35
+ server.close()
36
+ }
37
+ })
38
+
39
+ test('sends a real Content-Type, not a blank one - a browser refuses to load a <script type="module"> without one', async () => {
40
+ const { server, url } = await serveStatic(gulpConfig.get('srcPath'))
41
+ try {
42
+ const html = await fetch(`${url}/index.html`)
43
+ expect(html.headers.get('content-type')).toBe('text/html')
44
+ const script = await fetch(`${url}/module.js`)
45
+ expect(script.headers.get('content-type')).toBe('text/javascript')
46
+ } finally {
47
+ server.close()
48
+ }
49
+ })
50
+
51
+ test('responds 404 for a file that does not exist, rather than hanging or throwing', async () => {
52
+ const { server, url } = await serveStatic(gulpConfig.get('srcPath'))
53
+ try {
54
+ const response = await fetch(`${url}/nothing-here.html`)
55
+ expect(response.status).toBe(404)
56
+ } finally {
57
+ server.close()
58
+ }
59
+ })
60
+
61
+ test('two servers for the same directory each get their own free port', async () => {
62
+ const first = await serveStatic(gulpConfig.get('srcPath'))
63
+ const second = await serveStatic(gulpConfig.get('srcPath'))
64
+ try {
65
+ expect(first.url).not.toBe(second.url)
66
+ } finally {
67
+ first.server.close()
68
+ second.server.close()
69
+ }
70
+ })
71
+ })
@@ -9,7 +9,7 @@ const importGulpTsCompile = () => importModule('gulp-ts-compile')
9
9
  /**
10
10
  * Wrap a stream in a promise that resolves once it finishes writing.
11
11
  * @memberOf module:partials
12
- * @param {import('node:stream').Stream} stream
12
+ * @param {stream.Stream} stream
13
13
  * @returns {Promise<void>}
14
14
  */
15
15
  const installHint = 'Install it in your project with: npm install --save-dev typescript'
@@ -36,7 +36,7 @@ const streamToPromise = (stream) => new Promise((resolve, reject) => {
36
36
  * gulp-typescript when a removed API call started throwing under TypeScript 7.
37
37
  * @memberOf module:partials
38
38
  * @param {string} configPath - Path to a tsconfig.json file.
39
- * @returns {import('typescript').CompilerOptions}
39
+ * @returns {ts.CompilerOptions} TypeScript's compiler options
40
40
  */
41
41
  const readCompilerOptions = (ts, configPath) => {
42
42
  const configFile = ts.readConfigFile(configPath, ts.sys.readFile)
@@ -20,13 +20,18 @@ export const loadTypeDoc = (projectPath = process.cwd()) => importPeer('typedoc'
20
20
 
21
21
  const isSource = fileName => /\.(ts|tsx|mts|cts)$/.test(fileName) && !/\.d\.[cm]?ts$/.test(fileName) && !/\.(test|spec)\./.test(fileName)
22
22
 
23
- const listSources = dirPath => fs.readdirSync(dirPath, { withFileTypes: true }).flatMap(entry => {
24
- const entryPath = path.join(dirPath, entry.name)
25
- if (entry.isDirectory()) {
26
- return entry.name === 'node_modules' ? [] : listSources(entryPath)
27
- }
28
- return isSource(entry.name) ? [entryPath] : []
29
- })
23
+ const listSources = dirPath => fs.readdirSync(dirPath, { withFileTypes: true })
24
+ .filter(entry => entry.isFile() && isSource(entry.name))
25
+ .map(entry => path.join(dirPath, entry.name))
26
+
27
+ // Every folder below dirPath (at any depth), named by its path from dirPath, with the source files directly in it
28
+ const listFolders = (dirPath, prefix = '') => fs.readdirSync(dirPath, { withFileTypes: true })
29
+ .filter(entry => entry.isDirectory() && entry.name !== 'node_modules')
30
+ .flatMap(entry => {
31
+ const folderPath = path.join(dirPath, entry.name)
32
+ const name = `${prefix}${entry.name}`
33
+ return [{ name, path: folderPath, files: listSources(folderPath) }, ...listFolders(folderPath, `${name}/`)]
34
+ })
30
35
 
31
36
  const toIdentifier = fileName => fileName.replace(/[^\w$]+(\w)?/g, (match, letter) => (letter || '').toUpperCase()).replace(/^(\d)/, '_$1')
32
37
 
@@ -102,11 +107,12 @@ export const describeFile = filePath => {
102
107
 
103
108
  /**
104
109
  * Write one entry file per folder of the source directory into the entry directory. TypeDoc makes a module of every
105
- * entry file, so the modules of the documentation mirror the folders. The default export of each source file is
110
+ * entry file, so the modules of the documentation mirror the folders. Nested folders are modules of their own, named
111
+ * by their path (utilities/parsers), and a folder's module holds only the files directly in it. The default export of each source file is
106
112
  * re-exported under the name of the file (the source files hold one function each), and anything else a file exports
107
113
  * is re-exported as it is (that is where the types.ts files go). Index files (the barrels), tests and declaration
108
114
  * files are left out, but the first doc comment of a folder's index file becomes the description of its module.
109
- * A file which sits beside the folders (not an index or main file) is a module of its own, described by its header
115
+ * A file which sits beside the folders (not an index, main or browser file) is a module of its own, described by its header
110
116
  * comment. A source directory with no folders gets a single entry called index.
111
117
  * @memberOf module:partials
112
118
  * @param {Object} ts - The TypeScript compiler API.
@@ -117,15 +123,10 @@ export const describeFile = filePath => {
117
123
  export const writeDocEntries = (ts, srcDir, entryDir) => {
118
124
  const absoluteSrc = path.resolve(srcDir)
119
125
  fs.mkdirSync(entryDir, { recursive: true })
120
- const folders = fs.readdirSync(absoluteSrc, { withFileTypes: true })
121
- .filter(entry => entry.isDirectory() && entry.name !== 'node_modules')
122
- .map(entry => ({ name: entry.name, path: path.join(absoluteSrc, entry.name), files: listSources(path.join(absoluteSrc, entry.name)) }))
123
- .filter(folder => folder.files.length)
124
- const rootFiles = fs.readdirSync(absoluteSrc, { withFileTypes: true })
125
- .filter(entry => entry.isFile() && isSource(entry.name) && !/^(index|main)\./.test(entry.name))
126
- .map(entry => path.join(absoluteSrc, entry.name))
127
- // Folders make modules, and so do the files which sit beside them (except the barrel: index and main). A source
128
- // directory with no folders gets a single module called index which holds its files.
126
+ const folders = listFolders(absoluteSrc).filter(folder => folder.files.some(file => !/^index\./.test(path.basename(file))))
127
+ const rootFiles = listSources(absoluteSrc).filter(file => !/^(index|main|browser)\./.test(path.basename(file)))
128
+ // Folders make modules, and so do the files which sit beside them (except the barrels: index, main and browser, the
129
+ // browser bundle's entry). A source directory with no folders gets a single module called index which holds its files.
129
130
  const groups = folders.length
130
131
  ? [
131
132
  ...folders,
@@ -149,8 +150,11 @@ export const writeDocEntries = (ts, srcDir, entryDir) => {
149
150
  })
150
151
  const description = group.description ?? (group.path ? describeFolder(group.path) : '')
151
152
  // TypeDoc takes the first comment of an entry file which has the @module tag as the description of the module
152
- const moduleComment = description ? `/**\n${description.split('\n').map(line => ` * ${line}`.trimEnd()).join('\n')}\n * @module\n */\n\n` : ''
153
+ // A nested folder's module is named by its path (utilities/parsers), so it is not mistaken for its parent's
154
+ const moduleName = group.name.includes('/') ? ` ${group.name}` : ''
155
+ const moduleComment = description || moduleName ? `/**\n${description ? description.split('\n').map(line => ` * ${line}`.trimEnd()).join('\n') + '\n' : ''} * @module${moduleName}\n */\n\n` : ''
153
156
  const entryPath = path.join(entryDir, `${group.name}.ts`)
157
+ fs.mkdirSync(path.dirname(entryPath), { recursive: true })
154
158
  fs.writeFileSync(entryPath, moduleComment + lines.join('\n') + '\n')
155
159
  return entryPath
156
160
  })
@@ -196,7 +200,7 @@ export const typeDocsFor = (
196
200
  const tsconfig = path.join(workDir, 'tsconfig.json')
197
201
  fs.writeFileSync(tsconfig, JSON.stringify({
198
202
  ...(projectConfig ? { extends: path.resolve(projectConfig) } : {}),
199
- include: [path.join(workDir, 'entries', '*.ts').split(path.sep).join('/'), path.resolve(srcPath).split(path.sep).join('/') + '/**/*.ts'],
203
+ include: [path.join(workDir, 'entries', '**', '*.ts').split(path.sep).join('/'), path.resolve(srcPath).split(path.sep).join('/') + '/**/*.ts'],
200
204
  exclude: [path.resolve(srcPath).split(path.sep).join('/') + '/**/*.test.*']
201
205
  }))
202
206
  const indexFile = gulpConfig.get('docs.index')
@@ -120,6 +120,7 @@ describe('writeDocEntries', () => {
120
120
  const srcPath = gulpConfig.get('srcPath')
121
121
  write(`${srcPath}/simulate.ts`, "/**\n * Simulate what a user does.\n */\nimport add from './point/add'\nexport const click = () => add(1, 2)\nexport default { click }\n")
122
122
  write(`${srcPath}/simulate.test.ts`, "test('x', () => {})\n")
123
+ write(`${srcPath}/browser.ts`, "export * from './main'\n")
123
124
  const entryDir = `${srcPath}/../entries`
124
125
  const entries = writeDocEntries(ts, srcPath, entryDir)
125
126
  expect(entries.map(entry => path.basename(entry)).sort()).toEqual(['line.ts', 'point.ts', 'simulate.ts'])
@@ -129,6 +130,28 @@ describe('writeDocEntries', () => {
129
130
  )
130
131
  })
131
132
 
133
+ test('makes a module of each nested folder, named by its path, holding only the files directly in it', () => {
134
+ const srcPath = gulpConfig.get('srcPath')
135
+ write(`${srcPath}/point/polar/to-polar.ts`, 'export default (x: number): number => x\n')
136
+ write(`${srcPath}/point/polar/index.ts`, "/**\n * Polar coordinates.\n */\nexport * from './to-polar'\n")
137
+ write(`${srcPath}/point/deep/index.ts`, "export * from './deeper/far'\n")
138
+ write(`${srcPath}/point/deep/deeper/far.ts`, 'export const far = 1\n')
139
+ const entryDir = `${srcPath}/../entries`
140
+ const entries = writeDocEntries(ts, srcPath, entryDir)
141
+ const absoluteEntries = path.resolve(entryDir)
142
+ expect(entries.map(entry => path.relative(absoluteEntries, entry).split(path.sep).join('/')).sort())
143
+ .toEqual(['line.ts', 'point.ts', 'point/deep/deeper.ts', 'point/polar.ts'])
144
+ const absoluteSrc = path.resolve(srcPath).split(path.sep).join('/')
145
+ expect(fs.readFileSync(`${entryDir}/point.ts`, 'utf8')).not.toMatch(/polar|deep/)
146
+ expect(fs.readFileSync(`${entryDir}/point/polar.ts`, 'utf8')).toBe(
147
+ `/**\n * Polar coordinates.\n * @module point/polar\n */\n\nexport { default as toPolar } from '${absoluteSrc}/point/polar/to-polar'\n`
148
+ )
149
+ // A folder with only an index file (point/deep) has no module, and one without a description is still named
150
+ expect(fs.readFileSync(`${entryDir}/point/deep/deeper.ts`, 'utf8')).toBe(
151
+ `/**\n * @module point/deep/deeper\n */\n\nexport * from '${absoluteSrc}/point/deep/deeper/far'\n`
152
+ )
153
+ })
154
+
132
155
  test('makes one entry called index when the source has no folders', () => {
133
156
  const flat = `${gulpConfig.get('srcPath')}/../flat`
134
157
  write(`${flat}/one.ts`, 'export default 1\n')
@@ -17,6 +17,7 @@ export { distSeries } from './partials/distSeries.mjs'
17
17
  export { imagesFor } from './partials/imagesFor.mjs'
18
18
  export { importModule, importPeer, requirePeer } from './partials/loadPeer.mjs'
19
19
  export { jsdocDocsFor } from './partials/jsdocDocsFor.mjs'
20
+ export { loadPlaywright, launchBrowser } from './partials/loadPlaywright.mjs'
20
21
  export { minifyFor } from './partials/minifyFor.mjs'
21
22
  export { neostandardLint } from './partials/neostandardLint.mjs'
22
23
  export { readmeTemplate } from './partials/readmeTemplate.mjs'
@@ -24,5 +25,6 @@ export { removeDirectory } from './partials/removeDirectory.mjs'
24
25
  export { resolveDocsMethod } from './partials/resolveDocsMethod.mjs'
25
26
  export { runOnChange } from './partials/runOnChange.mjs'
26
27
  export { sassFor } from './partials/sassFor.mjs'
28
+ export { serveStatic } from './partials/serveStatic.mjs'
27
29
  export { tsFor } from './partials/tsFor.mjs'
28
30
  export { typeDocsFor } from './partials/typeDocsFor.mjs'
@@ -2,6 +2,6 @@ import * as partials from './partials.mjs'
2
2
 
3
3
  describe('partials', () => {
4
4
  test('is object with all partial exports', () => {
5
- expect(Object.keys(partials).length).toBe(21)
5
+ expect(Object.keys(partials).length).toBe(24)
6
6
  })
7
7
  })