Add collapsible sections, sticky version header, and style refinements for changelog highlights

This commit is contained in:
Ryan Vogel
2026-01-25 21:18:26 -05:00
committed by Dax Raad
parent eaad75b176
commit cc0085676b
3 changed files with 223 additions and 93 deletions

View File

@@ -6,21 +6,22 @@ type Release = {
html_url: string html_url: string
} }
type Highlight = { type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
source: string
type HighlightItem = {
title: string title: string
description: string description: string
shortDescription?: string shortDescription?: string
image?: { media: HighlightMedia
src: string
width: string
height: string
}
video?: string
} }
function parseHighlights(body: string): Highlight[] { type HighlightGroup = {
const highlights: Highlight[] = [] source: string
items: HighlightItem[]
}
function parseHighlights(body: string): HighlightGroup[] {
const groups = new Map<string, HighlightItem[]>()
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
let match let match
@@ -30,29 +31,32 @@ function parseHighlights(body: string): Highlight[] {
const titleMatch = content.match(/<h2>([^<]+)<\/h2>/) const titleMatch = content.match(/<h2>([^<]+)<\/h2>/)
const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/) const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/)
const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="([^"]*)"\s+src="([^"]+)"/) 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) const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
if (titleMatch) { let media: HighlightMedia | undefined
highlights.push({ if (videoMatch) {
source, 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], title: titleMatch[1],
description: pMatch?.[2] || "", description: pMatch?.[2] || "",
shortDescription: pMatch?.[1], shortDescription: pMatch?.[1],
image: imgMatch media,
? { }
width: imgMatch[1],
height: imgMatch[2], if (!groups.has(source)) {
src: imgMatch[4], groups.set(source, [])
} }
: undefined, groups.get(source)!.push(item)
video: videoMatch?.[1],
})
} }
} }
return highlights return Array.from(groups.entries()).map(([source, items]) => ({ source, items }))
} }
function parseMarkdown(body: string) { function parseMarkdown(body: string) {

View File

@@ -367,11 +367,18 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
position: sticky;
top: 80px;
align-self: start;
background: var(--color-background);
padding: 8px 0;
@media (max-width: 50rem) { @media (max-width: 50rem) {
position: static;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 0;
} }
[data-slot="version"] { [data-slot="version"] {
@@ -402,24 +409,26 @@
[data-component="section"] { [data-component="section"] {
h3 { h3 {
font-size: 14px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--color-text-strong); color: var(--color-text-strong);
margin-bottom: 8px; margin-bottom: 6px;
} }
ul { ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
padding-left: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 4px;
li { li {
color: var(--color-text); color: var(--color-text);
font-size: 13px;
line-height: 1.5; line-height: 1.5;
padding-left: 16px; padding-left: 12px;
position: relative; position: relative;
&::before { &::before {
@@ -431,7 +440,7 @@
[data-slot="author"] { [data-slot="author"] {
color: var(--color-text-weak); color: var(--color-text-weak);
font-size: 13px; font-size: 12px;
margin-left: 4px; margin-left: 4px;
text-decoration: none; text-decoration: none;
@@ -473,6 +482,72 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
[data-component="collapsible-sections"] {
display: flex;
flex-direction: column;
gap: 0;
}
[data-component="collapsible-section"] {
[data-slot="toggle"] {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: none;
padding: 6px 0;
cursor: pointer;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: var(--color-text-weak);
&:hover {
color: var(--color-text);
}
[data-slot="icon"] {
font-size: 10px;
}
}
ul {
list-style: none;
padding: 0;
margin: 0;
padding-left: 16px;
padding-bottom: 8px;
li {
color: var(--color-text);
font-size: 13px;
line-height: 1.5;
padding-left: 12px;
position: relative;
&::before {
content: "-";
position: absolute;
left: 0;
color: var(--color-text-weak);
}
[data-slot="author"] {
color: var(--color-text-weak);
font-size: 12px;
margin-left: 4px;
text-decoration: none;
&:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
}
}
}
[data-component="highlight"] { [data-component="highlight"] {
h4 { h4 {
font-size: 14px; font-size: 14px;
@@ -481,16 +556,29 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
p[data-slot="title"] { hr {
font-weight: 500; border: none;
color: var(--color-text-strong); border-top: 1px solid var(--color-border-weak);
margin-bottom: 4px; margin-bottom: 16px;
} }
p { [data-slot="highlight-item"] {
color: var(--color-text); margin-bottom: 24px;
line-height: 1.5;
margin-bottom: 12px; &:last-child {
margin-bottom: 0;
}
p[data-slot="title"] {
font-weight: 600;
font-size: 16px;
margin-bottom: 4px;
}
p {
font-size: 14px;
margin-bottom: 12px;
}
} }
img, img,

View File

@@ -5,7 +5,7 @@ import { Header } from "~/component/header"
import { Footer } from "~/component/footer" import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal" import { Legal } from "~/component/legal"
import { config } from "~/config" import { config } from "~/config"
import { For, Show } from "solid-js" import { For, Show, createSignal } from "solid-js"
type Release = { type Release = {
tag_name: string tag_name: string
@@ -40,21 +40,22 @@ function formatDate(dateString: string) {
}) })
} }
type Highlight = { type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
source: string
type HighlightItem = {
title: string title: string
description: string description: string
shortDescription?: string shortDescription?: string
image?: { media: HighlightMedia
src: string
width: string
height: string
}
video?: string
} }
function parseHighlights(body: string): Highlight[] { type HighlightGroup = {
const highlights: Highlight[] = [] source: string
items: HighlightItem[]
}
function parseHighlights(body: string): HighlightGroup[] {
const groups = new Map<string, HighlightItem[]>()
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
let match let match
@@ -64,33 +65,32 @@ function parseHighlights(body: string): Highlight[] {
const titleMatch = content.match(/<h2>([^<]+)<\/h2>/) const titleMatch = content.match(/<h2>([^<]+)<\/h2>/)
const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/) const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/)
const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="([^"]*)"\s+src="([^"]+)"/) 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) const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
if (titleMatch) { let media: HighlightMedia | undefined
highlights.push({ if (videoMatch) {
source, 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], title: titleMatch[1],
description: pMatch?.[2] || "", description: pMatch?.[2] || "",
shortDescription: pMatch?.[1], shortDescription: pMatch?.[1],
image: imgMatch media,
? { }
width: imgMatch[1],
height: imgMatch[2], if (!groups.has(source)) {
src: imgMatch[4], groups.set(source, [])
} }
: undefined, groups.get(source)!.push(item)
video: videoMatch?.[1],
})
} }
} }
return highlights return Array.from(groups.entries()).map(([source, items]) => ({ source, items }))
}
function toTitleCase(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
} }
function parseMarkdown(body: string) { function parseMarkdown(body: string) {
@@ -142,27 +142,60 @@ function ReleaseItem(props: { item: string }) {
) )
} }
function HighlightCard(props: { highlight: Highlight }) { function HighlightSection(props: { group: HighlightGroup }) {
return ( return (
<div data-component="highlight"> <div data-component="highlight">
<h4>{props.highlight.source}</h4> <h4>{props.group.source}</h4>
<p data-slot="title">{props.highlight.title}</p> <hr />
<p>{props.highlight.description}</p> <For each={props.group.items}>
<Show when={props.highlight.video}> {(item) => (
<video src={props.highlight.video} controls autoplay loop muted playsinline /> <div data-slot="highlight-item">
</Show> <p data-slot="title">{item.title}</p>
<Show when={props.highlight.image && !props.highlight.video}> <p>{item.description}</p>
<img <Show when={item.media.type === "video"}>
src={props.highlight.image!.src} <video src={item.media.src} controls autoplay loop muted playsinline />
alt={props.highlight.title} </Show>
width={props.highlight.image!.width} <Show when={item.media.type === "image"}>
height={props.highlight.image!.height} <img
/> src={item.media.src}
alt={item.title}
width={(item.media as { width: string }).width}
height={(item.media as { height: string }).height}
/>
</Show>
</div>
)}
</For>
</div>
)
}
function CollapsibleSection(props: { section: { title: string; items: string[] } }) {
const [open, setOpen] = createSignal(false)
return (
<div data-component="collapsible-section">
<button data-slot="toggle" onClick={() => setOpen(!open())}>
<span data-slot="icon">{open() ? "▾" : "▸"}</span>
<span>{props.section.title}</span>
</button>
<Show when={open()}>
<ul>
<For each={props.section.items}>{(item) => <ReleaseItem item={item} />}</For>
</ul>
</Show> </Show>
</div> </div>
) )
} }
function CollapsibleSections(props: { sections: { title: string; items: string[] }[] }) {
return (
<div data-component="collapsible-sections">
<For each={props.sections}>{(section) => <CollapsibleSection section={section} />}</For>
</div>
)
}
export default function Changelog() { export default function Changelog() {
const releases = createAsync(() => getReleases()) const releases = createAsync(() => getReleases())
@@ -198,19 +231,24 @@ export default function Changelog() {
<div data-slot="content"> <div data-slot="content">
<Show when={parsed().highlights.length > 0}> <Show when={parsed().highlights.length > 0}>
<div data-component="highlights"> <div data-component="highlights">
<For each={parsed().highlights}>{(highlight) => <HighlightCard highlight={highlight} />}</For> <For each={parsed().highlights}>{(group) => <HighlightSection group={group} />}</For>
</div> </div>
</Show> </Show>
<For each={parsed().sections}> <Show when={parsed().highlights.length > 0 && parsed().sections.length > 0}>
{(section) => ( <CollapsibleSections sections={parsed().sections} />
<div data-component="section"> </Show>
<h3>{section.title}</h3> <Show when={parsed().highlights.length === 0}>
<ul> <For each={parsed().sections}>
<For each={section.items}>{(item) => <ReleaseItem item={item} />}</For> {(section) => (
</ul> <div data-component="section">
</div> <h3>{section.title}</h3>
)} <ul>
</For> <For each={section.items}>{(item) => <ReleaseItem item={item} />}</For>
</ul>
</div>
)}
</For>
</Show>
</div> </div>
</article> </article>
) )