@thegetty/quire-cli 1.0.0-rc.36 → 1.0.0-rc.38

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 (91) hide show
  1. package/package.json +3 -2
  2. package/src/commands/build.js +3 -4
  3. package/src/commands/build.test.js +2 -7
  4. package/src/commands/clean.js +10 -2
  5. package/src/commands/clean.spec.js +10 -0
  6. package/src/commands/clean.test.js +122 -0
  7. package/src/commands/config.js +3 -1
  8. package/src/commands/config.test.js +42 -1
  9. package/src/commands/doctor.js +251 -0
  10. package/src/commands/doctor.spec.js +115 -0
  11. package/src/commands/doctor.test.js +1409 -0
  12. package/src/commands/epub.js +9 -2
  13. package/src/commands/info.js +79 -71
  14. package/src/commands/info.spec.js +8 -0
  15. package/src/commands/info.test.js +173 -76
  16. package/src/commands/pdf.js +10 -3
  17. package/src/commands/preview.js +8 -2
  18. package/src/commands/validate.js +39 -7
  19. package/src/commands/validate.spec.js +8 -0
  20. package/src/commands/validate.test.js +144 -0
  21. package/src/lib/11ty/api.js +25 -6
  22. package/src/lib/11ty/cli.js +17 -5
  23. package/src/lib/README.md +77 -22
  24. package/src/lib/conf/README.md +6 -0
  25. package/src/lib/conf/build-status.js +103 -0
  26. package/src/lib/conf/build-status.test.js +247 -0
  27. package/src/lib/conf/defaults.js +14 -0
  28. package/src/lib/conf/format.js +1 -1
  29. package/src/lib/conf/schema.js +46 -1
  30. package/src/lib/constants.js +28 -0
  31. package/src/lib/doctor/README.md +667 -0
  32. package/src/lib/doctor/checks/environment/cli-version.js +62 -0
  33. package/src/lib/doctor/checks/environment/cli-version.test.js +132 -0
  34. package/src/lib/doctor/checks/environment/git-available.js +77 -0
  35. package/src/lib/doctor/checks/environment/git-available.test.js +52 -0
  36. package/src/lib/doctor/checks/environment/index.js +13 -0
  37. package/src/lib/doctor/checks/environment/node-version.js +66 -0
  38. package/src/lib/doctor/checks/environment/node-version.test.js +17 -0
  39. package/src/lib/doctor/checks/environment/npm-available.js +66 -0
  40. package/src/lib/doctor/checks/environment/npm-available.test.js +52 -0
  41. package/src/lib/doctor/checks/environment/os-info.js +48 -0
  42. package/src/lib/doctor/checks/environment/os-info.test.js +81 -0
  43. package/src/lib/doctor/checks/environment/runtime-info.js +51 -0
  44. package/src/lib/doctor/checks/environment/runtime-info.test.js +132 -0
  45. package/src/lib/doctor/checks/outputs/epub-output.js +119 -0
  46. package/src/lib/doctor/checks/outputs/epub-output.test.js +277 -0
  47. package/src/lib/doctor/checks/outputs/index.js +10 -0
  48. package/src/lib/doctor/checks/outputs/pdf-output.js +144 -0
  49. package/src/lib/doctor/checks/outputs/pdf-output.test.js +377 -0
  50. package/src/lib/doctor/checks/outputs/stale-build.js +122 -0
  51. package/src/lib/doctor/checks/outputs/stale-build.test.js +282 -0
  52. package/src/lib/doctor/checks/project/data-files.js +56 -0
  53. package/src/lib/doctor/checks/project/data-files.test.js +125 -0
  54. package/src/lib/doctor/checks/project/dependencies.js +53 -0
  55. package/src/lib/doctor/checks/project/dependencies.test.js +71 -0
  56. package/src/lib/doctor/checks/project/index.js +11 -0
  57. package/src/lib/doctor/checks/project/quire-11ty.js +98 -0
  58. package/src/lib/doctor/checks/project/quire-11ty.test.js +170 -0
  59. package/src/lib/doctor/checks/project/quire-project.js +38 -0
  60. package/src/lib/doctor/checks/project/quire-project.test.js +47 -0
  61. package/src/lib/doctor/checks/tools/index.js +10 -0
  62. package/src/lib/doctor/checks/tools/pandoc-available.js +82 -0
  63. package/src/lib/doctor/checks/tools/pandoc-available.test.js +73 -0
  64. package/src/lib/doctor/checks/tools/prince-available.js +81 -0
  65. package/src/lib/doctor/checks/tools/prince-available.test.js +73 -0
  66. package/src/lib/doctor/constants.js +39 -0
  67. package/src/lib/doctor/formatDuration.js +108 -0
  68. package/src/lib/doctor/formatDuration.test.js +76 -0
  69. package/src/lib/doctor/formatters/human.js +257 -0
  70. package/src/lib/doctor/formatters/human.test.js +463 -0
  71. package/src/lib/doctor/formatters/index.js +8 -0
  72. package/src/lib/doctor/formatters/json.js +78 -0
  73. package/src/lib/doctor/formatters/json.test.js +174 -0
  74. package/src/lib/doctor/formatters/shared.js +129 -0
  75. package/src/lib/doctor/formatters/shared.test.js +194 -0
  76. package/src/lib/doctor/index.js +271 -0
  77. package/src/lib/doctor/index.test.js +797 -0
  78. package/src/lib/logger/debug.js +43 -1
  79. package/src/lib/logger/debug.test.js +128 -0
  80. package/src/lib/platform.js +95 -0
  81. package/src/lib/project/build.js +44 -25
  82. package/src/lib/project/build.test.js +30 -8
  83. package/src/lib/project/detect.js +16 -2
  84. package/src/lib/project/index.js +18 -2
  85. package/src/lib/project/output-paths.js +87 -0
  86. package/src/lib/project/output-paths.test.js +66 -0
  87. package/src/lib/project/paths.js +48 -0
  88. package/src/main.js +24 -2
  89. package/src/packageConfig.js +17 -0
  90. package/src/validators/validate-data-files.js +154 -0
  91. package/src/validators/validate-data-files.test.js +217 -0
@@ -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
+ })
@@ -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'
@@ -93,6 +94,14 @@ program.hook('preAction', (thisCommand) => {
93
94
  }
94
95
  })
95
96
 
97
+ /**
98
+ * Stop the reporter after every command to clear any active setInterval timers
99
+ * (e.g. elapsed time display) that would otherwise keep the event loop alive.
100
+ */
101
+ program.hook('postAction', () => {
102
+ reporter.stop()
103
+ })
104
+
96
105
  /**
97
106
  * Register each command as a subcommand of this program
98
107
  * @see https://github.com/tj/commander.js?tab=readme-ov-file#automated-help
@@ -190,12 +199,25 @@ commands.forEach((command) => {
190
199
  })
191
200
  }
192
201
 
193
- // Wrap action in centralized error handler
194
- // Using apply() preserves `this` context for `this.debug` and `this.logger`
202
+ /**
203
+ * Wrap action in centralized error handler.
204
+ * Using apply() preserves `this` context for `this.debug` and `this.logger`.
205
+ *
206
+ * Nota bene: Commander passes subcommand-local opts as the options argument,
207
+ * but global options (--verbose, --quiet, --debug) defined on the parent
208
+ * program are only available via optsWithGlobals(). We replace the options
209
+ * argument with the merged set so action handlers see all options uniformly.
210
+ */
195
211
  subCommand.action(async (...args) => {
196
212
  try {
213
+ // Commander Command instance is the last element in args array
214
+ // @see https://github.com/tj/commander.js#action-handler
215
+ const cmd = args[args.length - 1]
216
+ const mergedOpts = cmd.optsWithGlobals()
217
+ args[args.length - 2] = mergedOpts
197
218
  await action.apply(command, args)
198
219
  } catch (error) {
220
+ reporter.stop()
199
221
  const { debug } = program.opts()
200
222
  handleError(error, { debug })
201
223
  }
@@ -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
+ })