summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src/lib
diff options
context:
space:
mode:
authoradamelmore <[email protected]>2026-01-26 12:51:32 -0600
committeradamelmore <[email protected]>2026-01-26 12:51:35 -0600
commitde3b654dcd195448f1d19e55562b2d84a2db7c91 (patch)
tree864f23f276140c1d2830cb57065daea3d0016f45 /packages/console/app/src/lib
parent8b17ac656cb428b1eee5f425deb195dc61f844ba (diff)
downloadopencode-de3b654dcd195448f1d19e55562b2d84a2db7c91.tar.gz
opencode-de3b654dcd195448f1d19e55562b2d84a2db7c91.zip
chore: refactor changelog
Diffstat (limited to 'packages/console/app/src/lib')
-rw-r--r--packages/console/app/src/lib/changelog.ts146
1 files changed, 146 insertions, 0 deletions
diff --git a/packages/console/app/src/lib/changelog.ts b/packages/console/app/src/lib/changelog.ts
new file mode 100644
index 000000000..93a0d423c
--- /dev/null
+++ b/packages/console/app/src/lib/changelog.ts
@@ -0,0 +1,146 @@
+import { query } from "@solidjs/router"
+
+type Release = {
+ tag_name: string
+ name: string
+ body: string
+ published_at: string
+ html_url: string
+}
+
+export type HighlightMedia =
+ | { type: "video"; src: string }
+ | { type: "image"; src: string; width: string; height: string }
+
+export type HighlightItem = {
+ title: string
+ description: string
+ shortDescription?: string
+ media: HighlightMedia
+}
+
+export type HighlightGroup = {
+ source: string
+ items: HighlightItem[]
+}
+
+export type ChangelogRelease = {
+ tag: string
+ name: string
+ date: string
+ url: string
+ highlights: HighlightGroup[]
+ sections: { title: string; items: string[] }[]
+}
+
+export type ChangelogData = {
+ ok: boolean
+ releases: ChangelogRelease[]
+}
+
+export async function loadChangelog(): Promise<ChangelogData> {
+ 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: {
+ // best-effort edge caching (ignored outside Cloudflare)
+ cacheTtl: 60 * 5,
+ cacheEverything: true,
+ },
+ } as RequestInit).catch(() => undefined)
+
+ if (!response?.ok) return { ok: false, releases: [] }
+
+ const data = await response.json().catch(() => undefined)
+ if (!Array.isArray(data)) return { ok: false, releases: [] }
+
+ const releases = (data as Release[]).map((release) => {
+ const parsed = parseMarkdown(release.body || "")
+ return {
+ tag: release.tag_name,
+ name: release.name,
+ date: release.published_at,
+ url: release.html_url,
+ highlights: parsed.highlights,
+ sections: parsed.sections,
+ }
+ })
+
+ return { ok: true, releases }
+}
+
+export const changelog = query(async () => {
+ "use server"
+ const result = await loadChangelog()
+ return result.releases
+}, "changelog")
+
+function parseHighlights(body: string): HighlightGroup[] {
+ const groups = new Map<string, HighlightItem[]>()
+ const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
+ let match
+
+ while ((match = regex.exec(body)) !== null) {
+ const source = match[1]
+ 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)
+
+ const media = (() => {
+ if (videoMatch) return { type: "video", src: videoMatch[1] } satisfies HighlightMedia
+ if (imgMatch) {
+ return {
+ type: "image",
+ src: imgMatch[3],
+ width: imgMatch[1],
+ height: imgMatch[2],
+ } satisfies HighlightMedia
+ }
+ })()
+
+ if (!titleMatch || !media) continue
+
+ 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 lines = body.split("\n")
+ const sections: { title: string; items: string[] }[] = []
+ let current: { title: string; items: string[] } | null = null
+ let skip = false
+
+ for (const line of lines) {
+ if (line.startsWith("## ")) {
+ if (current) sections.push(current)
+ current = { title: line.slice(3).trim(), items: [] }
+ skip = false
+ continue
+ }
+
+ if (line.startsWith("**Thank you")) {
+ skip = true
+ continue
+ }
+
+ if (line.startsWith("- ") && !skip) current?.items.push(line.slice(2).trim())
+ }
+
+ if (current) sections.push(current)
+ return { sections, highlights: parseHighlights(body) }
+}