Просмотр исходного кода

Add highlight tag parsing for changelog with video support

Ryan Vogel 3 недель назад
Родитель
Сommit
ab3268896d

+ 110 - 0
packages/console/app/src/routes/changelog.json.ts

@@ -0,0 +1,110 @@
+type Release = {
+  tag_name: string
+  name: string
+  body: string
+  published_at: string
+  html_url: string
+}
+
+type Highlight = {
+  source: string
+  title: string
+  description: string
+  shortDescription?: string
+  image?: {
+    src: string
+    width: string
+    height: string
+  }
+  video?: string
+}
+
+function parseHighlights(body: string): Highlight[] {
+  const highlights: Highlight[] = []
+  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="([^"]+)"/)
+    // Match standalone GitHub asset URLs (videos)
+    const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
+
+    if (titleMatch) {
+      highlights.push({
+        source,
+        title: titleMatch[1],
+        description: pMatch?.[2] || "",
+        shortDescription: pMatch?.[1],
+        image: imgMatch
+          ? {
+              width: imgMatch[1],
+              height: imgMatch[2],
+              src: imgMatch[4],
+            }
+          : undefined,
+        video: videoMatch?.[1],
+      })
+    }
+  }
+
+  return highlights
+}
+
+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", {
+    headers: {
+      Accept: "application/vnd.github.v3+json",
+      "User-Agent": "OpenCode-Console",
+    },
+  })
+
+  if (!response.ok) {
+    return { releases: [] }
+  }
+
+  const releases = (await response.json()) as Release[]
+
+  return {
+    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,
+      }
+    }),
+  }
+}

+ 35 - 0
packages/console/app/src/routes/changelog/index.css

@@ -465,6 +465,41 @@
         }
       }
     }
+
+    [data-component="highlights"] {
+      display: flex;
+      flex-direction: column;
+      gap: 2rem;
+      margin-bottom: 1.5rem;
+    }
+
+    [data-component="highlight"] {
+      h4 {
+        font-size: 14px;
+        font-weight: 600;
+        color: var(--color-text-strong);
+        margin-bottom: 8px;
+      }
+
+      p[data-slot="title"] {
+        font-weight: 500;
+        color: var(--color-text-strong);
+        margin-bottom: 4px;
+      }
+
+      p {
+        color: var(--color-text);
+        line-height: 1.5;
+        margin-bottom: 12px;
+      }
+
+      img,
+      video {
+        max-width: 100%;
+        height: auto;
+        border-radius: 4px;
+      }
+    }
   }
 
   a {

+ 82 - 1
packages/console/app/src/routes/changelog/index.tsx

@@ -40,6 +40,59 @@ function formatDate(dateString: string) {
   })
 }
 
+type Highlight = {
+  source: string
+  title: string
+  description: string
+  shortDescription?: string
+  image?: {
+    src: string
+    width: string
+    height: string
+  }
+  video?: string
+}
+
+function parseHighlights(body: string): Highlight[] {
+  const highlights: Highlight[] = []
+  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="([^"]+)"/)
+    // Match standalone GitHub asset URLs (videos)
+    const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
+
+    if (titleMatch) {
+      highlights.push({
+        source,
+        title: titleMatch[1],
+        description: pMatch?.[2] || "",
+        shortDescription: pMatch?.[1],
+        image: imgMatch
+          ? {
+              width: imgMatch[1],
+              height: imgMatch[2],
+              src: imgMatch[4],
+            }
+          : undefined,
+        video: videoMatch?.[1],
+      })
+    }
+  }
+
+  return highlights
+}
+
+function toTitleCase(str: string): string {
+  return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
+}
+
 function parseMarkdown(body: string) {
   const lines = body.split("\n")
   const sections: { title: string; items: string[] }[] = []
@@ -60,7 +113,9 @@ function parseMarkdown(body: string) {
   }
   if (current) sections.push(current)
 
-  return { sections }
+  const highlights = parseHighlights(body)
+
+  return { sections, highlights }
 }
 
 function ReleaseItem(props: { item: string }) {
@@ -87,6 +142,27 @@ function ReleaseItem(props: { item: string }) {
   )
 }
 
+function HighlightCard(props: { highlight: Highlight }) {
+  return (
+    <div data-component="highlight">
+      <h4>{props.highlight.source}</h4>
+      <p data-slot="title">{props.highlight.title}</p>
+      <p>{props.highlight.description}</p>
+      <Show when={props.highlight.video}>
+        <video src={props.highlight.video} controls autoplay loop muted playsinline />
+      </Show>
+      <Show when={props.highlight.image && !props.highlight.video}>
+        <img
+          src={props.highlight.image!.src}
+          alt={props.highlight.title}
+          width={props.highlight.image!.width}
+          height={props.highlight.image!.height}
+        />
+      </Show>
+    </div>
+  )
+}
+
 export default function Changelog() {
   const releases = createAsync(() => getReleases())
 
@@ -120,6 +196,11 @@ export default function Changelog() {
                       <time dateTime={release.published_at}>{formatDate(release.published_at)}</time>
                     </header>
                     <div data-slot="content">
+                      <Show when={parsed().highlights.length > 0}>
+                        <div data-component="highlights">
+                          <For each={parsed().highlights}>{(highlight) => <HighlightCard highlight={highlight} />}</For>
+                        </div>
+                      </Show>
                       <For each={parsed().sections}>
                         {(section) => (
                           <div data-component="section">