mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
- Introduced multiple Vite plugins including `ogImagePlugin`, `feedSitemapPlugin`, `localesJsonPlugin`, and `manifestInjectPlugin` to enhance the build process and support dynamic content generation. - Implemented a custom hot module replacement (HMR) for JSON localization files to improve development experience. - Added a new `createDependencyChunksPlugin` for better chunk management in the build process. - Established a structure for handling localization files and generating corresponding assets, improving internationalization support. Signed-off-by: Innei <tukon479@gmail.com>
439 lines
13 KiB
TypeScript
439 lines
13 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import type { PhotoManifestItem } from '@afilmory/builder'
|
|
import type { Plugin } from 'vite'
|
|
|
|
import type { SiteConfig } from '../../../../site.config'
|
|
import { MANIFEST_PATH } from './__internal__/constants'
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
|
|
export function createFeedSitemapPlugin(siteConfig: SiteConfig): Plugin {
|
|
return {
|
|
name: 'feed-sitemap-generator',
|
|
apply: 'build',
|
|
generateBundle() {
|
|
try {
|
|
const photosData: PhotoManifestItem[] = JSON.parse(
|
|
readFileSync(MANIFEST_PATH, 'utf-8'),
|
|
).data
|
|
|
|
// Sort photos by date taken (newest first)
|
|
const sortedPhotos = photosData.sort(
|
|
(a, b) =>
|
|
new Date(b.dateTaken).getTime() - new Date(a.dateTaken).getTime(),
|
|
)
|
|
|
|
// Generate RSS feed
|
|
const rssXml = generateRSSFeed(sortedPhotos, siteConfig)
|
|
|
|
// Generate sitemap
|
|
const sitemapXml = generateSitemap(sortedPhotos, siteConfig)
|
|
|
|
// Emit RSS feed
|
|
this.emitFile({
|
|
type: 'asset',
|
|
fileName: 'feed.xml',
|
|
source: rssXml,
|
|
})
|
|
|
|
// Emit sitemap
|
|
this.emitFile({
|
|
type: 'asset',
|
|
fileName: 'sitemap.xml',
|
|
source: sitemapXml,
|
|
})
|
|
|
|
console.info(`Generated RSS feed with ${sortedPhotos.length} photos`)
|
|
console.info(`Generated sitemap with ${sortedPhotos.length + 1} URLs`)
|
|
} catch (error) {
|
|
console.error('Error generating RSS feed and sitemap:', error)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
function generateRSSFeed(
|
|
photos: PhotoManifestItem[],
|
|
config: SiteConfig,
|
|
): string {
|
|
const now = new Date().toUTCString()
|
|
const latestPhoto = photos[0]
|
|
const lastBuildDate = latestPhoto
|
|
? new Date(latestPhoto.dateTaken).toUTCString()
|
|
: now
|
|
|
|
// Take latest 20 photos for RSS feed
|
|
const recentPhotos = photos.slice(0, 20)
|
|
|
|
const rssItems = recentPhotos
|
|
.map((photo) => {
|
|
const photoUrl = `${config.url}/${photo.id}`
|
|
const pubDate = new Date(photo.dateTaken).toUTCString()
|
|
const tags = photo.tags.join(', ')
|
|
|
|
let description = photo.description || photo.title
|
|
if (tags) {
|
|
description += ` | Tags: ${tags}`
|
|
}
|
|
|
|
// Extract EXIF data for custom tags
|
|
const exifTags = generateExifTags(photo.exif, photo)
|
|
|
|
return ` <item>
|
|
<title><![CDATA[${photo.title}]]></title>
|
|
<link>${photoUrl}</link>
|
|
<guid isPermaLink="true">${photoUrl}</guid>
|
|
<description><![CDATA[${description}]]></description>
|
|
<pubDate>${pubDate}</pubDate>
|
|
${photo.tags.map((tag) => `<category><![CDATA[${tag}]]></category>`).join('\n ')}
|
|
<enclosure url="${photo.thumbnailUrl.startsWith('http') ? photo.thumbnailUrl : config.url + photo.thumbnailUrl}" type="image/webp" length="${photo.size}" />
|
|
${exifTags}
|
|
</item>`
|
|
})
|
|
.join('\n')
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:exif="https://afilmory.com/rss/exif/1.1">
|
|
<channel>
|
|
<title><![CDATA[${config.title}]]></title>
|
|
<link>${config.url}</link>
|
|
<description><![CDATA[${config.description}]]></description>
|
|
<language>zh-CN</language>
|
|
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
|
<pubDate>${now}</pubDate>
|
|
<ttl>60</ttl>
|
|
<copyright>Copyright ${config.author.name}</copyright>
|
|
|
|
<!-- Afilmory RSS EXIF Extension Protocol Metadata -->
|
|
<exif:version>1.1</exif:version>
|
|
<exif:protocol>afilmory-rss-exif</exif:protocol>
|
|
${
|
|
config.feed?.folo?.challenge
|
|
? `
|
|
<follow_challenge>
|
|
<feedId>${config.feed?.folo?.challenge.feedId}</feedId>
|
|
<userId>${config.feed?.folo?.challenge.userId}</userId>
|
|
</follow_challenge>
|
|
`
|
|
: ''
|
|
}
|
|
<atom:link href="${config.url}/feed.xml" rel="self" type="application/rss+xml" />
|
|
<managingEditor>${config.author.name}</managingEditor>
|
|
<webMaster>${config.author.name}</webMaster>
|
|
<generator>Vite RSS Generator</generator>
|
|
<image>
|
|
<url>${config.author.avatar || `${config.url}/favicon.ico`}</url>
|
|
<title><![CDATA[${config.title}]]></title>
|
|
<link>${config.url}</link>
|
|
</image>
|
|
${rssItems}
|
|
</channel>
|
|
</rss>`
|
|
}
|
|
|
|
function generateExifTags(exif: any, photo: PhotoManifestItem): string {
|
|
if (!exif) {
|
|
return ''
|
|
}
|
|
|
|
const tags: string[] = []
|
|
|
|
// === 基础相机设置参数 (basic) ===
|
|
|
|
// Aperture (光圈)
|
|
if (exif.Photo?.FNumber) {
|
|
tags.push(` <exif:aperture>f/${exif.Photo.FNumber}</exif:aperture>`)
|
|
}
|
|
|
|
// Shutter Speed (快门)
|
|
if (exif.Photo?.ExposureTime) {
|
|
const shutterSpeed =
|
|
exif.Photo.ExposureTime >= 1
|
|
? `${exif.Photo.ExposureTime}s`
|
|
: `1/${Math.round(1 / exif.Photo.ExposureTime)}s`
|
|
tags.push(` <exif:shutterSpeed>${shutterSpeed}</exif:shutterSpeed>`)
|
|
}
|
|
|
|
// ISO
|
|
if (exif.Photo?.ISOSpeedRatings) {
|
|
tags.push(` <exif:iso>${exif.Photo.ISOSpeedRatings}</exif:iso>`)
|
|
}
|
|
|
|
// Exposure Compensation (曝光补偿)
|
|
if (exif.Photo?.ExposureBiasValue !== undefined) {
|
|
const ev = exif.Photo.ExposureBiasValue
|
|
const evString = ev > 0 ? `+${ev}` : `${ev}`
|
|
tags.push(
|
|
` <exif:exposureCompensation>${evString} EV</exif:exposureCompensation>`,
|
|
)
|
|
}
|
|
|
|
// === 图像属性 (basic) ===
|
|
|
|
// Image Dimensions (图片宽度,高度)
|
|
tags.push(
|
|
` <exif:imageWidth>${photo.width}</exif:imageWidth>`,
|
|
` <exif:imageHeight>${photo.height}</exif:imageHeight>`,
|
|
)
|
|
|
|
// Date Taken (拍摄时间) - 转换为 ISO 8601 格式
|
|
if (exif.Photo?.DateTimeOriginal) {
|
|
try {
|
|
// 尝试解析 EXIF 日期格式 (YYYY:MM:DD HH:mm:ss)
|
|
const exifDate = exif.Photo.DateTimeOriginal.replaceAll(':', '-').replace(
|
|
/-(\d{2}:\d{2}:\d{2})/,
|
|
' $1',
|
|
)
|
|
const isoDate = new Date(exifDate).toISOString()
|
|
tags.push(` <exif:dateTaken>${isoDate}</exif:dateTaken>`)
|
|
} catch {
|
|
// 如果解析失败,使用 photo.dateTaken
|
|
const isoDate = new Date(photo.dateTaken).toISOString()
|
|
tags.push(` <exif:dateTaken>${isoDate}</exif:dateTaken>`)
|
|
}
|
|
} else {
|
|
const isoDate = new Date(photo.dateTaken).toISOString()
|
|
tags.push(` <exif:dateTaken>${isoDate}</exif:dateTaken>`)
|
|
}
|
|
|
|
// Camera Model (机型)
|
|
if (exif.Image?.Make && exif.Image?.Model) {
|
|
tags.push(
|
|
` <exif:camera><![CDATA[${exif.Image.Make} ${exif.Image.Model}]]></exif:camera>`,
|
|
)
|
|
}
|
|
|
|
// Orientation (图像方向)
|
|
if (exif.Image?.Orientation) {
|
|
tags.push(
|
|
` <exif:orientation>${exif.Image.Orientation}</exif:orientation>`,
|
|
)
|
|
}
|
|
|
|
// === 镜头参数 (lens) ===
|
|
|
|
// Lens Model (镜头)
|
|
if (exif.Photo?.LensModel) {
|
|
tags.push(
|
|
` <exif:lens><![CDATA[${exif.Photo.LensModel}]]></exif:lens>`,
|
|
)
|
|
}
|
|
|
|
// Focal Length (焦段)
|
|
if (exif.Photo?.FocalLength) {
|
|
tags.push(
|
|
` <exif:focalLength>${exif.Photo.FocalLength}mm</exif:focalLength>`,
|
|
)
|
|
}
|
|
|
|
// Focal Length in 35mm equivalent (等效 35mm 焦距)
|
|
if (exif.Photo?.FocalLengthIn35mmFilm) {
|
|
tags.push(
|
|
` <exif:focalLength35mm>${exif.Photo.FocalLengthIn35mmFilm}mm</exif:focalLength35mm>`,
|
|
)
|
|
}
|
|
|
|
// Max Aperture (镜头最大光圈)
|
|
if (exif.Photo?.MaxApertureValue) {
|
|
const maxAperture = Math.pow(2, exif.Photo.MaxApertureValue / 2)
|
|
tags.push(
|
|
` <exif:maxAperture>f/${maxAperture.toFixed(1)}</exif:maxAperture>`,
|
|
)
|
|
}
|
|
|
|
// === 位置信息 (location) ===
|
|
|
|
// GPS Coordinates
|
|
if (exif.GPS?.GPSLatitude && exif.GPS?.GPSLongitude) {
|
|
const lat = convertDMSToDD(exif.GPS.GPSLatitude, exif.GPS.GPSLatitudeRef)
|
|
const lng = convertDMSToDD(exif.GPS.GPSLongitude, exif.GPS.GPSLongitudeRef)
|
|
if (lat !== null && lng !== null) {
|
|
tags.push(
|
|
` <exif:gpsLatitude>${lat}</exif:gpsLatitude>`,
|
|
` <exif:gpsLongitude>${lng}</exif:gpsLongitude>`,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Altitude (海拔)
|
|
if (exif.GPS?.GPSAltitude) {
|
|
const altitude =
|
|
exif.GPS.GPSAltitudeRef === 1
|
|
? -exif.GPS.GPSAltitude
|
|
: exif.GPS.GPSAltitude
|
|
tags.push(` <exif:altitude>${altitude}m</exif:altitude>`)
|
|
}
|
|
|
|
// === 技术参数 (technical) ===
|
|
|
|
// White Balance (白平衡)
|
|
if (exif.Photo?.WhiteBalance !== undefined) {
|
|
const whiteBalanceMap = { 0: 'Auto', 1: 'Manual' }
|
|
const wb =
|
|
whiteBalanceMap[
|
|
exif.Photo.WhiteBalance as keyof typeof whiteBalanceMap
|
|
] || 'Auto'
|
|
tags.push(` <exif:whiteBalance>${wb}</exif:whiteBalance>`)
|
|
}
|
|
|
|
// Metering Mode (测光模式)
|
|
if (exif.Photo?.MeteringMode !== undefined) {
|
|
const meteringModeMap = {
|
|
0: 'Unknown',
|
|
1: 'Average',
|
|
2: 'Center-weighted',
|
|
3: 'Spot',
|
|
4: 'Multi-spot',
|
|
5: 'Pattern',
|
|
6: 'Partial',
|
|
}
|
|
const mode =
|
|
meteringModeMap[exif.Photo.MeteringMode as keyof typeof meteringModeMap]
|
|
if (mode && mode !== 'Unknown') {
|
|
tags.push(` <exif:meteringMode>${mode}</exif:meteringMode>`)
|
|
}
|
|
}
|
|
|
|
// Flash Mode (闪光灯模式)
|
|
if (exif.Photo?.Flash !== undefined) {
|
|
const flashFired = (exif.Photo.Flash & 0x01) !== 0
|
|
const flashMode = flashFired ? 'On' : 'Off'
|
|
tags.push(` <exif:flashMode>${flashMode}</exif:flashMode>`)
|
|
}
|
|
|
|
// Color Space (色彩空间)
|
|
if (exif.Photo?.ColorSpace !== undefined) {
|
|
const colorSpaceMap = { 1: 'sRGB', 65535: 'Uncalibrated' }
|
|
const colorSpace =
|
|
colorSpaceMap[exif.Photo.ColorSpace as keyof typeof colorSpaceMap] ||
|
|
'sRGB'
|
|
tags.push(` <exif:colorSpace>${colorSpace}</exif:colorSpace>`)
|
|
}
|
|
|
|
// === 高级参数 (advanced) ===
|
|
|
|
// Exposure Program (曝光程序)
|
|
if (exif.Photo?.ExposureProgram !== undefined) {
|
|
const exposureProgramMap = {
|
|
0: 'Not defined',
|
|
1: 'Manual',
|
|
2: 'Program',
|
|
3: 'Aperture Priority',
|
|
4: 'Shutter Priority',
|
|
5: 'Creative',
|
|
6: 'Action',
|
|
7: 'Portrait',
|
|
8: 'Landscape',
|
|
}
|
|
const program =
|
|
exposureProgramMap[
|
|
exif.Photo.ExposureProgram as keyof typeof exposureProgramMap
|
|
]
|
|
if (program && program !== 'Not defined') {
|
|
tags.push(` <exif:exposureProgram>${program}</exif:exposureProgram>`)
|
|
}
|
|
}
|
|
|
|
// Scene Mode (场景模式)
|
|
if (exif.Photo?.SceneCaptureType !== undefined) {
|
|
const sceneModeMap = {
|
|
0: 'Standard',
|
|
1: 'Landscape',
|
|
2: 'Portrait',
|
|
3: 'Night',
|
|
}
|
|
const scene =
|
|
sceneModeMap[exif.Photo.SceneCaptureType as keyof typeof sceneModeMap]
|
|
if (scene) {
|
|
tags.push(` <exif:sceneMode><![CDATA[${scene}]]></exif:sceneMode>`)
|
|
}
|
|
}
|
|
|
|
// Contrast (对比度)
|
|
if (exif.Photo?.Contrast !== undefined) {
|
|
const contrastMap = { 0: 'Normal', 1: 'Low', 2: 'High' }
|
|
const contrast =
|
|
contrastMap[exif.Photo.Contrast as keyof typeof contrastMap]
|
|
if (contrast) {
|
|
tags.push(` <exif:contrast>${contrast}</exif:contrast>`)
|
|
}
|
|
}
|
|
|
|
// Saturation (饱和度)
|
|
if (exif.Photo?.Saturation !== undefined) {
|
|
const saturationMap = { 0: 'Normal', 1: 'Low', 2: 'High' }
|
|
const saturation =
|
|
saturationMap[exif.Photo.Saturation as keyof typeof saturationMap]
|
|
if (saturation) {
|
|
tags.push(` <exif:saturation>${saturation}</exif:saturation>`)
|
|
}
|
|
}
|
|
|
|
// Sharpness (锐度)
|
|
if (exif.Photo?.Sharpness !== undefined) {
|
|
const sharpnessMap = { 0: 'Normal', 1: 'Soft', 2: 'Hard' }
|
|
const sharpness =
|
|
sharpnessMap[exif.Photo.Sharpness as keyof typeof sharpnessMap]
|
|
if (sharpness) {
|
|
tags.push(` <exif:sharpness>${sharpness}</exif:sharpness>`)
|
|
}
|
|
}
|
|
|
|
return tags.join('\n')
|
|
}
|
|
|
|
// Helper function to convert DMS (Degrees, Minutes, Seconds) to DD (Decimal Degrees)
|
|
function convertDMSToDD(dms: number[], ref: string): number | null {
|
|
if (!dms || dms.length !== 3) return null
|
|
|
|
const degrees = dms[0]
|
|
const minutes = dms[1]
|
|
const seconds = dms[2]
|
|
|
|
let dd = degrees + minutes / 60 + seconds / 3600
|
|
|
|
if (ref === 'S' || ref === 'W') {
|
|
dd = dd * -1
|
|
}
|
|
|
|
return Math.round(dd * 1000000) / 1000000 // 保留 6 位小数
|
|
}
|
|
|
|
function generateSitemap(
|
|
photos: PhotoManifestItem[],
|
|
config: SiteConfig,
|
|
): string {
|
|
const now = new Date().toISOString()
|
|
|
|
// Main page
|
|
const mainPageXml = ` <url>
|
|
<loc>${config.url}</loc>
|
|
<lastmod>${now}</lastmod>
|
|
<changefreq>daily</changefreq>
|
|
<priority>1.0</priority>
|
|
</url>`
|
|
|
|
// Photo pages
|
|
const photoUrls = photos
|
|
.map((photo) => {
|
|
const lastmod = new Date(
|
|
photo.lastModified || photo.dateTaken,
|
|
).toISOString()
|
|
return ` <url>
|
|
<loc>${config.url}/${photo.id}</loc>
|
|
<lastmod>${lastmod}</lastmod>
|
|
<changefreq>monthly</changefreq>
|
|
<priority>0.8</priority>
|
|
</url>`
|
|
})
|
|
.join('\n')
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
${mainPageXml}
|
|
${photoUrls}
|
|
</urlset>`
|
|
}
|