@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.
@@ -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
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Asset Resolution Utilities
3
+ *
4
+ * Resolves asset paths in content to file system locations.
5
+ * Supports both relative paths (./image.png) and absolute paths (/images/hero.png).
6
+ *
7
+ * In content-driven sites, markdown is the "code" - local asset references
8
+ * act as implicit imports and should be processed/optimized during build.
9
+ */
10
+
11
+ import { join, dirname, isAbsolute, normalize } from 'node:path'
12
+ import { existsSync } from 'node:fs'
13
+
14
+ // Image extensions we should process
15
+ const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg', '.avif']
16
+
17
+ // Video extensions we can extract posters from
18
+ const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv']
19
+
20
+ // PDF extension
21
+ const PDF_EXTENSION = '.pdf'
22
+
23
+ /**
24
+ * Check if a path is an external URL
25
+ */
26
+ function isExternalUrl(src) {
27
+ return /^(https?:)?\/\//.test(src) || src.startsWith('data:')
28
+ }
29
+
30
+ /**
31
+ * Check if a path is a processable image
32
+ */
33
+ function isImagePath(src) {
34
+ const ext = src.split('.').pop()?.toLowerCase()
35
+ return IMAGE_EXTENSIONS.some(e => e.slice(1) === ext)
36
+ }
37
+
38
+ /**
39
+ * Check if a path is a video file
40
+ */
41
+ function isVideoPath(src) {
42
+ const ext = '.' + (src.split('.').pop()?.toLowerCase() || '')
43
+ return VIDEO_EXTENSIONS.includes(ext)
44
+ }
45
+
46
+ /**
47
+ * Check if a path is a PDF file
48
+ */
49
+ function isPdfPath(src) {
50
+ return src.toLowerCase().endsWith(PDF_EXTENSION)
51
+ }
52
+
53
+ /**
54
+ * Resolve an asset path to absolute file system path
55
+ *
56
+ * @param {string} src - Original source path from content
57
+ * @param {string} contextPath - Path of the file containing the reference
58
+ * @param {string} siteRoot - Site root directory
59
+ * @returns {Object} Resolution result
60
+ */
61
+ export function resolveAssetPath(src, contextPath, siteRoot) {
62
+ // External URLs - don't process
63
+ if (isExternalUrl(src)) {
64
+ return { src, resolved: null, external: true }
65
+ }
66
+
67
+ // Already absolute path on filesystem
68
+ if (isAbsolute(src)) {
69
+ return { src, resolved: src, external: false }
70
+ }
71
+
72
+ let resolved
73
+
74
+ // Relative paths: ./image.png or ../image.png or just image.png
75
+ if (src.startsWith('./') || src.startsWith('../') || !src.startsWith('/')) {
76
+ const contextDir = dirname(contextPath)
77
+ resolved = normalize(join(contextDir, src))
78
+ }
79
+ // Absolute site paths: /images/hero.png
80
+ else if (src.startsWith('/')) {
81
+ // Check public folder first, then assets folder
82
+ const publicPath = join(siteRoot, 'public', src)
83
+ const assetsPath = join(siteRoot, 'assets', src)
84
+
85
+ if (existsSync(publicPath)) {
86
+ resolved = publicPath
87
+ } else if (existsSync(assetsPath)) {
88
+ resolved = assetsPath
89
+ } else {
90
+ // Default to public folder path even if it doesn't exist yet
91
+ resolved = publicPath
92
+ }
93
+ }
94
+
95
+ return {
96
+ src,
97
+ resolved,
98
+ external: false,
99
+ isImage: isImagePath(src),
100
+ isVideo: isVideoPath(src),
101
+ isPdf: isPdfPath(src)
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Walk a ProseMirror document and collect all asset references
107
+ *
108
+ * @param {Object} doc - ProseMirror document
109
+ * @param {Function} visitor - Callback for each asset: (node, path, attrName) => void
110
+ * attrName is 'src', 'poster', or 'preview'
111
+ * @param {string} [path=''] - Current path in document (for debugging)
112
+ */
113
+ export function walkContentAssets(doc, visitor, path = '') {
114
+ if (!doc) return
115
+
116
+ // Check for image nodes
117
+ if (doc.type === 'image' && doc.attrs?.src) {
118
+ visitor(doc, path, 'src')
119
+
120
+ // Also collect explicit poster/preview attributes as assets
121
+ if (doc.attrs.poster && !isExternalUrl(doc.attrs.poster)) {
122
+ visitor({ type: 'image', attrs: { src: doc.attrs.poster } }, path, 'poster')
123
+ }
124
+ if (doc.attrs.preview && !isExternalUrl(doc.attrs.preview)) {
125
+ visitor({ type: 'image', attrs: { src: doc.attrs.preview } }, path, 'preview')
126
+ }
127
+ }
128
+
129
+ // Recurse into content
130
+ if (doc.content && Array.isArray(doc.content)) {
131
+ doc.content.forEach((child, index) => {
132
+ walkContentAssets(child, visitor, `${path}/content[${index}]`)
133
+ })
134
+ }
135
+
136
+ // Handle marks (links can have images)
137
+ if (doc.marks && Array.isArray(doc.marks)) {
138
+ doc.marks.forEach((mark, index) => {
139
+ if (mark.attrs?.src) {
140
+ visitor(mark, `${path}/marks[${index}]`, 'src')
141
+ }
142
+ })
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Process all assets in a section's content and frontmatter
148
+ *
149
+ * @param {Object} section - Section object with content and params
150
+ * @param {string} markdownPath - Path to the markdown file
151
+ * @param {string} siteRoot - Site root directory
152
+ * @returns {Object} Asset collection result
153
+ * - assets: Asset manifest mapping original paths to resolved info
154
+ * - hasExplicitPoster: Set of video src paths that have explicit poster attributes
155
+ * - hasExplicitPreview: Set of PDF src paths that have explicit preview attributes
156
+ */
157
+ export function collectSectionAssets(section, markdownPath, siteRoot) {
158
+ const assets = {}
159
+ const hasExplicitPoster = new Set()
160
+ const hasExplicitPreview = new Set()
161
+
162
+ // Track current image node's src when we encounter poster/preview
163
+ let currentImageSrc = null
164
+
165
+ // Collect from ProseMirror content
166
+ if (section.content) {
167
+ walkContentAssets(section.content, (node, path, attrName) => {
168
+ const result = resolveAssetPath(node.attrs.src, markdownPath, siteRoot)
169
+
170
+ if (attrName === 'src') {
171
+ // Main src attribute - track it for potential poster/preview
172
+ currentImageSrc = node.attrs.src
173
+
174
+ // Check if this image has explicit poster/preview
175
+ if (node.attrs.poster) {
176
+ hasExplicitPoster.add(node.attrs.src)
177
+ }
178
+ if (node.attrs.preview) {
179
+ hasExplicitPreview.add(node.attrs.src)
180
+ }
181
+ }
182
+
183
+ if (!result.external && result.resolved) {
184
+ assets[node.attrs.src] = {
185
+ original: node.attrs.src,
186
+ resolved: result.resolved,
187
+ isImage: result.isImage,
188
+ isVideo: result.isVideo,
189
+ isPdf: result.isPdf
190
+ }
191
+ }
192
+ })
193
+ }
194
+
195
+ // Collect from frontmatter params (common media fields)
196
+ const mediaFields = [
197
+ 'image', 'background', 'backgroundImage', 'thumbnail',
198
+ 'poster', 'avatar', 'logo', 'icon',
199
+ 'video', 'videoSrc', 'media', 'file', 'pdf', 'document'
200
+ ]
201
+
202
+ for (const field of mediaFields) {
203
+ const value = section.params?.[field]
204
+ if (typeof value === 'string' && value) {
205
+ const result = resolveAssetPath(value, markdownPath, siteRoot)
206
+ if (!result.external && result.resolved) {
207
+ assets[value] = {
208
+ original: value,
209
+ resolved: result.resolved,
210
+ isImage: result.isImage,
211
+ isVideo: result.isVideo,
212
+ isPdf: result.isPdf
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ return { assets, hasExplicitPoster, hasExplicitPreview }
219
+ }
220
+
221
+ /**
222
+ * Merge multiple asset collection results
223
+ *
224
+ * @param {...Object} collections - Asset collection results from collectSectionAssets
225
+ * @returns {Object} Merged collection with combined assets and sets
226
+ */
227
+ export function mergeAssetCollections(...collections) {
228
+ const merged = {
229
+ assets: {},
230
+ hasExplicitPoster: new Set(),
231
+ hasExplicitPreview: new Set()
232
+ }
233
+
234
+ for (const collection of collections) {
235
+ // Handle both old format (plain object) and new format (with sets)
236
+ if (collection.assets) {
237
+ Object.assign(merged.assets, collection.assets)
238
+ collection.hasExplicitPoster?.forEach(p => merged.hasExplicitPoster.add(p))
239
+ collection.hasExplicitPreview?.forEach(p => merged.hasExplicitPreview.add(p))
240
+ } else {
241
+ // Legacy: plain asset manifest
242
+ Object.assign(merged.assets, collection)
243
+ }
244
+ }
245
+
246
+ return merged
247
+ }