mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-01 22:48:16 +00:00
chore: refactor changelog
This commit is contained in:
146
packages/console/app/src/lib/changelog.ts
Normal file
146
packages/console/app/src/lib/changelog.ts
Normal file
@@ -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) }
|
||||
}
|
||||
@@ -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",
|
||||
"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() {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user