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