diff --git a/packages/console/app/src/lib/changelog.ts b/packages/console/app/src/lib/changelog.ts new file mode 100644 index 0000000000..93a0d423c6 --- /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 { + 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() + const regex = /([\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>/) + const pMatch = content.match(/([^<]+)<\/p>/) + const imgMatch = content.match(/ { + 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 f82c8421d9..f06c1be9b4 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() - const regex = /([\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>/) - const pMatch = content.match(/([^<]+)<\/p>/) - const imgMatch = content.match(/ ({ 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", + "Content-Type": "application/json", + "Cache-Control": result.ok ? ok : error, + ...cors, }, - cf: { - // best-effort edge caching (ignored outside Cloudflare) - cacheTtl: 60 * 5, - cacheEverything: true, - }, - } 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 ec5cd4e9d8..dff0a427f7 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 { - 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(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 (
@@ -239,18 +121,6 @@ export default function Changelog() { No changelog entries found. View JSON

- -
-                {JSON.stringify(
-                  {
-                    server: data()?.meta,
-                    client: client()?.meta,
-                  },
-                  null,
-                  2,
-                )}
-              
-
{(release) => { return (