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(
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}