@uniweb/build 0.1.2 → 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.
- package/README.md +189 -11
- package/package.json +24 -4
- package/src/dev/index.js +9 -0
- package/src/dev/plugin.js +206 -0
- package/src/images.js +5 -3
- package/src/index.js +5 -0
- package/src/prerender.js +310 -0
- package/src/site/advanced-processors.js +393 -0
- package/src/site/asset-processor.js +281 -0
- package/src/site/assets.js +247 -0
- package/src/site/content-collector.js +344 -0
- package/src/site/index.js +32 -0
- package/src/site/plugin.js +497 -0
- package/src/vite-foundation-plugin.js +7 -3
package/src/prerender.js
ADDED
|
@@ -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, '&')
|
|
304
|
+
.replace(/</g, '<')
|
|
305
|
+
.replace(/>/g, '>')
|
|
306
|
+
.replace(/"/g, '"')
|
|
307
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|