From 699a9f21517560ca8d7ec4b877a006a45cc73ef1 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 20 Aug 2026 19:40:29 -0400 Subject: [PATCH] feat(changelog): every keel release, newest first, straight from GitHub Releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /{en,ar,fr}/changelog/ renders the full release list fetched at build time into a gitignored content collection — the same never-hand-copied pattern as the engine docs, no new dependency. Each release gets a version heading with an anchor, its date, a GitHub link, and the canonical English notes with headings demoted one level beneath it; an on-this-page TOC lists every version and the newest carries a chip. ar/fr translate the chrome and say the notes are English (the engine-docs policy, FR-7/8). A failed fetch degrades to a plain Releases link, never a broken build. Nav gains the page after News in all three locales; the Install page links the full history under the requirements line. Refs #41 --- .gitignore | 3 +- scripts/fetch-release.mjs | 56 +++++++++++- src/components/Header.astro | 1 + src/components/pages/ChangelogPage.astro | 106 +++++++++++++++++++++++ src/components/pages/InstallPage.astro | 3 + src/content.config.ts | 19 +++- src/i18n/config.ts | 1 + src/i18n/pages/changelog.ts | 66 ++++++++++++++ src/i18n/pages/index.ts | 3 + src/i18n/pages/install.ts | 14 +-- src/i18n/ui.ts | 3 + src/pages/ar/changelog.astro | 4 + src/pages/en/changelog.astro | 4 + src/pages/fr/changelog.astro | 4 + src/styles/global.css | 72 +++++++++++++++ 15 files changed, 351 insertions(+), 8 deletions(-) create mode 100644 src/components/pages/ChangelogPage.astro create mode 100644 src/i18n/pages/changelog.ts create mode 100644 src/pages/ar/changelog.astro create mode 100644 src/pages/en/changelog.astro create mode 100644 src/pages/fr/changelog.astro diff --git a/.gitignore b/.gitignore index 1006683..cfcece2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,9 @@ dist/ build/ .astro/ -# Fetched-at-build artifacts (engine docs, release, discussions) — never hand-copied +# Fetched-at-build artifacts (engine docs, releases, discussions) — never hand-copied src/content/engine-docs/ +src/content/changelog/ data/ # Cloudflare tooling diff --git a/scripts/fetch-release.mjs b/scripts/fetch-release.mjs index b4846c1..8e832d3 100644 --- a/scripts/fetch-release.mjs +++ b/scripts/fetch-release.mjs @@ -11,7 +11,7 @@ * A failed fetch never breaks the build; the Install page falls back to a * plain link to the Releases page. */ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; @@ -63,3 +63,57 @@ try { console.warn(` WARN: release fetch failed (${error.message}); Install page will link to the Releases page`); } } + +// --------------------------------------------------------------------------- +// #41 — the changelog page. The full release list, newest first, written into +// the `changelog` content collection (gitignored; this script is the only +// writer, exactly like the engine docs). Same failure policy: a failed fetch +// never breaks the build — the page falls back to a plain Releases link. +const CHANGELOG_DIR = join(root, "src/content/changelog"); +const LIST_API = "https://api.github.com/repos/CodeGateSoftware/keel/releases?per_page=100"; + +/** Demote every ATX heading one level, outside code fences, so each release's + * version heading (an H2 from the page template) stays the section's H2. */ +function demoteHeadings(markdown) { + let inFence = false; + return markdown + .split("\n") + .map((line) => { + if (/^\s*(```|~~~)/.test(line)) { + inFence = !inFence; + return line; + } + if (!inFence && /^#{1,5} \S/.test(line)) return `#${line}`; + return line; + }) + .join("\n"); +} + +try { + const response = await fetch(LIST_API, { headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const releases = (await response.json()).filter((release) => !release.draft); + + mkdirSync(CHANGELOG_DIR, { recursive: true }); + for (const stale of readdirSync(CHANGELOG_DIR)) { + if (stale.endsWith(".md")) rmSync(join(CHANGELOG_DIR, stale)); + } + for (const release of releases) { + const frontmatter = [ + "---", + `tag: ${JSON.stringify(release.tag_name)}`, + `name: ${JSON.stringify(release.name ?? release.tag_name)}`, + `publishedAt: ${JSON.stringify(release.published_at ?? "")}`, + `url: ${JSON.stringify(release.html_url)}`, + "---", + "", + ].join("\n"); + writeFileSync( + join(CHANGELOG_DIR, `${release.tag_name}.md`), + frontmatter + demoteHeadings(release.body ?? "") + "\n", + ); + } + console.log(` changelog: ${releases.length} releases -> src/content/changelog/`); +} catch (error) { + console.warn(` WARN: changelog fetch failed (${error.message}); keeping any existing files`); +} diff --git a/src/components/Header.astro b/src/components/Header.astro index 60419eb..f00bc60 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -16,6 +16,7 @@ const navItems: { key: Exclude; label: string }[] = [ { key: "install", label: chrome.nav.install }, { key: "docs", label: chrome.nav.docs }, { key: "news", label: chrome.nav.news }, + { key: "changelog", label: chrome.nav.changelog }, { key: "community", label: chrome.nav.community }, { key: "compliance", label: chrome.nav.compliance }, { key: "compare", label: chrome.nav.compare }, diff --git a/src/components/pages/ChangelogPage.astro b/src/components/pages/ChangelogPage.astro new file mode 100644 index 0000000..bac5a6e --- /dev/null +++ b/src/components/pages/ChangelogPage.astro @@ -0,0 +1,106 @@ +--- +import Base from "../../layouts/Base.astro"; +import { getCollection, render } from "astro:content"; +import { changelog } from "../../i18n/pages/changelog"; +import { + alternatesFor, + formatDate, + localePath, + ENGINE_RELEASES_URL, + type Locale, +} from "../../i18n/config"; +import { t } from "../../i18n/ui"; + +/** + * #41 — every keel release, newest first, rendered from the `changelog` + * content collection that scripts/fetch-release.mjs fills at build time + * (same pattern as the engine docs: fetched markdown, never hand-copied). + * Release notes stay in English — the canonical wording — on every locale. + */ +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const c = changelog[locale]; +const chrome = t(locale); + +const entries = (await getCollection("changelog")) + .slice() + .sort((a, b) => b.data.publishedAt.localeCompare(a.data.publishedAt)); +const rendered = await Promise.all( + entries.map(async (entry) => ({ entry, Content: (await render(entry)).Content })), +); +const latestTag = entries[0]?.data.tag ?? null; + +/** Fragment-safe anchor ids: v0.10.0 → v0-10-0 */ +const anchorFor = (tag: string) => tag.replace(/\./g, "-"); +--- + + +
+
+

{c.title}

+

{c.intro}

+

{c.englishOnly}

+
+ +
+ { + rendered.length === 0 ? ( +
+

{c.empty}

+

+ {chrome.actions.seeReleases} → +

+
+ ) : ( + <> + + + {rendered.map(({ entry, Content }) => ( + + ))} + + ) + } +
+
+ diff --git a/src/components/pages/InstallPage.astro b/src/components/pages/InstallPage.astro index 55b3883..f17ef42 100644 --- a/src/components/pages/InstallPage.astro +++ b/src/components/pages/InstallPage.astro @@ -96,6 +96,9 @@ const venvCommands = (os: "mac" | "win"): string => { }

{c.requirements}

+

+ {c.historyLink} +

{ diff --git a/src/content.config.ts b/src/content.config.ts index 3a5c621..dfad9f0 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,4 +1,4 @@ -import { defineCollection } from "astro:content"; +import { defineCollection, z } from "astro:content"; import { glob } from "astro/loaders"; /** @@ -11,6 +11,23 @@ const engineDocs = defineCollection({ loader: glob({ pattern: "*.md", base: "./src/content/engine-docs" }), }); +/** + * #41 — release notes, fetched at build time by scripts/fetch-release.mjs + * into src/content/changelog/ (gitignored; the fetch script is the only + * writer). One markdown file per release; frontmatter carries the tag, + * date and GitHub URL the changelog page renders around the body. + */ +const changelog = defineCollection({ + loader: glob({ pattern: "*.md", base: "./src/content/changelog" }), + schema: z.object({ + tag: z.string(), + name: z.string(), + publishedAt: z.string(), + url: z.string(), + }), +}); + export const collections = { "engine-docs": engineDocs, + changelog, }; diff --git a/src/i18n/config.ts b/src/i18n/config.ts index f896c97..f17de2a 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -29,6 +29,7 @@ export const pageKeys = [ "compliance", "compare", "about", + "changelog", "guides", ] as const; export type PageKey = (typeof pageKeys)[number]; diff --git a/src/i18n/pages/changelog.ts b/src/i18n/pages/changelog.ts new file mode 100644 index 0000000..9784420 --- /dev/null +++ b/src/i18n/pages/changelog.ts @@ -0,0 +1,66 @@ +import type { LocalizedPage } from "../config"; + +/** + * #41 — the changelog page. Release notes are fetched from GitHub Releases at + * build time into the `changelog` content collection (scripts/fetch-release.mjs + * is the only writer) and stay in English — the canonical wording — on every + * locale; ar/fr translate the chrome and say so, mirroring the engine-docs + * policy (FR-7/8). + * + * Sonar: fields are flat single strings on purpose — parallel tri-locale + * blocks trip the duplication gate otherwise (see CONTRIBUTING.md). + */ +export interface ChangelogContent { + title: string; + description: string; + intro: string; + englishOnly: string; + tocLabel: string; + latestLabel: string; + viewRelease: string; + empty: string; +} + +export const changelog: LocalizedPage = { + en: { + rev: "2026-08-20.1", + title: "Changelog — every keel release, newest first", + description: + "Every keel release with its install and configuration notes, newest first, pulled from GitHub Releases at build time.", + intro: + "Every version of keel, newest first, exactly as published to GitHub Releases. The build fetches the notes; nobody hand-updates this page.", + englishOnly: "Release notes are published in English — the text below is the canonical wording.", + tocLabel: "On this page", + latestLabel: "latest", + viewRelease: "View this release on GitHub", + empty: "The release list could not be fetched for this build.", + }, + ar: { + rev: "2026-08-20.1", + translatedFromRev: "2026-08-20.1", + title: "سجلُّ التغييرات — كلُّ إصدارات كيل، من الأحدث إلى الأقدم", + description: + "كلُّ إصدارٍ من كيل مع ملاحظات التثبيت والإعداد، من الأحدث إلى الأقدم، مجلوبةٌ وقتَ البناء من GitHub Releases.", + intro: + "كلُّ إصدارٍ من كيل، من الأحدث إلى الأقدم، كما نُشر على GitHub Releases تمامًا. البناءُ هو الذي يجلب الملاحظات، ولا أحدَ يُحدِّث هذه الصفحة يدويًّا.", + englishOnly: "تُنشر ملاحظات الإصدار بالإنجليزية، والنصُّ أدناه هو الصياغة المعتمدة.", + tocLabel: "في هذه الصفحة", + latestLabel: "الأحدث", + viewRelease: "شاهِد هذا الإصدار على GitHub", + empty: "تعذَّر جلبُ قائمة الإصدارات في هذا البناء.", + }, + fr: { + rev: "2026-08-20.1", + translatedFromRev: "2026-08-20.1", + title: "Journal des versions — chaque sortie de keel, de la plus récente à la plus ancienne", + description: + "Chaque version de keel avec ses notes d'installation et de configuration, de la plus récente à la plus ancienne, importées de GitHub Releases à la construction.", + intro: + "Chaque version de keel, de la plus récente à la plus ancienne, exactement comme publiée sur GitHub Releases. La construction importe les notes ; personne ne met cette page à jour à la main.", + englishOnly: "Les notes de version sont publiées en anglais ; le texte ci-dessous fait foi.", + tocLabel: "Sur cette page", + latestLabel: "la plus récente", + viewRelease: "Voir cette version sur GitHub", + empty: "La liste des versions n'a pas pu être importée pour cette construction.", + }, +}; diff --git a/src/i18n/pages/index.ts b/src/i18n/pages/index.ts index 608ede7..467664b 100644 --- a/src/i18n/pages/index.ts +++ b/src/i18n/pages/index.ts @@ -8,6 +8,7 @@ import { community, type CommunityContent } from "./community"; import { compliance, type ComplianceContent } from "./compliance"; import { compare, type CompareContent } from "./compare"; import { about, type AboutContent } from "./about"; +import { changelog, type ChangelogContent } from "./changelog"; /** * FR-8 — the layout reads this registry to stamp every translated page with @@ -26,6 +27,7 @@ export const pageDicts: Record, LocalizedPage = { en: { - rev: "2026-08-20.6", + rev: "2026-08-20.7", title: "Download keel — macOS & Windows", description: "Download keel for macOS or Windows. Version and links come from GitHub Releases at build time; the five-minute source path is here too.", @@ -86,6 +87,7 @@ export const install: LocalizedPage = { versionPrefix: "Latest release", requirements: "Requires Python 3.11 or later · downloaded from GitHub Releases — never mirrored here", otherPlatforms: "Linux and everything else: same wheels from the release page.", + historyLink: "Every version, newest first — the full changelog", cards: [ { name: "macOS", @@ -162,8 +164,8 @@ export const install: LocalizedPage = { }, ar: { - rev: "2026-08-20.6", - translatedFromRev: "2026-08-20.6", + rev: "2026-08-20.7", + translatedFromRev: "2026-08-20.7", title: "تنزيل كيل — macOS وWindows", description: "نزّل كيل لنظام macOS أو Windows. ويأتي رقمُ الإصدار وروابطه من GitHub Releases وقت البناء؛ ومسارُ التثبيت من المصدر في خمس دقائق هنا أيضًا.", @@ -171,6 +173,7 @@ export const install: LocalizedPage = { versionPrefix: "أحدث إصدار", requirements: "يتطلّب Python 3.11 أو أحدث · التنزيل من GitHub Releases — ولا يُنسخ هنا أبدًا", otherPlatforms: "لينكس وغيره: حزم wheel نفسها متاحةٌ في صفحة الإصدار.", + historyLink: "كلُّ الإصدارات، من الأحدث إلى الأقدم — السجلُّ الكامل للتغييرات", cards: [ { name: "macOS", @@ -247,8 +250,8 @@ export const install: LocalizedPage = { }, fr: { - rev: "2026-08-20.6", - translatedFromRev: "2026-08-20.6", + rev: "2026-08-20.7", + translatedFromRev: "2026-08-20.7", title: "Télécharger keel — macOS et Windows", description: "Téléchargez keel pour macOS ou Windows. Le numéro de version et les liens proviennent de GitHub Releases, récupérés au moment du build ; le parcours en cinq minutes depuis les sources figure également ici.", @@ -256,6 +259,7 @@ export const install: LocalizedPage = { versionPrefix: "Dernière version", requirements: "Nécessite Python 3.11 ou plus · téléchargé depuis GitHub Releases — jamais recopié ici", otherPlatforms: "Linux et le reste : les mêmes wheels, depuis la page des versions.", + historyLink: "Toutes les versions, de la plus récente à la plus ancienne — le journal complet", cards: [ { name: "macOS", diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index a11aefb..5010e89 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -14,6 +14,7 @@ export const ui = { install: "Install", docs: "Docs", news: "News", + changelog: "Changelog", community: "Community", compliance: "Compliance", compare: "Compare", @@ -100,6 +101,7 @@ export const ui = { install: "التثبيت", docs: "الوثائق", news: "الأخبار", + changelog: "سجلُّ التغييرات", community: "المجتمع", compliance: "الامتثال", compare: "مقارنة", @@ -192,6 +194,7 @@ export const ui = { install: "Installation", docs: "Documentation", news: "Actualités", + changelog: "Journal des versions", community: "Communauté", compliance: "Conformité", compare: "Comparatif", diff --git a/src/pages/ar/changelog.astro b/src/pages/ar/changelog.astro new file mode 100644 index 0000000..f85593c --- /dev/null +++ b/src/pages/ar/changelog.astro @@ -0,0 +1,4 @@ +--- +import ChangelogPage from "../../components/pages/ChangelogPage.astro"; +--- + diff --git a/src/pages/en/changelog.astro b/src/pages/en/changelog.astro new file mode 100644 index 0000000..97c5020 --- /dev/null +++ b/src/pages/en/changelog.astro @@ -0,0 +1,4 @@ +--- +import ChangelogPage from "../../components/pages/ChangelogPage.astro"; +--- + diff --git a/src/pages/fr/changelog.astro b/src/pages/fr/changelog.astro new file mode 100644 index 0000000..093a026 --- /dev/null +++ b/src/pages/fr/changelog.astro @@ -0,0 +1,4 @@ +--- +import ChangelogPage from "../../components/pages/ChangelogPage.astro"; +--- + diff --git a/src/styles/global.css b/src/styles/global.css index 15eb931..8500d67 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1656,3 +1656,75 @@ html[lang="fr"] .site-nav a { font-size: 0.87rem; padding-inline: 0.35rem; } + +/* Changelog (#41) — version TOC and per-release articles */ +.changelog-toc { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + padding: 1rem 1.25rem; + margin-bottom: 2.5rem; +} + +.changelog-toc .toc-label { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin: 0 0 0.6rem; +} + +.changelog-toc ul { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1.1rem; + margin: 0; + padding: 0; +} + +.changelog-toc a { + font-family: var(--font-mono); + font-size: 0.85rem; +} + +html[dir="rtl"] .changelog-toc a { + font-family: inherit; +} + +.latest-chip { + margin-inline-start: 0.45rem; + font-family: var(--font-sans); + font-size: 0.68rem; + color: var(--gold-2); + border: 1px solid var(--gold-2); + border-radius: 999px; + padding: 0.05rem 0.45rem; + vertical-align: middle; +} + +.release { + border-top: 1px solid var(--border); + padding-block: 2rem; +} + +.release h2 { + font-family: var(--font-mono); + font-size: 1.35rem; + margin-bottom: 0.25rem; +} + +html[dir="rtl"] .release h2 { + font-family: inherit; +} + +.release h2 a { + color: inherit; + text-decoration: none; +} + +.release .meta { + font-size: 0.85rem; + color: var(--muted); + margin-top: 0; +}