Fork of Chiri for Astro for my blog
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 13f0e403e527bf110a9effe0be589c9bfda2f62a 188 lines 6.6 kB view raw
1import type { APIContext, ImageMetadata } from 'astro' 2import { getImage } from 'astro:assets' 3import { getCollection, type CollectionEntry } from 'astro:content' 4import { Feed } from 'feed' 5import MarkdownIt from 'markdown-it' 6import { parse as htmlParser } from 'node-html-parser' 7import sanitizeHtml from 'sanitize-html' 8import { themeConfig } from '@/config' 9import path from 'node:path' 10 11const markdownParser = new MarkdownIt({ 12 html: true, 13 linkify: true, 14 typographer: true 15}) 16 17const imagesGlob = import.meta.glob<{ default: ImageMetadata }>( 18 '/src/content/posts/_assets/**/*.{jpeg,jpg,png,gif,webp}' 19) 20 21/** 22 * Fix relative image paths in HTML content and convert them to absolute URLs 23 * @param htmlContent - HTML string converted from Markdown 24 * @param baseUrl - Base URL of the website 25 * @param postPath - Current post path (e.g., 'some-post.md' or 'tech/another-post.md') 26 * @returns - HTML string with processed image paths 27 */ 28async function fixRelativeImagePaths(htmlContent: string, baseUrl: string, postPath: string): Promise<string> { 29 const root = htmlParser(htmlContent) 30 const imageTags = root.querySelectorAll('img') 31 const postDir = path.dirname(postPath) 32 33 for (const img of imageTags) { 34 const src = img.getAttribute('src') 35 if (!src) continue 36 37 if (/^(https?:\/\/|\/\/)/.test(src)) { 38 continue 39 } 40 41 if (src.startsWith('./') || src.startsWith('../')) { 42 // Build path relative to /src/content/posts 43 let resolvedPath: string 44 if (src.startsWith('./')) { 45 // ./xxx -> postDir/xxx 46 resolvedPath = path.posix.join('/src/content/posts', postDir, src.slice(2)) 47 } else { 48 // ../xxx -> Resolve to parent directory 49 resolvedPath = path.posix.resolve('/src/content/posts', postDir, src) 50 } 51 52 // Check if corresponding image module exists 53 if (imagesGlob[resolvedPath]) { 54 try { 55 const imageModule = await imagesGlob[resolvedPath]() 56 const metadata = imageModule.default 57 58 // In development environment, don't process images, use original paths to ensure cross-platform compatibility 59 if (import.meta.env.DEV) { 60 // Development environment: use relative paths 61 const relativePath = resolvedPath.replace('/src/content/posts/', '/') 62 const imageUrl = new URL(relativePath, baseUrl).toString() 63 img.setAttribute('src', imageUrl) 64 } else { 65 // Production environment: use getImage optimization 66 const processedImage = await getImage({ 67 src: metadata, 68 format: 'webp', 69 width: 800 70 }) 71 72 // Always use the optimized image path in production 73 img.setAttribute('src', new URL(processedImage.src, baseUrl).toString()) 74 } 75 } catch (error) { 76 console.error(`[Feed] Image processing failed: ${src} -> ${resolvedPath}`, error) 77 // Use original path as fallback when error occurs 78 const relativePath = resolvedPath.replace('/src/content/posts/', '/') 79 const imageUrl = new URL(relativePath, baseUrl).toString() 80 img.setAttribute('src', imageUrl) 81 } 82 } else { 83 console.warn(`[Feed] Image module not found: ${resolvedPath}`) 84 console.warn(`[Feed] Available image modules:`, Object.keys(imagesGlob)) 85 } 86 } else if (src.startsWith('/')) { 87 img.setAttribute('src', new URL(src, baseUrl).toString()) 88 } 89 } 90 91 return root.toString() 92} 93 94/** 95 * Generate a generic Feed instance 96 */ 97async function generateFeedInstance(context: APIContext) { 98 const siteUrl = (context.site?.toString() || themeConfig.site.website).replace(/\/$/, '') 99 const { title = '', description = '', author = '', language = 'en-US' } = themeConfig.site 100 101 const feed = new Feed({ 102 title: title, 103 description: description, 104 id: siteUrl, 105 link: siteUrl, 106 language: language, 107 copyright: `Copyright © ${new Date().getFullYear()} ${author}`, 108 updated: new Date(), 109 generator: 'Astro Chiri Feed Generator', 110 feedLinks: { 111 rss: `${siteUrl}/rss.xml`, 112 atom: `${siteUrl}/atom.xml` 113 }, 114 author: { 115 name: author, 116 link: siteUrl 117 } 118 }) 119 120 const posts = await getCollection('posts', ({ id }: CollectionEntry<'posts'>) => !id.startsWith('_')) 121 const sortedPosts = posts.sort( 122 (a: CollectionEntry<'posts'>, b: CollectionEntry<'posts'>) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf() 123 ) 124 125 for (const post of sortedPosts) { 126 const postSlug = post.id.replace(/\.[^/.]+$/, '') 127 const postUrl = new URL(postSlug, siteUrl).toString() 128 const rawHtml = markdownParser.render(post.body || '') 129 const processedHtml = await fixRelativeImagePaths(rawHtml, siteUrl, post.id) 130 const cleanHtml = sanitizeHtml(processedHtml, { 131 allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'div', 'span']), 132 allowedAttributes: { 133 ...sanitizeHtml.defaults.allowedAttributes, 134 '*': ['class', 'id'], 135 a: ['href', 'title', 'target', 'rel'], 136 img: ['src', 'alt', 'title', 'width', 'height'] 137 } 138 }) 139 140 // Generate plain text summary for description 141 const plainText = sanitizeHtml(cleanHtml, { allowedTags: [], allowedAttributes: {} }).replace(/\s+/g, ' ').trim() 142 const description = plainText.length > 200 ? plainText.slice(0, 200) + '...' : plainText 143 144 feed.addItem({ 145 title: post.data.title, 146 id: postUrl, 147 link: postUrl, 148 description: description, 149 content: cleanHtml, 150 date: post.data.pubDate, 151 published: post.data.pubDate 152 }) 153 } 154 155 return feed 156} 157 158/** 159 * Generate RSS 2.0 feed 160 */ 161export async function generateRSS(context: APIContext) { 162 const feed = await generateFeedInstance(context) 163 const rssXml = feed 164 .rss2() 165 .replace( 166 '<?xml version="1.0" encoding="utf-8"?>', 167 '<?xml version="1.0" encoding="utf-8"?>\n<?xml-stylesheet type="text/xsl" href="/feeds/rss-style.xsl"?>' 168 ) 169 return new Response(rssXml, { 170 headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } 171 }) 172} 173 174/** 175 * Generate Atom 1.0 feed 176 */ 177export async function generateAtom(context: APIContext) { 178 const feed = await generateFeedInstance(context) 179 const atomXml = feed 180 .atom1() 181 .replace( 182 '<?xml version="1.0" encoding="utf-8"?>', 183 '<?xml version="1.0" encoding="utf-8"?>\n<?xml-stylesheet type="text/xsl" href="/feeds/atom-style.xsl"?>' 184 ) 185 return new Response(atomXml, { 186 headers: { 'Content-Type': 'application/atom+xml; charset=utf-8' } 187 }) 188}