Fork of Chiri for Astro for my blog
0
fork

Configure Feed

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

chore: adjust RSS image format, add RSS XML styling, fix build error when toggling linkcard

the3ash 8259ffa8 cb94b527

+408 -418
+1
package.json
··· 25 25 "astro": "^5.12.0", 26 26 "astro-og-canvas": "^0.7.0", 27 27 "canvaskit-wasm": "^0.40.0", 28 + "feed": "^5.1.0", 28 29 "katex": "^0.16.22", 29 30 "markdown-it": "^14.1.0", 30 31 "mdast-util-to-string": "^4.0.0",
+19
pnpm-lock.yaml
··· 32 32 canvaskit-wasm: 33 33 specifier: ^0.40.0 34 34 version: 0.40.0 35 + feed: 36 + specifier: ^5.1.0 37 + version: 5.1.0 35 38 katex: 36 39 specifier: ^0.16.22 37 40 version: 0.16.22 ··· 2154 2157 fecha@4.2.3: 2155 2158 resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} 2156 2159 2160 + feed@5.1.0: 2161 + resolution: {integrity: sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg==} 2162 + engines: {node: '>=20', pnpm: '>=10'} 2163 + 2157 2164 fetch-blob@3.2.0: 2158 2165 resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} 2159 2166 engines: {node: ^12.20 || >= 14.13} ··· 4039 4046 resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} 4040 4047 engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} 4041 4048 4049 + xml-js@1.6.11: 4050 + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} 4051 + hasBin: true 4052 + 4042 4053 xss@1.0.15: 4043 4054 resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} 4044 4055 engines: {node: '>= 0.10.0'} ··· 6477 6488 6478 6489 fecha@4.2.3: {} 6479 6490 6491 + feed@5.1.0: 6492 + dependencies: 6493 + xml-js: 1.6.11 6494 + 6480 6495 fetch-blob@3.2.0: 6481 6496 dependencies: 6482 6497 node-domexception: 1.0.0 ··· 8881 8896 dependencies: 8882 8897 imurmurhash: 0.1.4 8883 8898 signal-exit: 4.1.0 8899 + 8900 + xml-js@1.6.11: 8901 + dependencies: 8902 + sax: 1.4.1 8884 8903 8885 8904 xss@1.0.15: 8886 8905 dependencies:
+92
public/feeds/atom-style.xsl
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" 3 + xmlns:atom="http://www.w3.org/2005/Atom"> 4 + <xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" /> 5 + <xsl:template match="/"> 6 + <html xmlns="http://www.w3.org/1999/xhtml"> 7 + <head> 8 + <title><xsl:value-of select="/atom:feed/atom:title" /> - Atom Feed</title> 9 + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> 10 + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> 11 + <style type="text/css"> 12 + body { 13 + font-family: 'Inter', -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 14 + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 15 + margin: 0 auto; 16 + margin-top: 6em; 17 + background-color: #fff; 18 + font-size: 0.9375rem; 19 + line-height: 1.45; 20 + color: rgba(0, 0, 0, 0.85); 21 + max-width: 22.5rem; 22 + } 23 + .header { 24 + display: flex; 25 + justify-content: space-between; 26 + align-items: center; 27 + } 28 + .divider { 29 + margin: 2em 0; 30 + width: 100%; 31 + height: 1px; 32 + background: repeating-linear-gradient( 33 + to right, 34 + currentColor 0, 35 + currentColor 3px, 36 + transparent 3px, 37 + transparent 6px 38 + ); 39 + opacity: 0.12; 40 + } 41 + .title { 42 + font-size: 1.0625rem; 43 + font-weight: 600; 44 + } 45 + .description { 46 + margin-bottom: 1.45em; 47 + } 48 + .footer { 49 + font-size: 0.8125rem; 50 + opacity: 0.475; 51 + } 52 + @media (prefers-color-scheme: dark) { 53 + body { 54 + background-color: #1c1c1c; 55 + color: #fff; 56 + } 57 + } 58 + </style> 59 + </head> 60 + <body> 61 + <div class="channel-meta"> 62 + <div class="header"> 63 + <span class="title"> 64 + <xsl:value-of select="/atom:feed/atom:title" /> 65 + </span> 66 + <span class="rss-icon"> 67 + <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"> 68 + <path fill="#F18F48" 69 + d="M5 21q-.825 0-1.412-.587T3 19t.588-1.412T5 17t1.413.588T7 19t-.587 1.413T5 21m13.5 0q-.65 0-1.088-.475T16.9 19.4q-.275-2.425-1.312-4.537T12.9 11.1T9.138 8.413T4.6 7.1q-.65-.075-1.125-.512T3 5.5t.45-1.062t1.075-.363q3.075.275 5.763 1.563t4.737 3.337t3.338 4.738t1.562 5.762q.05.625-.363 1.075T18.5 21m-6 0q-.625 0-1.075-.437T10.85 19.5q-.225-1.225-.787-2.262T8.65 15.35t-1.888-1.412T4.5 13.15q-.625-.125-1.062-.575T3 11.5q0-.65.45-1.075t1.075-.325q1.825.25 3.413 1.063t2.837 2.062t2.063 2.838t1.062 3.412q.1.625-.325 1.075T12.5 21" /> 70 + </svg> 71 + </span> 72 + </div> 73 + <div class="divider"></div> 74 + <div class="description"> 75 + To subscribe, copy the URL from the address bar and add it to your feed reader. 76 + </div> 77 + 78 + <div class="footer"> 79 + <span>Last Updated: </span> 80 + <xsl:variable name="dateStr" select="/atom:feed/atom:updated" /> 81 + <xsl:variable name="year" select="substring($dateStr, 1, 4)" /> 82 + <xsl:variable name="month" select="substring($dateStr, 6, 2)" /> 83 + <xsl:variable name="day" select="substring($dateStr, 9, 2)" /> 84 + <xsl:value-of select="concat($year, '.', $month, '.', $day)" /> 85 + </div> 86 + 87 + </div> 88 + 89 + </body> 90 + </html> 91 + </xsl:template> 92 + </xsl:stylesheet>
+107
public/feeds/rss-style.xsl
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> 3 + <xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" /> 4 + <xsl:template match="/"> 5 + <html xmlns="http://www.w3.org/1999/xhtml"> 6 + <head> 7 + <title><xsl:value-of select="/rss/channel/title" /> - RSS Feed</title> 8 + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> 9 + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> 10 + <style type="text/css"> 11 + body { 12 + font-family: 'Inter', -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 13 + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 14 + margin: 0 auto; 15 + margin-top: 6em; 16 + background-color: #fff; 17 + font-size: 0.9375rem; 18 + line-height: 1.45; 19 + color: rgba(0, 0, 0, 0.85); 20 + max-width: 22.5rem; 21 + } 22 + .header { 23 + display: flex; 24 + justify-content: space-between; 25 + align-items: center; 26 + } 27 + .divider { 28 + margin: 2em 0; 29 + width: 100%; 30 + height: 1px; 31 + background: repeating-linear-gradient( 32 + to right, 33 + currentColor 0, 34 + currentColor 3px, 35 + transparent 3px, 36 + transparent 6px 37 + ); 38 + opacity: 0.12; 39 + } 40 + .title { 41 + font-size: 1.0625rem; 42 + font-weight: 600; 43 + } 44 + .description { 45 + margin-bottom: 1.45em; 46 + } 47 + .footer { 48 + font-size: 0.8125rem; 49 + opacity: 0.475; 50 + } 51 + @media (prefers-color-scheme: dark) { 52 + body { 53 + background-color: #1c1c1c; 54 + color: #fff; 55 + } 56 + } 57 + </style> 58 + </head> 59 + <body> 60 + <div class="channel-meta"> 61 + <div class="header"> 62 + <span class="title"> 63 + <xsl:value-of select="/rss/channel/title" /> 64 + </span> 65 + <span class="rss-icon"> 66 + <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"> 67 + <path fill="#F18F48" 68 + d="M5 21q-.825 0-1.412-.587T3 19t.588-1.412T5 17t1.413.588T7 19t-.587 1.413T5 21m13.5 0q-.65 0-1.088-.475T16.9 19.4q-.275-2.425-1.312-4.537T12.9 11.1T9.138 8.413T4.6 7.1q-.65-.075-1.125-.512T3 5.5t.45-1.062t1.075-.363q3.075.275 5.763 1.563t4.737 3.337t3.338 4.738t1.562 5.762q.05.625-.363 1.075T18.5 21m-6 0q-.625 0-1.075-.437T10.85 19.5q-.225-1.225-.787-2.262T8.65 15.35t-1.888-1.412T4.5 13.15q-.625-.125-1.062-.575T3 11.5q0-.65.45-1.075t1.075-.325q1.825.25 3.413 1.063t2.837 2.062t2.063 2.838t1.062 3.412q.1.625-.325 1.075T12.5 21" /> 69 + </svg> 70 + </span> 71 + </div> 72 + <div class="divider"></div> 73 + <div class="description"> 74 + To subscribe, copy the URL from the address bar and add it to your feed reader. 75 + </div> 76 + 77 + <div class="footer"> 78 + <span>Last Updated: </span> 79 + <xsl:variable name="dateStr" select="/rss/channel/lastBuildDate" /> 80 + <xsl:variable name="year" select="substring($dateStr, 13, 4)" /> 81 + <xsl:variable name="month" select="substring($dateStr, 9, 3)" /> 82 + <xsl:variable name="day" select="substring($dateStr, 6, 2)" /> 83 + <xsl:variable name="monthNum"> 84 + <xsl:choose> 85 + <xsl:when test="$month='Jan'">01</xsl:when> 86 + <xsl:when test="$month='Feb'">02</xsl:when> 87 + <xsl:when test="$month='Mar'">03</xsl:when> 88 + <xsl:when test="$month='Apr'">04</xsl:when> 89 + <xsl:when test="$month='May'">05</xsl:when> 90 + <xsl:when test="$month='Jun'">06</xsl:when> 91 + <xsl:when test="$month='Jul'">07</xsl:when> 92 + <xsl:when test="$month='Aug'">08</xsl:when> 93 + <xsl:when test="$month='Sep'">09</xsl:when> 94 + <xsl:when test="$month='Oct'">10</xsl:when> 95 + <xsl:when test="$month='Nov'">11</xsl:when> 96 + <xsl:when test="$month='Dec'">12</xsl:when> 97 + </xsl:choose> 98 + </xsl:variable> 99 + <xsl:value-of select="concat($year, '.', $monthNum, '.', $day)" /> 100 + </div> 101 + 102 + </div> 103 + 104 + </body> 105 + </html> 106 + </xsl:template> 107 + </xsl:stylesheet>
+31 -13
scripts/toggle-proxy.ts
··· 26 26 // Helper to comment/uncomment adapter lines in astro.config.ts 27 27 function toggleAstroAdapter(comment: boolean) { 28 28 const astroConfig = fs.readFileSync(astroConfigPath, 'utf-8').split('\n') 29 - // 16: import netlify..., 19: adapter: netlify() (0-based) 30 - const importIdx = 16 31 - const adapterIdx = 19 29 + 30 + // Find the import line for netlify adapter (including commented lines) 31 + const importIndex = astroConfig.findIndex( 32 + (line) => line.trim().includes('import') && line.includes('netlify') 33 + ) 34 + 35 + // Find the adapter line (including commented lines) 36 + const adapterIndex = astroConfig.findIndex( 37 + (line) => line.trim().includes('adapter:') && line.includes('netlify') 38 + ) 39 + 40 + if (importIndex === -1 || adapterIndex === -1) { 41 + console.error('Could not find netlify adapter import or configuration') 42 + return 43 + } 44 + 32 45 if (comment) { 33 - if (!astroConfig[importIdx].trim().startsWith('//')) { 34 - astroConfig[importIdx] = '// ' + astroConfig[importIdx] 46 + // Comment out the import line if not already commented 47 + if (!astroConfig[importIndex].trim().startsWith('//')) { 48 + astroConfig[importIndex] = '// ' + astroConfig[importIndex] 35 49 } 36 - if (!astroConfig[adapterIdx].trim().startsWith('//')) { 37 - astroConfig[adapterIdx] = '// ' + astroConfig[adapterIdx] 50 + // Comment out the adapter line if not already commented 51 + if (!astroConfig[adapterIndex].trim().startsWith('//')) { 52 + astroConfig[adapterIndex] = '// ' + astroConfig[adapterIndex] 38 53 } 39 54 } else { 40 - if (astroConfig[importIdx].trim().startsWith('//')) { 41 - astroConfig[importIdx] = astroConfig[importIdx].replace(/^\/\/\s?/, '') 55 + // Uncomment the import line if commented 56 + if (astroConfig[importIndex].trim().startsWith('//')) { 57 + astroConfig[importIndex] = astroConfig[importIndex].replace(/^\/\/\s?/, '') 42 58 } 43 - if (astroConfig[adapterIdx].trim().startsWith('//')) { 44 - astroConfig[adapterIdx] = astroConfig[adapterIdx].replace(/^\/\/\s?/, '') 59 + // Uncomment the adapter line if commented 60 + if (astroConfig[adapterIndex].trim().startsWith('//')) { 61 + astroConfig[adapterIndex] = astroConfig[adapterIndex].replace(/^\/\/\s?/, '') 45 62 } 46 63 } 64 + 47 65 fs.writeFileSync(astroConfigPath, astroConfig.join('\n'), 'utf-8') 48 66 } 49 67 ··· 59 77 // If linkCard is enabled, restore proxy.ts and uncomment adapter 60 78 if (fs.existsSync(backupPath)) { 61 79 fs.renameSync(backupPath, proxyPath) 62 - console.log('🔵 proxy.ts enabled') 80 + console.log('🟢 proxy.ts enabled') 63 81 } 64 82 toggleAstroAdapter(false) 65 - console.log('🔵 adapter config uncommented') 83 + console.log('🟢 adapter config uncommented') 66 84 }
+1
src/pages/[...slug].astro
··· 12 12 props: post 13 13 })) 14 14 } 15 + 15 16 type Props = CollectionEntry<'posts'> 16 17 17 18 const post = Astro.props
+2 -94
src/pages/atom.xml.ts
··· 1 - import rss from '@astrojs/rss' 2 - import { getCollection } from 'astro:content' 3 - import { getImage } from 'astro:assets' 4 - import { themeConfig } from '@/config' 1 + import { generateAtom } from '@/utils/feed' 5 2 import type { APIContext } from 'astro' 6 - import MarkdownIt from 'markdown-it' 7 3 8 - // Create markdown-it instance 9 - const md = new MarkdownIt({ 10 - html: true, 11 - linkify: true, 12 - typographer: true 13 - }) 14 - 15 - export async function GET(context: APIContext) { 16 - const posts = await getCollection('posts') 17 - const filteredPosts = posts.filter((post) => !post.id.startsWith('_')) 18 - const sortedPosts = filteredPosts.sort( 19 - (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf() 20 - ) 21 - 22 - const items = await Promise.all( 23 - sortedPosts.map(async (post) => { 24 - // Process images in the post 25 - let content = post.body || '' 26 - 27 - // Find image references in the post 28 - const imageMatches = content.match(/!\[.*?\]\(([^)]+)\)/g) 29 - if (imageMatches) { 30 - for (const match of imageMatches) { 31 - const srcMatch = match.match(/!\[.*?\]\(([^)]+)\)/) 32 - if (srcMatch) { 33 - const originalSrc = srcMatch[1] 34 - // Try to process image using getImage 35 - try { 36 - // Build image path 37 - const imagePath = originalSrc.startsWith('./') 38 - ? originalSrc.substring(2) 39 - : originalSrc.startsWith('_assets/') 40 - ? originalSrc 41 - : `_assets/${originalSrc}` 42 - 43 - const image = await getImage({ 44 - src: `src/content/posts/${imagePath}`, 45 - width: 800, 46 - format: 'webp' 47 - }) 48 - 49 - // Replace image references in Markdown 50 - const fileName = 51 - imagePath 52 - .split('/') 53 - .pop() 54 - ?.replace(/\.[^/.]+$/, '') || 'image' 55 - const siteUrl = (context.site?.toString() || themeConfig.site.website).replace( 56 - /\/$/, 57 - '' 58 - ) 59 - const fullImageUrl = image.src.startsWith('http') 60 - ? image.src 61 - : `${siteUrl}${image.src}` 62 - content = content.replace( 63 - new RegExp( 64 - `!\\[.*?\\]\\(${originalSrc.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)`, 65 - 'g' 66 - ), 67 - `![${fileName}](${fullImageUrl})` 68 - ) 69 - } catch (error) { 70 - console.warn(`Failed to process image: ${originalSrc}`, error) 71 - } 72 - } 73 - } 74 - } 75 - 76 - // Convert Markdown to HTML 77 - const htmlContent = md.render(content) 78 - 79 - return { 80 - title: post.data.title, 81 - link: `/${post.id}/`, 82 - pubDate: post.data.pubDate, 83 - content: htmlContent 84 - } 85 - }) 86 - ) 87 - 88 - return rss({ 89 - title: themeConfig.site.title, 90 - description: themeConfig.site.description, 91 - site: context.site || themeConfig.site.website, 92 - items, 93 - customData: `<language>en-US</language>`, 94 - stylesheet: false 95 - }) 96 - } 4 + export const GET = (context: APIContext) => generateAtom(context)
+2 -93
src/pages/rss.xml.ts
··· 1 - import rss from '@astrojs/rss' 2 - import { getCollection } from 'astro:content' 3 - import { getImage } from 'astro:assets' 4 - import { themeConfig } from '@/config' 1 + import { generateRSS } from '@/utils/feed' 5 2 import type { APIContext } from 'astro' 6 - import MarkdownIt from 'markdown-it' 7 3 8 - // Create markdown-it instance 9 - const md = new MarkdownIt({ 10 - html: true, 11 - linkify: true, 12 - typographer: true 13 - }) 14 - 15 - export async function GET(context: APIContext) { 16 - const posts = await getCollection('posts') 17 - const filteredPosts = posts.filter((post) => !post.id.startsWith('_')) 18 - const sortedPosts = filteredPosts.sort( 19 - (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf() 20 - ) 21 - 22 - const items = await Promise.all( 23 - sortedPosts.map(async (post) => { 24 - // Process images in the post 25 - let content = post.body || '' 26 - 27 - // Find image references in the post 28 - const imageMatches = content.match(/!\[.*?\]\(([^)]+)\)/g) 29 - if (imageMatches) { 30 - for (const match of imageMatches) { 31 - const srcMatch = match.match(/!\[.*?\]\(([^)]+)\)/) 32 - if (srcMatch) { 33 - const originalSrc = srcMatch[1] 34 - // Try to process image using getImage 35 - try { 36 - // Build image path 37 - const imagePath = originalSrc.startsWith('./') 38 - ? originalSrc.substring(2) 39 - : originalSrc.startsWith('_assets/') 40 - ? originalSrc 41 - : `_assets/${originalSrc}` 42 - 43 - const image = await getImage({ 44 - src: `src/content/posts/${imagePath}`, 45 - width: 800, 46 - format: 'webp' 47 - }) 48 - 49 - // Replace image references in Markdown 50 - const fileName = 51 - imagePath 52 - .split('/') 53 - .pop() 54 - ?.replace(/\.[^/.]+$/, '') || 'image' 55 - const siteUrl = (context.site?.toString() || themeConfig.site.website).replace( 56 - /\/$/, 57 - '' 58 - ) 59 - const fullImageUrl = image.src.startsWith('http') 60 - ? image.src 61 - : `${siteUrl}${image.src}` 62 - content = content.replace( 63 - new RegExp( 64 - `!\\[.*?\\]\\(${originalSrc.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)`, 65 - 'g' 66 - ), 67 - `![${fileName}](${fullImageUrl})` 68 - ) 69 - } catch (error) { 70 - console.warn(`Failed to process image: ${originalSrc}`, error) 71 - } 72 - } 73 - } 74 - } 75 - 76 - // Convert Markdown to HTML 77 - const htmlContent = md.render(content) 78 - 79 - return { 80 - title: post.data.title, 81 - link: `/${post.id}/`, 82 - pubDate: post.data.pubDate, 83 - content: htmlContent 84 - } 85 - }) 86 - ) 87 - 88 - return rss({ 89 - title: themeConfig.site.title, 90 - description: themeConfig.site.description, 91 - site: context.site || themeConfig.site.website, 92 - items, 93 - customData: `<language>en-US</language>` 94 - }) 95 - } 4 + export const GET = (context: APIContext) => generateRSS(context)
+153 -218
src/utils/feed.ts
··· 1 + import type { APIContext, ImageMetadata } from 'astro' 2 + import { getImage } from 'astro:assets' 1 3 import { getCollection, type CollectionEntry } from 'astro:content' 2 - import { themeConfig } from '@/config' 3 - import type { APIContext } from 'astro' 4 + import { Feed } from 'feed' 4 5 import MarkdownIt from 'markdown-it' 5 - import { parse } from 'node-html-parser' 6 + import { parse as htmlParser } from 'node-html-parser' 6 7 import sanitizeHtml from 'sanitize-html' 7 - 8 - // Dynamically import all post images 9 - const imageModules = import.meta.glob('/src/content/posts/_assets/*.(jpg|jpeg|png|gif|webp)', { 10 - eager: true, 11 - query: '?url', 12 - import: 'default' 13 - }) 8 + import { themeConfig } from '@/config' 9 + import path from 'node:path' 14 10 15 - // Create image path mapping: filename -> URL 16 - const imagePathMap = new Map<string, string>() 17 - for (const [path, url] of Object.entries(imageModules)) { 18 - // Extract filename from path 19 - const fileName = path.split('/').pop() || '' 20 - imagePathMap.set(fileName, url as string) 21 - } 22 - 23 - // Create markdown-it instance with Astro-like configuration 24 - const md = new MarkdownIt({ 11 + const markdownParser = new MarkdownIt({ 25 12 html: true, 26 13 linkify: true, 27 14 typographer: true 28 15 }) 29 16 30 - // Custom image renderer to preserve original paths 31 - md.renderer.rules.image = function (tokens, idx) { 32 - const token = tokens[idx] 33 - if (!token.attrs) return '' 17 + const imagesGlob = import.meta.glob<{ default: ImageMetadata }>( 18 + '/src/content/posts/_assets/**/*.{jpeg,jpg,png,gif,webp}' 19 + ) 34 20 35 - const srcIndex = token.attrIndex('src') 36 - const src = srcIndex >= 0 ? token.attrs[srcIndex][1] : '' 37 - const altIndex = token.attrIndex('alt') 38 - const alt = altIndex >= 0 ? token.attrs[altIndex][1] : '' 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 + */ 28 + async 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) 39 36 40 - return `<img src="${src}" alt="${alt}" />` 41 - } 37 + for (const img of imageTags) { 38 + const src = img.getAttribute('src') 39 + if (!src) continue 42 40 43 - // Process image paths, convert relative paths to absolute paths 44 - function processImagePaths(html: string, siteUrl: string): string { 45 - const root = parse(html) 41 + if (/^(https?:\/\/|\/\/)/.test(src)) { 42 + continue 43 + } 46 44 47 - // Process img tags 48 - root.querySelectorAll('img').forEach((img) => { 49 - const src = img.getAttribute('src') 50 - if (src && !src.startsWith('http') && !src.startsWith('//')) { 51 - // If it's a relative path, convert to absolute path 52 - let absoluteSrc: string 53 - if (src.startsWith('/')) { 54 - // Absolute path (relative to website root) 55 - absoluteSrc = `${siteUrl}${src}` 56 - } else if (src.startsWith('./_assets/')) { 57 - // Relative path (starting with ./_assets/) - use image mapping 58 - const fileName = src.substring(2) // Remove ./ 59 - const mappedUrl = imagePathMap.get(fileName.split('/').pop() || '') 60 - if (mappedUrl) { 61 - // Ensure mapped URL is absolute 62 - absoluteSrc = mappedUrl.startsWith('http') ? mappedUrl : `${siteUrl}${mappedUrl}` 63 - } else { 64 - // If mapping not found, use original path 65 - absoluteSrc = `${siteUrl}/${fileName}` 66 - } 67 - } else if (src.startsWith('_assets/')) { 68 - // Relative path (starting with _assets/) - use image mapping 69 - const fileName = src.split('/').pop() || '' 70 - const mappedUrl = imagePathMap.get(fileName) 71 - if (mappedUrl) { 72 - // Ensure mapped URL is absolute 73 - absoluteSrc = mappedUrl.startsWith('http') ? mappedUrl : `${siteUrl}${mappedUrl}` 74 - } else { 75 - // If mapping not found, use original path 76 - absoluteSrc = `${siteUrl}/${src}` 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) 77 85 } 78 - } else if (src.startsWith('./')) { 79 - // Other relative paths (starting with ./) 80 - const fileName = src.substring(2) // Remove ./ 81 - absoluteSrc = `${siteUrl}/_assets/${fileName}` 82 86 } else { 83 - // Other relative paths 84 - absoluteSrc = `${siteUrl}/_assets/${src}` 87 + console.warn(`[Feed] Image module not found: ${resolvedPath}`) 88 + console.warn(`[Feed] Available image modules:`, Object.keys(imagesGlob)) 85 89 } 86 - img.setAttribute('src', absoluteSrc) 90 + } else if (src.startsWith('/')) { 91 + img.setAttribute('src', new URL(src, baseUrl).toString()) 87 92 } 88 - }) 93 + } 89 94 90 95 return root.toString() 91 96 } 92 97 93 - // Clean and format HTML 94 - function sanitizeAndFormat(html: string): string { 95 - return sanitizeHtml(html, { 96 - allowedTags: [ 97 - 'h1', 98 - 'h2', 99 - 'h3', 100 - 'h4', 101 - 'h5', 102 - 'h6', 103 - 'p', 104 - 'br', 105 - 'hr', 106 - 'ul', 107 - 'ol', 108 - 'li', 109 - 'blockquote', 110 - 'pre', 111 - 'code', 112 - 'strong', 113 - 'em', 114 - 'del', 115 - 'mark', 116 - 'a', 117 - 'img', 118 - 'table', 119 - 'thead', 120 - 'tbody', 121 - 'tr', 122 - 'th', 123 - 'td', 124 - 'sub', 125 - 'sup', 126 - 'abbr', 127 - 'kbd', 128 - 'div', 129 - 'span' 130 - ], 131 - allowedAttributes: { 132 - '*': ['class', 'id'], 133 - a: ['href', 'title', 'target', 'rel'], 134 - img: ['src', 'alt', 'title', 'width', 'height'], 135 - abbr: ['title'], 136 - code: ['class'], 137 - pre: ['class'] 98 + /** 99 + * Generate a generic Feed instance 100 + */ 101 + async 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` 138 117 }, 139 - allowedSchemes: ['http', 'https', 'mailto', 'tel'], 140 - transformTags: { 141 - a: (tagName: string, attribs: Record<string, string>) => { 142 - // Ensure external links open in a new window 143 - if (attribs.href && attribs.href.startsWith('http')) { 144 - return { 145 - tagName, 146 - attribs: { 147 - ...attribs, 148 - target: '_blank', 149 - rel: 'noopener noreferrer' 150 - } 151 - } 152 - } 153 - return { tagName, attribs } 154 - } 118 + author: { 119 + name: author, 120 + link: siteUrl 155 121 } 156 122 }) 157 - } 158 123 159 - export async function generateRSS(context: APIContext) { 160 - const posts = await getCollection('posts') 161 - const filteredPosts = posts.filter((post: CollectionEntry<'posts'>) => !post.id.startsWith('_')) 162 - const sortedPosts = filteredPosts.sort( 124 + const posts = await getCollection( 125 + 'posts', 126 + ({ id }: CollectionEntry<'posts'>) => !id.startsWith('_') 127 + ) 128 + const sortedPosts = posts.sort( 163 129 (a: CollectionEntry<'posts'>, b: CollectionEntry<'posts'>) => 164 130 b.data.pubDate.valueOf() - a.data.pubDate.valueOf() 165 131 ) 166 132 167 - // Ensure site URL doesn't end with slash 168 - const siteUrl = (context.site?.toString() || themeConfig.site.website).replace(/\/$/, '') 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 + }) 169 147 170 - const rss = `<?xml version="1.0" encoding="UTF-8" ?> 171 - <rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom"> 172 - <channel> 173 - <title>${themeConfig.site.title}</title> 174 - <link>${siteUrl}</link> 175 - <description>${themeConfig.site.description}</description> 176 - <language>zh-CN</language> 177 - <lastBuildDate>${new Date().toUTCString()}</lastBuildDate> 178 - <atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml" /> 179 - ${sortedPosts 180 - .map((post: CollectionEntry<'posts'>) => { 181 - // Convert Markdown to HTML 182 - const rawHtml = md.render(post.body || '') 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 + } 183 157 184 - // Process image paths 185 - const processedHtml = processImagePaths(rawHtml, siteUrl) 186 - // Clean and format HTML 187 - const cleanHtml = sanitizeAndFormat(processedHtml) 158 + return feed 159 + } 188 160 189 - return ` 190 - <item> 191 - <title><![CDATA[${post.data.title}]]></title> 192 - <link>${siteUrl}/${post.id}/</link> 193 - <guid>${siteUrl}/${post.id}/</guid> 194 - <pubDate>${post.data.pubDate.toUTCString()}</pubDate> 195 - <content:encoded><![CDATA[${cleanHtml}]]></content:encoded> 196 - </item> 197 - ` 198 - }) 199 - .join('')} 200 - </channel> 201 - </rss>` 202 - 203 - return new Response(rss, { 204 - headers: { 205 - 'Content-Type': 'application/xml; charset=utf-8' 206 - } 161 + /** 162 + * Generate RSS 2.0 feed 163 + */ 164 + export 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' } 207 174 }) 208 175 } 209 176 177 + /** 178 + * Generate Atom 1.0 feed 179 + */ 210 180 export async function generateAtom(context: APIContext) { 211 - const posts = await getCollection('posts') 212 - const filteredPosts = posts.filter((post: CollectionEntry<'posts'>) => !post.id.startsWith('_')) 213 - const sortedPosts = filteredPosts.sort( 214 - (a: CollectionEntry<'posts'>, b: CollectionEntry<'posts'>) => 215 - b.data.pubDate.valueOf() - a.data.pubDate.valueOf() 216 - ) 217 - 218 - // Ensure site URL doesn't end with slash 219 - const siteUrl = (context.site?.toString() || themeConfig.site.website).replace(/\/$/, '') 220 - 221 - const atom = `<?xml version="1.0" encoding="utf-8"?> 222 - <feed xmlns="http://www.w3.org/2005/Atom"> 223 - <title>${themeConfig.site.title}</title> 224 - <subtitle>${themeConfig.site.description}</subtitle> 225 - <link href="${siteUrl}/atom.xml" rel="self" type="application/atom+xml" /> 226 - <link href="${siteUrl}" /> 227 - <id>${siteUrl}</id> 228 - <updated>${new Date().toISOString()}</updated> 229 - ${sortedPosts 230 - .map((post: CollectionEntry<'posts'>) => { 231 - // Convert Markdown to HTML 232 - const rawHtml = md.render(post.body || '') 233 - // Process image paths 234 - const processedHtml = processImagePaths(rawHtml, siteUrl) 235 - // Clean and format HTML 236 - const cleanHtml = sanitizeAndFormat(processedHtml) 237 - 238 - return ` 239 - <entry> 240 - <title>${post.data.title}</title> 241 - <link href="${siteUrl}/${post.id}/" /> 242 - <id>${siteUrl}/${post.id}/</id> 243 - <published>${post.data.pubDate.toISOString()}</published> 244 - <content type="html"><![CDATA[${cleanHtml}]]></content> 245 - </entry> 246 - ` 247 - }) 248 - .join('')} 249 - </feed>` 250 - 251 - return new Response(atom, { 252 - headers: { 253 - 'Content-Type': 'application/xml; charset=utf-8' 254 - } 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' } 255 190 }) 256 191 }