@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,497 @@
1
+ /**
2
+ * Vite Plugin: Site Content
3
+ *
4
+ * Collects site content from pages/ directory and injects it into HTML.
5
+ * Watches for changes in development mode.
6
+ *
7
+ * SEO Features:
8
+ * - Generates sitemap.xml from collected pages
9
+ * - Generates robots.txt
10
+ * - Injects Open Graph, Twitter, and canonical meta tags
11
+ * - Supports hreflang for multi-locale sites
12
+ *
13
+ * @module @uniweb/build/site
14
+ *
15
+ * @example
16
+ * import { siteContentPlugin } from '@uniweb/build/site'
17
+ *
18
+ * export default defineConfig({
19
+ * plugins: [
20
+ * siteContentPlugin({
21
+ * sitePath: './site',
22
+ * inject: true,
23
+ * seo: {
24
+ * baseUrl: 'https://example.com',
25
+ * defaultImage: '/og-image.png',
26
+ * twitterHandle: '@example'
27
+ * }
28
+ * })
29
+ * ]
30
+ * })
31
+ */
32
+
33
+ import { resolve } from 'node:path'
34
+ import { watch } from 'node:fs'
35
+ import { collectSiteContent } from './content-collector.js'
36
+ import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
37
+ import { processAdvancedAssets } from './advanced-processors.js'
38
+
39
+ /**
40
+ * Generate sitemap.xml content
41
+ */
42
+ function generateSitemap(pages, baseUrl, locales = []) {
43
+ const urls = []
44
+
45
+ for (const page of pages) {
46
+ // Skip pages marked as noindex
47
+ if (page.seo?.noindex) continue
48
+
49
+ const loc = baseUrl + (page.route === '/' ? '' : page.route)
50
+ const lastmod = page.lastModified || new Date().toISOString().split('T')[0]
51
+ const changefreq = page.seo?.changefreq || 'weekly'
52
+ const priority = page.seo?.priority ?? (page.route === '/' ? '1.0' : '0.8')
53
+
54
+ let urlEntry = ` <url>\n <loc>${escapeXml(loc)}</loc>\n <lastmod>${lastmod.split('T')[0]}</lastmod>\n <changefreq>${changefreq}</changefreq>\n <priority>${priority}</priority>`
55
+
56
+ // Add hreflang entries for multi-locale sites
57
+ if (locales.length > 1) {
58
+ for (const locale of locales) {
59
+ const localeLoc = locale.default ? loc : `${baseUrl}/${locale.code}${page.route === '/' ? '' : page.route}`
60
+ urlEntry += `\n <xhtml:link rel="alternate" hreflang="${locale.code}" href="${escapeXml(localeLoc)}" />`
61
+ }
62
+ // Add x-default pointing to default locale
63
+ urlEntry += `\n <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(loc)}" />`
64
+ }
65
+
66
+ urlEntry += '\n </url>'
67
+ urls.push(urlEntry)
68
+ }
69
+
70
+ const xmlnsExtra = locales.length > 1 ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : ''
71
+
72
+ return `<?xml version="1.0" encoding="UTF-8"?>
73
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xmlnsExtra}>
74
+ ${urls.join('\n')}
75
+ </urlset>`
76
+ }
77
+
78
+ /**
79
+ * Escape special characters for XML
80
+ */
81
+ function escapeXml(str) {
82
+ return str
83
+ .replace(/&/g, '&amp;')
84
+ .replace(/</g, '&lt;')
85
+ .replace(/>/g, '&gt;')
86
+ .replace(/"/g, '&quot;')
87
+ .replace(/'/g, '&apos;')
88
+ }
89
+
90
+ /**
91
+ * Generate robots.txt content
92
+ */
93
+ function generateRobotsTxt(baseUrl, options = {}) {
94
+ const {
95
+ disallow = [],
96
+ allow = [],
97
+ crawlDelay = null,
98
+ additionalSitemaps = []
99
+ } = options
100
+
101
+ let content = 'User-agent: *\n'
102
+
103
+ for (const path of allow) {
104
+ content += `Allow: ${path}\n`
105
+ }
106
+
107
+ for (const path of disallow) {
108
+ content += `Disallow: ${path}\n`
109
+ }
110
+
111
+ if (crawlDelay) {
112
+ content += `Crawl-delay: ${crawlDelay}\n`
113
+ }
114
+
115
+ content += `\nSitemap: ${baseUrl}/sitemap.xml\n`
116
+
117
+ for (const sitemap of additionalSitemaps) {
118
+ content += `Sitemap: ${sitemap}\n`
119
+ }
120
+
121
+ return content
122
+ }
123
+
124
+ /**
125
+ * Generate meta tags for SEO
126
+ */
127
+ function generateMetaTags(siteContent, seoOptions) {
128
+ const { baseUrl, defaultImage, twitterHandle, locales = [] } = seoOptions
129
+ const siteConfig = siteContent.config || {}
130
+ const tags = []
131
+
132
+ const siteName = siteConfig.name || siteConfig.title || ''
133
+ const siteDescription = siteConfig.description || ''
134
+ const ogImage = siteConfig.image || defaultImage
135
+
136
+ // Basic meta
137
+ if (siteDescription) {
138
+ tags.push(`<meta name="description" content="${escapeHtml(siteDescription)}">`)
139
+ }
140
+
141
+ // Canonical URL (for homepage)
142
+ if (baseUrl) {
143
+ tags.push(`<link rel="canonical" href="${baseUrl}/">`)
144
+ }
145
+
146
+ // Open Graph
147
+ tags.push(`<meta property="og:type" content="website">`)
148
+ if (siteName) {
149
+ tags.push(`<meta property="og:site_name" content="${escapeHtml(siteName)}">`)
150
+ tags.push(`<meta property="og:title" content="${escapeHtml(siteName)}">`)
151
+ }
152
+ if (siteDescription) {
153
+ tags.push(`<meta property="og:description" content="${escapeHtml(siteDescription)}">`)
154
+ }
155
+ if (baseUrl) {
156
+ tags.push(`<meta property="og:url" content="${baseUrl}/">`)
157
+ }
158
+ if (ogImage) {
159
+ const imageUrl = ogImage.startsWith('http') ? ogImage : `${baseUrl}${ogImage}`
160
+ tags.push(`<meta property="og:image" content="${imageUrl}">`)
161
+ }
162
+
163
+ // Twitter Card
164
+ tags.push(`<meta name="twitter:card" content="summary_large_image">`)
165
+ if (twitterHandle) {
166
+ tags.push(`<meta name="twitter:site" content="${twitterHandle}">`)
167
+ }
168
+ if (siteName) {
169
+ tags.push(`<meta name="twitter:title" content="${escapeHtml(siteName)}">`)
170
+ }
171
+ if (siteDescription) {
172
+ tags.push(`<meta name="twitter:description" content="${escapeHtml(siteDescription)}">`)
173
+ }
174
+ if (ogImage) {
175
+ const imageUrl = ogImage.startsWith('http') ? ogImage : `${baseUrl}${ogImage}`
176
+ tags.push(`<meta name="twitter:image" content="${imageUrl}">`)
177
+ }
178
+
179
+ // Hreflang for multi-locale sites
180
+ if (baseUrl && locales.length > 1) {
181
+ for (const locale of locales) {
182
+ const href = locale.default ? `${baseUrl}/` : `${baseUrl}/${locale.code}/`
183
+ tags.push(`<link rel="alternate" hreflang="${locale.code}" href="${href}">`)
184
+ }
185
+ tags.push(`<link rel="alternate" hreflang="x-default" href="${baseUrl}/">`)
186
+ }
187
+
188
+ return tags.join('\n ')
189
+ }
190
+
191
+ /**
192
+ * Escape HTML special characters
193
+ */
194
+ function escapeHtml(str) {
195
+ return str
196
+ .replace(/&/g, '&amp;')
197
+ .replace(/</g, '&lt;')
198
+ .replace(/>/g, '&gt;')
199
+ .replace(/"/g, '&quot;')
200
+ }
201
+
202
+ /**
203
+ * Create the site content plugin
204
+ *
205
+ * @param {Object} options
206
+ * @param {string} [options.sitePath='./'] - Path to site directory
207
+ * @param {string} [options.pagesDir='pages'] - Pages directory name
208
+ * @param {string} [options.variableName='__SITE_CONTENT__'] - Script ID for injected content
209
+ * @param {boolean} [options.inject=true] - Inject content into HTML
210
+ * @param {string} [options.filename='site-content.json'] - Output filename
211
+ * @param {boolean} [options.watch=true] - Watch for changes in dev mode
212
+ * @param {Object} [options.seo] - SEO configuration
213
+ * @param {string} [options.seo.baseUrl] - Base URL for sitemap and canonical URLs
214
+ * @param {string} [options.seo.defaultImage] - Default OG image path
215
+ * @param {string} [options.seo.twitterHandle] - Twitter handle for cards
216
+ * @param {Array} [options.seo.locales] - Locales for hreflang [{code: 'en', default: true}, {code: 'es'}]
217
+ * @param {Object} [options.seo.robots] - robots.txt configuration
218
+ * @param {Array} [options.seo.robots.disallow] - Paths to disallow
219
+ * @param {Array} [options.seo.robots.allow] - Paths to explicitly allow
220
+ * @param {number} [options.seo.robots.crawlDelay] - Crawl delay in seconds
221
+ * @param {Object} [options.assets] - Asset processing configuration
222
+ * @param {boolean} [options.assets.process=true] - Process assets in production builds
223
+ * @param {boolean} [options.assets.convertToWebp=true] - Convert images to WebP
224
+ * @param {number} [options.assets.quality=80] - WebP quality (1-100)
225
+ * @param {string} [options.assets.outputDir='assets'] - Output subdirectory for processed assets
226
+ * @param {boolean} [options.assets.videoPosters=true] - Extract poster frames from videos (requires ffmpeg)
227
+ * @param {boolean} [options.assets.pdfThumbnails=true] - Generate thumbnails for PDFs (requires pdf-lib)
228
+ */
229
+ export function siteContentPlugin(options = {}) {
230
+ const {
231
+ sitePath = './',
232
+ pagesDir = 'pages',
233
+ variableName = '__SITE_CONTENT__',
234
+ inject = true,
235
+ filename = 'site-content.json',
236
+ watch: shouldWatch = true,
237
+ seo = {},
238
+ assets: assetsConfig = {}
239
+ } = options
240
+
241
+ // Extract asset processing options
242
+ const assetsOptions = {
243
+ process: assetsConfig.process !== false, // Default true
244
+ convertToWebp: assetsConfig.convertToWebp !== false, // Default true
245
+ quality: assetsConfig.quality || 80,
246
+ outputDir: assetsConfig.outputDir || 'assets',
247
+ videoPosters: assetsConfig.videoPosters !== false, // Default true
248
+ pdfThumbnails: assetsConfig.pdfThumbnails !== false // Default true
249
+ }
250
+
251
+ // Extract SEO options with defaults
252
+ const seoEnabled = !!seo.baseUrl
253
+ const seoOptions = {
254
+ baseUrl: seo.baseUrl?.replace(/\/$/, '') || '', // Remove trailing slash
255
+ defaultImage: seo.defaultImage || null,
256
+ twitterHandle: seo.twitterHandle || null,
257
+ locales: seo.locales || [],
258
+ robots: seo.robots || {}
259
+ }
260
+
261
+ let siteContent = null
262
+ let resolvedSitePath = null
263
+ let resolvedOutDir = null
264
+ let isProduction = false
265
+ let watcher = null
266
+ let server = null
267
+
268
+ return {
269
+ name: 'uniweb:site-content',
270
+
271
+ configResolved(config) {
272
+ resolvedSitePath = resolve(config.root, sitePath)
273
+ resolvedOutDir = resolve(config.root, config.build.outDir)
274
+ isProduction = config.command === 'build'
275
+ },
276
+
277
+ async buildStart() {
278
+ // Collect content at build start
279
+ try {
280
+ siteContent = await collectSiteContent(resolvedSitePath)
281
+ console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
282
+ } catch (err) {
283
+ console.error('[site-content] Failed to collect content:', err.message)
284
+ siteContent = { config: {}, theme: {}, pages: [] }
285
+ }
286
+ },
287
+
288
+ configureServer(devServer) {
289
+ server = devServer
290
+
291
+ // Watch for content changes in dev mode
292
+ if (shouldWatch) {
293
+ const watchPath = resolve(resolvedSitePath, pagesDir)
294
+
295
+ // Debounce rebuilds
296
+ let rebuildTimeout = null
297
+ const scheduleRebuild = () => {
298
+ if (rebuildTimeout) clearTimeout(rebuildTimeout)
299
+ rebuildTimeout = setTimeout(async () => {
300
+ console.log('[site-content] Content changed, rebuilding...')
301
+ try {
302
+ siteContent = await collectSiteContent(resolvedSitePath)
303
+ console.log(`[site-content] Rebuilt ${siteContent.pages?.length || 0} pages`)
304
+
305
+ // Send full reload to client
306
+ server.ws.send({ type: 'full-reload' })
307
+ } catch (err) {
308
+ console.error('[site-content] Rebuild failed:', err.message)
309
+ }
310
+ }, 100)
311
+ }
312
+
313
+ try {
314
+ watcher = watch(watchPath, { recursive: true }, scheduleRebuild)
315
+ console.log(`[site-content] Watching ${watchPath}`)
316
+ } catch (err) {
317
+ console.warn('[site-content] Could not watch pages directory:', err.message)
318
+ }
319
+ }
320
+
321
+ // Serve content and SEO files
322
+ devServer.middlewares.use((req, res, next) => {
323
+ if (req.url === `/${filename}`) {
324
+ res.setHeader('Content-Type', 'application/json')
325
+ res.end(JSON.stringify(siteContent, null, 2))
326
+ return
327
+ }
328
+
329
+ // Serve sitemap.xml in dev mode
330
+ if (req.url === '/sitemap.xml' && seoEnabled && siteContent?.pages) {
331
+ res.setHeader('Content-Type', 'application/xml')
332
+ res.end(generateSitemap(siteContent.pages, seoOptions.baseUrl, seoOptions.locales))
333
+ return
334
+ }
335
+
336
+ // Serve robots.txt in dev mode
337
+ if (req.url === '/robots.txt' && seoEnabled) {
338
+ res.setHeader('Content-Type', 'text/plain')
339
+ res.end(generateRobotsTxt(seoOptions.baseUrl, seoOptions.robots))
340
+ return
341
+ }
342
+
343
+ next()
344
+ })
345
+ },
346
+
347
+ transformIndexHtml(html) {
348
+ if (!siteContent) return html
349
+
350
+ let headInjection = ''
351
+
352
+ // Inject SEO meta tags
353
+ if (seoEnabled) {
354
+ const metaTags = generateMetaTags(siteContent, seoOptions)
355
+ if (metaTags) {
356
+ headInjection += ` ${metaTags}\n`
357
+ }
358
+ }
359
+
360
+ // Inject content as JSON script tag
361
+ if (inject) {
362
+ headInjection += ` <script type="application/json" id="${variableName}">${JSON.stringify(siteContent)}</script>\n`
363
+ }
364
+
365
+ if (!headInjection) return html
366
+
367
+ // Insert before </head>
368
+ return html.replace('</head>', headInjection + ' </head>')
369
+ },
370
+
371
+ async generateBundle() {
372
+ let finalContent = siteContent
373
+
374
+ // Process assets in production builds
375
+ if (isProduction && assetsOptions.process && siteContent?.assets) {
376
+ const assetCount = Object.keys(siteContent.assets).length
377
+
378
+ if (assetCount > 0) {
379
+ console.log(`[site-content] Processing ${assetCount} assets...`)
380
+
381
+ // Process standard assets (images)
382
+ const { pathMapping, results } = await processAssets(siteContent.assets, {
383
+ outputDir: resolvedOutDir,
384
+ assetsSubdir: assetsOptions.outputDir,
385
+ convertToWebp: assetsOptions.convertToWebp,
386
+ quality: assetsOptions.quality
387
+ })
388
+
389
+ // Process advanced assets (videos, PDFs)
390
+ const advancedEnabled = assetsOptions.videoPosters || assetsOptions.pdfThumbnails
391
+ let advancedResults = null
392
+
393
+ if (advancedEnabled) {
394
+ const { posterMapping, thumbnailMapping, results: advResults } = await processAdvancedAssets(
395
+ siteContent.assets,
396
+ {
397
+ outputDir: resolvedOutDir,
398
+ assetsSubdir: assetsOptions.outputDir,
399
+ videoPosters: assetsOptions.videoPosters,
400
+ pdfThumbnails: assetsOptions.pdfThumbnails,
401
+ quality: assetsOptions.quality,
402
+ // Pass explicit poster/preview sets to skip auto-generation
403
+ hasExplicitPoster: siteContent.hasExplicitPoster || new Set(),
404
+ hasExplicitPreview: siteContent.hasExplicitPreview || new Set()
405
+ }
406
+ )
407
+ advancedResults = advResults
408
+
409
+ // Log advanced processing results
410
+ if (advResults.videos.processed > 0) {
411
+ console.log(`[site-content] Extracted ${advResults.videos.processed} video posters`)
412
+ }
413
+ if (advResults.videos.explicit > 0) {
414
+ console.log(`[site-content] Skipped ${advResults.videos.explicit} videos with explicit posters`)
415
+ }
416
+ if (advResults.pdfs.processed > 0) {
417
+ console.log(`[site-content] Generated ${advResults.pdfs.processed} PDF thumbnails`)
418
+ }
419
+ if (advResults.pdfs.explicit > 0) {
420
+ console.log(`[site-content] Skipped ${advResults.pdfs.explicit} PDFs with explicit previews`)
421
+ }
422
+
423
+ // Merge poster and thumbnail mappings into the path mapping
424
+ // Videos: add a .poster property to the content (not replace the src)
425
+ // PDFs: add a .thumbnail property to the content
426
+ finalContent = rewriteSiteContentPaths(siteContent, pathMapping)
427
+
428
+ // Add poster and thumbnail metadata to the site content
429
+ if (Object.keys(posterMapping).length > 0 || Object.keys(thumbnailMapping).length > 0) {
430
+ finalContent._assetMeta = {
431
+ posters: posterMapping,
432
+ thumbnails: thumbnailMapping
433
+ }
434
+ }
435
+ } else {
436
+ // Rewrite paths in content
437
+ finalContent = rewriteSiteContentPaths(siteContent, pathMapping)
438
+ }
439
+
440
+ // Log results
441
+ const sizeKB = (results.totalSize / 1024).toFixed(1)
442
+ console.log(`[site-content] Processed ${results.processed} assets (${results.converted} converted to WebP, ${sizeKB}KB total)`)
443
+
444
+ if (results.failed > 0) {
445
+ console.warn(`[site-content] ${results.failed} assets failed to process`)
446
+ }
447
+ }
448
+ } else {
449
+ // In dev or when processing is disabled, just remove the assets manifest
450
+ finalContent = { ...siteContent }
451
+ delete finalContent.assets
452
+ }
453
+
454
+ // Clean up internal properties that shouldn't be in the output (Sets don't serialize)
455
+ delete finalContent.hasExplicitPoster
456
+ delete finalContent.hasExplicitPreview
457
+
458
+ // Emit content as JSON file in production build
459
+ this.emitFile({
460
+ type: 'asset',
461
+ fileName: filename,
462
+ source: JSON.stringify(finalContent, null, 2)
463
+ })
464
+
465
+ // Generate SEO files if enabled
466
+ if (seoEnabled && finalContent?.pages) {
467
+ // Generate sitemap.xml
468
+ const sitemap = generateSitemap(finalContent.pages, seoOptions.baseUrl, seoOptions.locales)
469
+ this.emitFile({
470
+ type: 'asset',
471
+ fileName: 'sitemap.xml',
472
+ source: sitemap
473
+ })
474
+ console.log('[site-content] Generated sitemap.xml')
475
+
476
+ // Generate robots.txt
477
+ const robotsTxt = generateRobotsTxt(seoOptions.baseUrl, seoOptions.robots)
478
+ this.emitFile({
479
+ type: 'asset',
480
+ fileName: 'robots.txt',
481
+ source: robotsTxt
482
+ })
483
+ console.log('[site-content] Generated robots.txt')
484
+ }
485
+ },
486
+
487
+ closeBundle() {
488
+ // Clean up watcher
489
+ if (watcher) {
490
+ watcher.close()
491
+ watcher = null
492
+ }
493
+ }
494
+ }
495
+ }
496
+
497
+ export default siteContentPlugin
@@ -67,8 +67,12 @@ export function foundationBuildPlugin(options = {}) {
67
67
  },
68
68
 
69
69
  async writeBundle() {
70
- // After bundle is written, generate schema.json
70
+ // After bundle is written, generate schema.json in meta folder
71
71
  const outDir = resolve(resolvedOutDir)
72
+ const metaDir = join(outDir, 'meta')
73
+
74
+ // Ensure meta directory exists
75
+ await mkdir(metaDir, { recursive: true })
72
76
 
73
77
  const schema = await buildSchemaWithPreviews(
74
78
  resolvedSrcDir,
@@ -76,10 +80,10 @@ export function foundationBuildPlugin(options = {}) {
76
80
  isProduction
77
81
  )
78
82
 
79
- const schemaPath = join(outDir, 'schema.json')
83
+ const schemaPath = join(metaDir, 'schema.json')
80
84
  await writeFile(schemaPath, JSON.stringify(schema, null, 2), 'utf-8')
81
85
 
82
- console.log(`Generated schema.json with ${Object.keys(schema).length - 1} components`)
86
+ console.log(`Generated meta/schema.json with ${Object.keys(schema).length - 1} components`)
83
87
  },
84
88
  }
85
89
  }