mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat: add feed sitemap generation plugin and update site URL in config
- Introduced a new Vite plugin for generating RSS feeds and sitemaps based on photo data. - Updated the example configuration file to reflect the new site URL format. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
1
apps/web/src/global.d.ts
vendored
1
apps/web/src/global.d.ts
vendored
@@ -25,6 +25,7 @@ declare global {
|
||||
|
||||
const APP_NAME: string
|
||||
const BUILT_DATE: string
|
||||
const GIT_COMMIT_HASH: string
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { siteConfig } from '@config'
|
||||
import { photoLoader } from '@photo-gallery/data'
|
||||
import { repository } from '@pkg'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -50,18 +48,6 @@ export const ActionGroup = () => {
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{siteConfig.extra?.accessRepo && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-10 w-10 rounded-full border-0 bg-gray-100 transition-all duration-200 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
onClick={() => window.open(repository.url, '_blank')}
|
||||
title={t('action.view.github')}
|
||||
>
|
||||
<i className="i-mingcute-github-line text-base text-gray-600 dark:text-gray-300" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 标签筛选按钮 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { siteConfig } from '@config'
|
||||
import { photoLoader } from '@photo-gallery/data'
|
||||
import { repository } from '@pkg'
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -58,6 +59,34 @@ export const MasonryHeaderMasonryItem = ({
|
||||
{siteConfig.name}
|
||||
</h2>
|
||||
|
||||
{/* Social media links */}
|
||||
{siteConfig.social && (
|
||||
<div className="mt-1 mb-3 flex items-center justify-center gap-3">
|
||||
{siteConfig.social.github && (
|
||||
<a
|
||||
href={`https://github.com/${siteConfig.social.github}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-text-secondary flex items-center justify-center p-2 duration-200 hover:text-[#E7E8E8]"
|
||||
title="GitHub"
|
||||
>
|
||||
<i className="i-mingcute-github-fill text-sm" />
|
||||
</a>
|
||||
)}
|
||||
{siteConfig.social.twitter && (
|
||||
<a
|
||||
href={`https://twitter.com/${siteConfig.social.twitter.replace('@', '')}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-text-secondary flex items-center justify-center p-2 duration-200 hover:text-[#1da1f2]"
|
||||
title="Twitter"
|
||||
>
|
||||
<i className="i-mingcute-twitter-fill text-sm" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('gallery.photos', { count: data?.length || 0 })}
|
||||
</p>
|
||||
@@ -79,6 +108,18 @@ export const MasonryHeaderMasonryItem = ({
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
<span className="ml-1">
|
||||
(
|
||||
<a
|
||||
href={`${repository.url}/commit/${GIT_COMMIT_HASH}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{GIT_COMMIT_HASH.slice(0, 6)}
|
||||
</a>
|
||||
)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import { rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
@@ -13,6 +14,7 @@ import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
import PKG from '../../package.json'
|
||||
import { ogImagePlugin } from '../../plugins/og-image-plugin'
|
||||
import { createDependencyChunksPlugin } from '../../plugins/vite/deps'
|
||||
import { createFeedSitemapPlugin } from '../../plugins/vite/feed-sitemap'
|
||||
import { siteConfig } from '../../site.config'
|
||||
|
||||
if (process.env.CI) {
|
||||
@@ -56,6 +58,7 @@ export default defineConfig({
|
||||
siteName: siteConfig.name,
|
||||
siteUrl: siteConfig.url,
|
||||
}),
|
||||
createFeedSitemapPlugin(siteConfig),
|
||||
createHtmlPlugin({
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
@@ -81,5 +84,15 @@ export default defineConfig({
|
||||
APP_DEV_CWD: JSON.stringify(process.cwd()),
|
||||
APP_NAME: JSON.stringify(PKG.name),
|
||||
BUILT_DATE: JSON.stringify(new Date().toLocaleDateString()),
|
||||
GIT_COMMIT_HASH: JSON.stringify(getGitHash()),
|
||||
},
|
||||
})
|
||||
|
||||
function getGitHash() {
|
||||
try {
|
||||
return execSync('git rev-parse HEAD').toString().trim()
|
||||
} catch (e) {
|
||||
console.error('Failed to get git hash', e)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "Innei's Photo Gallery",
|
||||
"title": "Innei's Photo Gallery",
|
||||
"description": "Capturing beautiful moments in life, documenting daily warmth and emotions through my lens.",
|
||||
"url": "https://gallery.innei.in",
|
||||
"url": "https://afilmory.innei.in/",
|
||||
"accentColor": "#007bff",
|
||||
"author": {
|
||||
"name": "Photo Gallery",
|
||||
@@ -10,9 +10,7 @@
|
||||
"avatar": "//cdn.jsdelivr.net/gh/Innei/static@master/avatar.png"
|
||||
},
|
||||
"social": {
|
||||
"twitter": "@__oQuery"
|
||||
},
|
||||
"extra": {
|
||||
"accessRepo": true
|
||||
"twitter": "@__oQuery",
|
||||
"github": "Innei"
|
||||
}
|
||||
}
|
||||
188
plugins/vite/feed-sitemap.ts
Normal file
188
plugins/vite/feed-sitemap.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
interface PhotoData {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
dateTaken: string
|
||||
views: number
|
||||
tags: string[]
|
||||
originalUrl: string
|
||||
thumbnailUrl: string
|
||||
blurhash: string
|
||||
width: number
|
||||
height: number
|
||||
aspectRatio: number
|
||||
s3Key: string
|
||||
lastModified: string
|
||||
size: number
|
||||
exif?: any
|
||||
isLivePhoto: boolean
|
||||
}
|
||||
|
||||
interface SiteConfig {
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
url: string
|
||||
author: {
|
||||
name: string
|
||||
url: string
|
||||
avatar?: string
|
||||
}
|
||||
}
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
export function createFeedSitemapPlugin(siteConfig: SiteConfig): Plugin {
|
||||
return {
|
||||
name: 'feed-sitemap-generator',
|
||||
apply: 'build',
|
||||
generateBundle() {
|
||||
try {
|
||||
// Read photos manifest
|
||||
const manifestPath = resolve(
|
||||
__dirname,
|
||||
'../../packages/data/src/photos-manifest.json',
|
||||
)
|
||||
const photosData: PhotoData[] = JSON.parse(
|
||||
readFileSync(manifestPath, 'utf-8'),
|
||||
)
|
||||
|
||||
// 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: PhotoData[], 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}`
|
||||
}
|
||||
|
||||
return ` <item>
|
||||
<title><![CDATA[${escapeXml(photo.title)}]]></title>
|
||||
<link>${photoUrl}</link>
|
||||
<guid isPermaLink="true">${photoUrl}</guid>
|
||||
<description><![CDATA[${escapeXml(description)}]]></description>
|
||||
<pubDate>${pubDate}</pubDate>
|
||||
${photo.tags.map((tag) => `<category><![CDATA[${escapeXml(tag)}]]></category>`).join('\n ')}
|
||||
<enclosure url="${photo.thumbnailUrl.startsWith('http') ? photo.thumbnailUrl : config.url + photo.thumbnailUrl}" type="image/webp" length="${photo.size}" />
|
||||
</item>`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title><![CDATA[${escapeXml(config.title)}]]></title>
|
||||
<link>${config.url}</link>
|
||||
<description><![CDATA[${escapeXml(config.description)}]]></description>
|
||||
<language>zh-CN</language>
|
||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||
<pubDate>${now}</pubDate>
|
||||
<ttl>60</ttl>
|
||||
<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[${escapeXml(config.title)}]]></title>
|
||||
<link>${config.url}</link>
|
||||
</image>
|
||||
${rssItems}
|
||||
</channel>
|
||||
</rss>`
|
||||
}
|
||||
|
||||
function generateSitemap(photos: PhotoData[], 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>`
|
||||
}
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
@@ -10,7 +10,6 @@ interface SiteConfig {
|
||||
accentColor: string
|
||||
author: Author
|
||||
social?: Social
|
||||
extra?: Extra
|
||||
}
|
||||
interface Author {
|
||||
name: string
|
||||
@@ -19,9 +18,7 @@ interface Author {
|
||||
}
|
||||
interface Social {
|
||||
twitter: string
|
||||
}
|
||||
interface Extra {
|
||||
accessRepo: boolean
|
||||
github: string
|
||||
}
|
||||
|
||||
const defaultConfig: SiteConfig = {
|
||||
@@ -38,6 +35,7 @@ const defaultConfig: SiteConfig = {
|
||||
},
|
||||
social: {
|
||||
twitter: '@__oQuery',
|
||||
github: 'Innei',
|
||||
},
|
||||
}
|
||||
export const siteConfig: SiteConfig = merge(defaultConfig, userConfig) as any
|
||||
|
||||
Reference in New Issue
Block a user