417 lines
13 KiB
Go
417 lines
13 KiB
Go
package html
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
. "maragu.dev/gomponents"
|
|
. "maragu.dev/gomponents/html"
|
|
)
|
|
|
|
// Release is a single GitHub release with per-platform download URLs.
|
|
type Release struct {
|
|
TagName string // Full GitHub tag, e.g. "v4.0.0-q-canary.12"
|
|
Version string // Base semver, e.g. "4.0.0"
|
|
CanaryBuild int // >0 for canary, e.g. 12 from "v4.0.0-q-canary.12"
|
|
Date string // Human date for UI, e.g. "May 2026"
|
|
PublishedAt time.Time // For update feed pub_date
|
|
IsCanary bool
|
|
Assets map[string]map[string]string // os -> arch -> URL
|
|
}
|
|
|
|
// DownloadURL returns the asset URL for the given OS and arch, or "" if unavailable.
|
|
func (r Release) DownloadURL(os, arch string) string {
|
|
if m := r.Assets[os]; m != nil {
|
|
return m[arch]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// DisplayVersion returns a human-readable version string.
|
|
// Canary builds render as "4.0.0 Canary #12".
|
|
func (r Release) DisplayVersion() string {
|
|
if r.CanaryBuild > 0 {
|
|
return fmt.Sprintf("%s Canary #%d", r.Version, r.CanaryBuild)
|
|
}
|
|
return r.Version
|
|
}
|
|
|
|
func buildFallback(version, date string, published time.Time, canary bool) Release {
|
|
assets := make(map[string]map[string]string)
|
|
for _, os := range []string{"mac", "windows", "linux"} {
|
|
assets[os] = make(map[string]string)
|
|
for _, arch := range []string{"arm64", "x64"} {
|
|
assets[os][arch] = fallbackURL(version, os, arch)
|
|
}
|
|
}
|
|
tag := "v" + version
|
|
if canary {
|
|
tag = "v" + version + "-q-canary.1" // placeholder build
|
|
}
|
|
return Release{
|
|
TagName: tag,
|
|
Version: version,
|
|
Date: date,
|
|
PublishedAt: published,
|
|
IsCanary: canary,
|
|
Assets: assets,
|
|
}
|
|
}
|
|
|
|
func fallbackURL(version, os, arch string) string {
|
|
switch os {
|
|
case "windows":
|
|
return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper.Setup.%s.exe", version, version)
|
|
case "linux":
|
|
if arch == "arm64" {
|
|
return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper-%s-arm64.AppImage", version, version)
|
|
}
|
|
return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper-%s.AppImage", version, version)
|
|
default: // mac
|
|
if arch == "arm64" {
|
|
return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper-%s-arm64.dmg", version, version)
|
|
}
|
|
return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper-%s.dmg", version, version)
|
|
}
|
|
}
|
|
|
|
// DownloadProps carries platform selection and release list for the download page.
|
|
type DownloadProps struct {
|
|
DetectedOS string
|
|
DetectedArch string
|
|
SelectedOS string
|
|
SelectedArch string
|
|
ArchExplicit bool // true if arch came from an explicit ?arch= query param rather than auto-detection
|
|
Releases []Release
|
|
}
|
|
|
|
type platformOption struct {
|
|
OS string
|
|
Arch string
|
|
Label string
|
|
}
|
|
|
|
var platforms = []platformOption{
|
|
{"mac", "arm64", "macOS · Apple Silicon"},
|
|
{"mac", "x64", "macOS · Intel"},
|
|
{"windows", "x64", "Windows · x64"},
|
|
{"linux", "x64", "Linux · x64"},
|
|
{"linux", "arm64", "Linux · ARM64"},
|
|
}
|
|
|
|
func fileExt(os string) string {
|
|
switch os {
|
|
case "windows":
|
|
return ".exe"
|
|
case "linux":
|
|
return ".AppImage"
|
|
case "linux-deb":
|
|
return ".deb"
|
|
case "linux-rpm":
|
|
return ".rpm"
|
|
case "linux-snap":
|
|
return ".snap"
|
|
case "linux-pacman":
|
|
return ".pacman"
|
|
case "linux-flatpak":
|
|
return ".flatpak"
|
|
default:
|
|
return ".dmg"
|
|
}
|
|
}
|
|
|
|
func platformLabel(os, arch string) string {
|
|
effectiveOS := os
|
|
if os == "linux-rpm" {
|
|
effectiveOS = "linux"
|
|
}
|
|
for _, p := range platforms {
|
|
if p.OS == effectiveOS && p.Arch == arch {
|
|
return p.Label
|
|
}
|
|
}
|
|
return effectiveOS + " · " + arch
|
|
}
|
|
|
|
// DownloadPage renders the download page with OS/arch selection and version list.
|
|
func DownloadPage(props PageProps, dl DownloadProps) Node {
|
|
props.Title = "Download — Quine Hyper"
|
|
props.Description = "Download Quine Hyper for macOS, Windows, or Linux."
|
|
|
|
// Split into stable and canary.
|
|
var stable, canary []Release
|
|
for _, r := range dl.Releases {
|
|
if r.IsCanary {
|
|
canary = append(canary, r)
|
|
} else {
|
|
stable = append(stable, r)
|
|
}
|
|
}
|
|
|
|
content := []Node{
|
|
If(dl.SelectedOS == "mac" && !dl.ArchExplicit, macArchDetectScript()),
|
|
H1(Class("font-mono text-3xl font-bold text-white"), Text("Download Quine Hyper")),
|
|
Div(Class("mt-8"),
|
|
P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-3"), Text("Platform")),
|
|
Div(Class("flex flex-wrap gap-2"),
|
|
Group(platformButtons(dl)),
|
|
),
|
|
If(strings.HasPrefix(dl.SelectedOS, "linux"),
|
|
linuxFormatToggle(dl),
|
|
),
|
|
),
|
|
}
|
|
if len(stable) > 0 {
|
|
content = append(content, stableSection(stable, dl))
|
|
}
|
|
if len(canary) > 0 {
|
|
content = append(content, canarySection(canary, dl))
|
|
}
|
|
content = append(content,
|
|
Div(Class("mt-6 text-center"),
|
|
A(
|
|
Href("https://github.com/quine-global/hyper/releases"),
|
|
Class("font-mono text-xs text-zinc-500 hover:text-white transition-colors underline underline-offset-4"),
|
|
Text("All releases on GitHub ↗"),
|
|
),
|
|
),
|
|
)
|
|
|
|
return page(props,
|
|
Div(Class("mx-auto max-w-xl py-16"),
|
|
Group(content),
|
|
),
|
|
)
|
|
}
|
|
|
|
func stableSection(stable []Release, dl DownloadProps) Node {
|
|
latest := stable[0]
|
|
older := stable[1:]
|
|
url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch)
|
|
|
|
return Group{
|
|
Div(Class("mt-10"),
|
|
Div(Class("mb-4 flex items-center gap-3"),
|
|
P(Class("font-mono text-xs uppercase tracking-widest text-seafoam-400"), Text("Stable releases")),
|
|
),
|
|
|
|
// Primary download card
|
|
Div(Class("min-h-[200px] flex items-center rounded-xl border border-seafoam-400/30 bg-zinc-900 p-6"),
|
|
Div(Class("flex w-full items-center justify-between gap-4 flex-wrap"),
|
|
Div(
|
|
Span(Class("font-mono text-xs font-semibold uppercase tracking-widest text-seafoam-400"),
|
|
Text("Latest Stable"),
|
|
),
|
|
H2(Class("mt-1 font-mono text-xl font-bold text-white"),
|
|
Text("Quine Hyper v"+latest.DisplayVersion()),
|
|
),
|
|
P(Class("mt-1 font-mono text-xs text-zinc-500"),
|
|
Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)),
|
|
),
|
|
),
|
|
Div(Class("flex-1")),
|
|
downloadButton(url, "bg-seafoam-400 hover:bg-seafoam-500 text-black"),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Older stable releases
|
|
If(len(older) > 0,
|
|
Div(Class("mt-8"),
|
|
P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-4"), Text("Older releases")),
|
|
Div(Class("divide-y divide-zinc-800 rounded-xl border border-zinc-800"),
|
|
Group(releaseRows(older, dl, "text-seafoam-400 hover:text-seafoam-500")),
|
|
),
|
|
),
|
|
),
|
|
}
|
|
}
|
|
|
|
func canarySection(canary []Release, dl DownloadProps) Node {
|
|
latest := canary[0]
|
|
older := canary[1:]
|
|
url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch)
|
|
|
|
return Div(Class("mt-10"),
|
|
Div(Class("mb-4 flex items-center gap-3"),
|
|
P(Class("font-mono text-xs uppercase tracking-widest text-canary-500"), Text("Canary releases")),
|
|
Span(Class("font-mono text-xs text-zinc-600"), Text("— unstable, may contain bugs")),
|
|
),
|
|
|
|
// Latest canary card
|
|
Div(Class("rounded-xl border border-canary-500/30 bg-zinc-900 p-6"),
|
|
Div(Class("flex items-center justify-between gap-4 flex-wrap"),
|
|
Div(
|
|
Span(Class("font-mono text-xs font-semibold uppercase tracking-widest text-canary-400"),
|
|
Text("Latest Canary"),
|
|
),
|
|
H2(Class("mt-1 font-mono text-base font-bold text-white"),
|
|
Text("Quine Hyper v"+latest.DisplayVersion()),
|
|
),
|
|
P(Class("mt-1 font-mono text-xs text-zinc-500"),
|
|
Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)),
|
|
),
|
|
),
|
|
downloadButton(url, "bg-canary-500 hover:bg-canary-400 text-black", "py-2 text-xs"),
|
|
),
|
|
),
|
|
|
|
// Older canary releases
|
|
If(len(older) > 0,
|
|
Div(Class("mt-4 divide-y divide-zinc-800 rounded-xl border border-zinc-800"),
|
|
Group(releaseRows(older, dl, "text-canary-400 hover:text-canary-300")),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
func downloadButton(url, colorCls string, extraCls ...string) Node {
|
|
base := "inline-flex items-center justify-center gap-2 rounded-lg w-[135px] font-mono font-semibold transition-colors shrink-0 "
|
|
|
|
sizeCls := "px-6 py-3 text-sm"
|
|
if len(extraCls) > 0 && extraCls[0] != "" {
|
|
sizeCls = extraCls[0]
|
|
}
|
|
|
|
btnCls := base + sizeCls + " " + colorCls
|
|
unavailableCls := base + sizeCls + " border border-zinc-800 text-zinc-600"
|
|
|
|
if url == "" {
|
|
return Span(Class(unavailableCls), Text("Not available"))
|
|
}
|
|
return A(
|
|
Href(url),
|
|
Class(btnCls),
|
|
Text("↓ Download"),
|
|
)
|
|
}
|
|
|
|
func releaseRows(releases []Release, dl DownloadProps, linkCls string) []Node {
|
|
nodes := make([]Node, len(releases))
|
|
for i, r := range releases {
|
|
url := r.DownloadURL(dl.SelectedOS, dl.SelectedArch)
|
|
var link Node
|
|
if url != "" {
|
|
link = A(
|
|
Href(url),
|
|
Class("font-mono text-xs transition-colors "+linkCls),
|
|
Text("↓ "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)),
|
|
)
|
|
} else {
|
|
link = Span(
|
|
Class("font-mono text-xs text-zinc-600"),
|
|
Text("Not available"),
|
|
)
|
|
}
|
|
nodes[i] = Div(Class("flex items-center justify-between px-5 py-4"),
|
|
Div(Class("flex items-center gap-4"),
|
|
Span(Class("font-mono text-sm font-semibold text-white"), Text("v"+r.DisplayVersion())),
|
|
Span(Class("font-mono text-xs text-zinc-500"), Text(r.Date)),
|
|
),
|
|
link,
|
|
)
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
func platformButtons(dl DownloadProps) []Node {
|
|
nodes := make([]Node, len(platforms))
|
|
// Treat linux-rpm as linux for platform button highlight/detection purposes.
|
|
effectiveSelectedOS := dl.SelectedOS
|
|
if strings.HasPrefix(dl.SelectedOS, "linux") {
|
|
effectiveSelectedOS = "linux"
|
|
}
|
|
for i, p := range platforms {
|
|
isSelected := p.OS == effectiveSelectedOS && p.Arch == dl.SelectedArch
|
|
isDetected := p.OS == dl.DetectedOS && p.Arch == dl.DetectedArch
|
|
|
|
// Preserve the linux format when switching arch; snap is x64-only so don't carry it to arm64.
|
|
targetOS := p.OS
|
|
if p.OS == "linux" && strings.HasPrefix(dl.SelectedOS, "linux-") {
|
|
if dl.SelectedOS != "linux-snap" || p.Arch == "x64" {
|
|
targetOS = dl.SelectedOS
|
|
}
|
|
}
|
|
|
|
var cls string
|
|
if isSelected {
|
|
cls = "inline-flex items-center gap-1.5 rounded-md bg-primary-500 px-3 py-1.5 font-mono text-xs font-semibold text-black"
|
|
} else {
|
|
cls = "inline-flex items-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 font-mono text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors"
|
|
}
|
|
|
|
children := []Node{Text(p.Label)}
|
|
if isDetected && !isSelected {
|
|
children = append(children,
|
|
Span(Class("rounded bg-zinc-700 px-1 py-0.5 text-zinc-400 text-[10px]"), Text("auto")),
|
|
)
|
|
} else if isDetected && isSelected {
|
|
children = append(children,
|
|
Span(Class("rounded bg-black/20 px-1 py-0.5 text-[10px]"), Text("auto")),
|
|
)
|
|
}
|
|
|
|
nodes[i] = A(
|
|
Href(fmt.Sprintf("/download?os=%s&arch=%s", targetOS, p.Arch)),
|
|
Class(cls),
|
|
Group(children),
|
|
)
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
// macArchDetectScript corrects the arm64 default guess on real Intel Macs.
|
|
// Browser User-Agent strings report "Intel Mac OS X" even on Apple Silicon, so
|
|
// the server can't tell arm64 from x64 from headers alone. The WebGL renderer
|
|
// string is the one client-side signal that actually differs: Apple Silicon
|
|
// reports something containing "Apple" (e.g. "Apple M2"); real Intel/AMD/Nvidia
|
|
// GPUs never do. If it doesn't look like Apple Silicon, reload with ?arch=x64.
|
|
func macArchDetectScript() Node {
|
|
return Script(Raw(`(function(){
|
|
try {
|
|
var c = document.createElement('canvas');
|
|
var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
|
|
if (!gl) return;
|
|
var ext = gl.getExtension('WEBGL_debug_renderer_info');
|
|
if (!ext) return;
|
|
var renderer = String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || '');
|
|
if (!renderer || /Apple/i.test(renderer)) return;
|
|
var url = new URL(window.location.href);
|
|
url.searchParams.set('arch', 'x64');
|
|
window.location.replace(url.toString());
|
|
} catch (e) {}
|
|
})();`))
|
|
}
|
|
|
|
func linuxFormatToggle(dl DownloadProps) Node {
|
|
selectedCls := "inline-flex items-center rounded-md bg-primary-500 px-3 py-1.5 font-mono text-xs font-semibold text-black"
|
|
unselectedCls := "inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 font-mono text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors"
|
|
cls := func(targetOS string) string {
|
|
if dl.SelectedOS == targetOS {
|
|
return selectedCls
|
|
}
|
|
return unselectedCls
|
|
}
|
|
href := func(targetOS string) string {
|
|
return fmt.Sprintf("/download?os=%s&arch=%s", targetOS, dl.SelectedArch)
|
|
}
|
|
|
|
buttons := []Node{
|
|
A(Href(href("linux")), Class(cls("linux")), Text("AppImage")),
|
|
A(Href(href("linux-deb")), Class(cls("linux-deb")), Text("DEB")),
|
|
A(Href(href("linux-rpm")), Class(cls("linux-rpm")), Text("RPM")),
|
|
A(Href(href("linux-pacman")), Class(cls("linux-pacman")), Text("Pacman")),
|
|
A(Href(href("linux-flatpak")), Class(cls("linux-flatpak")), Text("Flatpak")),
|
|
}
|
|
if dl.SelectedArch == "x64" {
|
|
buttons = append(buttons, A(Href(href("linux-snap")), Class(cls("linux-snap")), Text("Snap")))
|
|
}
|
|
|
|
return Div(Class("mt-4"),
|
|
P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-3"), Text("Format")),
|
|
Div(Class("flex items-center gap-2"),
|
|
Group(buttons),
|
|
),
|
|
)
|
|
}
|