@thegetty/quire-cli 1.0.0-rc.35 → 1.0.0-rc.37

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/patches/README.md +19 -0
  6. package/patches/install-npm-version+1.0.9.patch +12119 -0
  7. package/schemas/objects.schema.json +12 -38
  8. package/schemas/publication.schema.json +0 -22
  9. package/schemas/references.schema.json +0 -1
  10. package/src/Command.js +26 -6
  11. package/src/Command.spec.js +99 -0
  12. package/src/commands/README.md +213 -122
  13. package/src/commands/build.js +37 -36
  14. package/src/commands/build.spec.js +113 -0
  15. package/src/commands/build.test.js +397 -0
  16. package/src/commands/clean.js +25 -19
  17. package/src/commands/clean.spec.js +108 -0
  18. package/src/commands/clean.test.js +260 -0
  19. package/src/commands/config.js +251 -0
  20. package/src/commands/config.spec.js +107 -0
  21. package/src/commands/config.test.js +715 -0
  22. package/src/commands/create.js +42 -14
  23. package/src/commands/create.spec.js +112 -0
  24. package/src/commands/create.test.js +415 -0
  25. package/src/commands/epub.js +59 -30
  26. package/src/commands/epub.spec.js +114 -0
  27. package/src/commands/epub.test.js +503 -0
  28. package/src/commands/index.js +9 -2
  29. package/src/commands/info.js +18 -24
  30. package/src/commands/info.spec.js +64 -0
  31. package/src/commands/info.test.js +415 -0
  32. package/src/commands/pdf.js +58 -84
  33. package/src/commands/pdf.spec.js +114 -0
  34. package/src/commands/pdf.test.js +464 -0
  35. package/src/commands/preview.js +32 -31
  36. package/src/commands/preview.spec.js +97 -0
  37. package/src/commands/preview.test.js +250 -0
  38. package/src/commands/use.js +56 -0
  39. package/src/commands/use.spec.js +61 -0
  40. package/src/commands/use.test.js +280 -0
  41. package/src/commands/validate.js +31 -19
  42. package/src/commands/validate.spec.js +82 -0
  43. package/src/commands/validate.test.js +234 -0
  44. package/src/commands/workflows.js +70 -0
  45. package/src/errors/build/build-failed-error.js +19 -0
  46. package/src/errors/build/config-field-missing-error.js +20 -0
  47. package/src/errors/build/config-file-not-found-error.js +20 -0
  48. package/src/errors/build/index.js +11 -0
  49. package/src/errors/index.js +48 -0
  50. package/src/errors/install/dependency-install-error.js +19 -0
  51. package/src/errors/install/directory-not-empty-error.js +25 -0
  52. package/src/errors/install/index.js +12 -0
  53. package/src/errors/install/invalid-path-error.js +31 -0
  54. package/src/errors/install/invalid-starter-error.js +27 -0
  55. package/src/errors/install/version-not-found-error.js +35 -0
  56. package/src/errors/output/epub-generation-error.js +19 -0
  57. package/src/errors/output/index.js +14 -0
  58. package/src/errors/output/invalid-epub-library-error.js +20 -0
  59. package/src/errors/output/invalid-pdf-library-error.js +20 -0
  60. package/src/errors/output/missing-build-output-error.js +21 -0
  61. package/src/errors/output/pdf-generation-error.js +24 -0
  62. package/src/errors/output/tool-not-found-error.js +37 -0
  63. package/src/errors/project/index.js +10 -0
  64. package/src/errors/project/not-in-project-error.js +20 -0
  65. package/src/errors/project/project-create-error.js +21 -0
  66. package/src/errors/quire-error.js +27 -0
  67. package/src/errors/validation/validation-error.js +20 -11
  68. package/src/helpers/clean.js +1 -1
  69. package/src/helpers/docs-url.js +32 -0
  70. package/src/helpers/test-cwd.js +5 -6
  71. package/src/helpers/test-cwd.test.js +192 -0
  72. package/src/helpers/which.js +10 -4
  73. package/src/lib/11ty/README.md +135 -19
  74. package/src/lib/11ty/api.js +196 -94
  75. package/src/lib/11ty/cli.js +91 -37
  76. package/src/lib/11ty/index.js +64 -5
  77. package/src/lib/11ty/index.test.js +655 -0
  78. package/src/lib/README.md +275 -0
  79. package/src/lib/commander/index.js +100 -0
  80. package/src/lib/commander/index.test.js +86 -0
  81. package/src/lib/commander/options.js +195 -0
  82. package/src/lib/commander/options.test.js +109 -0
  83. package/src/lib/conf/README.md +84 -73
  84. package/src/lib/conf/config.js +5 -3
  85. package/src/lib/conf/config.test.js +281 -0
  86. package/src/lib/conf/defaults.js +44 -0
  87. package/src/lib/conf/format.js +60 -0
  88. package/src/lib/conf/format.test.js +106 -0
  89. package/src/lib/conf/helpers.js +91 -0
  90. package/src/lib/conf/helpers.test.js +136 -0
  91. package/src/lib/conf/index.js +22 -0
  92. package/src/lib/conf/schema.js +53 -8
  93. package/src/lib/epub/README.md +133 -2
  94. package/src/lib/epub/engines.js +46 -0
  95. package/src/lib/epub/epub.js +36 -11
  96. package/src/lib/epub/index.js +111 -21
  97. package/src/lib/epub/index.test.js +518 -0
  98. package/src/lib/epub/pandoc.js +33 -4
  99. package/src/lib/epub/pandoc.test.js +122 -0
  100. package/src/lib/epub/schema.js +21 -0
  101. package/src/lib/error/README.md +170 -0
  102. package/src/lib/error/handler.js +107 -0
  103. package/src/lib/git/README.md +151 -2
  104. package/src/lib/git/index.js +217 -13
  105. package/src/lib/git/index.spec.js +80 -0
  106. package/src/lib/git/index.test.js +453 -0
  107. package/src/lib/installer/index.js +309 -0
  108. package/src/lib/installer/index.spec.js +83 -0
  109. package/src/lib/installer/index.test.js +545 -0
  110. package/src/lib/logger/README.md +424 -0
  111. package/src/lib/logger/debug.js +132 -0
  112. package/src/lib/logger/debug.spec.js +130 -0
  113. package/src/lib/logger/debug.test.js +128 -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,518 @@
1
+ import esmock from 'esmock'
2
+ import path from 'node:path'
3
+ import sinon from 'sinon'
4
+ import test from 'ava'
5
+
6
+ /**
7
+ * EPUB Module Integration Tests
8
+ *
9
+ * Tests the EPUB generation façade module behavior with mocked dependencies.
10
+ * Uses esmock to isolate the module from file system and external EPUB tools.
11
+ */
12
+
13
+ /**
14
+ * Create a cross-platform absolute path for testing
15
+ * @param {...string} segments - Path segments to join
16
+ * @returns {string} Platform-appropriate absolute path
17
+ */
18
+ function testPath(...segments) {
19
+ const root = process.platform === 'win32' ? 'C:\\' : '/'
20
+ return path.join(root, ...segments)
21
+ }
22
+
23
+ test.beforeEach((t) => {
24
+ t.context.sandbox = sinon.createSandbox()
25
+ })
26
+
27
+ test.afterEach.always((t) => {
28
+ t.context.sandbox.restore()
29
+ })
30
+
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ // Library resolution tests
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+
35
+ test('generateEpub resolves epubjs library correctly', async (t) => {
36
+ const { sandbox } = t.context
37
+
38
+ const mockEpubLib = sandbox.stub().resolves()
39
+ const mockDynamicImport = sandbox.stub().resolves({ default: mockEpubLib })
40
+
41
+ const mockPaths = {
42
+ getProjectRoot: () => testPath('project'),
43
+ getEpubDir: () => '_epub'
44
+ }
45
+
46
+ const mockFs = {
47
+ existsSync: sandbox.stub().returns(true),
48
+ mkdirSync: sandbox.stub()
49
+ }
50
+
51
+ const mockReporter = {
52
+ start: sandbox.stub(),
53
+ detail: sandbox.stub(),
54
+ succeed: sandbox.stub(),
55
+ fail: sandbox.stub()
56
+ }
57
+
58
+ const generateEpub = await esmock('./index.js', {
59
+ '#helpers/os-utils.js': { dynamicImport: mockDynamicImport },
60
+ '#lib/project/index.js': mockPaths,
61
+ 'fs-extra': mockFs,
62
+ '#lib/reporter/index.js': { default: mockReporter }
63
+ })
64
+
65
+ await generateEpub.default({ lib: 'epubjs' })
66
+
67
+ t.true(mockDynamicImport.calledOnce)
68
+ t.true(mockDynamicImport.firstCall.args[0].includes('epub.js'))
69
+ t.true(mockReporter.start.calledWith(sinon.match(/Epub\.js/)))
70
+ })
71
+
72
+ test('generateEpub resolves pandoc library correctly', async (t) => {
73
+ const { sandbox } = t.context
74
+
75
+ const mockEpubLib = sandbox.stub().resolves()
76
+ const mockDynamicImport = sandbox.stub().resolves({ default: mockEpubLib })
77
+ const mockWhich = sandbox.stub().returns('/usr/local/bin/pandoc')
78
+
79
+ const mockPaths = {
80
+ getProjectRoot: () => testPath('project'),
81
+ getEpubDir: () => '_epub'
82
+ }
83
+
84
+ const mockFs = {
85
+ existsSync: sandbox.stub().returns(true),
86
+ mkdirSync: sandbox.stub()
87
+ }
88
+
89
+ const mockReporter = {
90
+ start: sandbox.stub(),
91
+ detail: sandbox.stub(),
92
+ succeed: sandbox.stub(),
93
+ fail: sandbox.stub()
94
+ }
95
+
96
+ const generateEpub = await esmock('./index.js', {
97
+ '#helpers/os-utils.js': { dynamicImport: mockDynamicImport },
98
+ '#helpers/which.js': { default: mockWhich },
99
+ '#lib/project/index.js': mockPaths,
100
+ 'fs-extra': mockFs,
101
+ '#lib/reporter/index.js': { default: mockReporter }
102
+ })
103
+
104
+ await generateEpub.default({ lib: 'pandoc' })
105
+
106
+ t.true(mockDynamicImport.firstCall.args[0].includes('pandoc.js'))
107
+ t.true(mockReporter.start.calledWith(sinon.match(/Pandoc/)))
108
+ })
109
+
110
+ test('generateEpub normalizes library name variations', async (t) => {
111
+ const { sandbox } = t.context
112
+
113
+ const mockEpubLib = sandbox.stub().resolves()
114
+ const mockDynamicImport = sandbox.stub().resolves({ default: mockEpubLib })
115
+ const mockWhich = sandbox.stub().returns('/usr/local/bin/pandoc')
116
+
117
+ const mockPaths = {
118
+ getProjectRoot: () => testPath('project'),
119
+ getEpubDir: () => '_epub'
120
+ }
121
+
122
+ const mockFs = {
123
+ existsSync: sandbox.stub().returns(true),
124
+ mkdirSync: sandbox.stub()
125
+ }
126
+
127
+ const mockReporter = {
128
+ start: sandbox.stub(),
129
+ detail: sandbox.stub(),
130
+ succeed: sandbox.stub(),
131
+ fail: sandbox.stub()
132
+ }
133
+
134
+ const generateEpub = await esmock('./index.js', {
135
+ '#helpers/os-utils.js': { dynamicImport: mockDynamicImport },
136
+ '#helpers/which.js': { default: mockWhich },
137
+ '#lib/project/index.js': mockPaths,
138
+ 'fs-extra': mockFs,
139
+ '#lib/reporter/index.js': { default: mockReporter }
140
+ })
141
+
142
+ // Test "epub-js" variant (with hyphen)
143
+ await generateEpub.default({ lib: 'epub-js' })
144
+ t.true(mockDynamicImport.firstCall.args[0].includes('epub.js'))
145
+
146
+ // Test "PANDOC" variant (uppercase)
147
+ mockDynamicImport.resetHistory()
148
+ await generateEpub.default({ lib: 'PANDOC' })
149
+ t.true(mockDynamicImport.firstCall.args[0].includes('pandoc.js'))
150
+ })
151
+
152
+ test('generateEpub defaults to epubjs when no library specified', async (t) => {
153
+ const { sandbox } = t.context
154
+
155
+ const mockEpubLib = sandbox.stub().resolves()
156
+ const mockDynamicImport = sandbox.stub().resolves({ default: mockEpubLib })
157
+
158
+ const mockPaths = {
159
+ getProjectRoot: () => testPath('project'),
160
+ getEpubDir: () => '_epub'
161
+ }
162
+
163
+ const mockFs = {
164
+ existsSync: sandbox.stub().returns(true),
165
+ mkdirSync: sandbox.stub()
166
+ }
167
+
168
+ const mockReporter = {
169
+ start: sandbox.stub(),
170
+ detail: sandbox.stub(),
171
+ succeed: sandbox.stub(),
172
+ fail: sandbox.stub()
173
+ }
174
+
175
+ const generateEpub = await esmock('./index.js', {
176
+ '#helpers/os-utils.js': { dynamicImport: mockDynamicImport },
177
+ '#lib/project/index.js': mockPaths,
178
+ 'fs-extra': mockFs,
179
+ '#lib/reporter/index.js': { default: mockReporter }
180
+ })
181
+
182
+ await generateEpub.default({})
183
+
184
+ t.true(mockDynamicImport.firstCall.args[0].includes('epub.js'))
185
+ })
186
+
187
+ // ─────────────────────────────────────────────────────────────────────────────
188
+ // Error handling tests
189
+ // ─────────────────────────────────────────────────────────────────────────────
190
+
191
+ test('generateEpub throws InvalidEpubLibraryError for unrecognized library', async (t) => {
192
+ const { sandbox } = t.context
193
+
194
+ const mockPaths = {
195
+ getProjectRoot: () => testPath('project'),
196
+ getEpubDir: () => '_epub'
197
+ }
198
+
199
+ const mockReporter = {
200
+ start: sandbox.stub(),
201
+ detail: sandbox.stub(),
202
+ succeed: sandbox.stub(),
203
+ fail: sandbox.stub()
204
+ }
205
+
206
+ const generateEpub = await esmock('./index.js', {
207
+ '#helpers/os-utils.js': { dynamicImport: sandbox.stub() },
208
+ '#lib/project/index.js': mockPaths,
209
+ 'fs-extra': { existsSync: sandbox.stub(), mkdirSync: sandbox.stub() },
210
+ '#lib/reporter/index.js': { default: mockReporter }
211
+ })
212
+
213
+ const error = await t.throwsAsync(() => generateEpub.default({ lib: 'unknown-library' }))
214
+
215
+ t.is(error.code, 'INVALID_EPUB_LIBRARY')
216
+ t.true(error.message.includes('unknown-library'))
217
+ })
218
+
219
+ // ─────────────────────────────────────────────────────────────────────────────
220
+ // Output path tests
221
+ // ─────────────────────────────────────────────────────────────────────────────
222
+
223
+ test('generateEpub uses default output path based on library name', async (t) => {
224
+ const { sandbox } = t.context
225
+
226
+ const mockEpubLib = sandbox.stub().resolves()
227
+ const mockDynamicImport = sandbox.stub().resolves({ default: mockEpubLib })
228
+
229
+ const mockPaths = {
230
+ getProjectRoot: () => testPath('project'),
231
+ getEpubDir: () => '_epub'
232
+ }
233
+
234
+ const mockFs = {
235
+ existsSync: sandbox.stub().returns(true),
236
+ mkdirSync: sandbox.stub()
237
+ }
238
+
239
+ const mockReporter = {
240
+ start: sandbox.stub(),
241
+ detail: sandbox.stub(),
242
+ succeed: sandbox.stub(),
243
+ fail: sandbox.stub()
244
+ }
245
+
246
+ const generateEpub = await esmock('./index.js', {
247
+ '#helpers/os-utils.js': { dynamicImport: mockDynamicImport },
248
+ '#lib/project/index.js': mockPaths,
249
+ 'fs-extra': mockFs,
250
+ '#lib/reporter/index.js': { default: mockReporter }
251
+ })
252
+
253
+ const output = await generateEpub.default({ lib: 'epubjs' })
254
+
255
+ t.is(output, path.join(testPath('project'), 'epubjs.epub'))
256
+ })
257
+
258
+ test('generateEpub uses custom output path when provided', async (t) => {
259
+ const { sandbox } = t.context
260
+
261
+ const mockEpubLib = sandbox.stub().resolves()
262
+ const mockDynamicImport = sandbox.stub().resolves({ default: mockEpubLib })
263
+
264
+ const mockPaths = {
265
+ getProjectRoot: () => testPath('project'),
266
+ getEpubDir: () => '_epub'
267
+ }
268
+
269
+ const mockFs = {
270
+ existsSync: sandbox.stub().returns(true),
271
+ mkdirSync: sandbox.stub()
272
+ }
273
+
274
+ const mockReporter = {
275
+ start: sandbox.stub(),
276
+ detail: sandbox.stub(),
277
+ succeed: sandbox.stub(),
278
+ fail: sandbox.stub()
279
+ }
280
+
281
+ const generateEpub = await esmock('./index.js', {
282
+ '#helpers/os-utils.js': { dynamicImport: mockDynamicImport },
283
+ '#lib/project/index.js': mockPaths,
284
+ 'fs-extra': mockFs,
285
+ '#lib/reporter/index.js': { default: mockReporter }
286
+ })
287
+
288
+ const output = await generateEpub.default({ lib: 'epubjs', output: 'my-book.epub' })
289
+
290
+ t.is(output, path.join(testPath('project'), 'my-book.epub'))
291
+ })
292
+
293
+ test('generateEpub resolves relative output path against project root', async (t) => {
294
+ const { sandbox } = t.context
295
+
296
+ const mockEpubLib = sandbox.stub().resolves()
297
+ const mockDynamicImport = sandbox.stub().resolves({ default: mockEpubLib })
298
+
299
+ const mockPaths = {
300
+ getProjectRoot: () => testPath('project'),
301
+ getEpubDir: () => '_epub'
302
+ }
303
+
304
+ const mockFs = {
305
+ existsSync: sandbox.stub().returns(true),
306
+ mkdirSync: sandbox.stub()
307
+ }
308
+
309
+ const mockReporter = {
310
+ start: sandbox.stub(),
311
+ detail: sandbox.stub(),
312
+ succeed: sandbox.stub(),
313
+ fail: sandbox.stub()
314
+ }
315
+
316
+ const generateEpub = await esmock('./index.js', {
317
+ '#helpers/os-utils.js': { dynamicImport: mockDynamicImport },
318
+ '#lib/project/index.js': mockPaths,
319
+ 'fs-extra': mockFs,
320
+ '#lib/reporter/index.js': { default: mockReporter }
321
+ })
322
+
323
+ const output = await generateEpub.default({ lib: 'epubjs', output: '_downloads/book' })
324
+
325
+ t.is(output, path.join(testPath('project'), '_downloads', 'book.epub'))
326
+ })
327
+
328
+ test('generateEpub uses absolute output path directly', async (t) => {
329
+ const { sandbox } = t.context
330
+
331
+ const mockEpubLib = sandbox.stub().resolves()
332
+ const mockDynamicImport = sandbox.stub().resolves({ default: mockEpubLib })
333
+
334
+ const mockPaths = {
335
+ getProjectRoot: () => testPath('project'),
336
+ getEpubDir: () => '_epub'
337
+ }
338
+
339
+ const mockFs = {
340
+ existsSync: sandbox.stub().returns(true),
341
+ mkdirSync: sandbox.stub()
342
+ }
343
+
344
+ const mockReporter = {
345
+ start: sandbox.stub(),
346
+ detail: sandbox.stub(),
347
+ succeed: sandbox.stub(),
348
+ fail: sandbox.stub()
349
+ }
350
+
351
+ const generateEpub = await esmock('./index.js', {
352
+ '#helpers/os-utils.js': { dynamicImport: mockDynamicImport },
353
+ '#lib/project/index.js': mockPaths,
354
+ 'fs-extra': mockFs,
355
+ '#lib/reporter/index.js': { default: mockReporter }
356
+ })
357
+
358
+ const customPath = testPath('custom', 'path', 'book.epub')
359
+ const output = await generateEpub.default({ lib: 'epubjs', output: customPath })
360
+
361
+ t.is(output, customPath)
362
+ })
363
+
364
+ test('generateEpub adds .epub extension if missing', async (t) => {
365
+ const { sandbox } = t.context
366
+
367
+ const mockEpubLib = sandbox.stub().resolves()
368
+ const mockDynamicImport = sandbox.stub().resolves({ default: mockEpubLib })
369
+
370
+ const mockPaths = {
371
+ getProjectRoot: () => testPath('project'),
372
+ getEpubDir: () => '_epub'
373
+ }
374
+
375
+ const mockFs = {
376
+ existsSync: sandbox.stub().returns(true),
377
+ mkdirSync: sandbox.stub()
378
+ }
379
+
380
+ const mockReporter = {
381
+ start: sandbox.stub(),
382
+ detail: sandbox.stub(),
383
+ succeed: sandbox.stub(),
384
+ fail: sandbox.stub()
385
+ }
386
+
387
+ const generateEpub = await esmock('./index.js', {
388
+ '#helpers/os-utils.js': { dynamicImport: mockDynamicImport },
389
+ '#lib/project/index.js': mockPaths,
390
+ 'fs-extra': mockFs,
391
+ '#lib/reporter/index.js': { default: mockReporter }
392
+ })
393
+
394
+ const output = await generateEpub.default({ lib: 'epubjs', output: 'my-book' })
395
+
396
+ t.is(output, path.join(testPath('project'), 'my-book.epub'))
397
+ })
398
+
399
+ test('generateEpub creates output directory if missing', async (t) => {
400
+ const { sandbox } = t.context
401
+
402
+ const mockEpubLib = sandbox.stub().resolves()
403
+ const mockDynamicImport = sandbox.stub().resolves({ default: mockEpubLib })
404
+
405
+ const mockPaths = {
406
+ getProjectRoot: () => testPath('project'),
407
+ getEpubDir: () => '_epub'
408
+ }
409
+
410
+ const mockFs = {
411
+ existsSync: sandbox.stub().returns(false),
412
+ mkdirSync: sandbox.stub()
413
+ }
414
+
415
+ const mockReporter = {
416
+ start: sandbox.stub(),
417
+ detail: sandbox.stub(),
418
+ succeed: sandbox.stub(),
419
+ fail: sandbox.stub()
420
+ }
421
+
422
+ const generateEpub = await esmock('./index.js', {
423
+ '#helpers/os-utils.js': { dynamicImport: mockDynamicImport },
424
+ '#lib/project/index.js': mockPaths,
425
+ 'fs-extra': mockFs,
426
+ '#lib/reporter/index.js': { default: mockReporter }
427
+ })
428
+
429
+ await generateEpub.default({ lib: 'epubjs', output: '_downloads/book.epub' })
430
+
431
+ t.true(mockFs.mkdirSync.calledOnce)
432
+ t.is(mockFs.mkdirSync.firstCall.args[0], path.join(testPath('project'), '_downloads'))
433
+ t.deepEqual(mockFs.mkdirSync.firstCall.args[1], { recursive: true })
434
+ })
435
+
436
+ // ─────────────────────────────────────────────────────────────────────────────
437
+ // EPUB library invocation tests
438
+ // ─────────────────────────────────────────────────────────────────────────────
439
+
440
+ test('generateEpub passes correct arguments to EPUB library', async (t) => {
441
+ const { sandbox } = t.context
442
+
443
+ const mockEpubLib = sandbox.stub().resolves()
444
+ const mockDynamicImport = sandbox.stub().resolves({ default: mockEpubLib })
445
+
446
+ const mockPaths = {
447
+ getProjectRoot: () => testPath('project'),
448
+ getEpubDir: () => '_epub'
449
+ }
450
+
451
+ const mockFs = {
452
+ existsSync: sandbox.stub().returns(true),
453
+ mkdirSync: sandbox.stub()
454
+ }
455
+
456
+ const mockReporter = {
457
+ start: sandbox.stub(),
458
+ detail: sandbox.stub(),
459
+ succeed: sandbox.stub(),
460
+ fail: sandbox.stub()
461
+ }
462
+
463
+ const generateEpub = await esmock('./index.js', {
464
+ '#helpers/os-utils.js': { dynamicImport: mockDynamicImport },
465
+ '#lib/project/index.js': mockPaths,
466
+ 'fs-extra': mockFs,
467
+ '#lib/reporter/index.js': { default: mockReporter }
468
+ })
469
+
470
+ await generateEpub.default({ lib: 'epubjs', debug: true })
471
+
472
+ t.true(mockEpubLib.calledOnce)
473
+ const [input, output, options] = mockEpubLib.firstCall.args
474
+ t.is(input, path.join(testPath('project'), '_epub'))
475
+ t.is(output, path.join(testPath('project'), 'epubjs.epub'))
476
+ t.true(options.debug)
477
+ })
478
+
479
+ test('generateEpub passes options through to EPUB library', async (t) => {
480
+ const { sandbox } = t.context
481
+
482
+ const mockEpubLib = sandbox.stub().resolves()
483
+ const mockDynamicImport = sandbox.stub().resolves({ default: mockEpubLib })
484
+
485
+ const mockPaths = {
486
+ getProjectRoot: () => testPath('project'),
487
+ getEpubDir: () => '_epub'
488
+ }
489
+
490
+ const mockFs = {
491
+ existsSync: sandbox.stub().returns(true),
492
+ mkdirSync: sandbox.stub()
493
+ }
494
+
495
+ const mockReporter = {
496
+ start: sandbox.stub(),
497
+ detail: sandbox.stub(),
498
+ succeed: sandbox.stub(),
499
+ fail: sandbox.stub()
500
+ }
501
+
502
+ const generateEpub = await esmock('./index.js', {
503
+ '#helpers/os-utils.js': { dynamicImport: mockDynamicImport },
504
+ '#lib/project/index.js': mockPaths,
505
+ 'fs-extra': mockFs,
506
+ '#lib/reporter/index.js': { default: mockReporter }
507
+ })
508
+
509
+ await generateEpub.default({
510
+ lib: 'epubjs',
511
+ debug: true,
512
+ customOption: 'value'
513
+ })
514
+
515
+ const [, , options] = mockEpubLib.firstCall.args
516
+ t.true(options.debug)
517
+ t.is(options.customOption, 'value')
518
+ })
@@ -1,8 +1,14 @@
1
1
  import { execa } from 'execa'
2
2
  import fs from 'fs-extra'
3
3
  import path from 'node:path'
4
- import paths, { projectRoot } from '#lib/11ty/paths.js'
5
- import which from '#helpers/which.js'
4
+ import { EpubGenerationError } from '#src/errors/index.js'
5
+ import createDebug from '#debug'
6
+ import ENGINES from './engines.js'
7
+
8
+ const debug = createDebug('lib:epub:pandoc')
9
+
10
+ /** Re-export engine metadata from central registry */
11
+ export const metadata = ENGINES.pandoc
6
12
 
7
13
  /**
8
14
  * Filter directory entries for XHTML files
@@ -19,9 +25,18 @@ const xhtmlFiles = (dirent) => {
19
25
  /**
20
26
  * A façade module for interacting with Pandoc CLI.
21
27
  * @see https://pandoc.org/MANUAL.html#general-options
28
+ *
29
+ * Pandoc availability is checked by the parent façade (lib/epub/index.js)
30
+ * before this module is loaded, so we can assume pandoc is in PATH.
31
+ *
32
+ * @param {string} input - Path to the input directory containing XHTML files
33
+ * @param {string} output - Path where the EPUB should be written
34
+ * @param {Object} [options={}] - Generation options
35
+ * @returns {Promise<void>}
22
36
  */
23
37
  export default async (input, output, options = {}) => {
24
- which('pandoc')
38
+ debug('input: %s', input)
39
+ debug('output: %s', output)
25
40
 
26
41
  const inputs = fs.readdirSync(input)
27
42
  .map((entry) => path.join(input, entry))
@@ -38,5 +53,19 @@ export default async (input, output, options = {}) => {
38
53
  `--standalone`,
39
54
  ]
40
55
 
41
- await execa('pandoc', [...cmdOptions, ...inputs])
56
+ debug('cmd options: %O', cmdOptions)
57
+ debug('inputs: %d XHTML files', inputs.length)
58
+
59
+ try {
60
+ const { stderr } = await execa('pandoc', [...cmdOptions, ...inputs])
61
+ // Pandoc may emit warnings to stderr even on success
62
+ if (stderr) {
63
+ debug('pandoc stderr: %s', stderr)
64
+ }
65
+ } catch (error) {
66
+ const details = error.stderr || error.message
67
+ throw new EpubGenerationError(`Pandoc EPUB generation failed: ${details}`)
68
+ }
69
+
70
+ debug('complete')
42
71
  }
@@ -0,0 +1,122 @@
1
+ import esmock from 'esmock'
2
+ import path from 'node:path'
3
+ import sinon from 'sinon'
4
+ import test from 'ava'
5
+
6
+ /**
7
+ * Pandoc EPUB Engine Integration Tests
8
+ *
9
+ * Tests error handling and logging paths that are difficult to trigger in E2E tests.
10
+ * The happy path is covered by E2E tests with real Pandoc installation.
11
+ */
12
+
13
+ /**
14
+ * Create a cross-platform absolute path for testing
15
+ * @param {...string} segments - Path segments to join
16
+ * @returns {string} Platform-appropriate absolute path
17
+ */
18
+ function testPath(...segments) {
19
+ const root = process.platform === 'win32' ? 'C:\\' : '/'
20
+ return path.join(root, ...segments)
21
+ }
22
+
23
+ test.beforeEach((t) => {
24
+ t.context.sandbox = sinon.createSandbox()
25
+
26
+ // Mock fs-extra
27
+ t.context.mockFs = {
28
+ readdirSync: t.context.sandbox.stub().returns(['chapter1.xhtml', 'chapter2.xhtml']),
29
+ lstatSync: t.context.sandbox.stub().returns({ isFile: () => true })
30
+ }
31
+ })
32
+
33
+ test.afterEach.always((t) => {
34
+ t.context.sandbox.restore()
35
+ })
36
+
37
+ test('pandoc logs stderr via debug when present', async (t) => {
38
+ const { sandbox, mockFs } = t.context
39
+
40
+ const mockExeca = sandbox.stub().resolves({ stderr: 'warning: deprecated option used' })
41
+ const mockDebug = sandbox.stub()
42
+ const mockCreateDebug = sandbox.stub().returns(mockDebug)
43
+
44
+ const pandoc = await esmock('./pandoc.js', {
45
+ execa: { execa: mockExeca },
46
+ 'fs-extra': mockFs,
47
+ '#debug': { default: mockCreateDebug }
48
+ })
49
+
50
+ await pandoc.default(testPath('input'), testPath('output.epub'), {})
51
+
52
+ t.true(mockDebug.calledWith('pandoc stderr: %s', 'warning: deprecated option used'))
53
+ })
54
+
55
+ test('pandoc does not log stderr when empty', async (t) => {
56
+ const { sandbox, mockFs } = t.context
57
+
58
+ const mockExeca = sandbox.stub().resolves({ stderr: '' })
59
+ const mockDebug = sandbox.stub()
60
+ const mockCreateDebug = sandbox.stub().returns(mockDebug)
61
+
62
+ const pandoc = await esmock('./pandoc.js', {
63
+ execa: { execa: mockExeca },
64
+ 'fs-extra': mockFs,
65
+ '#debug': { default: mockCreateDebug }
66
+ })
67
+
68
+ await pandoc.default(testPath('input'), testPath('output.epub'), {})
69
+
70
+ t.false(mockDebug.calledWith(sinon.match('pandoc stderr')))
71
+ })
72
+
73
+ test('pandoc throws EpubGenerationError when execa fails', async (t) => {
74
+ const { sandbox, mockFs } = t.context
75
+
76
+ const execaError = new Error('Pandoc failed')
77
+ execaError.stderr = 'error: unknown input format'
78
+
79
+ const mockExeca = sandbox.stub().rejects(execaError)
80
+
81
+ const pandoc = await esmock('./pandoc.js', {
82
+ execa: { execa: mockExeca },
83
+ 'fs-extra': mockFs
84
+ })
85
+
86
+ const error = await t.throwsAsync(() =>
87
+ pandoc.default(testPath('input'), testPath('output.epub'), {})
88
+ )
89
+
90
+ t.is(error.code, 'EPUB_FAILED')
91
+ // EpubGenerationError formats message as: "EPUB generation failed: <details>"
92
+ // where <details> includes tool name, operation, and stderr/message
93
+ t.true(error.message.includes('EPUB generation failed'))
94
+ t.true(error.message.includes('Pandoc'))
95
+ t.true(error.message.includes('unknown input format'))
96
+ })
97
+
98
+ test('pandoc filters only XHTML files from input directory', async (t) => {
99
+ const { sandbox } = t.context
100
+
101
+ const mockExeca = sandbox.stub().resolves({ stderr: '' })
102
+
103
+ // Mock readdirSync to return mixed files
104
+ const mockFs = {
105
+ readdirSync: sandbox.stub().returns(['chapter1.xhtml', 'image.png', 'chapter2.xhtml', 'style.css']),
106
+ lstatSync: sandbox.stub().returns({ isFile: () => true })
107
+ }
108
+
109
+ const pandoc = await esmock('./pandoc.js', {
110
+ execa: { execa: mockExeca },
111
+ 'fs-extra': mockFs
112
+ })
113
+
114
+ await pandoc.default(testPath('input'), testPath('output.epub'), {})
115
+
116
+ // Verify only XHTML files were passed to pandoc
117
+ const [, args] = mockExeca.firstCall.args
118
+ const inputFiles = args.filter(arg => arg.endsWith('.xhtml'))
119
+ t.is(inputFiles.length, 2)
120
+ t.true(inputFiles[0].endsWith('chapter1.xhtml'))
121
+ t.true(inputFiles[1].endsWith('chapter2.xhtml'))
122
+ })