forked from
quillmatiq.com/augment
Fork of Chiri for Astro for my blog
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}