@thegetty/quire-cli 1.0.0-rc.37 → 1.0.0-rc.39
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/package.json +5 -2
- package/src/commands/build.js +13 -6
- package/src/commands/clean.js +10 -2
- package/src/commands/clean.spec.js +10 -0
- package/src/commands/clean.test.js +122 -0
- package/src/commands/config.js +3 -1
- package/src/commands/config.test.js +42 -1
- package/src/commands/doctor.js +251 -0
- package/src/commands/doctor.spec.js +115 -0
- package/src/commands/doctor.test.js +1409 -0
- package/src/commands/epub.js +9 -2
- package/src/commands/help.js +60 -0
- package/src/commands/help.test.js +132 -0
- package/src/commands/info.js +79 -71
- package/src/commands/info.spec.js +8 -0
- package/src/commands/info.test.js +173 -76
- package/src/commands/pdf.js +10 -3
- package/src/commands/validate.js +39 -7
- package/src/commands/validate.spec.js +8 -0
- package/src/commands/validate.test.js +144 -0
- package/src/errors/help/help-topic-not-found-error.js +22 -0
- package/src/errors/help/index.js +8 -0
- package/src/errors/index.js +6 -0
- package/src/errors/input/index.js +8 -0
- package/src/errors/input/invalid-input-error.js +21 -0
- package/src/helpers/pager.js +59 -0
- package/src/helpers/pager.test.js +85 -0
- package/src/lib/README.md +77 -22
- package/src/lib/conf/README.md +6 -0
- package/src/lib/conf/build-status.js +103 -0
- package/src/lib/conf/build-status.test.js +247 -0
- package/src/lib/conf/defaults.js +14 -0
- package/src/lib/conf/format.js +1 -1
- package/src/lib/conf/schema.js +46 -1
- package/src/lib/constants.js +28 -0
- package/src/lib/doctor/README.md +667 -0
- package/src/lib/doctor/checks/environment/cli-version.js +62 -0
- package/src/lib/doctor/checks/environment/cli-version.test.js +132 -0
- package/src/lib/doctor/checks/environment/git-available.js +77 -0
- package/src/lib/doctor/checks/environment/git-available.test.js +52 -0
- package/src/lib/doctor/checks/environment/index.js +13 -0
- package/src/lib/doctor/checks/environment/node-version.js +66 -0
- package/src/lib/doctor/checks/environment/node-version.test.js +17 -0
- package/src/lib/doctor/checks/environment/npm-available.js +66 -0
- package/src/lib/doctor/checks/environment/npm-available.test.js +52 -0
- package/src/lib/doctor/checks/environment/os-info.js +48 -0
- package/src/lib/doctor/checks/environment/os-info.test.js +81 -0
- package/src/lib/doctor/checks/environment/runtime-info.js +51 -0
- package/src/lib/doctor/checks/environment/runtime-info.test.js +132 -0
- package/src/lib/doctor/checks/outputs/epub-output.js +119 -0
- package/src/lib/doctor/checks/outputs/epub-output.test.js +277 -0
- package/src/lib/doctor/checks/outputs/index.js +10 -0
- package/src/lib/doctor/checks/outputs/pdf-output.js +144 -0
- package/src/lib/doctor/checks/outputs/pdf-output.test.js +377 -0
- package/src/lib/doctor/checks/outputs/stale-build.js +122 -0
- package/src/lib/doctor/checks/outputs/stale-build.test.js +282 -0
- package/src/lib/doctor/checks/project/data-files.js +56 -0
- package/src/lib/doctor/checks/project/data-files.test.js +125 -0
- package/src/lib/doctor/checks/project/dependencies.js +53 -0
- package/src/lib/doctor/checks/project/dependencies.test.js +71 -0
- package/src/lib/doctor/checks/project/index.js +11 -0
- package/src/lib/doctor/checks/project/quire-11ty.js +98 -0
- package/src/lib/doctor/checks/project/quire-11ty.test.js +170 -0
- package/src/lib/doctor/checks/project/quire-project.js +38 -0
- package/src/lib/doctor/checks/project/quire-project.test.js +47 -0
- package/src/lib/doctor/checks/tools/index.js +10 -0
- package/src/lib/doctor/checks/tools/pandoc-available.js +82 -0
- package/src/lib/doctor/checks/tools/pandoc-available.test.js +73 -0
- package/src/lib/doctor/checks/tools/prince-available.js +81 -0
- package/src/lib/doctor/checks/tools/prince-available.test.js +73 -0
- package/src/lib/doctor/constants.js +39 -0
- package/src/lib/doctor/formatDuration.js +108 -0
- package/src/lib/doctor/formatDuration.test.js +76 -0
- package/src/lib/doctor/formatters/human.js +257 -0
- package/src/lib/doctor/formatters/human.test.js +463 -0
- package/src/lib/doctor/formatters/index.js +8 -0
- package/src/lib/doctor/formatters/json.js +78 -0
- package/src/lib/doctor/formatters/json.test.js +174 -0
- package/src/lib/doctor/formatters/shared.js +129 -0
- package/src/lib/doctor/formatters/shared.test.js +194 -0
- package/src/lib/doctor/index.js +271 -0
- package/src/lib/doctor/index.test.js +797 -0
- package/src/lib/help/frontmatter.js +77 -0
- package/src/lib/help/frontmatter.test.js +139 -0
- package/src/lib/help/index.js +135 -0
- package/src/lib/help/index.test.js +160 -0
- package/src/lib/help/topics/configuration.md +77 -0
- package/src/lib/help/topics/debugging.md +69 -0
- package/src/lib/help/topics/epub.md +74 -0
- package/src/lib/help/topics/pdf.md +74 -0
- package/src/lib/help/topics/publishing.md +80 -0
- package/src/lib/help/topics/workflows.md +50 -0
- package/src/lib/platform.js +95 -0
- package/src/lib/project/build.js +44 -25
- package/src/lib/project/build.test.js +30 -8
- package/src/lib/project/detect.js +16 -2
- package/src/lib/project/index.js +18 -2
- package/src/lib/project/output-paths.js +87 -0
- package/src/lib/project/output-paths.test.js +66 -0
- package/src/lib/project/paths.js +48 -0
- package/src/main.js +38 -3
- package/src/packageConfig.js +17 -0
- package/src/validators/validate-data-files.js +154 -0
- package/src/validators/validate-data-files.test.js +217 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output path resolution module
|
|
3
|
+
*
|
|
4
|
+
* Centralizes knowledge of where PDF and EPUB output files are located.
|
|
5
|
+
* Used by the doctor command and build detection to find output artifacts
|
|
6
|
+
* using the same logic as the generation commands.
|
|
7
|
+
*
|
|
8
|
+
* @module lib/project/output-paths
|
|
9
|
+
*/
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { ENGINES as PDF_ENGINES } from '#lib/pdf/schema.js'
|
|
12
|
+
import { ENGINES as EPUB_ENGINES } from '#lib/epub/schema.js'
|
|
13
|
+
|
|
14
|
+
export { PDF_ENGINES, EPUB_ENGINES }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get all possible PDF output file paths for a project
|
|
18
|
+
*
|
|
19
|
+
* PDF output paths depend on configuration:
|
|
20
|
+
* - With config.pdf: `{outputDir}/{config.pdf.outputDir}/{config.pdf.filename}.pdf`
|
|
21
|
+
* - Without config (default): `{projectRoot}/{engine}.pdf`
|
|
22
|
+
*
|
|
23
|
+
* This reuses the same resolution logic as `lib/pdf/index.js#getOutputPath`.
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} [options]
|
|
26
|
+
* @param {string} [options.projectRoot=process.cwd()] - Project root directory
|
|
27
|
+
* @param {string} [options.outputDir='_site'] - Build output directory (relative)
|
|
28
|
+
* @param {Object} [options.pdfConfig] - PDF config from config.yaml (config.pdf)
|
|
29
|
+
* @param {string} [options.pdfConfig.outputDir] - PDF output subdirectory
|
|
30
|
+
* @param {string} [options.pdfConfig.filename] - PDF filename (without extension)
|
|
31
|
+
* @returns {string[]} Array of absolute paths to check for PDF output
|
|
32
|
+
*/
|
|
33
|
+
export function getPdfOutputPaths(options = {}) {
|
|
34
|
+
const {
|
|
35
|
+
projectRoot = process.cwd(),
|
|
36
|
+
outputDir = '_site',
|
|
37
|
+
pdfConfig,
|
|
38
|
+
} = options
|
|
39
|
+
|
|
40
|
+
const paths = []
|
|
41
|
+
|
|
42
|
+
if (pdfConfig && pdfConfig.outputDir && pdfConfig.filename) {
|
|
43
|
+
// Config-aware path: matches lib/pdf/index.js getOutputPath()
|
|
44
|
+
paths.push(
|
|
45
|
+
path.join(projectRoot, outputDir, pdfConfig.outputDir, `${pdfConfig.filename}.pdf`)
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Default engine-named paths (always check these as fallback)
|
|
50
|
+
for (const engine of PDF_ENGINES) {
|
|
51
|
+
paths.push(path.join(projectRoot, `${engine}.pdf`))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return paths
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get all possible EPUB output file paths for a project
|
|
59
|
+
*
|
|
60
|
+
* EPUB output is at `{projectRoot}/{engine}.epub` for each supported engine.
|
|
61
|
+
* The `_epub` directory is an intermediate build artifact, not the final output.
|
|
62
|
+
*
|
|
63
|
+
* This reuses the same resolution logic as `commands/epub.js`.
|
|
64
|
+
*
|
|
65
|
+
* @param {Object} [options]
|
|
66
|
+
* @param {string} [options.projectRoot=process.cwd()] - Project root directory
|
|
67
|
+
* @returns {string[]} Array of absolute paths to check for EPUB output
|
|
68
|
+
*/
|
|
69
|
+
export function getEpubOutputPaths(options = {}) {
|
|
70
|
+
const { projectRoot = process.cwd() } = options
|
|
71
|
+
|
|
72
|
+
return EPUB_ENGINES.map(
|
|
73
|
+
(engine) => path.join(projectRoot, `${engine}.epub`)
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get the intermediate EPUB build directory path
|
|
79
|
+
*
|
|
80
|
+
* @param {Object} [options]
|
|
81
|
+
* @param {string} [options.projectRoot=process.cwd()] - Project root directory
|
|
82
|
+
* @returns {string} Absolute path to _epub directory
|
|
83
|
+
*/
|
|
84
|
+
export function getEpubBuildDir(options = {}) {
|
|
85
|
+
const { projectRoot = process.cwd() } = options
|
|
86
|
+
return path.join(projectRoot, '_epub')
|
|
87
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import test from 'ava'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { getPdfOutputPaths, getEpubOutputPaths, getEpubBuildDir } from './output-paths.js'
|
|
4
|
+
|
|
5
|
+
const projectRoot = '/test-project'
|
|
6
|
+
|
|
7
|
+
test('getPdfOutputPaths returns default engine paths', (t) => {
|
|
8
|
+
const paths = getPdfOutputPaths({ projectRoot })
|
|
9
|
+
|
|
10
|
+
t.true(paths.includes(path.join(projectRoot, 'pagedjs.pdf')))
|
|
11
|
+
t.true(paths.includes(path.join(projectRoot, 'prince.pdf')))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('getPdfOutputPaths includes config-aware path when pdfConfig provided', (t) => {
|
|
15
|
+
const pdfConfig = { outputDir: 'pdf', filename: 'my-publication' }
|
|
16
|
+
const paths = getPdfOutputPaths({ projectRoot, outputDir: '_site', pdfConfig })
|
|
17
|
+
|
|
18
|
+
// Config path should be first
|
|
19
|
+
t.is(paths[0], path.join(projectRoot, '_site', 'pdf', 'my-publication.pdf'))
|
|
20
|
+
// Default paths should still be present
|
|
21
|
+
t.true(paths.includes(path.join(projectRoot, 'pagedjs.pdf')))
|
|
22
|
+
t.true(paths.includes(path.join(projectRoot, 'prince.pdf')))
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('getPdfOutputPaths skips config path when pdfConfig is incomplete', (t) => {
|
|
26
|
+
// Missing filename
|
|
27
|
+
const paths = getPdfOutputPaths({ projectRoot, pdfConfig: { outputDir: 'pdf' } })
|
|
28
|
+
|
|
29
|
+
// Should only have default engine paths
|
|
30
|
+
t.is(paths.length, 2)
|
|
31
|
+
t.true(paths.includes(path.join(projectRoot, 'pagedjs.pdf')))
|
|
32
|
+
t.true(paths.includes(path.join(projectRoot, 'prince.pdf')))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('getPdfOutputPaths uses cwd when no projectRoot provided', (t) => {
|
|
36
|
+
const paths = getPdfOutputPaths()
|
|
37
|
+
|
|
38
|
+
t.true(paths.includes(path.join(process.cwd(), 'pagedjs.pdf')))
|
|
39
|
+
t.true(paths.includes(path.join(process.cwd(), 'prince.pdf')))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('getEpubOutputPaths returns default engine paths', (t) => {
|
|
43
|
+
const paths = getEpubOutputPaths({ projectRoot })
|
|
44
|
+
|
|
45
|
+
t.true(paths.includes(path.join(projectRoot, 'epubjs.epub')))
|
|
46
|
+
t.true(paths.includes(path.join(projectRoot, 'pandoc.epub')))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('getEpubOutputPaths uses cwd when no projectRoot provided', (t) => {
|
|
50
|
+
const paths = getEpubOutputPaths()
|
|
51
|
+
|
|
52
|
+
t.true(paths.includes(path.join(process.cwd(), 'epubjs.epub')))
|
|
53
|
+
t.true(paths.includes(path.join(process.cwd(), 'pandoc.epub')))
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('getEpubBuildDir returns _epub path', (t) => {
|
|
57
|
+
const dir = getEpubBuildDir({ projectRoot })
|
|
58
|
+
|
|
59
|
+
t.is(dir, path.join(projectRoot, '_epub'))
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('getEpubBuildDir uses cwd when no projectRoot provided', (t) => {
|
|
63
|
+
const dir = getEpubBuildDir()
|
|
64
|
+
|
|
65
|
+
t.is(dir, path.join(process.cwd(), '_epub'))
|
|
66
|
+
})
|
package/src/lib/project/paths.js
CHANGED
|
@@ -5,6 +5,54 @@ import path from 'node:path'
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url)
|
|
6
6
|
const __dirname = path.dirname(__filename)
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Source directories that contain content for building.
|
|
10
|
+
*
|
|
11
|
+
* These directories are monitored for changes during preview and are used
|
|
12
|
+
* to determine if a build is stale. Changes to files in these directories
|
|
13
|
+
* indicate the site needs to be rebuilt.
|
|
14
|
+
*
|
|
15
|
+
* @constant {ReadonlyArray<string>}
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* // Check all source directories for changes
|
|
19
|
+
* for (const dir of SOURCE_DIRECTORIES) {
|
|
20
|
+
* const mtime = getLatestMtime(dir)
|
|
21
|
+
* // ...
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
export const SOURCE_DIRECTORIES = Object.freeze([
|
|
25
|
+
'_data',
|
|
26
|
+
'_includes',
|
|
27
|
+
'_layouts',
|
|
28
|
+
'content',
|
|
29
|
+
'static',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Path to data files directory (relative to project root)
|
|
34
|
+
*
|
|
35
|
+
* Contains YAML configuration files for the publication:
|
|
36
|
+
* - publication.yaml - Publication metadata
|
|
37
|
+
* - config.yaml - Site configuration
|
|
38
|
+
* - figures.yaml - Figure definitions
|
|
39
|
+
* - objects.yaml - Object catalog entries
|
|
40
|
+
* - references.yaml - Bibliography entries
|
|
41
|
+
*
|
|
42
|
+
* @constant {string}
|
|
43
|
+
*/
|
|
44
|
+
export const DATA_DIR = 'content/_data'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Required data files that must exist in a Quire project.
|
|
48
|
+
*
|
|
49
|
+
* These files are validated by the doctor command and the validate command.
|
|
50
|
+
* Missing required files will trigger a warning.
|
|
51
|
+
*
|
|
52
|
+
* @constant {ReadonlyArray<string>}
|
|
53
|
+
*/
|
|
54
|
+
export const REQUIRED_DATA_FILES = Object.freeze(['publication.yaml'])
|
|
55
|
+
|
|
8
56
|
/**
|
|
9
57
|
* Project path configuration
|
|
10
58
|
*
|
package/src/main.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import commands from '#src/commands/index.js'
|
|
10
10
|
import config from '#lib/conf/config.js'
|
|
11
11
|
import { handleError } from '#lib/error/handler.js'
|
|
12
|
+
import reporter from '#lib/reporter/index.js'
|
|
12
13
|
import { docsUrl, DOCS_BASE } from '#helpers/docs-url.js'
|
|
13
14
|
import packageConfig from '#src/packageConfig.js'
|
|
14
15
|
import { enableDebug } from '#lib/logger/debug.js'
|
|
@@ -33,7 +34,14 @@ Output Modes:
|
|
|
33
34
|
|
|
34
35
|
Set defaults: quire settings set verbose true
|
|
35
36
|
|
|
37
|
+
Paging:
|
|
38
|
+
--no-pager Disable paging for long output
|
|
39
|
+
NO_PAGER=1 Disable paging via environment variable
|
|
40
|
+
PAGER=cat Traditional Unix alternative (passes output through)
|
|
41
|
+
|
|
36
42
|
Environment Variables:
|
|
43
|
+
NO_PAGER=1 Disable paging for long output
|
|
44
|
+
PAGER=<program> Set pager program (default: less). Use PAGER=cat to disable
|
|
37
45
|
DEBUG=quire:* Enable debug output for all modules
|
|
38
46
|
DEBUG=quire:lib:pdf Enable debug output for PDF module only
|
|
39
47
|
DEBUG=quire:lib:* Enable debug output for all lib modules
|
|
@@ -48,7 +56,7 @@ Examples:
|
|
|
48
56
|
/**
|
|
49
57
|
* Quire CLI implements the command pattern.
|
|
50
58
|
*
|
|
51
|
-
* The
|
|
59
|
+
* The \`main\` module acts as the _receiver_, parsing input from the client,
|
|
52
60
|
* calling the appropriate command module(s), managing messages between modules,
|
|
53
61
|
* and sending formatted messages to the client for display.
|
|
54
62
|
*/
|
|
@@ -61,6 +69,7 @@ program
|
|
|
61
69
|
.addOption(arrayToOption(quietOption))
|
|
62
70
|
.addOption(arrayToOption(verboseOption))
|
|
63
71
|
.addOption(arrayToOption(debugOption))
|
|
72
|
+
.option('--no-pager', 'disable paging for long output')
|
|
64
73
|
.addHelpText('after', mainHelpText)
|
|
65
74
|
.configureHelp({
|
|
66
75
|
helpWidth: 80,
|
|
@@ -91,6 +100,19 @@ program.hook('preAction', (thisCommand) => {
|
|
|
91
100
|
if (opts.debug ?? config.get('debug')) {
|
|
92
101
|
enableDebug('quire:*')
|
|
93
102
|
}
|
|
103
|
+
|
|
104
|
+
// --no-pager sets pager to false; propagate via env var for pager utility
|
|
105
|
+
if (opts.pager === false) {
|
|
106
|
+
process.env.NO_PAGER = '1'
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Stop the reporter after every command to clear any active setInterval timers
|
|
112
|
+
* (e.g. elapsed time display) that would otherwise keep the event loop alive.
|
|
113
|
+
*/
|
|
114
|
+
program.hook('postAction', () => {
|
|
115
|
+
reporter.stop()
|
|
94
116
|
})
|
|
95
117
|
|
|
96
118
|
/**
|
|
@@ -190,12 +212,25 @@ commands.forEach((command) => {
|
|
|
190
212
|
})
|
|
191
213
|
}
|
|
192
214
|
|
|
193
|
-
|
|
194
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Wrap action in centralized error handler.
|
|
217
|
+
* Using apply() preserves `this` context for `this.debug` and `this.logger`.
|
|
218
|
+
*
|
|
219
|
+
* Nota bene: Commander passes subcommand-local opts as the options argument,
|
|
220
|
+
* but global options (--verbose, --quiet, --debug) defined on the parent
|
|
221
|
+
* program are only available via optsWithGlobals(). We replace the options
|
|
222
|
+
* argument with the merged set so action handlers see all options uniformly.
|
|
223
|
+
*/
|
|
195
224
|
subCommand.action(async (...args) => {
|
|
196
225
|
try {
|
|
226
|
+
// Commander Command instance is the last element in args array
|
|
227
|
+
// @see https://github.com/tj/commander.js#action-handler
|
|
228
|
+
const cmd = args[args.length - 1]
|
|
229
|
+
const mergedOpts = cmd.optsWithGlobals()
|
|
230
|
+
args[args.length - 2] = mergedOpts
|
|
197
231
|
await action.apply(command, args)
|
|
198
232
|
} catch (error) {
|
|
233
|
+
reporter.stop()
|
|
199
234
|
const { debug } = program.opts()
|
|
200
235
|
handleError(error, { debug })
|
|
201
236
|
}
|
package/src/packageConfig.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dirname } from 'node:path'
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { readPackageUpSync } from 'read-package-up'
|
|
4
|
+
import which from '#helpers/which.js'
|
|
4
5
|
|
|
5
6
|
const __filename = fileURLToPath(import.meta.url)
|
|
6
7
|
const __dirname = dirname(__filename)
|
|
@@ -12,4 +13,20 @@ const __dirname = dirname(__filename)
|
|
|
12
13
|
*/
|
|
13
14
|
const { packageJson } = readPackageUpSync({ cwd: __dirname, normalize: true })
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the full filesystem path to the quire CLI executable
|
|
18
|
+
*
|
|
19
|
+
* Uses the `which` helper to find the first `quire` executable in PATH.
|
|
20
|
+
* Returns null if the executable is not found.
|
|
21
|
+
*
|
|
22
|
+
* @returns {string|null} Absolute path to the quire executable, or null
|
|
23
|
+
*/
|
|
24
|
+
export function binPath() {
|
|
25
|
+
try {
|
|
26
|
+
return which('quire')
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
export default packageJson
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data files validation for Quire projects
|
|
3
|
+
*
|
|
4
|
+
* Validates YAML files in content/_data/ directory:
|
|
5
|
+
* - Required files exist (publication.yaml)
|
|
6
|
+
* - YAML syntax is correct
|
|
7
|
+
* - Files conform to their JSON schemas (when schema exists)
|
|
8
|
+
* - No duplicate IDs in arrays
|
|
9
|
+
*
|
|
10
|
+
* @module validators/validate-data-files
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import yaml from 'js-yaml'
|
|
15
|
+
import Ajv from 'ajv'
|
|
16
|
+
import addFormats from 'ajv-formats'
|
|
17
|
+
import { DATA_DIR, REQUIRED_DATA_FILES } from '#lib/project/index.js'
|
|
18
|
+
import { getSchemaForDocument, checkForDuplicateIds } from './utils.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validation result for a single file
|
|
22
|
+
* @typedef {Object} FileValidationResult
|
|
23
|
+
* @property {string} file - Filename
|
|
24
|
+
* @property {boolean} valid - Whether the file is valid
|
|
25
|
+
* @property {string[]} errors - List of error messages
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validation result for all data files
|
|
30
|
+
* @typedef {Object} DataFilesValidationResult
|
|
31
|
+
* @property {boolean} valid - Whether all files are valid
|
|
32
|
+
* @property {string[]} errors - All error messages across all files
|
|
33
|
+
* @property {number} fileCount - Number of YAML files found
|
|
34
|
+
* @property {FileValidationResult[]} files - Per-file validation results
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate a single YAML file
|
|
39
|
+
*
|
|
40
|
+
* @param {string} filePath - Absolute path to the YAML file
|
|
41
|
+
* @returns {FileValidationResult} Validation result
|
|
42
|
+
*/
|
|
43
|
+
export function validateYamlFile(filePath) {
|
|
44
|
+
const errors = []
|
|
45
|
+
const filename = path.basename(filePath)
|
|
46
|
+
|
|
47
|
+
// Check file exists
|
|
48
|
+
if (!fs.existsSync(filePath)) {
|
|
49
|
+
return { file: filename, valid: false, errors: [`File not found: ${filename}`] }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Read and parse YAML
|
|
53
|
+
let doc
|
|
54
|
+
try {
|
|
55
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
56
|
+
doc = yaml.load(content)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const loc = error.mark ? ` at line ${error.mark.line + 1}` : ''
|
|
59
|
+
errors.push(`${filename}: YAML syntax error${loc} - ${error.reason || error.message}`)
|
|
60
|
+
return { file: filename, valid: false, errors }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Validate against schema if one exists
|
|
64
|
+
const schema = getSchemaForDocument(filePath)
|
|
65
|
+
if (schema) {
|
|
66
|
+
const ajv = new Ajv({ allErrors: true })
|
|
67
|
+
addFormats(ajv)
|
|
68
|
+
const validate = ajv.compile(schema)
|
|
69
|
+
const valid = validate(doc)
|
|
70
|
+
|
|
71
|
+
if (!valid) {
|
|
72
|
+
for (const err of validate.errors) {
|
|
73
|
+
const errPath = err.instancePath || '(root)'
|
|
74
|
+
errors.push(`${filename}: ${errPath} ${err.message}`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check for duplicate IDs using the existing utility
|
|
80
|
+
// Note: checkForDuplicateIds throws, so we need to catch
|
|
81
|
+
try {
|
|
82
|
+
checkForDuplicateIds(doc, filePath)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// Extract the duplicate ID info from the error
|
|
85
|
+
const match = error.reason?.match(/Duplicate IDs found: (.+)/)
|
|
86
|
+
if (match) {
|
|
87
|
+
errors.push(`${filename}: duplicate IDs found: ${match[1]}`)
|
|
88
|
+
} else {
|
|
89
|
+
errors.push(`${filename}: ${error.reason || error.message}`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { file: filename, valid: errors.length === 0, errors }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Validate all data files in content/_data/
|
|
98
|
+
*
|
|
99
|
+
* @returns {DataFilesValidationResult} Validation result
|
|
100
|
+
*/
|
|
101
|
+
export function validateDataFiles() {
|
|
102
|
+
const allErrors = []
|
|
103
|
+
const fileResults = []
|
|
104
|
+
|
|
105
|
+
// Check if data directory exists
|
|
106
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
107
|
+
return {
|
|
108
|
+
valid: true,
|
|
109
|
+
errors: [],
|
|
110
|
+
fileCount: 0,
|
|
111
|
+
files: [],
|
|
112
|
+
notInProject: true,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check required files exist
|
|
117
|
+
for (const required of REQUIRED_DATA_FILES) {
|
|
118
|
+
const filePath = path.join(DATA_DIR, required)
|
|
119
|
+
if (!fs.existsSync(filePath)) {
|
|
120
|
+
allErrors.push(`Required file missing: ${required}`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Find all YAML files
|
|
125
|
+
let yamlFiles = []
|
|
126
|
+
try {
|
|
127
|
+
yamlFiles = fs.readdirSync(DATA_DIR)
|
|
128
|
+
.filter((file) => file.endsWith('.yaml') || file.endsWith('.yml'))
|
|
129
|
+
.map((file) => path.join(DATA_DIR, file))
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return {
|
|
132
|
+
valid: false,
|
|
133
|
+
errors: [`Cannot read ${DATA_DIR}: ${error.message}`],
|
|
134
|
+
fileCount: 0,
|
|
135
|
+
files: [],
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Validate each file
|
|
140
|
+
for (const filePath of yamlFiles) {
|
|
141
|
+
const result = validateYamlFile(filePath)
|
|
142
|
+
fileResults.push(result)
|
|
143
|
+
allErrors.push(...result.errors)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
valid: allErrors.length === 0,
|
|
148
|
+
errors: allErrors,
|
|
149
|
+
fileCount: yamlFiles.length,
|
|
150
|
+
files: fileResults,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export default validateDataFiles
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import test from 'ava'
|
|
2
|
+
import sinon from 'sinon'
|
|
3
|
+
import esmock from 'esmock'
|
|
4
|
+
|
|
5
|
+
test.beforeEach((t) => {
|
|
6
|
+
t.context.sandbox = sinon.createSandbox()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test.afterEach.always((t) => {
|
|
10
|
+
t.context.sandbox.restore()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('validateDataFiles returns notInProject when data dir does not exist', async (t) => {
|
|
14
|
+
const { sandbox } = t.context
|
|
15
|
+
|
|
16
|
+
const { validateDataFiles } = await esmock('./validate-data-files.js', {
|
|
17
|
+
'node:fs': {
|
|
18
|
+
default: {
|
|
19
|
+
existsSync: sandbox.stub().returns(false),
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const result = validateDataFiles()
|
|
25
|
+
|
|
26
|
+
t.true(result.valid)
|
|
27
|
+
t.true(result.notInProject)
|
|
28
|
+
t.is(result.fileCount, 0)
|
|
29
|
+
t.deepEqual(result.errors, [])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('validateDataFiles returns error when required file is missing', async (t) => {
|
|
33
|
+
const { sandbox } = t.context
|
|
34
|
+
|
|
35
|
+
const existsStub = sandbox.stub()
|
|
36
|
+
existsStub.withArgs('content/_data').returns(true)
|
|
37
|
+
existsStub.withArgs('content/_data/publication.yaml').returns(false)
|
|
38
|
+
existsStub.returns(false)
|
|
39
|
+
|
|
40
|
+
const path = await import('node:path')
|
|
41
|
+
const { validateDataFiles } = await esmock('./validate-data-files.js', {
|
|
42
|
+
'node:fs': {
|
|
43
|
+
default: {
|
|
44
|
+
existsSync: existsStub,
|
|
45
|
+
readdirSync: sandbox.stub().returns([]),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
'node:path': {
|
|
49
|
+
default: path.default,
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const result = validateDataFiles()
|
|
54
|
+
|
|
55
|
+
t.false(result.valid)
|
|
56
|
+
t.true(result.errors.includes('Required file missing: publication.yaml'))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('validateDataFiles validates all YAML files in directory', async (t) => {
|
|
60
|
+
const { sandbox } = t.context
|
|
61
|
+
|
|
62
|
+
const existsStub = sandbox.stub()
|
|
63
|
+
existsStub.withArgs('content/_data').returns(true)
|
|
64
|
+
existsStub.withArgs('content/_data/publication.yaml').returns(true)
|
|
65
|
+
existsStub.returns(true)
|
|
66
|
+
|
|
67
|
+
const readFileStub = sandbox.stub()
|
|
68
|
+
readFileStub.callsFake((filePath) => {
|
|
69
|
+
if (filePath.includes('publication.yaml')) {
|
|
70
|
+
return 'title: Test Publication'
|
|
71
|
+
}
|
|
72
|
+
if (filePath.includes('config.yaml')) {
|
|
73
|
+
return 'setting: value'
|
|
74
|
+
}
|
|
75
|
+
throw new Error('ENOENT')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const path = await import('node:path')
|
|
79
|
+
const { validateDataFiles } = await esmock('./validate-data-files.js', {
|
|
80
|
+
'node:fs': {
|
|
81
|
+
default: {
|
|
82
|
+
existsSync: existsStub,
|
|
83
|
+
readdirSync: sandbox.stub().returns(['publication.yaml', 'config.yaml']),
|
|
84
|
+
readFileSync: readFileStub,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
'node:path': {
|
|
88
|
+
default: path.default,
|
|
89
|
+
},
|
|
90
|
+
'./utils.js': {
|
|
91
|
+
getSchemaForDocument: sandbox.stub().returns(null),
|
|
92
|
+
checkForDuplicateIds: sandbox.stub(),
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const result = validateDataFiles()
|
|
97
|
+
|
|
98
|
+
t.true(result.valid)
|
|
99
|
+
t.is(result.fileCount, 2)
|
|
100
|
+
t.is(result.files.length, 2)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('validateYamlFile returns error for invalid YAML syntax', async (t) => {
|
|
104
|
+
const { sandbox } = t.context
|
|
105
|
+
|
|
106
|
+
const path = await import('node:path')
|
|
107
|
+
const { validateYamlFile } = await esmock('./validate-data-files.js', {
|
|
108
|
+
'node:fs': {
|
|
109
|
+
default: {
|
|
110
|
+
existsSync: sandbox.stub().returns(true),
|
|
111
|
+
readFileSync: sandbox.stub().returns('title: [invalid yaml'),
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
'node:path': {
|
|
115
|
+
default: path.default,
|
|
116
|
+
},
|
|
117
|
+
'./utils.js': {
|
|
118
|
+
getSchemaForDocument: sandbox.stub().returns(null),
|
|
119
|
+
checkForDuplicateIds: sandbox.stub(),
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const result = validateYamlFile('content/_data/test.yaml')
|
|
124
|
+
|
|
125
|
+
t.false(result.valid)
|
|
126
|
+
t.true(result.errors.some((e) => e.includes('YAML syntax error')))
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('validateYamlFile returns error when file not found', async (t) => {
|
|
130
|
+
const { sandbox } = t.context
|
|
131
|
+
|
|
132
|
+
const path = await import('node:path')
|
|
133
|
+
const { validateYamlFile } = await esmock('./validate-data-files.js', {
|
|
134
|
+
'node:fs': {
|
|
135
|
+
default: {
|
|
136
|
+
existsSync: sandbox.stub().returns(false),
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
'node:path': {
|
|
140
|
+
default: path.default,
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const result = validateYamlFile('content/_data/missing.yaml')
|
|
145
|
+
|
|
146
|
+
t.false(result.valid)
|
|
147
|
+
t.true(result.errors.some((e) => e.includes('File not found')))
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('validateYamlFile validates against schema when available', async (t) => {
|
|
151
|
+
const { sandbox } = t.context
|
|
152
|
+
|
|
153
|
+
const schema = {
|
|
154
|
+
type: 'object',
|
|
155
|
+
required: ['title'],
|
|
156
|
+
properties: {
|
|
157
|
+
title: { type: 'string' },
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const path = await import('node:path')
|
|
162
|
+
const { validateYamlFile } = await esmock('./validate-data-files.js', {
|
|
163
|
+
'node:fs': {
|
|
164
|
+
default: {
|
|
165
|
+
existsSync: sandbox.stub().returns(true),
|
|
166
|
+
readFileSync: sandbox.stub().returns('notitle: value'),
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
'node:path': {
|
|
170
|
+
default: path.default,
|
|
171
|
+
},
|
|
172
|
+
'./utils.js': {
|
|
173
|
+
getSchemaForDocument: sandbox.stub().returns(schema),
|
|
174
|
+
checkForDuplicateIds: sandbox.stub(),
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const result = validateYamlFile('content/_data/test.yaml')
|
|
179
|
+
|
|
180
|
+
t.false(result.valid)
|
|
181
|
+
t.true(result.errors.some((e) => e.includes('title')))
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('validateYamlFile catches duplicate ID errors', async (t) => {
|
|
185
|
+
const { sandbox } = t.context
|
|
186
|
+
|
|
187
|
+
const path = await import('node:path')
|
|
188
|
+
const { validateYamlFile } = await esmock('./validate-data-files.js', {
|
|
189
|
+
'node:fs': {
|
|
190
|
+
default: {
|
|
191
|
+
existsSync: sandbox.stub().returns(true),
|
|
192
|
+
readFileSync: sandbox.stub().returns('title: Test'),
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
'node:path': {
|
|
196
|
+
default: path.default,
|
|
197
|
+
},
|
|
198
|
+
'./utils.js': {
|
|
199
|
+
getSchemaForDocument: sandbox.stub().returns(null),
|
|
200
|
+
checkForDuplicateIds: sandbox.stub().throws({
|
|
201
|
+
reason: 'Duplicate IDs found: fig-1, fig-2',
|
|
202
|
+
}),
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const result = validateYamlFile('content/_data/figures.yaml')
|
|
207
|
+
|
|
208
|
+
t.false(result.valid)
|
|
209
|
+
t.true(result.errors.some((e) => e.includes('duplicate IDs')))
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('default export is validateDataFiles', async (t) => {
|
|
213
|
+
const module = await import('./validate-data-files.js')
|
|
214
|
+
|
|
215
|
+
t.is(typeof module.default, 'function')
|
|
216
|
+
t.is(module.default, module.validateDataFiles)
|
|
217
|
+
})
|