Skip to content
3 changes: 3 additions & 0 deletions app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
BLOG_FEEDS,
BLOGS_WITHOUT_FEED,
CALENDAR_DISPLAY_COUNT,
LOCALES_CODES,
RSS_DISPLAY_COUNT,
} from "@/lib/constants"

Expand Down Expand Up @@ -128,6 +129,8 @@ const Page = async ({ params }: { params: Promise<{ locale: Lang }> }) => {
)
}

export const generateStaticParams = async () => LOCALES_CODES
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forces homepage to builds paths at build, which allows throw Error to break the build


export async function generateMetadata({
params,
}: {
Expand Down
69 changes: 40 additions & 29 deletions src/lib/api/fetchPosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,47 @@ import { fetchXml } from "./fetchRSS"

export const fetchAttestantPosts = async () => {
const BASE_URL = "https://www.attestant.io/posts/"
const htmlData = (await fetchXml(BASE_URL)) as HTMLResult
const allItems: RSSItem[] = []

// Extract div containing list of posts from deeply nested HTML structure
const postsContainer =
htmlData.html.body[0].div[0].div[1].div[0].div[0].div[0].div
try {
const htmlData = (await fetchXml(BASE_URL)) as HTMLResult

const posts: RSSItem[] = postsContainer
.map(({ a }) => {
const [
{
$: { href },
h4: [{ _: title }],
div: [{ _: content }, { _: pubDate }],
},
] = a
const { href: link } = new URL(href, BASE_URL)
return {
title,
link,
content,
source: "Attestant",
sourceUrl: BASE_URL,
sourceFeedUrl: BASE_URL,
imgSrc: "/images/attestant-logo.svg",
pubDate,
}
})
.sort(
(a: RSSItem, b: RSSItem) =>
new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime()
// Extract div containing list of posts from deeply nested HTML structure
const postsContainer =
htmlData.html.body[0].div[0].div[1].div[0].div[0].div[0].div

const sortedPosts = postsContainer
.map(({ a }) => {
const [
{
$: { href },
h4: [{ _: title }],
div: [{ _: content }, { _: pubDate }],
},
] = a
const { href: link } = new URL(href, BASE_URL)
return {
title,
link,
content,
source: "Attestant",
sourceUrl: BASE_URL,
sourceFeedUrl: BASE_URL,
imgSrc: "/images/attestant-logo.svg",
pubDate,
}
})
.sort(
(a: RSSItem, b: RSSItem) =>
new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime()
)
allItems.push(...sortedPosts)
} catch (error) {
console.error(
"Error fetching Attestant posts:",
error instanceof Error ? error.message : error
)
return posts
}

return allItems
}
228 changes: 118 additions & 110 deletions src/lib/api/fetchRSS.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { parseString } from "xml2js"

import type {
AtomElement,
AtomResult,
RSSChannel,
RSSItem,
RSSResult,
} from "../types"
import { RSS_DISPLAY_COUNT } from "../constants"
import type { AtomElement, AtomResult, RSSItem, RSSResult } from "../types"
import { isValidDate } from "../utils/date"

/**
Expand All @@ -18,108 +13,125 @@ export const fetchRSS = async (xmlUrl: string | string[]) => {
const urls = Array.isArray(xmlUrl) ? xmlUrl : [xmlUrl]
const allItems: RSSItem[][] = []
for (const url of urls) {
const response = (await fetchXml(url)) as RSSResult | AtomResult
try {
const response = (await fetchXml(url)) as RSSResult | AtomResult

if ("rss" in response) {
const [mainChannel] = response.rss.channel as RSSChannel[]
const [source] = mainChannel.title
const [sourceUrl] = mainChannel.link
const channelImage = mainChannel.image ? mainChannel.image[0].url[0] : ""
if ("rss" in response) {
const [mainChannel] = response.rss.channel
const [source] = mainChannel.title
const [sourceUrl] = mainChannel.link
const channelImage = mainChannel.image
? mainChannel.image[0].url[0]
: ""

const parsedRssItems = mainChannel.item
// Filter out items with invalid dates
.filter((item) => {
if (!item.pubDate) return false
const [pubDate] = item.pubDate
return isValidDate(pubDate)
})
// Sort by pubDate (most recent is first in array
.sort((a, b) => {
const dateA = new Date(a.pubDate[0])
const dateB = new Date(b.pubDate[0])
return dateB.getTime() - dateA.getTime()
})
// Map to RSSItem object
.map((item) => {
const getImgSrc = () => {
if (item["content:encoded"])
return item["content:encoded"][0].match(
/https?:\/\/[^"]*?\.(jpe?g|png|webp)/g
)?.[0]
if (item.enclosure) return item.enclosure[0].$.url
if (item["media:content"]) return item["media:content"][0].$.url
return channelImage
}
return {
pubDate: item.pubDate[0],
title: item.title[0],
link: item.link[0],
imgSrc: getImgSrc(),
source,
sourceUrl,
sourceFeedUrl: url,
} as RSSItem
})
const parsedRssItems = mainChannel.item
// Filter out items with invalid dates
.filter((item) => {
if (!item.pubDate) return false
const [pubDate] = item.pubDate
return isValidDate(pubDate)
})
// Sort by pubDate (most recent is first in array
.sort((a, b) => {
const dateA = new Date(a.pubDate[0])
const dateB = new Date(b.pubDate[0])
return dateB.getTime() - dateA.getTime()
})
// Map to RSSItem object
.map((item) => {
const getImgSrc = () => {
if (item["content:encoded"])
return item["content:encoded"][0].match(
/https?:\/\/[^"]*?\.(jpe?g|png|webp)/g
)?.[0]
if (item.enclosure) return item.enclosure[0].$.url
if (item["media:content"]) return item["media:content"][0].$.url
return channelImage
}
return {
pubDate: item.pubDate[0],
title: item.title[0],
link: item.link[0],
imgSrc: getImgSrc(),
source,
sourceUrl,
sourceFeedUrl: url,
}
})

allItems.push(parsedRssItems)
} else if ("feed" in response) {
const [source] = response.feed.title
const [sourceUrl] = response.feed.id
const feedImage = response.feed.icon?.[0]
allItems.push(parsedRssItems)
} else if ("feed" in response) {
const [source] = response.feed.title
const [sourceUrl] = response.feed.id
const feedImage = response.feed.icon?.[0]

const parsedAtomItems = response.feed.entry
// Filter out items with invalid dates
.filter((entry) => {
if (!entry.updated) return false
const [published] = entry.updated
return isValidDate(published)
})
// Sort by published (most recent is first in array
.sort((a, b) => {
const dateA = new Date(a.updated[0])
const dateB = new Date(b.updated[0])
return dateB.getTime() - dateA.getTime()
})
// Map to RSSItem object
.map((entry) => {
const getString = (el?: AtomElement[]): string => {
if (!el) return ""
const [firstEl] = el
if (typeof firstEl === "string") return firstEl
return firstEl._ || ""
}
const getHref = (): string => {
if (!entry.link) {
console.warn(`No link found for RSS url: ${url}`)
return ""
const parsedAtomItems = response.feed.entry
// Filter out items with invalid dates
.filter((entry) => {
if (!entry.updated) return false
const [published] = entry.updated
return isValidDate(published)
})
// Sort by published (most recent is first in array
.sort((a, b) => {
const dateA = new Date(a.updated[0])
const dateB = new Date(b.updated[0])
return dateB.getTime() - dateA.getTime()
})
// Map to RSSItem object
.map((entry) => {
const getString = (el?: AtomElement[]): string => {
if (!el) return ""
const [firstEl] = el
if (typeof firstEl === "string") return firstEl
return firstEl._ || ""
}
const getHref = (): string => {
if (!entry.link) {
console.warn(`No link found for RSS url: ${url}`)
return ""
}
const link = entry.link[0]
if (typeof link === "string") return link
return link.$.href || ""
}
const getImgSrc = (): string => {
const imgRegEx = /https?:\/\/[^"]*?\.(jpe?g|png|webp)/g
const contentMatch = getString(entry.content).match(imgRegEx)
if (contentMatch) return contentMatch[0]
const summaryMatch = getString(entry.summary).match(imgRegEx)
if (summaryMatch) return summaryMatch[0]
return feedImage || ""
}
return {
pubDate: entry.updated[0],
title: getString(entry.title),
link: getHref(),
imgSrc: getImgSrc(),
source,
sourceUrl,
sourceFeedUrl: url,
}
const link = entry.link[0]
if (typeof link === "string") return link
return link.$.href || ""
}
const getImgSrc = (): string => {
const imgRegEx = /https?:\/\/[^"]*?\.(jpe?g|png|webp)/g
const contentMatch = getString(entry.content).match(imgRegEx)
if (contentMatch) return contentMatch[0]
const summaryMatch = getString(entry.summary).match(imgRegEx)
if (summaryMatch) return summaryMatch[0]
return feedImage || ""
}
return {
pubDate: entry.updated[0],
title: getString(entry.title),
link: getHref(),
imgSrc: getImgSrc(),
source,
sourceUrl,
sourceFeedUrl: url,
} as RSSItem
})
})

allItems.push(parsedAtomItems)
allItems.push(parsedAtomItems)
} else {
throw new Error(
`Error parsing XML, invalid RSSResult or AtomResult type: ${url}`
)
}
} catch (error) {
console.error(error instanceof Error ? error.message : error)
// Do not break build for single fetch failure
continue
}
}
return allItems as RSSItem[][]

// Only break build if insufficient number of items fetched
if (allItems.length < RSS_DISPLAY_COUNT)
throw new Error("Insufficient number of RSS items fetched")
Comment on lines +131 to +132
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't actually breaking the build as-is, since it is contained inside an api function call... 🤔 cc: @pettinarip

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, with the addition of generateStaticParams on the homepage, this throw should properly break the build if the RSS fetch doesn't return the 6 results desired to display on the homepage.


return allItems
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function fetch will never throw, is that the intention? if we want to break the build, we should

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want a singular feed to break the build (ie. recent example where one blog changed their feed link)... these cases should just skip over that feed.

BUT, in the event something happens where we don't get ANY feed results, then that should break the build... which was the intent of throwing an error at the end if there aren't enough results.

}

/**
Expand All @@ -135,16 +147,12 @@ export const fetchXml = async (url: string) => {
credentials: "omit", // Don't send or receive cookies
})
const xml = await response.text()
let returnObject: Record<string, unknown> = {}
parseString(xml, (err, result) => {
if (err) {
throw err // Throw the error to be caught by the outer try-catch
}
returnObject = result
return await new Promise<Record<string, unknown>>((resolve, reject) => {
parseString(xml, (err, result) => {
err ? reject(err) : resolve(result)
})
})
return returnObject
} catch (error) {
console.error("Error fetching or parsing XML:", url, error)
throw error
throw new Error(`Error fetching or parsing XML: ${url}`)
}
}
4 changes: 2 additions & 2 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,8 @@ export const COMMUNITY_BLOGS: CommunityBlog[] = [
feed: "https://medium.com/feed/ethereum-cat-herders",
},
{
href: "http://geodework.com/blog",
feed: "http://geodework.com/feed.xml",
href: "https://geodework.com/blog",
feed: "https://geodework.com/feed.xml",
},
]

Expand Down