@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,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, '&')
|
|
84
|
+
.replace(/</g, '<')
|
|
85
|
+
.replace(/>/g, '>')
|
|
86
|
+
.replace(/"/g, '"')
|
|
87
|
+
.replace(/'/g, ''')
|
|
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, '&')
|
|
197
|
+
.replace(/</g, '<')
|
|
198
|
+
.replace(/>/g, '>')
|
|
199
|
+
.replace(/"/g, '"')
|
|
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(
|
|
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
|
}
|