summaryrefslogtreecommitdiffhomepage
path: root/packages/console
diff options
context:
space:
mode:
authorRyan Vogel <[email protected]>2026-01-25 20:56:37 -0500
committerDax Raad <[email protected]>2026-01-25 21:26:43 -0500
commitab3268896d2387a1dba36df2881a10a2ebf8dce6 (patch)
tree9ecd839882fdb8aaebcd9cbff48e3b44c33aab5c /packages/console
parent3d23d2df716bf44c8a26b445ee188cbee94c023b (diff)
downloadopencode-ab3268896d2387a1dba36df2881a10a2ebf8dce6.tar.gz
opencode-ab3268896d2387a1dba36df2881a10a2ebf8dce6.zip
Add highlight tag parsing for changelog with video support
Diffstat (limited to 'packages/console')
-rw-r--r--packages/console/app/src/routes/changelog.json.ts110
-rw-r--r--packages/console/app/src/routes/changelog/index.css35
-rw-r--r--packages/console/app/src/routes/changelog/index.tsx83
3 files changed, 227 insertions, 1 deletions
diff --git a/packages/console/app/src/routes/changelog.json.ts b/packages/console/app/src/routes/changelog.json.ts
new file mode 100644
index 000000000..b668229f8
--- /dev/null
+++ b/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,
+ }
+ }),
+ }
+}
diff --git a/packages/console/app/src/routes/changelog/index.css b/packages/console/app/src/routes/changelog/index.css
index a445c7447..a06fb0055 100644
--- a/packages/console/app/src/routes/changelog/index.css
+++ b/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 {
diff --git a/packages/console/app/src/routes/changelog/index.tsx b/packages/console/app/src/routes/changelog/index.tsx
index c1b931fe3..34fd5f83b 100644
--- a/packages/console/app/src/routes/changelog/index.tsx
+++ b/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">