@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.
- 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/docs.js +217 -0
- package/src/images.js +5 -3
- package/src/index.js +11 -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
|
@@ -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
|
+
}
|