@uniweb/build 0.1.2 → 0.1.5

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,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
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Asset Processor
3
+ *
4
+ * Processes site assets (images) during build:
5
+ * - Converts images to WebP for optimization
6
+ * - Generates content-hashed filenames for cache busting
7
+ * - Caches processed assets to avoid redundant work
8
+ *
9
+ * @module @uniweb/build/site
10
+ */
11
+
12
+ import { readFile, writeFile, mkdir, stat, copyFile } from 'node:fs/promises'
13
+ import { existsSync } from 'node:fs'
14
+ import { join, basename, extname, dirname } from 'node:path'
15
+ import { createHash } from 'node:crypto'
16
+ import sharp from 'sharp'
17
+
18
+ // Image formats that can be converted to WebP
19
+ const CONVERTIBLE_FORMATS = ['.png', '.jpg', '.jpeg', '.gif']
20
+
21
+ // Image formats to pass through without conversion
22
+ const PASSTHROUGH_FORMATS = ['.svg', '.webp', '.avif', '.ico']
23
+
24
+ /**
25
+ * Generate a content hash for a file
26
+ */
27
+ async function getFileHash(filePath) {
28
+ const content = await readFile(filePath)
29
+ return createHash('md5').update(content).digest('hex').slice(0, 8)
30
+ }
31
+
32
+ /**
33
+ * Convert an image to WebP format
34
+ *
35
+ * @param {Buffer} input - Input image buffer
36
+ * @param {Object} options - Conversion options
37
+ * @returns {Promise<Buffer>} WebP buffer
38
+ */
39
+ async function convertToWebp(input, options = {}) {
40
+ const { quality = 80 } = options
41
+
42
+ return sharp(input)
43
+ .webp({ quality })
44
+ .toBuffer()
45
+ }
46
+
47
+ /**
48
+ * Process a single asset
49
+ *
50
+ * @param {Object} asset - Asset info from manifest
51
+ * @param {Object} options - Processing options
52
+ * @returns {Promise<Object>} Processed asset info
53
+ */
54
+ export async function processAsset(asset, options = {}) {
55
+ const {
56
+ outputDir,
57
+ assetsSubdir = 'assets',
58
+ convertToWebp: shouldConvert = true,
59
+ quality = 80
60
+ } = options
61
+
62
+ const { original, resolved, isImage } = asset
63
+
64
+ // Check if source file exists
65
+ if (!existsSync(resolved)) {
66
+ console.warn(`[asset-processor] Source not found: ${resolved}`)
67
+ return {
68
+ original,
69
+ output: original, // Keep original path as fallback
70
+ processed: false,
71
+ error: 'Source not found'
72
+ }
73
+ }
74
+
75
+ const ext = extname(resolved).toLowerCase()
76
+ const name = basename(resolved, ext)
77
+
78
+ try {
79
+ // Generate content hash for cache busting
80
+ const hash = await getFileHash(resolved)
81
+
82
+ let outputBuffer
83
+ let outputExt = ext
84
+ let converted = false
85
+
86
+ if (isImage && shouldConvert && CONVERTIBLE_FORMATS.includes(ext)) {
87
+ // Convert to WebP
88
+ const input = await readFile(resolved)
89
+ outputBuffer = await convertToWebp(input, { quality })
90
+ outputExt = '.webp'
91
+ converted = true
92
+ } else if (isImage && PASSTHROUGH_FORMATS.includes(ext)) {
93
+ // Copy as-is for SVG, WebP, etc.
94
+ outputBuffer = await readFile(resolved)
95
+ } else {
96
+ // Non-image or unknown format - copy as-is
97
+ outputBuffer = await readFile(resolved)
98
+ }
99
+
100
+ // Generate output filename with hash
101
+ const outputFilename = `${name}-${hash}${outputExt}`
102
+ const outputPath = join(outputDir, assetsSubdir, outputFilename)
103
+
104
+ // Ensure output directory exists
105
+ await mkdir(dirname(outputPath), { recursive: true })
106
+
107
+ // Write processed file
108
+ await writeFile(outputPath, outputBuffer)
109
+
110
+ // Return the URL path (relative to site root)
111
+ const outputUrl = `/${assetsSubdir}/${outputFilename}`
112
+
113
+ return {
114
+ original,
115
+ output: outputUrl,
116
+ resolved,
117
+ outputPath,
118
+ processed: true,
119
+ converted,
120
+ size: outputBuffer.length
121
+ }
122
+ } catch (error) {
123
+ console.warn(`[asset-processor] Failed to process ${resolved}:`, error.message)
124
+ return {
125
+ original,
126
+ output: original,
127
+ processed: false,
128
+ error: error.message
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Process all assets in a manifest
135
+ *
136
+ * @param {Object} assetManifest - Asset manifest from content collector
137
+ * @param {Object} options - Processing options
138
+ * @returns {Promise<Object>} Mapping of original paths to output URLs
139
+ */
140
+ export async function processAssets(assetManifest, options = {}) {
141
+ const pathMapping = {}
142
+ const results = {
143
+ processed: 0,
144
+ converted: 0,
145
+ failed: 0,
146
+ totalSize: 0
147
+ }
148
+
149
+ const entries = Object.entries(assetManifest)
150
+
151
+ for (const [originalPath, asset] of entries) {
152
+ const result = await processAsset(asset, options)
153
+ pathMapping[originalPath] = result.output
154
+
155
+ if (result.processed) {
156
+ results.processed++
157
+ results.totalSize += result.size || 0
158
+ if (result.converted) {
159
+ results.converted++
160
+ }
161
+ } else {
162
+ results.failed++
163
+ }
164
+ }
165
+
166
+ return { pathMapping, results }
167
+ }
168
+
169
+ /**
170
+ * Rewrite asset paths in ProseMirror content
171
+ *
172
+ * @param {Object} content - ProseMirror document
173
+ * @param {Object} pathMapping - Map of original paths to new paths
174
+ * @returns {Object} Content with rewritten paths
175
+ */
176
+ export function rewriteContentPaths(content, pathMapping) {
177
+ if (!content) return content
178
+
179
+ // Deep clone to avoid mutating original
180
+ const result = JSON.parse(JSON.stringify(content))
181
+
182
+ function walk(node) {
183
+ // Rewrite image src
184
+ if (node.type === 'image' && node.attrs?.src) {
185
+ const newPath = pathMapping[node.attrs.src]
186
+ if (newPath) {
187
+ node.attrs.src = newPath
188
+ }
189
+ }
190
+
191
+ // Recurse into content
192
+ if (node.content && Array.isArray(node.content)) {
193
+ node.content.forEach(walk)
194
+ }
195
+
196
+ // Check marks
197
+ if (node.marks && Array.isArray(node.marks)) {
198
+ node.marks.forEach(mark => {
199
+ if (mark.attrs?.src) {
200
+ const newPath = pathMapping[mark.attrs.src]
201
+ if (newPath) {
202
+ mark.attrs.src = newPath
203
+ }
204
+ }
205
+ })
206
+ }
207
+ }
208
+
209
+ walk(result)
210
+ return result
211
+ }
212
+
213
+ /**
214
+ * Rewrite asset paths in section params (frontmatter)
215
+ *
216
+ * @param {Object} params - Section params
217
+ * @param {Object} pathMapping - Map of original paths to new paths
218
+ * @returns {Object} Params with rewritten paths
219
+ */
220
+ export function rewriteParamPaths(params, pathMapping) {
221
+ if (!params) return params
222
+
223
+ const result = { ...params }
224
+ const imageFields = ['image', 'background', 'backgroundImage', 'thumbnail', 'poster', 'avatar', 'logo', 'icon']
225
+
226
+ for (const field of imageFields) {
227
+ if (result[field] && pathMapping[result[field]]) {
228
+ result[field] = pathMapping[result[field]]
229
+ }
230
+ }
231
+
232
+ return result
233
+ }
234
+
235
+ /**
236
+ * Rewrite all asset paths in site content
237
+ *
238
+ * @param {Object} siteContent - Full site content object
239
+ * @param {Object} pathMapping - Map of original paths to new paths
240
+ * @returns {Object} Site content with rewritten paths
241
+ */
242
+ export function rewriteSiteContentPaths(siteContent, pathMapping) {
243
+ // Deep clone
244
+ const result = JSON.parse(JSON.stringify(siteContent))
245
+
246
+ function processSection(section) {
247
+ if (section.content) {
248
+ section.content = rewriteContentPaths(section.content, pathMapping)
249
+ }
250
+ if (section.params) {
251
+ section.params = rewriteParamPaths(section.params, pathMapping)
252
+ }
253
+ if (section.subsections) {
254
+ section.subsections.forEach(processSection)
255
+ }
256
+ }
257
+
258
+ function processPage(page) {
259
+ if (page.sections) {
260
+ page.sections.forEach(processSection)
261
+ }
262
+ }
263
+
264
+ // Process all pages
265
+ if (result.pages) {
266
+ result.pages.forEach(processPage)
267
+ }
268
+
269
+ // Process header and footer
270
+ if (result.header) {
271
+ processPage(result.header)
272
+ }
273
+ if (result.footer) {
274
+ processPage(result.footer)
275
+ }
276
+
277
+ // Remove the assets manifest from output (no longer needed at runtime)
278
+ delete result.assets
279
+
280
+ return result
281
+ }