Explorar o código

chore: refactor changelog

adamelmore hai 3 semanas
pai
achega
de3b654dcd

+ 146 - 0
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) }
+}

+ 8 - 158
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() {

+ 6 - 136
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 (