@uniweb/build 0.1.1 → 0.1.4

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.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * SSG Prerendering for Uniweb Sites
3
+ *
4
+ * Renders each page to static HTML at build time.
5
+ * The output includes full HTML with hydration support.
6
+ */
7
+
8
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
9
+ import { existsSync } from 'node:fs'
10
+ import { join, dirname } from 'node:path'
11
+ import { pathToFileURL } from 'node:url'
12
+
13
+ // Lazily loaded dependencies (ESM with React)
14
+ let React, renderToString, createUniweb
15
+
16
+ /**
17
+ * Load dependencies dynamically
18
+ * These are ESM modules that may not be available at import time
19
+ */
20
+ async function loadDependencies() {
21
+ if (React) return // Already loaded
22
+
23
+ const [reactMod, serverMod, coreMod] = await Promise.all([
24
+ import('react'),
25
+ import('react-dom/server'),
26
+ import('@uniweb/core')
27
+ ])
28
+
29
+ React = reactMod.default || reactMod
30
+ renderToString = serverMod.renderToString
31
+ createUniweb = coreMod.createUniweb
32
+ }
33
+
34
+ /**
35
+ * Pre-render all pages in a built site to static HTML
36
+ *
37
+ * @param {string} siteDir - Path to the site directory
38
+ * @param {Object} options
39
+ * @param {string} options.foundationDir - Path to foundation directory (default: ../foundation)
40
+ * @param {function} options.onProgress - Progress callback
41
+ * @returns {Promise<{pages: number, files: string[]}>}
42
+ */
43
+ export async function prerenderSite(siteDir, options = {}) {
44
+ const {
45
+ foundationDir = join(siteDir, '..', 'foundation'),
46
+ onProgress = () => {}
47
+ } = options
48
+
49
+ const distDir = join(siteDir, 'dist')
50
+
51
+ // Verify build exists
52
+ if (!existsSync(distDir)) {
53
+ throw new Error(`Site must be built first. No dist directory found at: ${distDir}`)
54
+ }
55
+
56
+ // Load dependencies
57
+ onProgress('Loading dependencies...')
58
+ await loadDependencies()
59
+
60
+ // Load site content
61
+ onProgress('Loading site content...')
62
+ const contentPath = join(distDir, 'site-content.json')
63
+ if (!existsSync(contentPath)) {
64
+ throw new Error(`site-content.json not found at: ${contentPath}`)
65
+ }
66
+ const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
67
+
68
+ // Load the HTML shell
69
+ onProgress('Loading HTML shell...')
70
+ const shellPath = join(distDir, 'index.html')
71
+ if (!existsSync(shellPath)) {
72
+ throw new Error(`index.html not found at: ${shellPath}`)
73
+ }
74
+ const htmlShell = await readFile(shellPath, 'utf8')
75
+
76
+ // Load the foundation module
77
+ onProgress('Loading foundation...')
78
+ const foundationPath = join(foundationDir, 'dist', 'foundation.js')
79
+ if (!existsSync(foundationPath)) {
80
+ throw new Error(`Foundation not found at: ${foundationPath}. Build foundation first.`)
81
+ }
82
+ const foundationUrl = pathToFileURL(foundationPath).href
83
+ const foundation = await import(foundationUrl)
84
+
85
+ // Initialize the Uniweb runtime (this sets globalThis.uniweb)
86
+ onProgress('Initializing runtime...')
87
+ const uniweb = createUniweb(siteContent)
88
+ uniweb.setFoundation(foundation)
89
+
90
+ if (foundation.config || foundation.site) {
91
+ uniweb.setFoundationConfig(foundation.config || foundation.site)
92
+ }
93
+
94
+ // Pre-render each page
95
+ const renderedFiles = []
96
+ const pages = uniweb.activeWebsite.pages
97
+
98
+ for (const page of pages) {
99
+ const route = page.route
100
+ onProgress(`Rendering ${route}...`)
101
+
102
+ // Set this as the active page
103
+ uniweb.activeWebsite.setActivePage(route)
104
+
105
+ // Create the page element
106
+ // Note: We don't need StaticRouter for SSG since we're just rendering
107
+ // components to strings. The routing context isn't needed for static HTML.
108
+ const element = React.createElement(PageRenderer, { page, foundation })
109
+
110
+ // Render to HTML string
111
+ let renderedContent
112
+ try {
113
+ renderedContent = renderToString(element)
114
+ } catch (err) {
115
+ console.warn(`Warning: Failed to render ${route}: ${err.message}`)
116
+ if (process.env.DEBUG) {
117
+ console.error(err.stack)
118
+ }
119
+ continue
120
+ }
121
+
122
+ // Inject into shell
123
+ const html = injectContent(htmlShell, renderedContent, page, siteContent)
124
+
125
+ // Determine output path
126
+ const outputPath = getOutputPath(distDir, route)
127
+ await mkdir(dirname(outputPath), { recursive: true })
128
+ await writeFile(outputPath, html)
129
+
130
+ renderedFiles.push(outputPath)
131
+ onProgress(` → ${outputPath.replace(distDir, 'dist')}`)
132
+ }
133
+
134
+ onProgress(`Pre-rendered ${renderedFiles.length} pages`)
135
+
136
+ return {
137
+ pages: renderedFiles.length,
138
+ files: renderedFiles
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Minimal page renderer for SSG
144
+ * Renders blocks using foundation components
145
+ */
146
+ function PageRenderer({ page, foundation }) {
147
+ const blocks = page.getPageBlocks()
148
+
149
+ return React.createElement(
150
+ 'main',
151
+ null,
152
+ blocks.map((block, index) =>
153
+ React.createElement(BlockRenderer, {
154
+ key: block.id || index,
155
+ block,
156
+ foundation
157
+ })
158
+ )
159
+ )
160
+ }
161
+
162
+ /**
163
+ * Block renderer - maps block to foundation component
164
+ */
165
+ function BlockRenderer({ block, foundation }) {
166
+ // Get component from foundation
167
+ const componentName = block.component
168
+ let Component = null
169
+
170
+ if (typeof foundation.getComponent === 'function') {
171
+ Component = foundation.getComponent(componentName)
172
+ } else if (foundation[componentName]) {
173
+ Component = foundation[componentName]
174
+ }
175
+
176
+ if (!Component) {
177
+ // Return placeholder for unknown components
178
+ return React.createElement(
179
+ 'div',
180
+ {
181
+ className: 'block-placeholder',
182
+ 'data-component': componentName,
183
+ style: { display: 'none' }
184
+ },
185
+ `Component: ${componentName}`
186
+ )
187
+ }
188
+
189
+ // Build content object (same as runtime's BlockRenderer)
190
+ let content
191
+ if (block.parsedContent?.raw) {
192
+ content = block.parsedContent.raw
193
+ } else {
194
+ content = {
195
+ ...block.parsedContent,
196
+ ...block.properties,
197
+ _prosemirror: block.parsedContent
198
+ }
199
+ }
200
+
201
+ // Build wrapper props
202
+ const theme = block.themeName
203
+ const className = theme || ''
204
+ const wrapperProps = {
205
+ id: `Section${block.id}`,
206
+ className
207
+ }
208
+
209
+ // Component props
210
+ const componentProps = {
211
+ content,
212
+ params: block.properties,
213
+ block,
214
+ page: globalThis.uniweb?.activeWebsite?.activePage,
215
+ website: globalThis.uniweb?.activeWebsite,
216
+ input: block.input
217
+ }
218
+
219
+ return React.createElement(
220
+ 'div',
221
+ wrapperProps,
222
+ React.createElement(Component, componentProps)
223
+ )
224
+ }
225
+
226
+ /**
227
+ * Inject rendered content into HTML shell
228
+ */
229
+ function injectContent(shell, renderedContent, page, siteContent) {
230
+ let html = shell
231
+
232
+ // Replace the empty root div with pre-rendered content
233
+ // Handle various formats of root div
234
+ html = html.replace(
235
+ /<div id="root">[\s\S]*?<\/div>/,
236
+ `<div id="root">${renderedContent}</div>`
237
+ )
238
+
239
+ // Update page title
240
+ if (page.title) {
241
+ html = html.replace(
242
+ /<title>.*?<\/title>/,
243
+ `<title>${escapeHtml(page.title)}</title>`
244
+ )
245
+ }
246
+
247
+ // Add meta description if available
248
+ if (page.description) {
249
+ const metaDesc = `<meta name="description" content="${escapeHtml(page.description)}">`
250
+ if (html.includes('<meta name="description"')) {
251
+ html = html.replace(
252
+ /<meta name="description"[^>]*>/,
253
+ metaDesc
254
+ )
255
+ } else {
256
+ html = html.replace(
257
+ '</head>',
258
+ ` ${metaDesc}\n </head>`
259
+ )
260
+ }
261
+ }
262
+
263
+ // Inject site content as JSON for hydration
264
+ // This allows the client-side React to hydrate with the same data
265
+ const contentScript = `<script id="__SITE_CONTENT__" type="application/json">${JSON.stringify(siteContent)}</script>`
266
+ if (!html.includes('__SITE_CONTENT__')) {
267
+ html = html.replace(
268
+ '</head>',
269
+ ` ${contentScript}\n </head>`
270
+ )
271
+ }
272
+
273
+ return html
274
+ }
275
+
276
+ /**
277
+ * Get output path for a route
278
+ */
279
+ function getOutputPath(distDir, route) {
280
+ // Normalize route
281
+ let normalizedRoute = route
282
+
283
+ // Handle root route
284
+ if (normalizedRoute === '/' || normalizedRoute === '') {
285
+ return join(distDir, 'index.html')
286
+ }
287
+
288
+ // Remove leading slash
289
+ if (normalizedRoute.startsWith('/')) {
290
+ normalizedRoute = normalizedRoute.slice(1)
291
+ }
292
+
293
+ // Create directory structure: /about -> /about/index.html
294
+ return join(distDir, normalizedRoute, 'index.html')
295
+ }
296
+
297
+ /**
298
+ * Escape HTML special characters
299
+ */
300
+ function escapeHtml(str) {
301
+ if (!str) return ''
302
+ return String(str)
303
+ .replace(/&/g, '&amp;')
304
+ .replace(/</g, '&lt;')
305
+ .replace(/>/g, '&gt;')
306
+ .replace(/"/g, '&quot;')
307
+ .replace(/'/g, '&#39;')
308
+ }
309
+
310
+ export default prerenderSite
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Advanced Asset Processors
3
+ *
4
+ * Optional processors for advanced asset types:
5
+ * - Video poster extraction (requires ffmpeg)
6
+ * - PDF thumbnail generation (requires pdf-lib)
7
+ *
8
+ * These features gracefully degrade if dependencies aren't available.
9
+ *
10
+ * @module @uniweb/build/site
11
+ */
12
+
13
+ import { spawn } from 'node:child_process'
14
+ import { writeFile, mkdir, readFile } from 'node:fs/promises'
15
+ import { existsSync } from 'node:fs'
16
+ import { join, dirname, basename, extname } from 'node:path'
17
+ import { createHash } from 'node:crypto'
18
+ import sharp from 'sharp'
19
+
20
+ // Video extensions we can extract posters from
21
+ const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv']
22
+
23
+ // Check if a file is a video
24
+ export function isVideoFile(filePath) {
25
+ const ext = extname(filePath).toLowerCase()
26
+ return VIDEO_EXTENSIONS.includes(ext)
27
+ }
28
+
29
+ // Check if a file is a PDF
30
+ export function isPdfFile(filePath) {
31
+ return extname(filePath).toLowerCase() === '.pdf'
32
+ }
33
+
34
+ /**
35
+ * Check if ffmpeg is available on the system
36
+ */
37
+ let ffmpegAvailable = null
38
+ export async function checkFfmpeg() {
39
+ if (ffmpegAvailable !== null) return ffmpegAvailable
40
+
41
+ return new Promise((resolve) => {
42
+ const proc = spawn('ffmpeg', ['-version'], { stdio: 'ignore' })
43
+ proc.on('error', () => {
44
+ ffmpegAvailable = false
45
+ resolve(false)
46
+ })
47
+ proc.on('close', (code) => {
48
+ ffmpegAvailable = code === 0
49
+ resolve(ffmpegAvailable)
50
+ })
51
+ })
52
+ }
53
+
54
+ /**
55
+ * Extract a poster frame from a video using ffmpeg
56
+ *
57
+ * @param {string} videoPath - Path to the video file
58
+ * @param {string} outputPath - Path for the output image
59
+ * @param {Object} options - Extraction options
60
+ * @returns {Promise<Object>} Result with success status and output path
61
+ */
62
+ export async function extractVideoPoster(videoPath, outputPath, options = {}) {
63
+ const {
64
+ timestamp = '00:00:01', // Default to 1 second in
65
+ width = 1280, // Max width
66
+ quality = 80 // WebP quality
67
+ } = options
68
+
69
+ // Check if ffmpeg is available
70
+ const hasFFmpeg = await checkFfmpeg()
71
+ if (!hasFFmpeg) {
72
+ return {
73
+ success: false,
74
+ error: 'ffmpeg not available',
75
+ skipped: true
76
+ }
77
+ }
78
+
79
+ // Check if video exists
80
+ if (!existsSync(videoPath)) {
81
+ return {
82
+ success: false,
83
+ error: 'Video file not found'
84
+ }
85
+ }
86
+
87
+ // Ensure output directory exists
88
+ await mkdir(dirname(outputPath), { recursive: true })
89
+
90
+ // Temporary file for raw frame
91
+ const tempPath = outputPath.replace(/\.[^.]+$/, '.tmp.png')
92
+
93
+ return new Promise((resolve) => {
94
+ // Extract frame with ffmpeg
95
+ const args = [
96
+ '-ss', timestamp,
97
+ '-i', videoPath,
98
+ '-vframes', '1',
99
+ '-vf', `scale='min(${width},iw)':-1`,
100
+ '-y',
101
+ tempPath
102
+ ]
103
+
104
+ const proc = spawn('ffmpeg', args, { stdio: 'pipe' })
105
+
106
+ let stderr = ''
107
+ proc.stderr?.on('data', (data) => {
108
+ stderr += data.toString()
109
+ })
110
+
111
+ proc.on('error', (err) => {
112
+ resolve({
113
+ success: false,
114
+ error: `ffmpeg error: ${err.message}`
115
+ })
116
+ })
117
+
118
+ proc.on('close', async (code) => {
119
+ if (code !== 0 || !existsSync(tempPath)) {
120
+ resolve({
121
+ success: false,
122
+ error: `ffmpeg failed with code ${code}`
123
+ })
124
+ return
125
+ }
126
+
127
+ try {
128
+ // Convert to WebP for optimization
129
+ await sharp(tempPath)
130
+ .webp({ quality })
131
+ .toFile(outputPath)
132
+
133
+ // Clean up temp file
134
+ const fs = await import('node:fs/promises')
135
+ await fs.unlink(tempPath).catch(() => {})
136
+
137
+ resolve({
138
+ success: true,
139
+ outputPath,
140
+ type: 'webp'
141
+ })
142
+ } catch (err) {
143
+ resolve({
144
+ success: false,
145
+ error: `Failed to convert poster: ${err.message}`
146
+ })
147
+ }
148
+ })
149
+ })
150
+ }
151
+
152
+ /**
153
+ * Generate a thumbnail from a PDF's first page
154
+ *
155
+ * Uses pdf-lib to extract the first page dimensions and sharp to render.
156
+ * Falls back gracefully if pdf-lib is not available.
157
+ *
158
+ * @param {string} pdfPath - Path to the PDF file
159
+ * @param {string} outputPath - Path for the output image
160
+ * @param {Object} options - Generation options
161
+ * @returns {Promise<Object>} Result with success status and output path
162
+ */
163
+ export async function generatePdfThumbnail(pdfPath, outputPath, options = {}) {
164
+ const {
165
+ width = 800,
166
+ quality = 80,
167
+ page = 0 // First page
168
+ } = options
169
+
170
+ // Check if PDF exists
171
+ if (!existsSync(pdfPath)) {
172
+ return {
173
+ success: false,
174
+ error: 'PDF file not found'
175
+ }
176
+ }
177
+
178
+ try {
179
+ // Try to import pdf-lib dynamically
180
+ let PDFDocument
181
+ try {
182
+ const pdfLib = await import('pdf-lib')
183
+ PDFDocument = pdfLib.PDFDocument
184
+ } catch {
185
+ return {
186
+ success: false,
187
+ error: 'pdf-lib not available',
188
+ skipped: true
189
+ }
190
+ }
191
+
192
+ // Load the PDF
193
+ const pdfBytes = await readFile(pdfPath)
194
+ const pdfDoc = await PDFDocument.load(pdfBytes)
195
+
196
+ const pages = pdfDoc.getPages()
197
+ if (pages.length === 0) {
198
+ return {
199
+ success: false,
200
+ error: 'PDF has no pages'
201
+ }
202
+ }
203
+
204
+ const firstPage = pages[page] || pages[0]
205
+ const { width: pdfWidth, height: pdfHeight } = firstPage.getSize()
206
+
207
+ // Calculate dimensions maintaining aspect ratio
208
+ const aspectRatio = pdfHeight / pdfWidth
209
+ const outputWidth = Math.min(width, pdfWidth)
210
+ const outputHeight = Math.round(outputWidth * aspectRatio)
211
+
212
+ // Create a placeholder thumbnail with PDF info
213
+ // Note: Full PDF rendering would require additional dependencies like pdf2pic or puppeteer
214
+ // For now, we create a styled placeholder that indicates it's a PDF
215
+ const svg = `
216
+ <svg width="${outputWidth}" height="${outputHeight}" xmlns="http://www.w3.org/2000/svg">
217
+ <defs>
218
+ <linearGradient id="bg" x1="0%" y1="0%" x2="0%" y2="100%">
219
+ <stop offset="0%" style="stop-color:#f8f9fa"/>
220
+ <stop offset="100%" style="stop-color:#e9ecef"/>
221
+ </linearGradient>
222
+ </defs>
223
+ <rect width="100%" height="100%" fill="url(#bg)"/>
224
+ <rect x="10" y="10" width="${outputWidth - 20}" height="${outputHeight - 20}"
225
+ fill="white" stroke="#dee2e6" stroke-width="1" rx="4"/>
226
+ <text x="50%" y="45%" text-anchor="middle" font-family="system-ui, sans-serif"
227
+ font-size="48" fill="#6c757d">PDF</text>
228
+ <text x="50%" y="60%" text-anchor="middle" font-family="system-ui, sans-serif"
229
+ font-size="14" fill="#adb5bd">${pages.length} page${pages.length > 1 ? 's' : ''}</text>
230
+ </svg>
231
+ `
232
+
233
+ // Ensure output directory exists
234
+ await mkdir(dirname(outputPath), { recursive: true })
235
+
236
+ // Convert SVG to WebP
237
+ await sharp(Buffer.from(svg))
238
+ .webp({ quality })
239
+ .toFile(outputPath)
240
+
241
+ return {
242
+ success: true,
243
+ outputPath,
244
+ type: 'webp',
245
+ pageCount: pages.length,
246
+ placeholder: true // Indicates this is a placeholder, not a true render
247
+ }
248
+ } catch (err) {
249
+ return {
250
+ success: false,
251
+ error: `PDF processing failed: ${err.message}`
252
+ }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Process a video or PDF asset, generating appropriate thumbnails
258
+ *
259
+ * @param {Object} asset - Asset info
260
+ * @param {Object} options - Processing options
261
+ * @returns {Promise<Object>} Processing result with poster/thumbnail info
262
+ */
263
+ export async function processAdvancedAsset(asset, options = {}) {
264
+ const {
265
+ outputDir,
266
+ assetsSubdir = 'assets',
267
+ videoPosters = true,
268
+ pdfThumbnails = true,
269
+ quality = 80
270
+ } = options
271
+
272
+ const { resolved } = asset
273
+
274
+ if (!existsSync(resolved)) {
275
+ return { processed: false, error: 'File not found' }
276
+ }
277
+
278
+ const ext = extname(resolved).toLowerCase()
279
+ const name = basename(resolved, ext)
280
+
281
+ // Generate content hash for cache busting
282
+ const content = await readFile(resolved)
283
+ const hash = createHash('md5').update(content).digest('hex').slice(0, 8)
284
+
285
+ // Handle video files
286
+ if (isVideoFile(resolved) && videoPosters) {
287
+ const posterFilename = `${name}-poster-${hash}.webp`
288
+ const posterPath = join(outputDir, assetsSubdir, posterFilename)
289
+
290
+ const result = await extractVideoPoster(resolved, posterPath, { quality })
291
+
292
+ if (result.success) {
293
+ return {
294
+ processed: true,
295
+ type: 'video',
296
+ poster: `/${assetsSubdir}/${posterFilename}`
297
+ }
298
+ } else if (result.skipped) {
299
+ // ffmpeg not available - not an error, just skip
300
+ return { processed: false, skipped: true, reason: result.error }
301
+ } else {
302
+ return { processed: false, error: result.error }
303
+ }
304
+ }
305
+
306
+ // Handle PDF files
307
+ if (isPdfFile(resolved) && pdfThumbnails) {
308
+ const thumbFilename = `${name}-thumb-${hash}.webp`
309
+ const thumbPath = join(outputDir, assetsSubdir, thumbFilename)
310
+
311
+ const result = await generatePdfThumbnail(resolved, thumbPath, { quality })
312
+
313
+ if (result.success) {
314
+ return {
315
+ processed: true,
316
+ type: 'pdf',
317
+ thumbnail: `/${assetsSubdir}/${thumbFilename}`,
318
+ pageCount: result.pageCount,
319
+ placeholder: result.placeholder
320
+ }
321
+ } else if (result.skipped) {
322
+ // pdf-lib not available - not an error, just skip
323
+ return { processed: false, skipped: true, reason: result.error }
324
+ } else {
325
+ return { processed: false, error: result.error }
326
+ }
327
+ }
328
+
329
+ // Not a video or PDF, or processing disabled
330
+ return { processed: false, skipped: true, reason: 'Not an advanced asset type' }
331
+ }
332
+
333
+ /**
334
+ * Process all advanced assets in a manifest
335
+ *
336
+ * @param {Object} assetManifest - Asset manifest
337
+ * @param {Object} options - Processing options
338
+ * @param {Set} [options.hasExplicitPoster] - Set of video paths with explicit poster attributes
339
+ * @param {Set} [options.hasExplicitPreview] - Set of PDF paths with explicit preview attributes
340
+ * @returns {Promise<Object>} Results with poster/thumbnail mappings
341
+ */
342
+ export async function processAdvancedAssets(assetManifest, options = {}) {
343
+ const {
344
+ hasExplicitPoster = new Set(),
345
+ hasExplicitPreview = new Set(),
346
+ ...processingOptions
347
+ } = options
348
+
349
+ const posterMapping = {} // video src -> poster url
350
+ const thumbnailMapping = {} // pdf src -> thumbnail url
351
+ const results = {
352
+ videos: { processed: 0, skipped: 0, explicit: 0 },
353
+ pdfs: { processed: 0, skipped: 0, explicit: 0 }
354
+ }
355
+
356
+ for (const [originalPath, asset] of Object.entries(assetManifest)) {
357
+ if (isVideoFile(asset.resolved || '')) {
358
+ // Skip auto-generation if explicit poster was provided
359
+ if (hasExplicitPoster.has(originalPath)) {
360
+ results.videos.explicit++
361
+ continue
362
+ }
363
+
364
+ const result = await processAdvancedAsset(asset, processingOptions)
365
+ if (result.processed && result.poster) {
366
+ posterMapping[originalPath] = result.poster
367
+ results.videos.processed++
368
+ } else {
369
+ results.videos.skipped++
370
+ }
371
+ } else if (isPdfFile(asset.resolved || '')) {
372
+ // Skip auto-generation if explicit preview was provided
373
+ if (hasExplicitPreview.has(originalPath)) {
374
+ results.pdfs.explicit++
375
+ continue
376
+ }
377
+
378
+ const result = await processAdvancedAsset(asset, processingOptions)
379
+ if (result.processed && result.thumbnail) {
380
+ thumbnailMapping[originalPath] = {
381
+ url: result.thumbnail,
382
+ pageCount: result.pageCount,
383
+ placeholder: result.placeholder
384
+ }
385
+ results.pdfs.processed++
386
+ } else {
387
+ results.pdfs.skipped++
388
+ }
389
+ }
390
+ }
391
+
392
+ return { posterMapping, thumbnailMapping, results }
393
+ }