diff --git a/src/components/ChangelogFilters/useChangelogFilters.ts b/src/components/ChangelogFilters/useChangelogFilters.ts index ef18c108cb3..87280e484e9 100644 --- a/src/components/ChangelogFilters/useChangelogFilters.ts +++ b/src/components/ChangelogFilters/useChangelogFilters.ts @@ -69,8 +69,10 @@ export const useChangelogFilters = ({ items }: UseChangelogFiltersProps) => { const changelogList = document.querySelector(".changelog-list") as HTMLElement if (filters.searchTerm) { - // Search takes priority - filter by search term + // Combine search with any active filters: a row must match both. const searchLower = filters.searchTerm.toLowerCase() + const hasFilters = + filters.selectedProducts.length > 0 || filters.selectedNetworks.length > 0 || filters.selectedTypes.length > 0 let visibleCount = 0 changelogItems.forEach((item) => { @@ -81,7 +83,12 @@ export const useChangelogFilters = ({ items }: UseChangelogFiltersProps) => { changelogItem?.name.toLowerCase().includes(searchLower) || changelogItem?.["text-description"]?.toLowerCase().includes(searchLower) - if (matchesSearch) { + const passesFilters = + !hasFilters || + (changelogItem && + matchesFilters(changelogItem, filters.selectedProducts, filters.selectedNetworks, filters.selectedTypes)) + + if (matchesSearch && passesFilters) { ;(item as HTMLElement).style.display = "" visibleCount++ } else { diff --git a/src/components/ChangelogSnippet/ChangelogSnippet.astro b/src/components/ChangelogSnippet/ChangelogSnippet.astro index 7c251b503ac..e544d7d661b 100644 --- a/src/components/ChangelogSnippet/ChangelogSnippet.astro +++ b/src/components/ChangelogSnippet/ChangelogSnippet.astro @@ -1,45 +1,16 @@ --- import { SvgArrowRight2, Typography } from "@chainlink/blocks" -import { SearchClient, searchClient } from "@algolia/client-search" import ChangelogCard from "./ChangelogCard.astro" -import { AlgoliaQuery, type ChangelogItem } from "./types" +import type { AlgoliaQuery } from "./types" import styles from "./ChangelogSnippet.module.css" -import { getSecret } from "astro:env/server" +import { getLatestChangelogForTopic } from "~/utils/changelog" interface Props { query: AlgoliaQuery } const { query } = Astro.props - -const appId = getSecret("ALGOLIA_APP_ID") -const apiKey = getSecret("PUBLIC_ALGOLIA_SEARCH_PUBLIC_API_KEY") - -let client: SearchClient -let latestLog: ChangelogItem | undefined = undefined - -// Initialize client if appId and apiKey are available to avoid needing to update -// the github actions with the new keys (satisfies linkcheck-internal) -if (appId && apiKey) { - client = searchClient(appId, apiKey) - - const req = await client.search({ - requests: [ - { - indexName: "Changelog", - restrictSearchableAttributes: ["topic"], - query, - hitsPerPage: 1, - }, - ], - }) - - const firstResult = req.results[0] - const results = "hits" in firstResult ? (firstResult.hits as ChangelogItem[]) : [] - - // logs are returned sorted by created_at DESC - latestLog = results[0] -} +const latestLog = getLatestChangelogForTopic(query) --- { diff --git a/src/pages/changelog.astro b/src/pages/changelog.astro index dd94e3cc890..4b02210edad 100644 --- a/src/pages/changelog.astro +++ b/src/pages/changelog.astro @@ -3,59 +3,17 @@ import BaseLayout from "~/layouts/BaseLayout.astro" import * as CONFIG from "../config" import { Typography } from "@chainlink/blocks" import { ChangelogFilters } from "~/components/ChangelogFilters/ChangelogFilters.tsx" -import { getSecret } from "astro:env/server" -import { searchClient, SearchClient } from "@algolia/client-search" -import { ChangelogItem } from "~/components/ChangelogSnippet/types" import ChangelogCard from "~/components/ChangelogSnippet/ChangelogCard.astro" import { getUniqueNetworks, getUniqueTopics, getUniqueTypes } from "~/utils/changelogFilters" +import { getChangelogItems } from "~/utils/changelog" const formattedContentTitle = `${CONFIG.PAGE.titleFallback} | ${CONFIG.SITE.title}` -const appId = getSecret("ALGOLIA_APP_ID") -const apiKey = getSecret("PUBLIC_ALGOLIA_SEARCH_PUBLIC_API_KEY") - -let client: SearchClient -let logs: ChangelogItem[] | undefined = undefined - -if (appId && apiKey) { - client = searchClient(appId, apiKey) - - const firstReq = await client.search({ - requests: [ - { - indexName: "Changelog", - page: 0, - hitsPerPage: 1000, - }, - ], - }) - - const firstResult = firstReq.results[0] - let allHits = "hits" in firstResult ? (firstResult.hits as ChangelogItem[]) : [] - const nbPages = "nbPages" in firstResult ? firstResult.nbPages : 1 - - if (nbPages && nbPages > 1) { - const remainingRequests = Array.from({ length: nbPages - 1 }, (_, i) => ({ - indexName: "Changelog", - page: i + 1, - hitsPerPage: 1000, - })) - - const remainingResults = await client.search({ requests: remainingRequests }) - - remainingResults.results.forEach((result) => { - if ("hits" in result) { - allHits = [...allHits, ...(result.hits as ChangelogItem[])] - } - }) - } - - logs = allHits -} +const logs = getChangelogItems() // Extract unique filter values -const products = logs ? getUniqueTopics(logs) : [] -const networks = logs ? getUniqueNetworks(logs) : [] -const types = logs ? getUniqueTypes(logs) : [] +const products = getUniqueTopics(logs) +const networks = getUniqueNetworks(logs) +const types = getUniqueTypes(logs) --- @@ -67,7 +25,7 @@ const types = logs ? getUniqueTypes(logs) : []
{ - logs?.map((log, index) => ( + logs.map((log, index) => (
= 25 ? "display: none;" : ""}>
@@ -84,7 +42,7 @@ const types = logs ? getUniqueTypes(logs) : []
{ - logs && logs.length > 25 && ( + logs.length > 25 && (
diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts new file mode 100644 index 00000000000..32b07ab3eaa --- /dev/null +++ b/src/utils/changelog.ts @@ -0,0 +1,289 @@ +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import { marked } from "marked" +import type { AlgoliaQuery, ChangelogItem } from "~/components/ChangelogSnippet/types.ts" + +const CHAINLINK_DOCS_URL = "https://docs.chain.link" +const GENERIC_TOKEN_ICON = + "https://cdn.prod.website-files.com/64cc2c23d8dbd707cdb556d8/678f76f95f2fbf3fef5e80bb_generic-token.svg" + +interface RawNetwork { + displayName: string + iconUrl: string +} + +interface RawRelatedToken { + assetName?: string + baseAsset?: string + quoteAsset?: string + network?: string + url: string + iconUrl?: string + productTypeCode?: string + displayName?: string +} + +interface RawNewNetwork { + displayName: string + network: string + url: string +} + +interface RawChangelogEntry { + category: string + date: string + description: string + title: string + topic: string + relatedNetworks?: string[] + relatedTokens?: RawRelatedToken[] + newNetworks?: RawNewNetwork[] +} + +interface RawChangelog { + networks: Record + data: RawChangelogEntry[] +} + +const CATEGORY_LABELS: Record = { + integration: "Integration", + release: "Release", + deprecation: "Deprecated", + feature: "Feature", + update: "Update", + cre: "CRE", + dta: "DTA", + ace: "ACE", +} + +const TOPIC_QUERY_MAP: Record = { + ccip: "CCIP", + "data-streams": "Data Streams", + "smart-data": "SmartData", + nodes: "Nodes", + "data-feeds": "Data Feeds", + functions: "Functions", + automation: "Automation", + vrf: "VRF", + general: "General", +} + +let cached: ChangelogItem[] | undefined + +function formatTitleToSlug(title: string): string { + return title + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/\./g, "-") + .replace(/[^a-z0-9-]/g, "") +} + +function ensureAbsoluteUrl(url: string): string { + try { + return new URL(url).href + } catch { + return new URL(url, CHAINLINK_DOCS_URL).href + } +} + +function formatLinksInDescription(description: string): string { + const withMd = description.replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (_, text, href) => `[${text}](${ensureAbsoluteUrl(href)})` + ) + return withMd.replace(/]*href="([^"]+)"[^>]*>(.*?)<\/a>/g, (_, href, text) => { + return `${text}` + }) +} + +function categoryToType(category: string): string { + const known = CATEGORY_LABELS[category.toLowerCase()] + if (known) return known + return category.charAt(0).toUpperCase() + category.slice(1).toLowerCase() +} + +function relatedNetworksHtml(entry: RawChangelogEntry, networks: Record): string { + if (!entry.relatedNetworks || entry.relatedNetworks.length === 0) return "" + + const seen: string[] = [] + const icons: string[] = [] + + entry.relatedNetworks.forEach((network, index) => { + const info = networks[network] + if (!info || !info.iconUrl || seen.includes(network)) return + seen.push(network) + + const image = + index < 4 + ? `${info.displayName}` + : "" + const hidden = `` + icons.push((image + hidden).replace(/\n/g, "")) + }) + + if (icons.length === 0) return "" + + const moreNetworks = seen.length > 4 ? `
+${seen.length - 4}
` : "" + + return `
${icons.join("")}${moreNetworks}
` +} + +function networkImage(network: string | undefined, networks: Record): string { + if (network && networks[network]?.iconUrl) return networks[network].iconUrl + return GENERIC_TOKEN_ICON +} + +function networkName(network: string | undefined, networks: Record): string { + if (network && networks[network]?.displayName) return networks[network].displayName + return network || "" +} + +function relatedTokenHtml(args: { + iconUrl?: string + network?: string + linkLabel: string + link: string + additionalInfo?: string + networks: Record +}): string { + const { iconUrl, network, linkLabel, link, additionalInfo, networks } = args + const html = `
+
+ + ${network ? `` : ""} +
+ + ${additionalInfo ? `` : ""} +
` + return html.replace(/\n/g, "") +} + +function relatedNetworkRowHtml(args: { + network: string + link: string + linkLabel: string + networks: Record +}): string { + const { network, link, linkLabel, networks } = args + const src = networkImage(network, networks) + const html = `
+
+ +
+ +
` + return html.replace(/\n/g, "") +} + +function relatedItemsHtml(entry: RawChangelogEntry, networks: Record): string { + const list = entry.relatedTokens || entry.newNetworks + if (!list) return "" + + return list + .map((raw) => { + const entryAny = raw as RawRelatedToken & RawNewNetwork + switch (entry.topic) { + case "Data Streams": { + let linkLabel = entryAny.baseAsset || "" + if (entryAny.quoteAsset) linkLabel += ` / ${entryAny.quoteAsset}` + return relatedTokenHtml({ + iconUrl: entryAny.iconUrl, + network: entryAny.network, + linkLabel, + link: entryAny.url, + networks, + }) + } + case "SmartData": + return relatedTokenHtml({ + iconUrl: entryAny.iconUrl, + network: entryAny.network, + linkLabel: `${entryAny.baseAsset || ""} ${entryAny.productTypeCode || ""}`.trim(), + link: entryAny.url, + additionalInfo: networkName(entryAny.network, networks), + networks, + }) + case "Data Feeds": { + let linkLabel = entryAny.baseAsset || "" + if (entryAny.quoteAsset) linkLabel += ` / ${entryAny.quoteAsset}` + return relatedTokenHtml({ + iconUrl: entryAny.iconUrl, + network: entryAny.network, + linkLabel, + link: entryAny.url, + additionalInfo: networkName(entryAny.network, networks), + networks, + }) + } + case "CCIP": + if (entryAny.displayName) { + return relatedNetworkRowHtml({ + network: entryAny.network, + link: entryAny.url, + linkLabel: entryAny.displayName, + networks, + }) + } + return relatedTokenHtml({ + iconUrl: entryAny.iconUrl, + linkLabel: entryAny.assetName || "", + link: entryAny.url, + networks, + }) + default: + return "" + } + }) + .join("") +} + +function buildDescription(entry: RawChangelogEntry): string { + return formatLinksInDescription(entry.description) +} + +function markdownToHtml(markdown: string): string { + return marked.parse(markdown, { async: false, gfm: true }).toString().replace(/\n/g, "") +} + +function loadRaw(): RawChangelog { + const path = resolve(process.cwd(), "public/changelog.json") + return JSON.parse(readFileSync(path, "utf-8")) as RawChangelog +} + +export function getChangelogItems(): ChangelogItem[] { + if (cached) return cached + + const raw = loadRaw() + + cached = raw.data.map((entry, index) => { + const slug = formatTitleToSlug(entry.title) + const descriptionMd = buildDescription(entry) + const descriptionHtml = markdownToHtml(descriptionMd) + const tokenListHtml = relatedItemsHtml(entry, raw.networks) + const dateIso = new Date(entry.date).toISOString() + + return { + id: `${entry.date}-${slug}-${index}`, + objectID: `${entry.date}-${slug}-${index}`, + name: entry.title, + slug, + topic: entry.topic, + type: categoryToType(entry.category), + "date-of-release": dateIso, + "text-description": descriptionHtml + tokenListHtml, + networks: relatedNetworksHtml(entry, raw.networks), + hash: "", + createdOn: dateIso, + lastPublished: dateIso, + lastUpdated: dateIso, + } + }) + + return cached +} + +export function getLatestChangelogForTopic(query: AlgoliaQuery): ChangelogItem | undefined { + const topic = TOPIC_QUERY_MAP[query] + if (!topic) return undefined + return getChangelogItems().find((item) => item.topic === topic) +}