@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.
- package/CHANGELOG.md +36 -0
- package/README.md +9 -0
- package/bin/cli.js +19 -1
- package/package.json +21 -8
- package/patches/README.md +19 -0
- package/patches/install-npm-version+1.0.9.patch +12119 -0
- package/schemas/objects.schema.json +12 -38
- package/schemas/publication.schema.json +0 -22
- package/schemas/references.schema.json +0 -1
- package/src/Command.js +26 -6
- package/src/Command.spec.js +99 -0
- package/src/commands/README.md +213 -122
- package/src/commands/build.js +37 -36
- package/src/commands/build.spec.js +113 -0
- package/src/commands/build.test.js +397 -0
- package/src/commands/clean.js +25 -19
- package/src/commands/clean.spec.js +108 -0
- package/src/commands/clean.test.js +260 -0
- package/src/commands/config.js +251 -0
- package/src/commands/config.spec.js +107 -0
- package/src/commands/config.test.js +715 -0
- package/src/commands/create.js +42 -14
- package/src/commands/create.spec.js +112 -0
- package/src/commands/create.test.js +415 -0
- package/src/commands/epub.js +59 -30
- package/src/commands/epub.spec.js +114 -0
- package/src/commands/epub.test.js +503 -0
- package/src/commands/index.js +9 -2
- package/src/commands/info.js +18 -24
- package/src/commands/info.spec.js +64 -0
- package/src/commands/info.test.js +415 -0
- package/src/commands/pdf.js +58 -84
- package/src/commands/pdf.spec.js +114 -0
- package/src/commands/pdf.test.js +464 -0
- package/src/commands/preview.js +32 -31
- package/src/commands/preview.spec.js +97 -0
- package/src/commands/preview.test.js +250 -0
- package/src/commands/use.js +56 -0
- package/src/commands/use.spec.js +61 -0
- package/src/commands/use.test.js +280 -0
- package/src/commands/validate.js +31 -19
- package/src/commands/validate.spec.js +82 -0
- package/src/commands/validate.test.js +234 -0
- package/src/commands/workflows.js +70 -0
- package/src/errors/build/build-failed-error.js +19 -0
- package/src/errors/build/config-field-missing-error.js +20 -0
- package/src/errors/build/config-file-not-found-error.js +20 -0
- package/src/errors/build/index.js +11 -0
- package/src/errors/index.js +48 -0
- package/src/errors/install/dependency-install-error.js +19 -0
- package/src/errors/install/directory-not-empty-error.js +25 -0
- package/src/errors/install/index.js +12 -0
- package/src/errors/install/invalid-path-error.js +31 -0
- package/src/errors/install/invalid-starter-error.js +27 -0
- package/src/errors/install/version-not-found-error.js +35 -0
- package/src/errors/output/epub-generation-error.js +19 -0
- package/src/errors/output/index.js +14 -0
- package/src/errors/output/invalid-epub-library-error.js +20 -0
- package/src/errors/output/invalid-pdf-library-error.js +20 -0
- package/src/errors/output/missing-build-output-error.js +21 -0
- package/src/errors/output/pdf-generation-error.js +24 -0
- package/src/errors/output/tool-not-found-error.js +37 -0
- package/src/errors/project/index.js +10 -0
- package/src/errors/project/not-in-project-error.js +20 -0
- package/src/errors/project/project-create-error.js +21 -0
- package/src/errors/quire-error.js +27 -0
- package/src/errors/validation/validation-error.js +20 -11
- package/src/helpers/clean.js +1 -1
- package/src/helpers/docs-url.js +32 -0
- package/src/helpers/test-cwd.js +5 -6
- package/src/helpers/test-cwd.test.js +192 -0
- package/src/helpers/which.js +10 -4
- package/src/lib/11ty/README.md +135 -19
- package/src/lib/11ty/api.js +196 -94
- package/src/lib/11ty/cli.js +91 -37
- package/src/lib/11ty/index.js +64 -5
- package/src/lib/11ty/index.test.js +655 -0
- package/src/lib/README.md +275 -0
- package/src/lib/commander/index.js +100 -0
- package/src/lib/commander/index.test.js +86 -0
- package/src/lib/commander/options.js +195 -0
- package/src/lib/commander/options.test.js +109 -0
- package/src/lib/conf/README.md +84 -73
- package/src/lib/conf/config.js +5 -3
- package/src/lib/conf/config.test.js +281 -0
- package/src/lib/conf/defaults.js +44 -0
- package/src/lib/conf/format.js +60 -0
- package/src/lib/conf/format.test.js +106 -0
- package/src/lib/conf/helpers.js +91 -0
- package/src/lib/conf/helpers.test.js +136 -0
- package/src/lib/conf/index.js +22 -0
- package/src/lib/conf/schema.js +53 -8
- package/src/lib/epub/README.md +133 -2
- package/src/lib/epub/engines.js +46 -0
- package/src/lib/epub/epub.js +36 -11
- package/src/lib/epub/index.js +111 -21
- package/src/lib/epub/index.test.js +518 -0
- package/src/lib/epub/pandoc.js +33 -4
- package/src/lib/epub/pandoc.test.js +122 -0
- package/src/lib/epub/schema.js +21 -0
- package/src/lib/error/README.md +170 -0
- package/src/lib/error/handler.js +107 -0
- package/src/lib/git/README.md +151 -2
- package/src/lib/git/index.js +217 -13
- package/src/lib/git/index.spec.js +80 -0
- package/src/lib/git/index.test.js +453 -0
- package/src/lib/installer/index.js +309 -0
- package/src/lib/installer/index.spec.js +83 -0
- package/src/lib/installer/index.test.js +545 -0
- package/src/lib/logger/README.md +424 -0
- package/src/lib/logger/debug.js +132 -0
- package/src/lib/logger/debug.spec.js +130 -0
- package/src/lib/logger/debug.test.js +128 -0
- package/src/lib/logger/index.js +228 -0
- package/src/lib/logger/index.spec.js +131 -0
- package/src/lib/logger/index.test.js +477 -0
- package/src/lib/npm/README.md +127 -0
- package/src/lib/npm/index.js +198 -0
- package/src/lib/npm/index.spec.js +60 -0
- package/src/lib/npm/index.test.js +355 -0
- package/src/lib/pdf/README.md +131 -0
- package/src/lib/pdf/engines.js +46 -0
- package/src/lib/pdf/index.js +124 -21
- package/src/lib/pdf/index.test.js +708 -0
- package/src/lib/pdf/paged.js +100 -52
- package/src/lib/pdf/paged.test.js +366 -0
- package/src/lib/pdf/prince.js +115 -37
- package/src/lib/pdf/prince.test.js +202 -0
- package/src/lib/pdf/schema.js +21 -0
- package/src/lib/pdf/split.js +61 -33
- package/src/lib/pdf/split.test.js +445 -0
- package/src/lib/process/manager.js +110 -0
- package/src/lib/process/manager.test.js +55 -0
- package/src/lib/project/build.js +143 -0
- package/src/lib/project/build.test.js +253 -0
- package/src/lib/project/config.js +48 -0
- package/src/lib/project/config.test.js +134 -0
- package/src/{helpers/is-quire.js → lib/project/detect.js} +5 -3
- package/src/lib/project/detect.test.js +157 -0
- package/src/lib/project/index.js +40 -0
- package/src/lib/project/paths.js +224 -0
- package/src/lib/project/version.js +110 -0
- package/src/lib/project/version.test.js +350 -0
- package/src/lib/reporter/README.md +211 -2
- package/src/lib/reporter/index.js +512 -0
- package/src/lib/reporter/index.test.js +593 -0
- package/src/main.js +134 -47
- package/src/main.spec.js +61 -0
- package/src/main.test.js +347 -0
- package/src/validators/utils.js +2 -1
- package/src/commands/conf.js +0 -43
- package/src/commands/version.js +0 -43
- package/src/lib/11ty/paths.js +0 -103
- package/src/lib/i18n/README.md +0 -3
- package/src/lib/i18n/config.js +0 -53
- package/src/lib/i18n/index.js +0 -43
- package/src/lib/i18n/localeService.js +0 -58
- package/src/lib/quire/README.md +0 -19
- 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
|
+
})
|
package/src/lib/epub/pandoc.js
CHANGED
|
@@ -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
|
|
5
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|