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

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 (159) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +9 -0
  3. package/bin/cli.js +19 -1
  4. package/package.json +21 -8
  5. package/schemas/config.schema.json +194 -0
  6. package/schemas/figures.schema.json +56 -0
  7. package/schemas/layout.schema.json +5 -0
  8. package/schemas/objects.schema.json +62 -0
  9. package/schemas/publication.schema.json +140 -0
  10. package/schemas/references.schema.json +29 -0
  11. package/src/Command.js +26 -6
  12. package/src/Command.spec.js +99 -0
  13. package/src/commands/README.md +213 -122
  14. package/src/commands/build.js +46 -37
  15. package/src/commands/build.spec.js +113 -0
  16. package/src/commands/build.test.js +402 -0
  17. package/src/commands/clean.js +25 -19
  18. package/src/commands/clean.spec.js +108 -0
  19. package/src/commands/clean.test.js +260 -0
  20. package/src/commands/config.js +251 -0
  21. package/src/commands/config.spec.js +107 -0
  22. package/src/commands/config.test.js +715 -0
  23. package/src/commands/create.js +42 -14
  24. package/src/commands/create.spec.js +112 -0
  25. package/src/commands/create.test.js +415 -0
  26. package/src/commands/epub.js +59 -30
  27. package/src/commands/epub.spec.js +114 -0
  28. package/src/commands/epub.test.js +503 -0
  29. package/src/commands/index.js +9 -2
  30. package/src/commands/info.js +18 -24
  31. package/src/commands/info.spec.js +64 -0
  32. package/src/commands/info.test.js +415 -0
  33. package/src/commands/pdf.js +58 -84
  34. package/src/commands/pdf.spec.js +114 -0
  35. package/src/commands/pdf.test.js +464 -0
  36. package/src/commands/preview.js +26 -31
  37. package/src/commands/preview.spec.js +97 -0
  38. package/src/commands/preview.test.js +250 -0
  39. package/src/commands/use.js +56 -0
  40. package/src/commands/use.spec.js +61 -0
  41. package/src/commands/use.test.js +280 -0
  42. package/src/commands/validate.js +31 -19
  43. package/src/commands/validate.spec.js +82 -0
  44. package/src/commands/validate.test.js +234 -0
  45. package/src/commands/workflows.js +70 -0
  46. package/src/errors/build/build-failed-error.js +19 -0
  47. package/src/errors/build/config-field-missing-error.js +20 -0
  48. package/src/errors/build/config-file-not-found-error.js +20 -0
  49. package/src/errors/build/index.js +11 -0
  50. package/src/errors/index.js +48 -0
  51. package/src/errors/install/dependency-install-error.js +19 -0
  52. package/src/errors/install/directory-not-empty-error.js +25 -0
  53. package/src/errors/install/index.js +12 -0
  54. package/src/errors/install/invalid-path-error.js +31 -0
  55. package/src/errors/install/invalid-starter-error.js +27 -0
  56. package/src/errors/install/version-not-found-error.js +35 -0
  57. package/src/errors/output/epub-generation-error.js +19 -0
  58. package/src/errors/output/index.js +14 -0
  59. package/src/errors/output/invalid-epub-library-error.js +20 -0
  60. package/src/errors/output/invalid-pdf-library-error.js +20 -0
  61. package/src/errors/output/missing-build-output-error.js +21 -0
  62. package/src/errors/output/pdf-generation-error.js +24 -0
  63. package/src/errors/output/tool-not-found-error.js +37 -0
  64. package/src/errors/project/index.js +10 -0
  65. package/src/errors/project/not-in-project-error.js +20 -0
  66. package/src/errors/project/project-create-error.js +21 -0
  67. package/src/errors/quire-error.js +27 -0
  68. package/src/errors/validation/validation-error.js +20 -11
  69. package/src/helpers/clean.js +1 -1
  70. package/src/helpers/docs-url.js +32 -0
  71. package/src/helpers/test-cwd.js +5 -6
  72. package/src/helpers/test-cwd.test.js +192 -0
  73. package/src/helpers/which.js +10 -4
  74. package/src/lib/11ty/README.md +135 -19
  75. package/src/lib/11ty/api.js +176 -93
  76. package/src/lib/11ty/cli.js +77 -35
  77. package/src/lib/11ty/index.js +64 -5
  78. package/src/lib/11ty/index.test.js +655 -0
  79. package/src/lib/README.md +275 -0
  80. package/src/lib/commander/index.js +100 -0
  81. package/src/lib/commander/index.test.js +86 -0
  82. package/src/lib/commander/options.js +195 -0
  83. package/src/lib/commander/options.test.js +109 -0
  84. package/src/lib/conf/README.md +84 -73
  85. package/src/lib/conf/config.js +5 -3
  86. package/src/lib/conf/config.test.js +281 -0
  87. package/src/lib/conf/defaults.js +44 -0
  88. package/src/lib/conf/format.js +60 -0
  89. package/src/lib/conf/format.test.js +106 -0
  90. package/src/lib/conf/helpers.js +91 -0
  91. package/src/lib/conf/helpers.test.js +136 -0
  92. package/src/lib/conf/index.js +22 -0
  93. package/src/lib/conf/schema.js +53 -8
  94. package/src/lib/epub/README.md +133 -2
  95. package/src/lib/epub/engines.js +46 -0
  96. package/src/lib/epub/epub.js +36 -11
  97. package/src/lib/epub/index.js +111 -21
  98. package/src/lib/epub/index.test.js +518 -0
  99. package/src/lib/epub/pandoc.js +33 -4
  100. package/src/lib/epub/pandoc.test.js +122 -0
  101. package/src/lib/epub/schema.js +21 -0
  102. package/src/lib/error/README.md +170 -0
  103. package/src/lib/error/handler.js +107 -0
  104. package/src/lib/git/README.md +151 -2
  105. package/src/lib/git/index.js +217 -13
  106. package/src/lib/git/index.spec.js +80 -0
  107. package/src/lib/git/index.test.js +453 -0
  108. package/src/lib/installer/index.js +309 -0
  109. package/src/lib/installer/index.spec.js +83 -0
  110. package/src/lib/installer/index.test.js +545 -0
  111. package/src/lib/logger/README.md +424 -0
  112. package/src/lib/logger/debug.js +90 -0
  113. package/src/lib/logger/debug.spec.js +130 -0
  114. package/src/lib/logger/index.js +228 -0
  115. package/src/lib/logger/index.spec.js +131 -0
  116. package/src/lib/logger/index.test.js +477 -0
  117. package/src/lib/npm/README.md +127 -0
  118. package/src/lib/npm/index.js +198 -0
  119. package/src/lib/npm/index.spec.js +60 -0
  120. package/src/lib/npm/index.test.js +355 -0
  121. package/src/lib/pdf/README.md +131 -0
  122. package/src/lib/pdf/engines.js +46 -0
  123. package/src/lib/pdf/index.js +124 -21
  124. package/src/lib/pdf/index.test.js +708 -0
  125. package/src/lib/pdf/paged.js +100 -52
  126. package/src/lib/pdf/paged.test.js +366 -0
  127. package/src/lib/pdf/prince.js +115 -37
  128. package/src/lib/pdf/prince.test.js +202 -0
  129. package/src/lib/pdf/schema.js +21 -0
  130. package/src/lib/pdf/split.js +61 -33
  131. package/src/lib/pdf/split.test.js +445 -0
  132. package/src/lib/process/manager.js +110 -0
  133. package/src/lib/process/manager.test.js +55 -0
  134. package/src/lib/project/build.js +143 -0
  135. package/src/lib/project/build.test.js +253 -0
  136. package/src/lib/project/config.js +48 -0
  137. package/src/lib/project/config.test.js +134 -0
  138. package/src/{helpers/is-quire.js → lib/project/detect.js} +5 -3
  139. package/src/lib/project/detect.test.js +157 -0
  140. package/src/lib/project/index.js +40 -0
  141. package/src/lib/project/paths.js +224 -0
  142. package/src/lib/project/version.js +110 -0
  143. package/src/lib/project/version.test.js +350 -0
  144. package/src/lib/reporter/README.md +211 -2
  145. package/src/lib/reporter/index.js +512 -0
  146. package/src/lib/reporter/index.test.js +593 -0
  147. package/src/main.js +134 -47
  148. package/src/main.spec.js +61 -0
  149. package/src/main.test.js +347 -0
  150. package/src/validators/utils.js +2 -1
  151. package/src/commands/conf.js +0 -43
  152. package/src/commands/version.js +0 -43
  153. package/src/lib/11ty/paths.js +0 -103
  154. package/src/lib/i18n/README.md +0 -3
  155. package/src/lib/i18n/config.js +0 -53
  156. package/src/lib/i18n/index.js +0 -43
  157. package/src/lib/i18n/localeService.js +0 -58
  158. package/src/lib/quire/README.md +0 -19
  159. package/src/lib/quire/index.js +0 -350
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Build detection module
3
+ *
4
+ * Detects if build output exists and provides build status information.
5
+ *
6
+ * @module lib/project/build
7
+ */
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import paths from './paths.js'
11
+
12
+ /**
13
+ * Get build output information
14
+ *
15
+ * @param {string} [projectRoot] - Project root directory (defaults to cwd)
16
+ * @returns {Object} Build status information
17
+ */
18
+ export function getBuildInfo(projectRoot = paths.getProjectRoot()) {
19
+ const sitePath = path.join(projectRoot, '_site')
20
+ const epubPath = path.join(projectRoot, '_epub')
21
+ const pdfFiles = ['pagedjs.pdf', 'prince.pdf']
22
+
23
+ const info = {
24
+ site: {
25
+ exists: fs.existsSync(sitePath),
26
+ path: sitePath,
27
+ mtime: null,
28
+ },
29
+ epub: {
30
+ exists: fs.existsSync(epubPath),
31
+ path: epubPath,
32
+ mtime: null,
33
+ },
34
+ pdf: {
35
+ exists: false,
36
+ paths: [],
37
+ mtime: null,
38
+ },
39
+ }
40
+
41
+ // Get modification times if directories exist
42
+ if (info.site.exists) {
43
+ info.site.mtime = fs.statSync(sitePath).mtime
44
+ }
45
+ if (info.epub.exists) {
46
+ info.epub.mtime = fs.statSync(epubPath).mtime
47
+ }
48
+
49
+ // Check for PDF files
50
+ for (const file of pdfFiles) {
51
+ const pdfPath = path.join(projectRoot, file)
52
+ if (fs.existsSync(pdfPath)) {
53
+ info.pdf.exists = true
54
+ info.pdf.paths.push(pdfPath)
55
+ // Use most recent mtime if multiple PDFs exist
56
+ const mtime = fs.statSync(pdfPath).mtime
57
+ if (!info.pdf.mtime || mtime > info.pdf.mtime) {
58
+ info.pdf.mtime = mtime
59
+ }
60
+ }
61
+ }
62
+
63
+ return info
64
+ }
65
+
66
+ /**
67
+ * Check if site build output exists
68
+ *
69
+ * @param {string} [projectRoot] - Project root directory (defaults to cwd)
70
+ * @returns {boolean} True if _site directory exists
71
+ */
72
+ export function hasSiteOutput(projectRoot = paths.getProjectRoot()) {
73
+ const sitePath = path.join(projectRoot, '_site')
74
+ return fs.existsSync(sitePath)
75
+ }
76
+
77
+ /**
78
+ * Check if epub build output exists
79
+ *
80
+ * @param {string} [projectRoot] - Project root directory (defaults to cwd)
81
+ * @returns {boolean} True if _epub directory exists
82
+ */
83
+ export function hasEpubOutput(projectRoot = paths.getProjectRoot()) {
84
+ const epubPath = path.join(projectRoot, '_epub')
85
+ return fs.existsSync(epubPath)
86
+ }
87
+
88
+ /**
89
+ * Check if PDF output exists
90
+ *
91
+ * Checks for PDF output at common locations:
92
+ * - Default library outputs: pagedjs.pdf, prince.pdf
93
+ * - Specific library output when lib option provided
94
+ *
95
+ * @param {Object} [options]
96
+ * @param {string} [options.lib] - Specific library to check ('pagedjs' or 'prince')
97
+ * @param {string} [options.projectRoot] - Project root directory
98
+ * @returns {boolean} True if PDF output exists
99
+ */
100
+ export function hasPdfOutput(options = {}) {
101
+ const { lib, projectRoot = paths.getProjectRoot() } = options
102
+
103
+ if (lib) {
104
+ const pdfPath = path.join(projectRoot, `${lib}.pdf`)
105
+ return fs.existsSync(pdfPath)
106
+ }
107
+
108
+ // Check for any common PDF output
109
+ const pdfFiles = ['pagedjs.pdf', 'prince.pdf']
110
+ return pdfFiles.some((file) => fs.existsSync(path.join(projectRoot, file)))
111
+ }
112
+
113
+ /**
114
+ * Require build output to exist, throwing an error if missing
115
+ *
116
+ * @param {Object} options
117
+ * @param {string} [options.type='site'] - Output type to check ('site' or 'epub')
118
+ * @param {string} [options.projectRoot] - Project root directory
119
+ * @throws {Error} If required build output does not exist
120
+ */
121
+ export function requireBuildOutput(options = {}) {
122
+ const { type = 'site', projectRoot = paths.getProjectRoot() } = options
123
+
124
+ if (type === 'site' && !hasSiteOutput(projectRoot)) {
125
+ const error = new Error('Build output not found. Run "quire build" first.')
126
+ error.code = 'ENOBUILD'
127
+ throw error
128
+ }
129
+
130
+ if (type === 'epub' && !hasEpubOutput(projectRoot)) {
131
+ const error = new Error('EPUB output not found. Run "quire build" first.')
132
+ error.code = 'ENOBUILD'
133
+ throw error
134
+ }
135
+ }
136
+
137
+ export default {
138
+ getBuildInfo,
139
+ hasEpubOutput,
140
+ hasPdfOutput,
141
+ hasSiteOutput,
142
+ requireBuildOutput,
143
+ }
@@ -0,0 +1,253 @@
1
+ import test from 'ava'
2
+ import path from 'node:path'
3
+ import { Volume, createFsFromVolume } from 'memfs'
4
+ import esmock from 'esmock'
5
+
6
+ /**
7
+ * Helper to create cross-platform paths for memfs
8
+ * memfs uses forward slashes internally, but we need to pass
9
+ * platform-native paths to the functions under test
10
+ */
11
+ const testRoot = '/test-project'
12
+ const nativePath = (...segments) => path.join(testRoot, ...segments)
13
+ const memfsPath = (...segments) => [testRoot, ...segments].join('/')
14
+
15
+ test.beforeEach((t) => {
16
+ // Create in-memory file system
17
+ t.context.vol = new Volume()
18
+ t.context.fs = createFsFromVolume(t.context.vol)
19
+ })
20
+
21
+ test.afterEach.always((t) => {
22
+ t.context.vol.reset()
23
+ })
24
+
25
+ test('hasSiteOutput returns true when _site exists', async (t) => {
26
+ const { vol } = t.context
27
+
28
+ vol.fromJSON({
29
+ [memfsPath('_site', 'index.html')]: '<html></html>',
30
+ })
31
+
32
+ const { hasSiteOutput } = await esmock('./build.js', {
33
+ 'node:fs': createFsFromVolume(vol),
34
+ })
35
+
36
+ t.true(hasSiteOutput(testRoot))
37
+ })
38
+
39
+ test('hasSiteOutput returns false when _site does not exist', async (t) => {
40
+ const { vol } = t.context
41
+
42
+ vol.fromJSON({
43
+ [memfsPath('.quire')]: '',
44
+ })
45
+
46
+ const { hasSiteOutput } = await esmock('./build.js', {
47
+ 'node:fs': createFsFromVolume(vol),
48
+ })
49
+
50
+ t.false(hasSiteOutput(testRoot))
51
+ })
52
+
53
+ test('hasEpubOutput returns true when _epub exists', async (t) => {
54
+ const { vol } = t.context
55
+
56
+ vol.fromJSON({
57
+ [memfsPath('_epub', 'content.opf')]: '<package></package>',
58
+ })
59
+
60
+ const { hasEpubOutput } = await esmock('./build.js', {
61
+ 'node:fs': createFsFromVolume(vol),
62
+ })
63
+
64
+ t.true(hasEpubOutput(testRoot))
65
+ })
66
+
67
+ test('hasEpubOutput returns false when _epub does not exist', async (t) => {
68
+ const { vol } = t.context
69
+
70
+ vol.fromJSON({
71
+ [memfsPath('.quire')]: '',
72
+ })
73
+
74
+ const { hasEpubOutput } = await esmock('./build.js', {
75
+ 'node:fs': createFsFromVolume(vol),
76
+ })
77
+
78
+ t.false(hasEpubOutput(testRoot))
79
+ })
80
+
81
+ test('getBuildInfo returns correct status for existing builds', async (t) => {
82
+ const { vol } = t.context
83
+
84
+ vol.fromJSON({
85
+ [memfsPath('_site', 'index.html')]: '<html></html>',
86
+ [memfsPath('_epub', 'content.opf')]: '<package></package>',
87
+ [memfsPath('pagedjs.pdf')]: '%PDF-1.4',
88
+ })
89
+
90
+ const { getBuildInfo } = await esmock('./build.js', {
91
+ 'node:fs': createFsFromVolume(vol),
92
+ })
93
+
94
+ const info = getBuildInfo(testRoot)
95
+
96
+ t.true(info.site.exists)
97
+ t.is(info.site.path, nativePath('_site'))
98
+ t.truthy(info.site.mtime)
99
+
100
+ t.true(info.epub.exists)
101
+ t.is(info.epub.path, nativePath('_epub'))
102
+ t.truthy(info.epub.mtime)
103
+
104
+ t.true(info.pdf.exists)
105
+ t.deepEqual(info.pdf.paths, [nativePath('pagedjs.pdf')])
106
+ t.truthy(info.pdf.mtime)
107
+ })
108
+
109
+ test('getBuildInfo returns correct status for missing builds', async (t) => {
110
+ const { vol } = t.context
111
+
112
+ vol.fromJSON({
113
+ [memfsPath('.quire')]: '',
114
+ })
115
+
116
+ const { getBuildInfo } = await esmock('./build.js', {
117
+ 'node:fs': createFsFromVolume(vol),
118
+ })
119
+
120
+ const info = getBuildInfo(testRoot)
121
+
122
+ t.false(info.site.exists)
123
+ t.is(info.site.mtime, null)
124
+
125
+ t.false(info.epub.exists)
126
+ t.is(info.epub.mtime, null)
127
+
128
+ t.false(info.pdf.exists)
129
+ t.deepEqual(info.pdf.paths, [])
130
+ t.is(info.pdf.mtime, null)
131
+ })
132
+
133
+ test('getBuildInfo returns multiple PDF paths when both exist', async (t) => {
134
+ const { vol } = t.context
135
+
136
+ vol.fromJSON({
137
+ [memfsPath('pagedjs.pdf')]: '%PDF-1.4',
138
+ [memfsPath('prince.pdf')]: '%PDF-1.4',
139
+ })
140
+
141
+ const { getBuildInfo } = await esmock('./build.js', {
142
+ 'node:fs': createFsFromVolume(vol),
143
+ })
144
+
145
+ const info = getBuildInfo(testRoot)
146
+
147
+ t.true(info.pdf.exists)
148
+ t.is(info.pdf.paths.length, 2)
149
+ t.true(info.pdf.paths.includes(nativePath('pagedjs.pdf')))
150
+ t.true(info.pdf.paths.includes(nativePath('prince.pdf')))
151
+ t.truthy(info.pdf.mtime)
152
+ })
153
+
154
+ test('requireBuildOutput throws when site output missing', async (t) => {
155
+ const { vol } = t.context
156
+
157
+ vol.fromJSON({
158
+ [memfsPath('.quire')]: '',
159
+ })
160
+
161
+ const { requireBuildOutput } = await esmock('./build.js', {
162
+ 'node:fs': createFsFromVolume(vol),
163
+ })
164
+
165
+ const error = t.throws(() => {
166
+ requireBuildOutput({ type: 'site', projectRoot: testRoot })
167
+ })
168
+
169
+ t.is(error.code, 'ENOBUILD')
170
+ t.regex(error.message, /quire build/)
171
+ })
172
+
173
+ test('requireBuildOutput throws when epub output missing', async (t) => {
174
+ const { vol } = t.context
175
+
176
+ vol.fromJSON({
177
+ [memfsPath('.quire')]: '',
178
+ })
179
+
180
+ const { requireBuildOutput } = await esmock('./build.js', {
181
+ 'node:fs': createFsFromVolume(vol),
182
+ })
183
+
184
+ const error = t.throws(() => {
185
+ requireBuildOutput({ type: 'epub', projectRoot: testRoot })
186
+ })
187
+
188
+ t.is(error.code, 'ENOBUILD')
189
+ })
190
+
191
+ test('requireBuildOutput does not throw when output exists', async (t) => {
192
+ const { vol } = t.context
193
+
194
+ vol.fromJSON({
195
+ [memfsPath('_site', 'index.html')]: '<html></html>',
196
+ [memfsPath('_epub', 'content.opf')]: '<package></package>',
197
+ })
198
+
199
+ const { requireBuildOutput } = await esmock('./build.js', {
200
+ 'node:fs': createFsFromVolume(vol),
201
+ })
202
+
203
+ t.notThrows(() => {
204
+ requireBuildOutput({ type: 'site', projectRoot: testRoot })
205
+ })
206
+
207
+ t.notThrows(() => {
208
+ requireBuildOutput({ type: 'epub', projectRoot: testRoot })
209
+ })
210
+ })
211
+
212
+ test('hasPdfOutput returns true when specific lib PDF exists', async (t) => {
213
+ const { vol } = t.context
214
+
215
+ vol.fromJSON({
216
+ [memfsPath('pagedjs.pdf')]: '%PDF-1.4',
217
+ })
218
+
219
+ const { hasPdfOutput } = await esmock('./build.js', {
220
+ 'node:fs': createFsFromVolume(vol),
221
+ })
222
+
223
+ t.true(hasPdfOutput({ lib: 'pagedjs', projectRoot: testRoot }))
224
+ t.false(hasPdfOutput({ lib: 'prince', projectRoot: testRoot }))
225
+ })
226
+
227
+ test('hasPdfOutput returns false when no PDF exists', async (t) => {
228
+ const { vol } = t.context
229
+
230
+ vol.fromJSON({
231
+ [memfsPath('.quire')]: '',
232
+ })
233
+
234
+ const { hasPdfOutput } = await esmock('./build.js', {
235
+ 'node:fs': createFsFromVolume(vol),
236
+ })
237
+
238
+ t.false(hasPdfOutput({ projectRoot: testRoot }))
239
+ })
240
+
241
+ test('hasPdfOutput returns true when any common PDF exists', async (t) => {
242
+ const { vol } = t.context
243
+
244
+ vol.fromJSON({
245
+ [memfsPath('prince.pdf')]: '%PDF-1.4',
246
+ })
247
+
248
+ const { hasPdfOutput } = await esmock('./build.js', {
249
+ 'node:fs': createFsFromVolume(vol),
250
+ })
251
+
252
+ t.true(hasPdfOutput({ projectRoot: testRoot }))
253
+ })
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Project configuration module
3
+ *
4
+ * Loads and validates Quire project configuration files.
5
+ *
6
+ * @module lib/project/config
7
+ */
8
+ import fs from 'fs-extra'
9
+ import path from 'node:path'
10
+ import { pathToFileURL } from 'node:url'
11
+ import yaml from 'js-yaml'
12
+ import paths from './paths.js'
13
+ import { ConfigFileNotFoundError } from '#src/errors/index.js'
14
+
15
+ /**
16
+ * Load and validate Quire project configuration
17
+ *
18
+ * @param {string} [projectRoot] - project root directory (defaults to cwd)
19
+ * @returns {Promise<Object>} validated configuration object
20
+ *
21
+ * @todo consider hardcoding a version check against .quire / project's package.json ver
22
+ */
23
+ export async function loadProjectConfig(projectRoot) {
24
+ const root = projectRoot || paths.getProjectRoot()
25
+ const configPath = path.join(root, 'content', '_data', 'config.yaml')
26
+
27
+ if (!fs.existsSync(configPath)) {
28
+ throw new ConfigFileNotFoundError(configPath)
29
+ }
30
+
31
+ const data = fs.readFileSync(configPath)
32
+ let config = yaml.load(data)
33
+
34
+ // NB: Schemas and validators are specific to the 11ty version of the project being built
35
+ const schemaPath = path.join(root, '_plugins', 'schemas', 'config.json')
36
+ const validatorPath = path.join(root, '_plugins', 'globalData', 'validator.js')
37
+
38
+ if (fs.existsSync(schemaPath) && fs.existsSync(validatorPath)) {
39
+ const { validateUserConfig } = await import(pathToFileURL(validatorPath))
40
+
41
+ const schemaJSON = fs.readFileSync(schemaPath)
42
+ const schema = JSON.parse(schemaJSON)
43
+
44
+ config = validateUserConfig('config', config, { config: schema })
45
+ }
46
+
47
+ return config
48
+ }
@@ -0,0 +1,134 @@
1
+ import test from 'ava'
2
+ import { Volume, createFsFromVolume } from 'memfs'
3
+ import sinon from 'sinon'
4
+ import esmock from 'esmock'
5
+
6
+ test.beforeEach((t) => {
7
+ // Create sinon sandbox for mocking
8
+ t.context.sandbox = sinon.createSandbox()
9
+
10
+ // Create in-memory file system
11
+ t.context.vol = new Volume()
12
+ t.context.fs = createFsFromVolume(t.context.vol)
13
+ })
14
+
15
+ test.afterEach.always((t) => {
16
+ // Restore all mocks
17
+ if (t.context.sandbox) {
18
+ t.context.sandbox.restore()
19
+ }
20
+
21
+ // Clear in-memory file system
22
+ t.context.vol.reset()
23
+ })
24
+
25
+ test('loadProjectConfig should throw ConfigFileNotFoundError when config file does not exist', async (t) => {
26
+ const { fs, vol } = t.context
27
+
28
+ // Setup empty directory
29
+ vol.fromJSON({
30
+ '/project/content/_data/.gitkeep': ''
31
+ })
32
+
33
+ const { loadProjectConfig } = await esmock('./config.js', {
34
+ 'fs-extra': {
35
+ existsSync: (p) => fs.existsSync(p),
36
+ readFileSync: (p) => fs.readFileSync(p)
37
+ },
38
+ './paths.js': {
39
+ default: {
40
+ getProjectRoot: () => '/project'
41
+ }
42
+ }
43
+ })
44
+
45
+ const error = await t.throwsAsync(() => loadProjectConfig('/project'))
46
+
47
+ t.is(error.code, 'CONFIG_FILE_NOT_FOUND')
48
+ t.true(error.message.includes('config.yaml'), 'error should include config path')
49
+ })
50
+
51
+ test('loadProjectConfig should load and parse YAML config file', async (t) => {
52
+ const { fs, vol } = t.context
53
+
54
+ // Setup config file
55
+ vol.fromJSON({
56
+ '/project/content/_data/config.yaml': 'title: Test Project\nauthor: Test Author'
57
+ })
58
+
59
+ // Mock fs-extra to use memfs
60
+ const { loadProjectConfig } = await esmock('./config.js', {
61
+ 'fs-extra': {
62
+ existsSync: (p) => fs.existsSync(p),
63
+ readFileSync: (p) => fs.readFileSync(p)
64
+ },
65
+ './paths.js': {
66
+ default: {
67
+ getProjectRoot: () => '/project'
68
+ }
69
+ }
70
+ })
71
+
72
+ const result = await loadProjectConfig('/project')
73
+
74
+ t.truthy(result, 'should return config object')
75
+ t.is(result.title, 'Test Project', 'should parse title')
76
+ t.is(result.author, 'Test Author', 'should parse author')
77
+ })
78
+
79
+ test('loadProjectConfig should load PDF configuration', async (t) => {
80
+ const { fs, vol } = t.context
81
+
82
+ // Setup config file with PDF settings
83
+ vol.fromJSON({
84
+ '/project/content/_data/config.yaml': `title: Test Project
85
+ pdf:
86
+ filename: test-book
87
+ outputDir: _pdf`
88
+ })
89
+
90
+ // Mock fs-extra to use memfs
91
+ const { loadProjectConfig } = await esmock('./config.js', {
92
+ 'fs-extra': {
93
+ existsSync: (p) => fs.existsSync(p),
94
+ readFileSync: (p) => fs.readFileSync(p)
95
+ },
96
+ './paths.js': {
97
+ default: {
98
+ getProjectRoot: () => '/project'
99
+ }
100
+ }
101
+ })
102
+
103
+ const result = await loadProjectConfig('/project')
104
+
105
+ t.truthy(result.pdf, 'should have pdf config')
106
+ t.is(result.pdf.filename, 'test-book', 'should parse pdf filename')
107
+ t.is(result.pdf.outputDir, '_pdf', 'should parse pdf outputDir')
108
+ })
109
+
110
+ test('loadProjectConfig should use default project root when not provided', async (t) => {
111
+ const { fs, vol } = t.context
112
+
113
+ // Setup config file
114
+ vol.fromJSON({
115
+ '/default/project/content/_data/config.yaml': 'title: Default Project'
116
+ })
117
+
118
+ // Mock fs-extra to use memfs
119
+ const { loadProjectConfig } = await esmock('./config.js', {
120
+ 'fs-extra': {
121
+ existsSync: (p) => fs.existsSync(p),
122
+ readFileSync: (p) => fs.readFileSync(p)
123
+ },
124
+ './paths.js': {
125
+ default: {
126
+ getProjectRoot: () => '/default/project'
127
+ }
128
+ }
129
+ })
130
+
131
+ const result = await loadProjectConfig()
132
+
133
+ t.is(result.title, 'Default Project', 'should use default project root')
134
+ })
@@ -1,6 +1,9 @@
1
1
  /**
2
- * A helper module to test if a directory is a Quire project root
3
- * @module is-quire
2
+ * Project detection module
3
+ *
4
+ * Detects if a directory is a Quire project root by checking for marker files.
5
+ *
6
+ * @module lib/project/detect
4
7
  */
5
8
  import fs from 'node:fs'
6
9
 
@@ -28,4 +31,3 @@ export default function (dirpath) {
28
31
  return fs.readdirSync(dirpath)
29
32
  .find((entry) => QUIRE_DOT_FILES.includes(entry))
30
33
  }
31
-