summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src
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
parent8b17ac656cb428b1eee5f425deb195dc61f844ba (diff)
downloadopencode-de3b654dcd195448f1d19e55562b2d84a2db7c91.tar.gz
opencode-de3b654dcd195448f1d19e55562b2d84a2db7c91.zip
chore: refactor changelog
Diffstat (limited to 'packages/console/app/src')
-rw-r--r--packages/console/app/src/lib/changelog.ts146
-rw-r--r--packages/console/app/src/routes/changelog.json.ts166
-rw-r--r--packages/console/app/src/routes/changelog/index.tsx142
3 files changed, 160 insertions, 294 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) }
+}
diff --git a/packages/console/app/src/routes/changelog.json.ts b/packages/console/app/src/routes/changelog.json.ts
index f82c8421d..f06c1be9b 100644
--- a/packages/console/app/src/routes/changelog.json.ts
+++ b/packages/console/app/src/routes/changelog.json.ts
@@ -1,24 +1,4 @@
-type Release = {
- tag_name: string
- name: string
- body: string
- published_at: string
- html_url: string
-}
-
-type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
-
-type HighlightItem = {
- title: string
- description: string
- shortDescription?: string
- media: HighlightMedia
-}
-
-type HighlightGroup = {
- source: string
- items: HighlightItem[]
-}
+import { loadChangelog } from "~/lib/changelog"
const cors = {
"Access-Control-Allow-Origin": "*",
@@ -29,147 +9,17 @@ const cors = {
const ok = "public, max-age=1, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400"
const error = "public, max-age=1, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400"
-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)
-
- 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 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)
- const title = line.slice(3).trim()
- current = { title, items: [] }
- skip = false
- } 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 }
-}
-
export async function GET() {
- const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", {
+ const result = await loadChangelog().catch(() => ({ ok: false, releases: [] }))
+
+ return new Response(JSON.stringify({ releases: result.releases }), {
+ status: result.ok ? 200 : 503,
headers: {
- Accept: "application/vnd.github.v3+json",
- "User-Agent": "OpenCode-Console",
- },
- cf: {
- // best-effort edge caching (ignored outside Cloudflare)
- cacheTtl: 60 * 5,
- cacheEverything: true,
+ "Content-Type": "application/json",
+ "Cache-Control": result.ok ? ok : error,
+ ...cors,
},
- } as any).catch((err) => {
- console.error("[changelog.json] fetch failed", {
- error: err instanceof Error ? err.message : String(err),
- })
- return undefined
})
-
- const fail = () =>
- new Response(JSON.stringify({ releases: [] }), {
- status: 503,
- headers: {
- "Content-Type": "application/json",
- "Cache-Control": error,
- ...cors,
- },
- })
-
- if (!response) return fail()
- if (!response.ok) {
- const body = await response.text().catch(() => undefined)
- console.error("[changelog.json] github non-ok", {
- status: response.status,
- remaining: response.headers.get("x-ratelimit-remaining"),
- reset: response.headers.get("x-ratelimit-reset"),
- body: body?.slice(0, 300),
- })
- return fail()
- }
-
- const data = await response.json().catch((err) => {
- console.error("[changelog.json] json parse failed", {
- error: err instanceof Error ? err.message : String(err),
- })
- return undefined
- })
- if (!Array.isArray(data)) {
- console.error("[changelog.json] invalid json", {
- type: typeof data,
- })
- return fail()
- }
-
- const releases = data as Release[]
-
- return new Response(
- JSON.stringify({
- releases: releases.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,
- }
- }),
- }),
- {
- headers: {
- "Content-Type": "application/json",
- "Cache-Control": ok,
- ...cors,
- },
- },
- )
}
export async function OPTIONS() {
diff --git a/packages/console/app/src/routes/changelog/index.tsx b/packages/console/app/src/routes/changelog/index.tsx
index ec5cd4e9d..dff0a427f 100644
--- a/packages/console/app/src/routes/changelog/index.tsx
+++ b/packages/console/app/src/routes/changelog/index.tsx
@@ -1,117 +1,13 @@
import "./index.css"
import { Title, Meta, Link } from "@solidjs/meta"
-import { createAsync, useSearchParams } from "@solidjs/router"
+import { createAsync } from "@solidjs/router"
import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import { config } from "~/config"
-import { For, Show, createSignal, onMount } from "solid-js"
-import { getRequestEvent } from "solid-js/web"
-
-type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
-
-type HighlightItem = {
- title: string
- description: string
- shortDescription?: string
- media: HighlightMedia
-}
-
-type HighlightGroup = {
- source: string
- items: HighlightItem[]
-}
-
-type ChangelogRelease = {
- tag: string
- name: string
- date: string
- url: string
- highlights: HighlightGroup[]
- sections: { title: string; items: string[] }[]
-}
-
-type LoadMeta = {
- endpoint: string
- ssr: boolean
- hasEvent: boolean
- ok: boolean
- status?: number
- contentType?: string
- error?: string
-}
-
-type Load = {
- releases: ChangelogRelease[]
- meta: LoadMeta
-}
-
-function endpoint() {
- const event = getRequestEvent()
- if (event) return new URL("/changelog.json", event.request.url).toString()
- if (!import.meta.env.SSR) return "/changelog.json"
- return `${config.baseUrl}/changelog.json`
-}
-
-async function getReleases(debug = false): Promise<Load> {
- const url = endpoint()
- const meta = {
- endpoint: url,
- ssr: import.meta.env.SSR,
- hasEvent: Boolean(getRequestEvent()),
- ok: false,
- } satisfies LoadMeta
-
- const response = await fetch(url).catch((err) => {
- console.error("[changelog] fetch failed", {
- ...meta,
- error: err instanceof Error ? err.message : String(err),
- })
- return undefined
- })
-
- if (!response) return { releases: [], meta: { ...meta, error: "fetch_failed" } }
- if (!response.ok) {
- const contentType = response.headers.get("content-type") ?? undefined
- const body = debug ? await response.text().catch(() => undefined) : undefined
- console.error("[changelog] fetch non-ok", {
- ...meta,
- status: response.status,
- contentType,
- body: body?.slice(0, 300),
- })
- return { releases: [], meta: { ...meta, status: response.status, contentType, error: "bad_status" } }
- }
-
- const contentType = response.headers.get("content-type") ?? undefined
- const copy = debug ? response.clone() : undefined
- const json = await response.json().catch(async (err) => {
- const body = copy ? await copy.text().catch(() => undefined) : undefined
- console.error("[changelog] json parse failed", {
- ...meta,
- status: response.status,
- contentType,
- error: err instanceof Error ? err.message : String(err),
- body: body?.slice(0, 300),
- })
- return undefined
- })
-
- const releases = Array.isArray(json?.releases) ? (json.releases as ChangelogRelease[]) : []
- if (!releases.length) {
- console.error("[changelog] empty releases", {
- ...meta,
- status: response.status,
- contentType,
- keys: json && typeof json === "object" ? Object.keys(json) : undefined,
- })
- }
-
- return {
- releases,
- meta: { ...meta, ok: true, status: response.status, contentType },
- }
-}
+import { changelog } from "~/lib/changelog"
+import type { HighlightGroup } from "~/lib/changelog"
+import { For, Show, createSignal } from "solid-js"
function formatDate(dateString: string) {
const date = new Date(dateString)
@@ -201,22 +97,8 @@ function CollapsibleSections(props: { sections: { title: string; items: string[]
}
export default function Changelog() {
- const [params] = useSearchParams()
- const debug = () => params.debug === "1"
- const data = createAsync(() => getReleases(debug()))
- const [client, setClient] = createSignal<Load | undefined>(undefined)
- const releases = () => client()?.releases ?? data()?.releases ?? []
-
- onMount(() => {
- queueMicrotask(async () => {
- const server = data()?.releases
- if (!server) return
- if (server.length) return
-
- const next = await getReleases(debug())
- setClient(next)
- })
- })
+ const data = createAsync(() => changelog())
+ const releases = () => data() ?? []
return (
<main data-page="changelog">
@@ -239,18 +121,6 @@ export default function Changelog() {
No changelog entries found. <a href="/changelog.json">View JSON</a>
</p>
</Show>
- <Show when={debug()}>
- <pre style={{ "font-size": "12px", "line-height": "1.4", padding: "12px" }}>
- {JSON.stringify(
- {
- server: data()?.meta,
- client: client()?.meta,
- },
- null,
- 2,
- )}
- </pre>
- </Show>
<For each={releases()}>
{(release) => {
return (