Compare commits

...

1 Commits

Author SHA1 Message Date
Ryan Vogel dda80da2c6 feat(console): add SWR caching to changelog endpoints
- Add Cloudflare edge caching (cf.cacheTtl) for GitHub API requests
- Add Cache-Control headers with s-maxage=300 and stale-while-revalidate=600
- Refactor /changelog to fetch from /changelog.json instead of GitHub directly
- Remove duplicate parsing logic from changelog page
2026-01-26 10:50:52 -05:00
2 changed files with 76 additions and 143 deletions

View File

@ -90,25 +90,32 @@ export async function GET() {
Accept: "application/vnd.github.v3+json", Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenCode-Console", "User-Agent": "OpenCode-Console",
}, },
}) cf: {
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as RequestInit)
if (!response.ok) { if (!response.ok) {
return { releases: [] } return Response.json({ releases: [] }, { status: 502 })
} }
const releases = (await response.json()) as Release[] const releases = (await response.json()) as Release[]
return { return Response.json(
releases: releases.map((release) => { {
const parsed = parseMarkdown(release.body || "") releases: releases.map((release) => {
return { const parsed = parseMarkdown(release.body || "")
tag: release.tag_name, return {
name: release.name, tag: release.tag_name,
date: release.published_at, name: release.name,
url: release.html_url, date: release.published_at,
highlights: parsed.highlights, url: release.html_url,
sections: parsed.sections, highlights: parsed.highlights,
} sections: parsed.sections,
}), }
} }),
},
{ headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" } },
)
} }

View File

@ -7,39 +7,6 @@ import { Legal } from "~/component/legal"
import { config } from "~/config" import { config } from "~/config"
import { For, Show, createSignal } from "solid-js" import { For, Show, createSignal } from "solid-js"
type Release = {
tag_name: string
name: string
body: string
published_at: string
html_url: string
}
const getReleases = query(async () => {
"use server"
const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenCode-Console",
},
cf: {
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any)
if (!response.ok) return []
return response.json() as Promise<Release[]>
}, "releases.get")
function formatDate(dateString: string) {
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
}
type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string } type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
type HighlightItem = { type HighlightItem = {
@ -54,68 +21,30 @@ type HighlightGroup = {
items: HighlightItem[] items: HighlightItem[]
} }
function parseHighlights(body: string): HighlightGroup[] { type ParsedRelease = {
const groups = new Map<string, HighlightItem[]>() tag: string
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g name: string
let match date: string
url: string
while ((match = regex.exec(body)) !== null) { highlights: HighlightGroup[]
const source = match[1] sections: { title: string; items: string[] }[]
const content = match[2]
const titleMatch = content.match(/<h2>([^<]+)<\/h2>/)
const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/)
const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="[^"]*"\s+src="([^"]+)"/)
const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
let media: HighlightMedia | undefined
if (videoMatch) {
media = { type: "video", src: videoMatch[1] }
} else if (imgMatch) {
media = { type: "image", src: imgMatch[3], width: imgMatch[1], height: imgMatch[2] }
}
if (titleMatch && media) {
const item: HighlightItem = {
title: titleMatch[1],
description: pMatch?.[2] || "",
shortDescription: pMatch?.[1],
media,
}
if (!groups.has(source)) {
groups.set(source, [])
}
groups.get(source)!.push(item)
}
}
return Array.from(groups.entries()).map(([source, items]) => ({ source, items }))
} }
function parseMarkdown(body: string) { const getReleases = query(async () => {
const lines = body.split("\n") "use server"
const sections: { title: string; items: string[] }[] = [] const response = await fetch(`${config.baseUrl}/changelog.json`)
let current: { title: string; items: string[] } | null = null if (!response.ok) return []
let skip = false const data = (await response.json()) as { releases: ParsedRelease[] }
return data.releases
}, "releases.get")
for (const line of lines) { function formatDate(dateString: string) {
if (line.startsWith("## ")) { const date = new Date(dateString)
if (current) sections.push(current) return date.toLocaleDateString("en-US", {
const title = line.slice(3).trim() year: "numeric",
current = { title, items: [] } month: "short",
skip = false day: "numeric",
} else if (line.startsWith("**Thank you")) { })
skip = true
} else if (line.startsWith("- ") && !skip) {
current?.items.push(line.slice(2).trim())
}
}
if (current) sections.push(current)
const highlights = parseHighlights(body)
return { sections, highlights }
} }
function ReleaseItem(props: { item: string }) { function ReleaseItem(props: { item: string }) {
@ -216,43 +145,40 @@ export default function Changelog() {
<section data-component="releases"> <section data-component="releases">
<For each={releases()}> <For each={releases()}>
{(release) => { {(release) => (
const parsed = () => parseMarkdown(release.body || "") <article data-component="release">
return ( <header>
<article data-component="release"> <div data-slot="version">
<header> <a href={release.url} target="_blank" rel="noopener noreferrer">
<div data-slot="version"> {release.tag}
<a href={release.html_url} target="_blank" rel="noopener noreferrer"> </a>
{release.tag_name}
</a>
</div>
<time dateTime={release.published_at}>{formatDate(release.published_at)}</time>
</header>
<div data-slot="content">
<Show when={parsed().highlights.length > 0}>
<div data-component="highlights">
<For each={parsed().highlights}>{(group) => <HighlightSection group={group} />}</For>
</div>
</Show>
<Show when={parsed().highlights.length > 0 && parsed().sections.length > 0}>
<CollapsibleSections sections={parsed().sections} />
</Show>
<Show when={parsed().highlights.length === 0}>
<For each={parsed().sections}>
{(section) => (
<div data-component="section">
<h3>{section.title}</h3>
<ul>
<For each={section.items}>{(item) => <ReleaseItem item={item} />}</For>
</ul>
</div>
)}
</For>
</Show>
</div> </div>
</article> <time dateTime={release.date}>{formatDate(release.date)}</time>
) </header>
}} <div data-slot="content">
<Show when={release.highlights.length > 0}>
<div data-component="highlights">
<For each={release.highlights}>{(group) => <HighlightSection group={group} />}</For>
</div>
</Show>
<Show when={release.highlights.length > 0 && release.sections.length > 0}>
<CollapsibleSections sections={release.sections} />
</Show>
<Show when={release.highlights.length === 0}>
<For each={release.sections}>
{(section) => (
<div data-component="section">
<h3>{section.title}</h3>
<ul>
<For each={section.items}>{(item) => <ReleaseItem item={item} />}</For>
</ul>
</div>
)}
</For>
</Show>
</div>
</article>
)}
</For> </For>
</section> </section>