mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(changelog): serve /changelog from a standalone worker (#54906)
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
name: deploy-changelog
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- services/changelog/**
|
||||
- .github/workflows/deploy-changelog.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-changelog
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'dev'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: services/changelog
|
||||
run: bun typecheck
|
||||
|
||||
- name: Test
|
||||
working-directory: services/changelog
|
||||
run: bun test
|
||||
|
||||
- name: Deploy
|
||||
working-directory: services/changelog
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -20,7 +20,6 @@
|
||||
"@solidjs/router": "catalog:",
|
||||
"@solidjs/start": "catalog:",
|
||||
"aws4fetch": "1.0.20",
|
||||
"marked": "catalog:",
|
||||
"nitro": "3.0.1-alpha.1",
|
||||
"number-flow": "0.6.2",
|
||||
"solid-js": "catalog:",
|
||||
|
||||
@@ -853,10 +853,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "تحديثات وتحسينات جديدة لـ OpenCode",
|
||||
"changelog.empty": "لم يتم العثور على مدخلات في سجل التغييرات.",
|
||||
"changelog.viewJson": "عرض JSON",
|
||||
"changelog.highlights": "أبرز التغييرات",
|
||||
"changelog.error": "تعذر تحميل سجل التغييرات حاليًا.",
|
||||
"changelog.viewOnGithub": "عرضه على GitHub",
|
||||
"changelog.v1": "ملاحظات إصدارات OpenCode 1 موجودة في GitHub Releases",
|
||||
|
||||
"bench.list.title": "المعيار",
|
||||
"bench.list.heading": "المعايير",
|
||||
|
||||
@@ -868,10 +868,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "Novas atualizações e melhorias no OpenCode",
|
||||
"changelog.empty": "Nenhuma entrada de changelog encontrada.",
|
||||
"changelog.viewJson": "Ver JSON",
|
||||
"changelog.highlights": "Destaques",
|
||||
"changelog.error": "Não foi possível carregar o changelog agora.",
|
||||
"changelog.viewOnGithub": "Ver no GitHub",
|
||||
"changelog.v1": "As notas de versão do OpenCode 1 estão no GitHub Releases",
|
||||
|
||||
"bench.list.title": "Benchmark",
|
||||
"bench.list.heading": "Benchmarks",
|
||||
|
||||
@@ -861,10 +861,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "Nye opdateringer og forbedringer til OpenCode",
|
||||
"changelog.empty": "Ingen changelog-indlæg fundet.",
|
||||
"changelog.viewJson": "Se JSON",
|
||||
"changelog.highlights": "Højdepunkter",
|
||||
"changelog.error": "Changeloggen kunne ikke indlæses lige nu.",
|
||||
"changelog.viewOnGithub": "Se den på GitHub",
|
||||
"changelog.v1": "Udgivelsesnoterne for OpenCode 1 findes på GitHub Releases",
|
||||
|
||||
"bench.list.title": "Benchmark",
|
||||
"bench.list.heading": "Benchmarks",
|
||||
|
||||
@@ -868,10 +868,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "Neue Updates und Verbesserungen für OpenCode",
|
||||
"changelog.empty": "Keine Changelog-Einträge gefunden.",
|
||||
"changelog.viewJson": "JSON ansehen",
|
||||
"changelog.highlights": "Highlights",
|
||||
"changelog.error": "Der Changelog konnte gerade nicht geladen werden.",
|
||||
"changelog.viewOnGithub": "Auf GitHub ansehen",
|
||||
"changelog.v1": "Die Release Notes für OpenCode 1 findest du auf GitHub Releases",
|
||||
|
||||
"bench.list.title": "Benchmark",
|
||||
"bench.list.heading": "Benchmarks",
|
||||
|
||||
@@ -866,10 +866,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "New updates and improvements to OpenCode",
|
||||
"changelog.empty": "No changelog entries found.",
|
||||
"changelog.viewJson": "View JSON",
|
||||
"changelog.highlights": "Highlights",
|
||||
"changelog.error": "The changelog could not be loaded right now.",
|
||||
"changelog.viewOnGithub": "View it on GitHub",
|
||||
"changelog.v1": "OpenCode 1 release notes are on GitHub Releases",
|
||||
|
||||
"bench.list.title": "Benchmark",
|
||||
"bench.list.heading": "Benchmarks",
|
||||
|
||||
@@ -868,10 +868,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "Nuevas actualizaciones y mejoras para OpenCode",
|
||||
"changelog.empty": "No se encontraron entradas en el registro de cambios.",
|
||||
"changelog.viewJson": "Ver JSON",
|
||||
"changelog.highlights": "Destacados",
|
||||
"changelog.error": "No se pudo cargar el registro de cambios en este momento.",
|
||||
"changelog.viewOnGithub": "Verlo en GitHub",
|
||||
"changelog.v1": "Las notas de la versión de OpenCode 1 están en GitHub Releases",
|
||||
|
||||
"bench.list.title": "Benchmark",
|
||||
"bench.list.heading": "Benchmarks",
|
||||
|
||||
@@ -877,10 +877,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "Nouvelles mises à jour et améliorations pour OpenCode",
|
||||
"changelog.empty": "Aucune entrée de changelog trouvée.",
|
||||
"changelog.viewJson": "Voir le JSON",
|
||||
"changelog.highlights": "Points forts",
|
||||
"changelog.error": "Impossible de charger le journal des modifications pour le moment.",
|
||||
"changelog.viewOnGithub": "Le consulter sur GitHub",
|
||||
"changelog.v1": "Les notes de version d’OpenCode 1 sont sur GitHub Releases",
|
||||
|
||||
"bench.list.title": "Benchmark",
|
||||
"bench.list.heading": "Benchmarks",
|
||||
|
||||
@@ -865,10 +865,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "Nuovi aggiornamenti e miglioramenti a OpenCode",
|
||||
"changelog.empty": "Nessuna voce del changelog trovata.",
|
||||
"changelog.viewJson": "Vedi JSON",
|
||||
"changelog.highlights": "In evidenza",
|
||||
"changelog.error": "Al momento non è possibile caricare il changelog.",
|
||||
"changelog.viewOnGithub": "Visualizzalo su GitHub",
|
||||
"changelog.v1": "Le note di rilascio di OpenCode 1 sono su GitHub Releases",
|
||||
|
||||
"bench.list.title": "Benchmark",
|
||||
"bench.list.heading": "Benchmark",
|
||||
|
||||
@@ -863,10 +863,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "OpenCodeの新しいアップデートと改善",
|
||||
"changelog.empty": "変更履歴が見つかりませんでした。",
|
||||
"changelog.viewJson": "JSONを表示",
|
||||
"changelog.highlights": "ハイライト",
|
||||
"changelog.error": "現在、変更履歴を読み込めません。",
|
||||
"changelog.viewOnGithub": "GitHub で見る",
|
||||
"changelog.v1": "OpenCode 1 のリリースノートは GitHub Releases にあります",
|
||||
|
||||
"bench.list.title": "ベンチマーク",
|
||||
"bench.list.heading": "ベンチマーク",
|
||||
|
||||
@@ -853,10 +853,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "OpenCode의 새로운 업데이트 및 개선 사항",
|
||||
"changelog.empty": "변경 내역 항목을 찾을 수 없습니다.",
|
||||
"changelog.viewJson": "JSON 보기",
|
||||
"changelog.highlights": "하이라이트",
|
||||
"changelog.error": "지금은 변경 로그를 불러올 수 없습니다.",
|
||||
"changelog.viewOnGithub": "GitHub에서 보기",
|
||||
"changelog.v1": "OpenCode 1 릴리스 노트는 GitHub Releases에 있습니다",
|
||||
|
||||
"bench.list.title": "벤치마크",
|
||||
"bench.list.heading": "벤치마크",
|
||||
|
||||
@@ -862,10 +862,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "Nye oppdateringer og forbedringer for OpenCode",
|
||||
"changelog.empty": "Ingen endringsloggoppføringer funnet.",
|
||||
"changelog.viewJson": "Vis JSON",
|
||||
"changelog.highlights": "Høydepunkter",
|
||||
"changelog.error": "Endringsloggen kunne ikke lastes inn akkurat nå.",
|
||||
"changelog.viewOnGithub": "Se den på GitHub",
|
||||
"changelog.v1": "Utgivelsesnotatene for OpenCode 1 finnes på GitHub Releases",
|
||||
|
||||
"bench.list.title": "Benchmark",
|
||||
"bench.list.heading": "Benchmarks",
|
||||
|
||||
@@ -865,10 +865,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "Nowe aktualizacje i ulepszenia OpenCode",
|
||||
"changelog.empty": "Nie znaleziono wpisów w dzienniku zmian.",
|
||||
"changelog.viewJson": "Zobacz JSON",
|
||||
"changelog.highlights": "Najważniejsze",
|
||||
"changelog.error": "Nie udało się teraz wczytać listy zmian.",
|
||||
"changelog.viewOnGithub": "Zobacz w serwisie GitHub",
|
||||
"changelog.v1": "Informacje o wydaniach OpenCode 1 znajdziesz w GitHub Releases",
|
||||
|
||||
"bench.list.title": "Benchmark",
|
||||
"bench.list.heading": "Benchmarki",
|
||||
|
||||
@@ -873,10 +873,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "Новые обновления и улучшения OpenCode",
|
||||
"changelog.empty": "Записи в списке изменений не найдены.",
|
||||
"changelog.viewJson": "Посмотреть JSON",
|
||||
"changelog.highlights": "Главное",
|
||||
"changelog.error": "Сейчас не удалось загрузить список изменений.",
|
||||
"changelog.viewOnGithub": "Посмотреть на GitHub",
|
||||
"changelog.v1": "Примечания к выпускам OpenCode 1 находятся в GitHub Releases",
|
||||
|
||||
"bench.list.title": "Бенчмарк",
|
||||
"bench.list.heading": "Бенчмарки",
|
||||
|
||||
@@ -858,10 +858,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "การอัปเดตและการปรับปรุงใหม่ใน OpenCode",
|
||||
"changelog.empty": "ไม่พบรายการบันทึกการเปลี่ยนแปลง",
|
||||
"changelog.viewJson": "ดู JSON",
|
||||
"changelog.highlights": "ไฮไลต์",
|
||||
"changelog.error": "ไม่สามารถโหลดบันทึกการเปลี่ยนแปลงได้ในขณะนี้",
|
||||
"changelog.viewOnGithub": "ดูบน GitHub",
|
||||
"changelog.v1": "บันทึกการเผยแพร่ของ OpenCode 1 อยู่ที่ GitHub Releases",
|
||||
|
||||
"bench.list.title": "Benchmark",
|
||||
"bench.list.heading": "Benchmarks",
|
||||
|
||||
@@ -868,10 +868,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "OpenCode için yeni güncellemeler ve iyileştirmeler",
|
||||
"changelog.empty": "Değişiklik günlüğü kaydı bulunamadı.",
|
||||
"changelog.viewJson": "JSON'u görüntüle",
|
||||
"changelog.highlights": "Öne çıkanlar",
|
||||
"changelog.error": "Değişiklik günlüğü şu anda yüklenemedi.",
|
||||
"changelog.viewOnGithub": "GitHub'da görüntüle",
|
||||
"changelog.v1": "OpenCode 1 sürüm notları GitHub Releases'ta",
|
||||
|
||||
"bench.list.title": "Benchmark",
|
||||
"bench.list.heading": "Benchmarklar",
|
||||
|
||||
@@ -825,10 +825,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "Нові оновлення та покращення OpenCode",
|
||||
"changelog.empty": "Записів у журналі змін не знайдено.",
|
||||
"changelog.viewJson": "Переглянути JSON",
|
||||
"changelog.highlights": "Головне",
|
||||
"changelog.error": "Зараз не вдалося завантажити список змін.",
|
||||
"changelog.viewOnGithub": "Переглянути на GitHub",
|
||||
"changelog.v1": "Примітки до випусків OpenCode 1 є в GitHub Releases",
|
||||
|
||||
"bench.list.title": "Бенчмарк",
|
||||
"bench.list.heading": "Бенчмарки",
|
||||
|
||||
@@ -831,10 +831,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "OpenCode 的新更新和改进",
|
||||
"changelog.empty": "未找到更新日志条目。",
|
||||
"changelog.viewJson": "查看 JSON",
|
||||
"changelog.highlights": "亮点",
|
||||
"changelog.error": "暂时无法加载更新日志。",
|
||||
"changelog.viewOnGithub": "在 GitHub 上查看",
|
||||
"changelog.v1": "OpenCode 1 的发布说明位于 GitHub Releases",
|
||||
|
||||
"bench.list.title": "基准测试",
|
||||
"bench.list.heading": "基准测试",
|
||||
|
||||
@@ -829,10 +829,6 @@ export const dict = {
|
||||
"changelog.hero.subtitle": "OpenCode 的新更新與改善",
|
||||
"changelog.empty": "找不到更新日誌項目。",
|
||||
"changelog.viewJson": "檢視 JSON",
|
||||
"changelog.highlights": "亮點",
|
||||
"changelog.error": "暫時無法載入更新日誌。",
|
||||
"changelog.viewOnGithub": "在 GitHub 上查看",
|
||||
"changelog.v1": "OpenCode 1 的發布說明位於 GitHub Releases",
|
||||
|
||||
"bench.list.title": "評測",
|
||||
"bench.list.heading": "評測",
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { query } from "@solidjs/router"
|
||||
import { waitUntil } from "@opencode/console-resource"
|
||||
import { parseChangelog, renderEntry, type ChangelogEntry } from "~/lib/changelog-markdown"
|
||||
|
||||
export const CHANGELOG_FILE_URL = "https://github.com/anomalyco/opencode/blob/dev/CHANGELOG.md"
|
||||
const SOURCE_URL = "https://raw.githubusercontent.com/anomalyco/opencode/dev/CHANGELOG.md"
|
||||
|
||||
// Fresh for five minutes, then served stale for up to a day while requests revalidate it in the background. A failed
|
||||
// revalidation keeps serving the stale copy, so a GitHub outage only shows an error once the day-old copy is evicted.
|
||||
const CACHE_KEY = "https://opencode.ai/changelog/source.md"
|
||||
const FRESH_SECONDS = 300
|
||||
const STALE_SECONDS = 86400
|
||||
const FETCHED_AT = "x-opencode-changelog-fetched-at"
|
||||
|
||||
export type ChangelogPage = { ok: boolean; entries: ChangelogEntry[] }
|
||||
|
||||
function defaultCache() {
|
||||
if (typeof caches === "undefined" || !("default" in caches)) return
|
||||
return (caches as CacheStorage & { default: Cache }).default
|
||||
}
|
||||
|
||||
async function refresh(cache: Cache | undefined) {
|
||||
const response = await fetch(SOURCE_URL, {
|
||||
headers: { "User-Agent": "OpenCode-Console" },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}).catch(() => undefined)
|
||||
if (!response?.ok) return
|
||||
const markdown = await response.text()
|
||||
await cache?.put(
|
||||
CACHE_KEY,
|
||||
new Response(markdown, {
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
"Cache-Control": `public, s-maxage=${FRESH_SECONDS + STALE_SECONDS}`,
|
||||
[FETCHED_AT]: String(Date.now()),
|
||||
},
|
||||
}),
|
||||
)
|
||||
return markdown
|
||||
}
|
||||
|
||||
async function loadSource() {
|
||||
const cache = defaultCache()
|
||||
const cached = await cache?.match(CACHE_KEY)
|
||||
if (!cached) return refresh(cache)
|
||||
if (Date.now() - Number(cached.headers.get(FETCHED_AT)) > FRESH_SECONDS * 1000) void waitUntil(refresh(cache))
|
||||
return cached.text()
|
||||
}
|
||||
|
||||
export const changelogPage = query(async (): Promise<ChangelogPage> => {
|
||||
"use server"
|
||||
const markdown = await loadSource().catch(() => undefined)
|
||||
if (markdown === undefined) return { ok: false, entries: [] }
|
||||
return { ok: true, entries: parseChangelog(markdown).map(renderEntry) }
|
||||
}, "changelog-page")
|
||||
@@ -1,3 +1,5 @@
|
||||
import { query } from "@solidjs/router"
|
||||
|
||||
type Release = {
|
||||
tag_name: string
|
||||
name: string
|
||||
@@ -69,6 +71,12 @@ export async function loadChangelog(): Promise<ChangelogData> {
|
||||
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
|
||||
|
||||
@@ -9,31 +9,24 @@
|
||||
}
|
||||
|
||||
[data-page="changelog"] {
|
||||
/* The stone-paper palette of the Anomaly notes (anoma.ly/notes), mapped onto the site's colour roles. */
|
||||
--color-background: hsl(48, 7%, 90%);
|
||||
--color-background-weak: hsl(45, 5%, 86%);
|
||||
--color-background-weak-hover: hsl(45, 5%, 82%);
|
||||
--color-background-strong: hsl(51, 10%, 13%);
|
||||
--color-background-strong-hover: hsl(40, 6%, 7%);
|
||||
--color-background-interactive: hsl(40, 19%, 82%);
|
||||
--color-background-interactive-weaker: hsl(40, 19%, 88%);
|
||||
--color-background: hsl(0, 20%, 99%);
|
||||
--color-background-weak: hsl(0, 8%, 97%);
|
||||
--color-background-weak-hover: hsl(0, 8%, 94%);
|
||||
--color-background-strong: hsl(0, 5%, 12%);
|
||||
--color-background-strong-hover: hsl(0, 5%, 18%);
|
||||
--color-background-interactive: hsl(62, 84%, 88%);
|
||||
--color-background-interactive-weaker: hsl(64, 74%, 95%);
|
||||
|
||||
--color-text: hsl(51, 10%, 13%);
|
||||
--color-text-weak: hsl(39, 5%, 51%);
|
||||
--color-text-weaker: hsl(39, 6%, 63%);
|
||||
--color-text-strong: hsl(40, 6%, 7%);
|
||||
--color-text-inverted: hsl(48, 7%, 90%);
|
||||
--color-text-label: hsl(39, 6%, 40%);
|
||||
--color-text-code: hsl(32, 13%, 31%);
|
||||
--color-text: hsl(0, 1%, 39%);
|
||||
--color-text-weak: hsl(0, 1%, 60%);
|
||||
--color-text-weaker: hsl(30, 2%, 81%);
|
||||
--color-text-strong: hsl(0, 5%, 12%);
|
||||
--color-text-inverted: hsl(0, 20%, 99%);
|
||||
|
||||
--color-border: hsl(48, 5%, 70%);
|
||||
--color-border-weak: hsl(48, 6%, 79%);
|
||||
--color-link-underline: hsla(38, 21%, 29%, 0.4);
|
||||
--color-link-underline-hover: hsl(38, 27%, 33%);
|
||||
--color-border: hsl(30, 2%, 81%);
|
||||
--color-border-weak: hsl(0, 1%, 85%);
|
||||
|
||||
--color-icon: hsl(39, 5%, 51%);
|
||||
|
||||
--font-serif: "IBM Plex Serif", Georgia, serif;
|
||||
--color-icon: hsl(0, 1%, 55%);
|
||||
|
||||
background: var(--color-background);
|
||||
font-family: var(--font-mono);
|
||||
@@ -41,28 +34,24 @@
|
||||
padding-bottom: 5rem;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--color-background: hsl(60, 7%, 3%);
|
||||
--color-background-weak: hsl(0, 0%, 7%);
|
||||
--color-background-weak-hover: hsl(0, 0%, 10%);
|
||||
--color-background-strong: hsl(37, 15%, 80%);
|
||||
--color-background-strong-hover: hsl(60, 9%, 98%);
|
||||
--color-background-interactive: hsl(40, 5%, 20%);
|
||||
--color-background-interactive-weaker: hsl(40, 5%, 12%);
|
||||
--color-background: hsl(0, 9%, 7%);
|
||||
--color-background-weak: hsl(0, 6%, 10%);
|
||||
--color-background-weak-hover: hsl(0, 6%, 15%);
|
||||
--color-background-strong: hsl(0, 15%, 94%);
|
||||
--color-background-strong-hover: hsl(0, 15%, 97%);
|
||||
--color-background-interactive: hsl(62, 100%, 90%);
|
||||
--color-background-interactive-weaker: hsl(60, 20%, 8%);
|
||||
|
||||
--color-text: hsl(37, 15%, 80%);
|
||||
--color-text-weak: hsl(0, 0%, 48%);
|
||||
--color-text-weaker: hsl(0, 0%, 40%);
|
||||
--color-text-strong: hsl(60, 9%, 98%);
|
||||
--color-text-inverted: hsl(60, 7%, 3%);
|
||||
--color-text-label: hsl(0, 0%, 67%);
|
||||
--color-text-code: hsl(33, 26%, 79%);
|
||||
--color-text: hsl(0, 4%, 71%);
|
||||
--color-text-weak: hsl(0, 2%, 49%);
|
||||
--color-text-weaker: hsl(0, 3%, 28%);
|
||||
--color-text-strong: hsl(0, 15%, 94%);
|
||||
--color-text-inverted: hsl(0, 9%, 7%);
|
||||
|
||||
--color-border: hsl(0, 0%, 22%);
|
||||
--color-border-weak: hsl(0, 0%, 16%);
|
||||
--color-link-underline: hsla(37, 15%, 58%, 0.4);
|
||||
--color-link-underline-hover: hsl(38, 25%, 68%);
|
||||
--color-border: hsl(0, 3%, 28%);
|
||||
--color-border-weak: hsl(0, 4%, 23%);
|
||||
|
||||
--color-icon: hsl(0, 0%, 48%);
|
||||
--color-icon: hsl(10, 3%, 43%);
|
||||
}
|
||||
|
||||
/* Header styles - copied from download */
|
||||
@@ -75,18 +64,9 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--color-background);
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
z-index: 10;
|
||||
|
||||
/* The notes' top fade: content dissolves into the paper under the sticky header instead of meeting a rule. */
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 100% 0 auto;
|
||||
height: 24px;
|
||||
background: linear-gradient(to bottom, var(--color-background), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
padding: 24px 1.5rem;
|
||||
}
|
||||
@@ -236,7 +216,7 @@
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 8rem;
|
||||
margin-top: 4rem;
|
||||
|
||||
@media (max-width: 65rem) {
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
@@ -295,16 +275,20 @@
|
||||
[data-component="container"] {
|
||||
max-width: 67.5rem;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--color-border-weak);
|
||||
border-top: none;
|
||||
|
||||
@media (max-width: 65rem) {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="content"] {
|
||||
max-width: calc(740px + 3rem);
|
||||
margin: 0 auto;
|
||||
padding: 14vh 1.5rem 0;
|
||||
font-family: var(--font-serif);
|
||||
font-variant-numeric: oldstyle-nums proportional-nums;
|
||||
font-feature-settings: "kern", "liga", "calt";
|
||||
text-rendering: optimizeLegibility;
|
||||
padding: 6rem 5rem;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
padding: 4rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="legal"] {
|
||||
@@ -330,240 +314,291 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Changelog Hero */
|
||||
[data-component="changelog-hero"] {
|
||||
margin-bottom: 96px;
|
||||
margin-bottom: 4rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font: 400 48px/1.2 var(--font-serif);
|
||||
letter-spacing: -0.015em;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-strong);
|
||||
margin-bottom: 12px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
font-size: 36px;
|
||||
}
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 20px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-weak);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
/* Releases */
|
||||
[data-component="releases"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 112px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
gap: 80px;
|
||||
}
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
[data-component="release"] {
|
||||
scroll-margin-top: 104px;
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 3rem;
|
||||
padding: 2rem 0;
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
|
||||
> header {
|
||||
margin-bottom: 40px;
|
||||
@media (max-width: 50rem) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font: 400 32px/1.3 var(--font-serif);
|
||||
letter-spacing: -0.01em;
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
align-self: start;
|
||||
background: var(--color-background);
|
||||
padding: 44px 0 8px;
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
position: static;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-slot="version"] {
|
||||
a {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--color-link-underline);
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 0.17em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
time {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-weak);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="content"] > [data-slot="markdown"] {
|
||||
margin-bottom: 40px;
|
||||
[data-slot="content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
[data-slot="label"] {
|
||||
font: 400 12px/1.5 var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
|
||||
[data-component="highlights"] {
|
||||
margin-bottom: 48px;
|
||||
|
||||
[data-slot="label"] {
|
||||
margin-bottom: 20px;
|
||||
[data-component="section"] {
|
||||
h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: 20px;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text);
|
||||
|
||||
@media (max-width: 500px) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
p[data-slot="title"] {
|
||||
font-style: italic;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="sections"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
}
|
||||
|
||||
[data-component="section"] {
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
|
||||
summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 14px 0;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "+";
|
||||
font: 400 14px/1 var(--font-mono);
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
|
||||
&:hover [data-slot="label"],
|
||||
&:hover::after {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
}
|
||||
|
||||
&[open] summary::after {
|
||||
content: "−";
|
||||
}
|
||||
|
||||
[data-slot="markdown"] {
|
||||
padding: 4px 0 28px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="markdown"] {
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
|
||||
h4 {
|
||||
font: italic 400 17px/1.4 var(--font-serif);
|
||||
color: var(--color-text-label);
|
||||
margin: 28px 0 12px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 12px;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 4px;
|
||||
|
||||
li {
|
||||
padding-left: 1.25em;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
padding-left: 12px;
|
||||
position: relative;
|
||||
text-wrap: pretty;
|
||||
|
||||
&::before {
|
||||
content: "–";
|
||||
content: "-";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--color-text-weaker);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.83em;
|
||||
font-variant-numeric: lining-nums tabular-nums;
|
||||
color: var(--color-text-code);
|
||||
white-space: nowrap;
|
||||
[data-component="contributors"] {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-weak);
|
||||
padding-top: 0.5rem;
|
||||
|
||||
span {
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
[data-component="highlights"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="notice"] {
|
||||
font-size: 20px;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
[data-component="collapsible-sections"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
[data-slot="v1"] {
|
||||
margin-top: 112px;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
[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);
|
||||
|
||||
[data-component="content"] a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--color-link-underline);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 0.17em;
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-strong);
|
||||
text-decoration-color: var(--color-link-underline-hover);
|
||||
[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"] {
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
[data-slot="highlight-item"] {
|
||||
margin-bottom: 48px;
|
||||
|
||||
&: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,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
|
||||
&:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,108 @@
|
||||
import "./index.css"
|
||||
import { Title, Meta } from "@solidjs/meta"
|
||||
import { createAsync } from "@solidjs/router"
|
||||
import { HttpStatusCode } from "@solidjs/start"
|
||||
import { Header } from "~/component/header"
|
||||
import { Footer } from "~/component/footer"
|
||||
import { Legal } from "~/component/legal"
|
||||
import { CHANGELOG_FILE_URL, changelogPage } from "~/lib/changelog-source"
|
||||
import type { ChangelogEntry } from "~/lib/changelog-markdown"
|
||||
import { For, Index, Show } from "solid-js"
|
||||
import { changelog } from "~/lib/changelog"
|
||||
import type { HighlightGroup } from "~/lib/changelog"
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { LocaleLinks } from "~/component/locale-links"
|
||||
|
||||
function formatDate(date: string, locale: string) {
|
||||
return new Date(`${date}T00:00:00Z`).toLocaleDateString(locale, {
|
||||
function formatDate(dateString: string, locale: string) {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
}
|
||||
|
||||
function Release(props: { entry: ChangelogEntry; open: boolean }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
function ReleaseItem(props: { item: string }) {
|
||||
const parts = () => {
|
||||
const match = props.item.match(/^(.+?)(\s*\(@([\w-]+)\))?$/)
|
||||
if (match) {
|
||||
return {
|
||||
text: match[1],
|
||||
username: match[3],
|
||||
}
|
||||
}
|
||||
return { text: props.item, username: undefined }
|
||||
}
|
||||
|
||||
return (
|
||||
<article data-component="release" id={props.entry.anchor}>
|
||||
<header>
|
||||
<h2>
|
||||
<a href={`#${props.entry.anchor}`}>{props.entry.tag}</a>
|
||||
</h2>
|
||||
<Show when={props.entry.date}>
|
||||
{(date) => <time dateTime={date()}>{formatDate(date(), language.tag(language.locale()))}</time>}
|
||||
</Show>
|
||||
</header>
|
||||
<div data-slot="content">
|
||||
<Show when={props.entry.intro}>
|
||||
<div data-slot="markdown" innerHTML={props.entry.intro} />
|
||||
</Show>
|
||||
<Show when={props.entry.highlights.length > 0}>
|
||||
<section data-component="highlights">
|
||||
<h3 data-slot="label">{i18n.t("changelog.highlights")}</h3>
|
||||
<ul>
|
||||
<For each={props.entry.highlights}>
|
||||
{(item) => (
|
||||
<li>
|
||||
<Show when={item.title}>
|
||||
<p data-slot="title" innerHTML={item.title} />
|
||||
</Show>
|
||||
<p innerHTML={item.body} />
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={props.entry.sections.length > 0}>
|
||||
<div data-component="sections">
|
||||
<For each={props.entry.sections}>
|
||||
{(section) => (
|
||||
<details data-component="section" open={props.open}>
|
||||
<summary>
|
||||
<span data-slot="label">{section.title}</span>
|
||||
</summary>
|
||||
<div data-slot="markdown" innerHTML={section.body} />
|
||||
</details>
|
||||
)}
|
||||
</For>
|
||||
<li>
|
||||
<span>{parts().text}</span>
|
||||
<Show when={parts().username}>
|
||||
<a data-slot="author" href={`https://github.com/${parts().username}`} target="_blank" rel="noopener noreferrer">
|
||||
(@{parts().username})
|
||||
</a>
|
||||
</Show>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function HighlightSection(props: { group: HighlightGroup }) {
|
||||
return (
|
||||
<div data-component="highlight">
|
||||
<h4>{props.group.source}</h4>
|
||||
<hr />
|
||||
<For each={props.group.items}>
|
||||
{(item) => (
|
||||
<div data-slot="highlight-item">
|
||||
<p data-slot="title">{item.title}</p>
|
||||
<p>{item.description}</p>
|
||||
<Show when={item.media.type === "video"}>
|
||||
<video src={item.media.src} controls autoplay loop muted playsinline />
|
||||
</Show>
|
||||
<Show when={item.media.type === "image"}>
|
||||
<img
|
||||
src={item.media.src}
|
||||
alt={item.title}
|
||||
width={(item.media as { width: string }).width}
|
||||
height={(item.media as { height: string }).height}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</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>
|
||||
</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() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const data = createAsync(() => changelogPage())
|
||||
const data = createAsync(() => changelog())
|
||||
const releases = () => data() ?? []
|
||||
|
||||
return (
|
||||
<main data-page="changelog">
|
||||
@@ -96,34 +120,51 @@ export default function Changelog() {
|
||||
</section>
|
||||
|
||||
<section data-component="releases">
|
||||
<Show when={data()}>
|
||||
{(page) => (
|
||||
<>
|
||||
<Show when={!page().ok}>
|
||||
<HttpStatusCode code={503} />
|
||||
<p data-slot="notice">
|
||||
{i18n.t("changelog.error")}{" "}
|
||||
<a href={CHANGELOG_FILE_URL} target="_blank" rel="noopener noreferrer">
|
||||
{i18n.t("changelog.viewOnGithub")}
|
||||
</a>
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={page().ok && page().entries.length === 0}>
|
||||
<p data-slot="notice">{i18n.t("changelog.empty")}</p>
|
||||
</Show>
|
||||
<Index each={page().entries}>
|
||||
{(entry, index) => <Release entry={entry()} open={index === 0} />}
|
||||
</Index>
|
||||
</>
|
||||
)}
|
||||
<Show when={releases().length === 0}>
|
||||
<p>
|
||||
{i18n.t("changelog.empty")}{" "}
|
||||
<a href={language.route("/changelog.json")}>{i18n.t("changelog.viewJson")}</a>
|
||||
</p>
|
||||
</Show>
|
||||
<For each={releases()}>
|
||||
{(release) => {
|
||||
return (
|
||||
<article data-component="release">
|
||||
<header>
|
||||
<div data-slot="version">
|
||||
<a href={release.url} target="_blank" rel="noopener noreferrer">
|
||||
{release.tag}
|
||||
</a>
|
||||
</div>
|
||||
<time dateTime={release.date}>{formatDate(release.date, language.tag(language.locale()))}</time>
|
||||
</header>
|
||||
<div data-slot="content">
|
||||
<Show when={release.highlights.length > 0}>
|
||||
<div data-component="highlights">
|
||||
<For each={release.highlights}>{(group) => <HighlightSection group={group} />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={release.highlights.length > 0 && release.sections.length > 0}>
|
||||
<CollapsibleSections sections={release.sections} />
|
||||
</Show>
|
||||
<Show when={release.highlights.length === 0}>
|
||||
<For each={release.sections}>
|
||||
{(section) => (
|
||||
<div data-component="section">
|
||||
<h3>{section.title}</h3>
|
||||
<ul>
|
||||
<For each={section.items}>{(item) => <ReleaseItem item={item} />}</For>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</section>
|
||||
|
||||
<p data-slot="v1">
|
||||
<a href="https://github.com/anomalyco/opencode/releases" target="_blank" rel="noopener noreferrer">
|
||||
{i18n.t("changelog.v1")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
|
||||
+6
-5
@@ -3,11 +3,12 @@
|
||||
Independently deployed services supporting OpenCode live here. Runtime packages
|
||||
and clients live in `packages/`.
|
||||
|
||||
| Service | Purpose | Deployment |
|
||||
| ---------------------- | -------------------------------------- | ---------------------------- |
|
||||
| [`www`](./www) | Website and V2 documentation | `deploy-www.yml`, Wrangler |
|
||||
| [`updates`](./updates) | Release metadata and update selection | `publish.yml`, Wrangler |
|
||||
| [`files`](./files) | Public file distribution at `/files/*` | `deploy-files.yml`, Wrangler |
|
||||
| Service | Purpose | Deployment |
|
||||
| -------------------------- | -------------------------------------- | -------------------------------- |
|
||||
| [`www`](./www) | Website and V2 documentation | `deploy-www.yml`, Wrangler |
|
||||
| [`updates`](./updates) | Release metadata and update selection | `publish.yml`, Wrangler |
|
||||
| [`files`](./files) | Public file distribution at `/files/*` | `deploy-files.yml`, Wrangler |
|
||||
| [`changelog`](./changelog) | Release notes at `/changelog` | `deploy-changelog.yml`, Wrangler |
|
||||
|
||||
These directories are Bun workspaces. Run package commands from each service's
|
||||
directory. Repository-wide Turbo commands discover them through `services/*`.
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# Changelog
|
||||
|
||||
Release notes for OpenCode V2, newest first.
|
||||
|
||||
## v2.0.27 — 2026-10-12
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Built-In Worktree Tools**: Ask OpenCode to use `worktree_create`, `worktree_list`, and `worktree_remove` in Code Mode. You can create isolated worktrees, move sessions into them, and remove them with normal edit approval. ([#53981](https://github.com/anomalyco/opencode/pull/53981))
|
||||
- **Safer Remote Pairing**: The first `opencode pair --remote` now warns that it will expose your server and asks for confirmation. For non-interactive use, pass `opencode pair --remote --yes`. ([#54561](https://github.com/anomalyco/opencode/pull/54561))
|
||||
- **Installable Web App**: Use Chrome or Edge’s **Install** button to install the OpenCode web app, including when it runs behind an authenticating proxy. ([#53905](https://github.com/anomalyco/opencode/pull/53905))
|
||||
- **Clear Session Failures**: Desktop and TUI timelines now show execution errors that happen before or between model steps instead of ending without an explanation. ([#53826](https://github.com/anomalyco/opencode/pull/53826))
|
||||
- **Automatic Project Cleanup**: Projects whose directories no longer exist are automatically hidden from the project list. Opening the directory again restores the project. ([#54354](https://github.com/anomalyco/opencode/pull/54354))
|
||||
|
||||
### Core
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Added the built-in Code Mode tools `opencode.worktree_create`, `opencode.worktree_list`, and `opencode.worktree_remove`. Removing a worktree requires edit approval, and `force: true` can discard modified or untracked files. ([#53981](https://github.com/anomalyco/opencode/pull/53981))
|
||||
- Added confirmation before `opencode pair --remote` first enables internet access, plus `--yes`/`-y` for scripts and other non-interactive use. ([#54561](https://github.com/anomalyco/opencode/pull/54561))
|
||||
- Automatically archived projects when all known directories disappear. Reopening a project restores it, while surviving clones and linked worktrees keep it active. ([#54354](https://github.com/anomalyco/opencode/pull/54354))
|
||||
- Raised the ChatGPT OAuth input limit for `gpt-6.1-sol` and `gpt-6.1-sol-fast` to 500,000 tokens. ([#54668](https://github.com/anomalyco/opencode/pull/54668))
|
||||
- Added Vertex AI routes for Interactions and Mistral models. ([#54175](https://github.com/anomalyco/opencode/pull/54175), [#54058](https://github.com/anomalyco/opencode/pull/54058))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Fixed updater detection for symlinked home directories and package-manager installations, increased probe timeouts, and now report unavailable updates clearly when the installation method cannot be detected. ([#54609](https://github.com/anomalyco/opencode/pull/54609), [#54622](https://github.com/anomalyco/opencode/pull/54622), [#54672](https://github.com/anomalyco/opencode/pull/54672))
|
||||
- Fixed GitHub Copilot routing for Gemini, Anthropic, and Responses models when live model metadata is unavailable, and accepted interleaved Claude reasoning values. ([#54208](https://github.com/anomalyco/opencode/pull/54208), [#54210](https://github.com/anomalyco/opencode/pull/54210), [#54201](https://github.com/anomalyco/opencode/pull/54201))
|
||||
- Fixed Google Vertex Application Default Credentials when `CLOUDSDK_CONFIG` points to a custom gcloud configuration directory. Thanks [@H-Max](https://github.com/H-Max). ([#54164](https://github.com/anomalyco/opencode/pull/54164))
|
||||
- Prevented ChatGPT token-sharing sessions from sending unsupported mid-session reasoning-effort updates. ([#54085](https://github.com/anomalyco/opencode/pull/54085))
|
||||
- Blocked `file://` access through Code Mode’s `fetch`, which now returns a clear filesystem-access error. Thanks [@ferdinandyb](https://github.com/ferdinandyb). ([#54510](https://github.com/anomalyco/opencode/pull/54510))
|
||||
- Fixed remote workspace file operations so driver-provided filesystem overrides are used correctly.
|
||||
- Preserved subagent notifications when location services are evicted, and propagated well-known configuration updates across locations with bounded HTTP timeouts. ([#54072](https://github.com/anomalyco/opencode/pull/54072), [#53821](https://github.com/anomalyco/opencode/pull/53821))
|
||||
|
||||
### TUI
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Displayed session execution failures that occur before or between model steps directly in the timeline. ([#53826](https://github.com/anomalyco/opencode/pull/53826))
|
||||
- Preserved a saved session’s unavailable model selection instead of silently replacing it with a fallback. ([#53822](https://github.com/anomalyco/opencode/pull/53822))
|
||||
- Added syntax highlighting for C++ module interface files such as `.cppm`, `.ixx`, and `.cxxm`. ([#53853](https://github.com/anomalyco/opencode/pull/53853))
|
||||
- Summarized integrations with multiple credentials as a connection count, preventing long labels from crowding the integration name. ([#54054](https://github.com/anomalyco/opencode/pull/54054))
|
||||
|
||||
### Desktop
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Made the web app installable from Chrome and Edge, including deployments behind cookie-based authenticating proxies. ([#53905](https://github.com/anomalyco/opencode/pull/53905))
|
||||
- Updated the session column layout and text wrapping for clearer long content. ([#54020](https://github.com/anomalyco/opencode/pull/54020))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Displayed session execution failures that occur before or between model steps directly in the timeline. ([#53826](https://github.com/anomalyco/opencode/pull/53826))
|
||||
- Fixed command-palette selection so newly arriving higher-ranked results become active until you explicitly choose a result. ([#54275](https://github.com/anomalyco/opencode/pull/54275))
|
||||
- Preserved bold, code, emphasis, and strike-through formatting inside Markdown link labels. ([#54451](https://github.com/anomalyco/opencode/pull/54451))
|
||||
- Fixed timeline file links and complete patch rendering, including unusual filenames and patches whose original alignment can be reused. ([#53641](https://github.com/anomalyco/opencode/pull/53641), [#54017](https://github.com/anomalyco/opencode/pull/54017))
|
||||
- Showed complete tool error text when an error is expanded. ([#53816](https://github.com/anomalyco/opencode/pull/53816))
|
||||
- Fixed browser-panel controls, page visibility, corner masks, and closed side-region overlays. ([#53907](https://github.com/anomalyco/opencode/pull/53907), [#54038](https://github.com/anomalyco/opencode/pull/54038), [#52461](https://github.com/anomalyco/opencode/pull/52461), [#54052](https://github.com/anomalyco/opencode/pull/54052))
|
||||
- Made the third-party notices dialog scrollable. ([#53911](https://github.com/anomalyco/opencode/pull/53911))
|
||||
|
||||
### SDK
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Added startup plugin seeding to Effect embedded hosts and workerd through `OpenCode.create({ plugins })` and `OpenCodeWorkerd.create({ plugins })`.
|
||||
- Re-exported `WorkspaceDriver`, `EnvironmentDriver`, and `EnvironmentFiles` from `@opencode/sdk/effect`.
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Ensured embedded hosts activate supplied plugins before model discovery, generation, and recovery begin. ([#54219](https://github.com/anomalyco/opencode/pull/54219))
|
||||
|
||||
### Extensions
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Extended `BridgeLayout` with viewport dimensions and optional border information so native extension surfaces can track window resizing and preserve rounded card borders.
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Kept desktop browser-panel corner masks and borders aligned while resizing or using custom themes. ([#52461](https://github.com/anomalyco/opencode/pull/52461))
|
||||
- Kept the browser site button clickable after the embedded page takes focus. ([#53907](https://github.com/anomalyco/opencode/pull/53907))
|
||||
|
||||
## v2.0.26 — 2026-10-08
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Google Cloud Credentials for Vertex**: Run `/connect`, choose **Vertex**, then select **Google Cloud credentials (gcloud auth or environment)** to use Application Default Credentials. You can choose or enter the Google Cloud project and location. ([#53798](https://github.com/anomalyco/opencode/pull/53798))
|
||||
- **Faster, Safer Session Snapshots**: Session snapshots now add less overhead, recover from interrupted or concurrent captures, restore large selections faster, and pack stored objects to reduce disk usage. ([#51996](https://github.com/anomalyco/opencode/pull/51996))
|
||||
- **Working Transcript Links**: You can click links anywhere in a transcript, including any row of a wrapped URL. Hyperlinks also work in more terminals, and `FORCE_HYPERLINK=1` can enable them when automatic detection does not. ([#53903](https://github.com/anomalyco/opencode/pull/53903), [#53904](https://github.com/anomalyco/opencode/pull/53904))
|
||||
- **Step 5 Preview Free**: You can now select **Step 5 Preview Free** from OpenCode’s free model lineup for a limited time.
|
||||
|
||||
### Core
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Added **Google Cloud credentials (gcloud auth or environment)** to the Vertex connection flow, with project and location choices discovered from environment variables, gcloud configurations, and Application Default Credentials. ([#53798](https://github.com/anomalyco/opencode/pull/53798))
|
||||
- Reduced session snapshot overhead, accelerated multi-file restores, made capture resilient to crashes and concurrent processes, and added lossless background packing of snapshot objects. ([#51996](https://github.com/anomalyco/opencode/pull/51996))
|
||||
- Added **Step 5 Preview Free** to OpenCode’s free models for a limited time and removed the retired **Fledge Alpha Free** listing.
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Prevented working-tree diffs from timing out or stalling the server in repositories with many untracked files by batching Git operations. ([#53449](https://github.com/anomalyco/opencode/pull/53449))
|
||||
- Fixed large revert selections failing when their paths exceeded the operating system’s command-line limit. ([#53956](https://github.com/anomalyco/opencode/pull/53956))
|
||||
- Fixed snapshot and undo handling for literal filenames containing Git pathspec characters, corrupted or locked snapshot indexes, concurrent captures, and project directories whose names begin with `..`. ([#51996](https://github.com/anomalyco/opencode/pull/51996))
|
||||
- Changed `opencode service` remote-access URLs to use a persistent random subdomain rather than a predictable route. Disabling and re-enabling remote access generates a new address. ([#53962](https://github.com/anomalyco/opencode/pull/53962))
|
||||
|
||||
### TUI
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Enabled OSC 8 hyperlinks in more terminals, including VS Code and other xterm.js terminals, Zed, mintty, and newer VTE terminals; `FORCE_HYPERLINK=1` now overrides terminal detection. ([#53904](https://github.com/anomalyco/opencode/pull/53904))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Fixed transcript links not opening when clicked, including URLs wrapped across multiple rows. ([#53903](https://github.com/anomalyco/opencode/pull/53903))
|
||||
- Kept activity summaries in place when expanding or collapsing details with `session.verbosity: "low"`. ([#52357](https://github.com/anomalyco/opencode/pull/52357))
|
||||
- Prevented the home-screen version footer from overlapping keyboard hints in short terminals. Thanks [@fihaaade](https://github.com/fihaaade). ([#53891](https://github.com/anomalyco/opencode/pull/53891))
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,34 @@
|
||||
# OpenCode Changelog
|
||||
|
||||
The changelog Worker renders the root `CHANGELOG.md` at `https://opencode.ai/changelog`.
|
||||
|
||||
It owns two routes, `opencode.ai/changelog` and `opencode.ai/changelog/*`. Requests for
|
||||
`/changelog.json` and localized pages such as `/de/changelog` still reach the Console.
|
||||
|
||||
## Content
|
||||
|
||||
Each request reads `CHANGELOG.md` from the `dev` branch on raw GitHub through the Workers
|
||||
cache. A copy stays fresh for five minutes. After that, the Worker serves the stale copy for up
|
||||
to a day and refreshes it in the background. If GitHub fails, the stale copy is kept. Releases
|
||||
appear without a deploy once the version-sync commit lands on `dev`.
|
||||
|
||||
Entries split on lines matching `^## v\d`. `### Highlights` items render first, and the
|
||||
remaining `###` sections render as collapsible details. Raw HTML in the notes is escaped.
|
||||
|
||||
`/changelog/<version>` redirects to that release's anchor.
|
||||
|
||||
## Fonts
|
||||
|
||||
`public/changelog/fonts` holds the Latin-1 subsets of IBM Plex Serif and IBM Plex Mono from
|
||||
`@ibm/plex`, licensed under the SIL Open Font License (`LICENSE.txt`).
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
bun dev
|
||||
bun test
|
||||
bun typecheck
|
||||
bun run build
|
||||
```
|
||||
|
||||
`deploy-changelog.yml` deploys with Wrangler on pushes to `dev` that touch this directory.
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/changelog",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "wrangler deploy --dry-run --outdir dist",
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"wrangler": "4.110.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/changelog/fonts/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,88 @@
|
||||
import { anchor, parseChangelog, renderEntry } from "./markdown"
|
||||
import { renderPage, type PageState } from "./page"
|
||||
|
||||
const SOURCE_URL = "https://raw.githubusercontent.com/anomalyco/opencode/dev/CHANGELOG.md"
|
||||
|
||||
// Fresh for five minutes, then served stale for up to a day while requests revalidate it in the background. A failed
|
||||
// revalidation keeps serving the stale copy, so a GitHub outage only shows an error once the day-old copy is evicted.
|
||||
const CACHE_KEY = "https://opencode.ai/changelog/source.md"
|
||||
const FRESH_SECONDS = 300
|
||||
const STALE_SECONDS = 86400
|
||||
const FETCHED_AT = "x-opencode-changelog-fetched-at"
|
||||
|
||||
const PAGE_CACHE = `public, max-age=60, s-maxage=${FRESH_SECONDS}, stale-while-revalidate=${STALE_SECONDS}`
|
||||
const SECURITY = {
|
||||
"Content-Security-Policy":
|
||||
"default-src 'none'; style-src 'unsafe-inline'; font-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
}
|
||||
|
||||
type Context = Pick<ExecutionContext, "waitUntil">
|
||||
|
||||
async function refresh(cache: Cache | undefined) {
|
||||
const response = await fetch(SOURCE_URL, {
|
||||
headers: { "User-Agent": "opencode-changelog" },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}).catch(() => undefined)
|
||||
if (!response?.ok) return
|
||||
const markdown = await response.text()
|
||||
await cache?.put(
|
||||
CACHE_KEY,
|
||||
new Response(markdown, {
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
"Cache-Control": `public, s-maxage=${FRESH_SECONDS + STALE_SECONDS}`,
|
||||
[FETCHED_AT]: String(Date.now()),
|
||||
},
|
||||
}),
|
||||
)
|
||||
return markdown
|
||||
}
|
||||
|
||||
/** Reads CHANGELOG.md through the colo's Workers cache; resolves undefined when no copy is available at all. */
|
||||
export async function loadSource(ctx: Context, cache: Cache | undefined = defaultCache()) {
|
||||
const cached = await cache?.match(CACHE_KEY)
|
||||
if (!cached) return refresh(cache)
|
||||
if (Date.now() - Number(cached.headers.get(FETCHED_AT)) > FRESH_SECONDS * 1000) ctx.waitUntil(refresh(cache))
|
||||
return cached.text()
|
||||
}
|
||||
|
||||
function defaultCache() {
|
||||
if (typeof caches === "undefined" || !("default" in caches)) return
|
||||
return (caches as CacheStorage & { default: Cache }).default
|
||||
}
|
||||
|
||||
function html(state: PageState, status: number, cacheControl: string, method: string) {
|
||||
return new Response(method === "HEAD" ? null : renderPage(state), {
|
||||
status,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": cacheControl, ...SECURITY },
|
||||
})
|
||||
}
|
||||
|
||||
function redirect(url: URL, location: string, status: number) {
|
||||
return Response.redirect(new URL(location, url).toString(), status)
|
||||
}
|
||||
|
||||
export async function handle(request: Request, load: () => Promise<string | undefined>) {
|
||||
if (request.method !== "GET" && request.method !== "HEAD")
|
||||
return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET, HEAD" } })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const path = url.pathname
|
||||
if (path === "/changelog/") return redirect(url, "/changelog", 308)
|
||||
const version = /^\/changelog\/(v\d[\w.-]*)$/.exec(path)
|
||||
if (version) return redirect(url, `/changelog#${anchor(version[1])}`, 302)
|
||||
if (path.startsWith("/changelog/")) return html({ type: "not-found" }, 404, "public, max-age=300", request.method)
|
||||
if (path !== "/changelog") return redirect(url, "/changelog", 302)
|
||||
|
||||
const markdown = await load().catch(() => undefined)
|
||||
if (markdown === undefined) return html({ type: "error" }, 503, "no-store", request.method)
|
||||
return html({ type: "ok", entries: parseChangelog(markdown).map(renderEntry) }, 200, PAGE_CACHE, request.method)
|
||||
}
|
||||
|
||||
export default {
|
||||
fetch(request, _env, ctx) {
|
||||
return handle(request, () => loadSource(ctx))
|
||||
},
|
||||
} satisfies ExportedHandler
|
||||
+1
-1
@@ -72,7 +72,7 @@ export function anchor(tag: string) {
|
||||
.replace(/^-|-$/g, "")
|
||||
}
|
||||
|
||||
function escape(value: string) {
|
||||
export function escape(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
@@ -0,0 +1,109 @@
|
||||
import { escape, type ChangelogEntry } from "./markdown"
|
||||
import { style } from "./style"
|
||||
|
||||
const SITE = "https://opencode.ai"
|
||||
const REPO = "https://github.com/anomalyco/opencode"
|
||||
export const CHANGELOG_FILE_URL = `${REPO}/blob/dev/CHANGELOG.md`
|
||||
|
||||
const logo =
|
||||
'<svg viewBox="0 0 234 42" role="img" aria-label="OpenCode" xmlns="http://www.w3.org/2000/svg"><path d="M18 30H6V18H18V30Z" class="i"/><path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" class="o"/><path d="M48 30H36V18H48V30Z" class="i"/><path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" class="o"/><path d="M84 24V30H66V24H84Z" class="i"/><path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" class="o"/><path d="M108 36H96V18H108V36Z" class="i"/><path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" class="o"/><path d="M144 30H126V18H144V30Z" class="i"/><path d="M144 12H126V30H144V36H120V6H144V12Z" class="c"/><path d="M168 30H156V18H168V30Z" class="i"/><path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" class="c"/><path d="M198 30H186V18H198V30Z" class="i"/><path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" class="c"/><path d="M234 24V30H216V24H234Z" class="i"/><path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" class="c"/></svg>'
|
||||
|
||||
const download =
|
||||
'<svg viewBox="0 0 18 18" fill="none" aria-hidden="true"><path d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/></svg>'
|
||||
|
||||
const external = 'target="_blank" rel="noopener noreferrer"'
|
||||
|
||||
function formatDate(date: string) {
|
||||
return new Date(`${date}T00:00:00Z`).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
}
|
||||
|
||||
function release(entry: ChangelogEntry, open: boolean) {
|
||||
const date = entry.date ? `<time datetime="${entry.date}">${formatDate(entry.date)}</time>` : ""
|
||||
const intro = entry.intro ? `<div class="markdown intro">${entry.intro}</div>` : ""
|
||||
const highlights =
|
||||
entry.highlights.length === 0
|
||||
? ""
|
||||
: `<section class="highlights"><h3 class="label">Highlights</h3><ul>${entry.highlights
|
||||
.map((item) => `<li>${item.title ? `<p class="title">${item.title}</p>` : ""}<p>${item.body}</p></li>`)
|
||||
.join("")}</ul></section>`
|
||||
const sections =
|
||||
entry.sections.length === 0
|
||||
? ""
|
||||
: `<div class="sections">${entry.sections
|
||||
.map(
|
||||
(section) =>
|
||||
`<details class="section"${open ? " open" : ""}><summary><span class="label">${escape(section.title)}</span></summary><div class="markdown">${section.body}</div></details>`,
|
||||
)
|
||||
.join("")}</div>`
|
||||
|
||||
return `<article class="release" id="${escape(entry.anchor)}"><header><h2><a href="#${escape(entry.anchor)}">${escape(entry.tag)}</a></h2>${date}</header>${intro}${highlights}${sections}</article>`
|
||||
}
|
||||
|
||||
export type PageState = { type: "ok"; entries: ChangelogEntry[] } | { type: "error" } | { type: "not-found" }
|
||||
|
||||
/** Renders the whole page; entries arrive with their Markdown already rendered to safe HTML. */
|
||||
export function renderPage(state: PageState) {
|
||||
const body = (() => {
|
||||
if (state.type === "error")
|
||||
return `<p class="notice">The changelog could not be loaded right now. <a href="${CHANGELOG_FILE_URL}" ${external}>View it on GitHub</a></p>`
|
||||
if (state.type === "not-found")
|
||||
return `<p class="notice">That page does not exist. <a href="/changelog">See all releases</a></p>`
|
||||
if (state.entries.length === 0) return `<p class="notice">No changelog entries found.</p>`
|
||||
return state.entries.map((entry, index) => release(entry, index === 0)).join("")
|
||||
})()
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OpenCode | Changelog</title>
|
||||
<meta name="description" content="OpenCode release notes and changelog">
|
||||
<link rel="canonical" href="${SITE}/changelog">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="preload" href="/changelog/fonts/IBMPlexSerif-Regular-Latin1.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<meta property="og:title" content="OpenCode | Changelog">
|
||||
<meta property="og:description" content="OpenCode release notes and changelog">
|
||||
<meta property="og:url" content="${SITE}/changelog">
|
||||
<style>${style}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top"><div class="top-inner">
|
||||
<a class="logo" href="${SITE}/">${logo}</a>
|
||||
<nav><ul class="nav">
|
||||
<li><a href="${REPO}" ${external}>GitHub</a></li>
|
||||
<li><a href="${SITE}/v2/docs">Docs</a></li>
|
||||
<li><a href="${SITE}/data">Data</a></li>
|
||||
<li><a href="${SITE}/zen">Zen</a></li>
|
||||
<li><a href="${SITE}/go">Go</a></li>
|
||||
<li><a href="${SITE}/enterprise">Enterprise</a></li>
|
||||
<li><a class="cta" href="${SITE}/download">${download}Download</a></li>
|
||||
</ul></nav>
|
||||
</div></header>
|
||||
<main>
|
||||
<section class="hero"><h1>Changelog</h1><p>New updates and improvements to OpenCode</p></section>
|
||||
<div class="releases">${body}</div>
|
||||
<p class="v1"><a href="${REPO}/releases" ${external}>OpenCode 1 release notes are on GitHub Releases</a></p>
|
||||
</main>
|
||||
<footer>
|
||||
<a href="${REPO}" ${external}>GitHub</a>
|
||||
<a href="${SITE}/docs">Docs</a>
|
||||
<a href="${SITE}/changelog">Changelog</a>
|
||||
<a href="${SITE}/discord">Discord</a>
|
||||
<a href="https://x.com/opencode">X</a>
|
||||
</footer>
|
||||
<div class="legal">
|
||||
<span>©${new Date().getUTCFullYear()} <a href="https://anoma.ly">Anomaly</a></span>
|
||||
<a href="${SITE}/brand">Brand</a>
|
||||
<a href="${SITE}/legal/privacy-policy">Privacy</a>
|
||||
<a href="${SITE}/legal/terms-of-service">Terms</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// The stone-paper palette, serif reading column and quiet chrome follow the Anomaly notes (anoma.ly/notes); the header,
|
||||
// footer and mono face follow opencode.ai.
|
||||
export const style = `
|
||||
@font-face { font-family: "IBM Plex Serif"; font-style: normal; font-weight: 400; font-display: swap; src: url("/changelog/fonts/IBMPlexSerif-Regular-Latin1.woff2") format("woff2"); }
|
||||
@font-face { font-family: "IBM Plex Serif"; font-style: italic; font-weight: 400; font-display: swap; src: url("/changelog/fonts/IBMPlexSerif-Italic-Latin1.woff2") format("woff2"); }
|
||||
@font-face { font-family: "IBM Plex Serif"; font-style: normal; font-weight: 600; font-display: swap; src: url("/changelog/fonts/IBMPlexSerif-SemiBold-Latin1.woff2") format("woff2"); }
|
||||
@font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 400; font-display: swap; src: url("/changelog/fonts/IBMPlexMono-Regular-Latin1.woff2") format("woff2"); }
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--paper: hsl(48, 7%, 90%);
|
||||
--paper-hover: hsl(45, 5%, 86%);
|
||||
--ink: hsl(51, 10%, 13%);
|
||||
--ink-strong: hsl(40, 6%, 7%);
|
||||
--ink-label: hsl(39, 6%, 40%);
|
||||
--ink-weak: hsl(39, 5%, 51%);
|
||||
--ink-weaker: hsl(39, 6%, 63%);
|
||||
--ink-code: hsl(32, 13%, 31%);
|
||||
--rule: hsl(48, 6%, 79%);
|
||||
--underline: hsla(38, 21%, 29%, 0.4);
|
||||
--underline-hover: hsl(38, 27%, 33%);
|
||||
--button: hsl(51, 10%, 13%);
|
||||
--button-hover: hsl(40, 6%, 7%);
|
||||
--button-ink: hsl(48, 7%, 90%);
|
||||
--logo-inner: #cfcecd;
|
||||
--logo-open: #656363;
|
||||
--logo-code: #211e1e;
|
||||
--serif: "IBM Plex Serif", Georgia, serif;
|
||||
--mono: "Berkeley Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--paper: hsl(60, 7%, 3%);
|
||||
--paper-hover: hsl(0, 0%, 7%);
|
||||
--ink: hsl(37, 15%, 80%);
|
||||
--ink-strong: hsl(60, 9%, 98%);
|
||||
--ink-label: hsl(0, 0%, 67%);
|
||||
--ink-weak: hsl(0, 0%, 48%);
|
||||
--ink-weaker: hsl(0, 0%, 40%);
|
||||
--ink-code: hsl(33, 26%, 79%);
|
||||
--rule: hsl(0, 0%, 16%);
|
||||
--underline: hsla(37, 15%, 58%, 0.4);
|
||||
--underline-hover: hsl(38, 25%, 68%);
|
||||
--button: hsl(37, 15%, 80%);
|
||||
--button-hover: hsl(60, 9%, 98%);
|
||||
--button-ink: hsl(60, 7%, 3%);
|
||||
--logo-inner: #4b4646;
|
||||
--logo-open: #b7b1b1;
|
||||
--logo-code: #f1ecec;
|
||||
}
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { background: var(--paper); }
|
||||
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 16px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||
h1, h2, h3, h4, p, ul { margin: 0; }
|
||||
::selection { background: hsl(40, 19%, 78%); color: var(--ink-strong); }
|
||||
|
||||
.top { position: sticky; top: 0; z-index: 10; background: var(--paper); }
|
||||
.top::after { content: ""; position: absolute; inset: 100% 0 auto; height: 24px; background: linear-gradient(to bottom, var(--paper), transparent); pointer-events: none; }
|
||||
.top-inner { max-width: 67.5rem; height: 80px; margin: 0 auto; padding: 0 5rem; display: flex; align-items: center; justify-content: space-between; gap: 2rem; }
|
||||
.logo { display: block; line-height: 0; }
|
||||
.logo svg { height: 34px; width: auto; }
|
||||
.logo .i { fill: var(--logo-inner); }
|
||||
.logo .o { fill: var(--logo-open); }
|
||||
.logo .c { fill: var(--logo-code); }
|
||||
.nav { display: flex; align-items: center; gap: 32px; list-style: none; padding: 0; }
|
||||
.nav a { color: var(--ink); text-decoration: none; white-space: nowrap; }
|
||||
.nav a:hover { text-decoration: underline; text-underline-offset: 2px; text-decoration-thickness: 1px; }
|
||||
.nav a.cta { display: flex; align-items: center; gap: 8px; padding: 8px 16px 8px 10px; border-radius: 4px; background: var(--button); color: var(--button-ink); font-weight: 500; }
|
||||
.nav a.cta:hover { background: var(--button-hover); text-decoration: none; }
|
||||
.nav a.cta svg { width: 16px; height: 16px; }
|
||||
@media (max-width: 60rem) { .top-inner { padding: 0 1.5rem; } .nav { gap: 24px; } }
|
||||
@media (max-width: 48rem) { .nav li:not(:last-child) { display: none; } }
|
||||
|
||||
main { max-width: calc(740px + 3rem); margin: 0 auto; padding: 14vh 1.5rem 0; font-family: var(--serif); font-variant-numeric: oldstyle-nums proportional-nums; font-feature-settings: "kern", "liga", "calt"; text-rendering: optimizeLegibility; }
|
||||
main a { color: inherit; text-decoration: underline; text-decoration-color: var(--underline); text-decoration-thickness: 1px; text-underline-offset: 0.17em; }
|
||||
main a:hover { color: var(--ink-strong); text-decoration-color: var(--underline-hover); }
|
||||
|
||||
.hero { margin-bottom: 96px; }
|
||||
.hero h1 { font: 400 48px/1.2 var(--serif); letter-spacing: -0.015em; color: var(--ink-strong); margin-bottom: 12px; }
|
||||
.hero p { font-size: 20px; line-height: 1.5; color: var(--ink-weak); }
|
||||
|
||||
.releases { display: flex; flex-direction: column; gap: 112px; }
|
||||
.release { scroll-margin-top: 104px; }
|
||||
.release > header { margin-bottom: 40px; }
|
||||
.release h2 { font: 400 32px/1.3 var(--serif); letter-spacing: -0.01em; }
|
||||
.release h2 a { color: var(--ink-strong); text-decoration: none; }
|
||||
.release h2 a:hover { text-decoration: underline; text-decoration-color: var(--underline); }
|
||||
.release time { display: block; margin-top: 6px; font-size: 16px; line-height: 1.4; color: var(--ink-weak); }
|
||||
|
||||
.label { font: 400 12px/1.5 var(--mono); letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-weak); }
|
||||
.intro { margin-bottom: 40px; }
|
||||
|
||||
.highlights { margin-bottom: 48px; }
|
||||
.highlights .label { margin-bottom: 20px; }
|
||||
.highlights ul { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 28px; }
|
||||
.highlights li { font-size: 20px; line-height: 1.65; }
|
||||
.highlights p { text-wrap: pretty; }
|
||||
.highlights .title { font-style: italic; color: var(--ink-strong); }
|
||||
|
||||
.sections { border-top: 1px solid var(--rule); }
|
||||
.section { border-bottom: 1px solid var(--rule); }
|
||||
.section summary { display: flex; align-items: baseline; justify-content: space-between; padding: 14px 0; cursor: pointer; list-style: none; }
|
||||
.section summary::-webkit-details-marker { display: none; }
|
||||
.section summary::after { content: "+"; font: 400 14px/1 var(--mono); color: var(--ink-weak); }
|
||||
.section[open] summary::after { content: "\\2212"; }
|
||||
.section summary:hover .label, .section summary:hover::after { color: var(--ink-strong); }
|
||||
.section .markdown { padding: 4px 0 28px; }
|
||||
|
||||
.markdown { font-size: 17px; line-height: 1.6; }
|
||||
.markdown h4 { font: italic 400 17px/1.4 var(--serif); color: var(--ink-label); margin: 28px 0 12px; }
|
||||
.markdown h4:first-child { margin-top: 0; }
|
||||
.markdown p { margin-bottom: 12px; text-wrap: pretty; }
|
||||
.markdown ul { list-style: none; padding: 0; margin: 0 0 12px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.markdown li { position: relative; padding-left: 1.25em; text-wrap: pretty; }
|
||||
.markdown li::before { content: "\\2013"; position: absolute; left: 0; color: var(--ink-weaker); }
|
||||
|
||||
main code { font-family: var(--mono); font-size: 0.83em; font-variant-numeric: lining-nums tabular-nums; color: var(--ink-code); white-space: nowrap; }
|
||||
main strong { font-weight: 600; }
|
||||
|
||||
.notice { font-size: 20px; line-height: 1.65; color: var(--ink-weak); }
|
||||
.v1 { margin-top: 112px; font-size: 16px; color: var(--ink-weak); }
|
||||
|
||||
footer { max-width: 67.5rem; margin: 8rem auto 0; display: flex; border-top: 1px solid var(--rule); }
|
||||
footer a { flex: 1; display: block; padding: 2rem 0; text-align: center; color: var(--ink-strong); text-decoration: none; }
|
||||
footer a + a { border-left: 1px solid var(--rule); }
|
||||
footer a:hover { background: var(--paper-hover); text-decoration: underline; text-underline-offset: 2px; text-decoration-thickness: 1px; }
|
||||
.legal { display: flex; justify-content: center; flex-wrap: wrap; gap: 32px; padding: 2rem 1.5rem 5rem; color: var(--ink-weak); }
|
||||
.legal a { color: var(--ink-weak); text-decoration: none; }
|
||||
.legal a:hover { color: var(--ink); text-decoration: underline; }
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
footer { flex-wrap: wrap; }
|
||||
footer a { flex: 1 0 50%; border-top: 1px solid var(--rule); }
|
||||
footer a + a { border-left: none; }
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.hero h1 { font-size: 36px; }
|
||||
.highlights li { font-size: 18px; }
|
||||
.releases { gap: 80px; }
|
||||
}
|
||||
`
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseChangelog, renderEntry, renderInline, renderMarkdown } from "../src/lib/changelog-markdown"
|
||||
import { parseChangelog, renderEntry, renderInline, renderMarkdown } from "../src/markdown"
|
||||
|
||||
const fixture = `# Changelog
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { handle, loadSource } from "../src/index"
|
||||
|
||||
const changelog = `# Changelog
|
||||
|
||||
## v2.0.27 — 2026-10-12
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Built-In Worktree Tools**: Use \`worktree_create\`. ([#53981](https://github.com/anomalyco/opencode/pull/53981))
|
||||
|
||||
### Core
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Fixed updater detection. (#54609)
|
||||
|
||||
## v2.0.26 — 2026-10-08
|
||||
|
||||
### Core
|
||||
|
||||
- Reduced snapshot overhead.
|
||||
`
|
||||
|
||||
const request = (path: string, method = "GET") => new Request(`https://opencode.ai${path}`, { method })
|
||||
|
||||
describe("handle", () => {
|
||||
test("renders every release with anchors, highlights and cache headers", async () => {
|
||||
const response = await handle(request("/changelog"), async () => changelog)
|
||||
const body = await response.text()
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8")
|
||||
expect(response.headers.get("cache-control")).toBe("public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
|
||||
expect(body.indexOf('id="v2-0-27"')).toBeLessThan(body.indexOf('id="v2-0-26"'))
|
||||
expect(body).toContain('<a href="#v2-0-27">v2.0.27</a>')
|
||||
expect(body).toContain('<time datetime="2026-10-12">October 12, 2026</time>')
|
||||
expect(body).toContain('<p class="title">Built-In Worktree Tools</p>')
|
||||
expect(body).toContain('<details class="section" open>')
|
||||
expect(body).toContain('<details class="section"><summary>')
|
||||
})
|
||||
|
||||
test("answers HEAD without a body", async () => {
|
||||
const response = await handle(request("/changelog", "HEAD"), async () => changelog)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toBe("")
|
||||
})
|
||||
|
||||
test("shows the empty state for a changelog without releases", async () => {
|
||||
const response = await handle(request("/changelog"), async () => "# Changelog\n")
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toContain("No changelog entries found.")
|
||||
})
|
||||
|
||||
test("returns a friendly 503 when no copy can be loaded", async () => {
|
||||
const response = await handle(request("/changelog"), async () => undefined)
|
||||
expect(response.status).toBe(503)
|
||||
expect(response.headers.get("cache-control")).toBe("no-store")
|
||||
expect(await response.text()).toContain("could not be loaded right now")
|
||||
})
|
||||
|
||||
test("redirects the trailing slash and version paths", async () => {
|
||||
const load = async () => changelog
|
||||
expect((await handle(request("/changelog/"), load)).headers.get("location")).toBe("https://opencode.ai/changelog")
|
||||
const version = await handle(request("/changelog/v2.0.26"), load)
|
||||
expect(version.status).toBe(302)
|
||||
expect(version.headers.get("location")).toBe("https://opencode.ai/changelog#v2-0-26")
|
||||
})
|
||||
|
||||
test("returns 404 for other subpaths and 405 for writes", async () => {
|
||||
expect((await handle(request("/changelog/nope"), async () => changelog)).status).toBe(404)
|
||||
expect((await handle(request("/changelog", "POST"), async () => changelog)).status).toBe(405)
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadSource", () => {
|
||||
const original = globalThis.fetch
|
||||
afterEach(() => {
|
||||
globalThis.fetch = original
|
||||
})
|
||||
|
||||
function memoryCache() {
|
||||
const entries = new Map<string, Response>()
|
||||
return {
|
||||
entries,
|
||||
cache: {
|
||||
match: async (key: string) => entries.get(key)?.clone(),
|
||||
put: async (key: string, response: Response) => void entries.set(key, response),
|
||||
} as unknown as Cache,
|
||||
}
|
||||
}
|
||||
|
||||
function stub(body: string | undefined) {
|
||||
const calls: string[] = []
|
||||
globalThis.fetch = (async (input: string) => {
|
||||
calls.push(input)
|
||||
return body === undefined ? new Response("", { status: 500 }) : new Response(body)
|
||||
}) as unknown as typeof fetch
|
||||
return calls
|
||||
}
|
||||
|
||||
test("fetches dev's CHANGELOG.md and serves the cached copy while fresh", async () => {
|
||||
const calls = stub(changelog)
|
||||
const { cache } = memoryCache()
|
||||
const waits: Promise<unknown>[] = []
|
||||
const ctx = { waitUntil: (promise: Promise<unknown>) => void waits.push(promise) }
|
||||
expect(await loadSource(ctx, cache)).toBe(changelog)
|
||||
expect(await loadSource(ctx, cache)).toBe(changelog)
|
||||
expect(calls).toEqual(["https://raw.githubusercontent.com/anomalyco/opencode/dev/CHANGELOG.md"])
|
||||
expect(waits).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("serves a stale copy and keeps it when revalidation fails", async () => {
|
||||
const { cache, entries } = memoryCache()
|
||||
entries.set(
|
||||
"https://opencode.ai/changelog/source.md",
|
||||
new Response("stale", { headers: { "x-opencode-changelog-fetched-at": String(Date.now() - 301_000) } }),
|
||||
)
|
||||
const calls = stub(undefined)
|
||||
const waits: Promise<unknown>[] = []
|
||||
expect(await loadSource({ waitUntil: (promise) => void waits.push(promise) }, cache)).toBe("stale")
|
||||
await Promise.all(waits)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(await entries.get("https://opencode.ai/changelog/source.md")?.clone().text()).toBe("stale")
|
||||
})
|
||||
|
||||
test("resolves undefined when GitHub fails and nothing is cached", async () => {
|
||||
stub(undefined)
|
||||
expect(await loadSource({ waitUntil: () => {} }, memoryCache().cache)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"lib": ["ES2023", "WebWorker"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
"types": ["@cloudflare/workers-types", "bun"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "opencode-changelog",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-07-15",
|
||||
"workers_dev": false,
|
||||
"preview_urls": false,
|
||||
"assets": {
|
||||
"directory": "./public",
|
||||
},
|
||||
// Exact path plus subpaths, so the Console keeps /changelog.json and the localized /<locale>/changelog pages.
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "opencode.ai/changelog",
|
||||
"zone_name": "opencode.ai",
|
||||
},
|
||||
{
|
||||
"pattern": "opencode.ai/changelog/*",
|
||||
"zone_name": "opencode.ai",
|
||||
},
|
||||
],
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user