diff --git a/package.json b/package.json index 8f30e3c..a022c08 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "version": "0.1.0", "private": true, "scripts": { - "fetch": "node scripts/fetch-release.mjs && node scripts/fetch-engine-docs.mjs && node scripts/fetch-discussions.mjs", + "fetch": "node scripts/fetch-release.mjs && node scripts/fetch-engine-docs.mjs && node scripts/fetch-discussions.mjs && node scripts/fetch-milestones.mjs", "dev": "npm run fetch && astro dev", "build": "npm run fetch && astro build", "preview": "astro preview", diff --git a/scripts/fetch-milestones.mjs b/scripts/fetch-milestones.mjs new file mode 100644 index 0000000..ab70dda --- /dev/null +++ b/scripts/fetch-milestones.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +/** + * #117 — the Roadmap page's data feed. + * + * Reads the keel repo's GitHub milestones (open + recently closed) and the + * issues/PRs under each, via the public REST endpoints, and writes + * data/milestones.json. The roadmap is a read of the repo's own working + * plan — no voting, no accounts, no subscribe box (#117's non-goals). + * + * Failure policy (same as fetch-release / fetch-discussions): a failed + * fetch never breaks the build. It degrades to the last known data and + * finally to an empty stub, at which point the Roadmap page shows a plain + * link to the GitHub milestones page. + */ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const OUT = join(root, "data/milestones.json"); +mkdirSync(join(root, "data"), { recursive: true }); + +const REPO_API = "https://api.github.com/repos/CodeGateSoftware/keel"; +const MILESTONES_URL = "https://github.com/CodeGateSoftware/keel/milestones"; +/** Open milestones first (that's the roadmap), then the recently shipped. */ +const OPEN_API = `${REPO_API}/milestones?state=open&per_page=50`; +const CLOSED_API = `${REPO_API}/milestones?state=closed&per_page=10`; +/** Issues *and* PRs under one milestone — the issues endpoint returns both. */ +const itemsApi = (number) => `${REPO_API}/issues?milestone=${number}&state=all&per_page=100`; + +/** Sane cap: the biggest milestone to date holds ~15 items; 30 leaves room + * without ever writing the whole repo into data/. The milestone's own + * open/closed counts stay in the output, so the page can say "and N more". */ +const MAX_ITEMS_PER_MILESTONE = 30; +/** Recently-shipped section: enough to show momentum, not a full history. */ +const MAX_CLOSED_MILESTONES = 5; + +const headers = { + accept: "application/vnd.github+json", + "user-agent": "keeltrading.com-milestones-fetch", +}; +if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + +async function getJson(url) { + const response = await fetch(url, { headers }); + if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`); + return response.json(); +} + +/** One milestone, shaped for the page. Items are capped; the full counts + * (openIssues + closedIssues) travel along so the page can point to GitHub + * for the overflow instead of hiding it. */ +async function readMilestone(milestone) { + let items = []; + try { + const raw = await getJson(itemsApi(milestone.number)); + items = (Array.isArray(raw) ? raw : []) + .slice(0, MAX_ITEMS_PER_MILESTONE) + .map((item) => ({ + number: item.number, + title: item.title, + state: item.state, + isPullRequest: item.pull_request !== undefined, + url: item.html_url, + })); + } catch (error) { + // The milestone itself still renders — without its item list. + console.warn(` WARN: items for milestone ${milestone.number} failed (${error.message})`); + } + + return { + number: milestone.number, + title: milestone.title, + description: milestone.description ?? "", + state: milestone.state, + url: milestone.html_url, + openIssues: milestone.open_issues ?? 0, + closedIssues: milestone.closed_issues ?? 0, + dueOn: milestone.due_on, + closedAt: milestone.closed_at, + items, + }; +} + +try { + const [openRaw, closedRaw] = await Promise.all([ + getJson(OPEN_API), + getJson(CLOSED_API), + ]); + + // Reading order (#117): dated milestones by nearest target first, undated + // after them (a milestone without a date is an intention, not a schedule), + // then the recently shipped, newest closure first. + const open = (Array.isArray(openRaw) ? openRaw : []) + .slice() + .sort((a, b) => { + if (a.due_on && b.due_on) return a.due_on.localeCompare(b.due_on); + if (a.due_on) return -1; + if (b.due_on) return 1; + return a.number - b.number; + }); + const closed = (Array.isArray(closedRaw) ? closedRaw : []) + .filter((m) => m.closed_at) + .sort((a, b) => b.closed_at.localeCompare(a.closed_at)) + .slice(0, MAX_CLOSED_MILESTONES); + + const milestones = []; + for (const milestone of [...open, ...closed]) { + milestones.push(await readMilestone(milestone)); + } + + writeFileSync( + OUT, + JSON.stringify( + { + milestonesUrl: MILESTONES_URL, + fetchedAt: new Date().toISOString(), + milestones, + }, + null, + 2, + ) + "\n", + ); + console.log(` roadmap: ${open.length} open + ${closed.length} recently closed -> data/milestones.json`); +} catch (error) { + const previous = existsSync(OUT) ? JSON.parse(readFileSync(OUT, "utf8")) : null; + if (previous?.milestones?.length) { + console.warn(` WARN: milestones fetch failed (${error.message}); keeping last-known ${previous.milestones.length} milestones`); + } else { + writeFileSync( + OUT, + JSON.stringify( + { + milestonesUrl: MILESTONES_URL, + fetchedAt: null, + milestones: [], + }, + null, + 2, + ) + "\n", + ); + console.warn(` WARN: milestones fetch failed (${error.message}); Roadmap page will link to GitHub milestones`); + } +} diff --git a/src/components/Header.astro b/src/components/Header.astro index fa69268..0e2b911 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -18,6 +18,7 @@ const navItems: { key: Exclude; label: string }[] = [ { key: "news", label: chrome.nav.news }, { key: "changelog", label: chrome.nav.changelog }, { key: "community", label: chrome.nav.community }, + { key: "roadmap", label: chrome.nav.roadmap }, { key: "compliance", label: chrome.nav.compliance }, { key: "compare", label: chrome.nav.compare }, { key: "about", label: chrome.nav.about }, diff --git a/src/components/pages/RoadmapPage.astro b/src/components/pages/RoadmapPage.astro new file mode 100644 index 0000000..f26b730 --- /dev/null +++ b/src/components/pages/RoadmapPage.astro @@ -0,0 +1,330 @@ +--- +import Base from "../../layouts/Base.astro"; +import { readDataFile } from "../docs/nav"; +import { roadmap } from "../../i18n/pages/roadmap"; +import { alternatesFor, formatDate, localePath, type Locale } from "../../i18n/config"; +import { t } from "../../i18n/ui"; + +/** + * #117 — the Roadmap page: a read of the engine repo's GitHub milestones, + * filled at build time by scripts/fetch-milestones.mjs into + * data/milestones.json (gitignored; this component only reads). Milestone + * and issue titles stay in their original language — they are the repo's + * own wording. Every item links to its issue; there is no voting, no + * account, and no subscribe box (#117's non-goals — the site only reads). + */ +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const c = roadmap[locale]; +const chrome = t(locale); + +interface RoadmapItem { + number: number; + title: string; + state: string; + isPullRequest: boolean; + url: string; +} +interface RoadmapMilestone { + number: number; + title: string; + description: string; + state: string; + url: string; + openIssues: number; + closedIssues: number; + dueOn: string | null; + closedAt: string | null; + items: RoadmapItem[]; +} +interface MilestonesFile { + milestonesUrl: string; + fetchedAt: string | null; + milestones: RoadmapMilestone[]; +} + +const data: MilestonesFile = + readDataFile("milestones.json") ?? { + milestonesUrl: "https://github.com/CodeGateSoftware/keel/milestones", + fetchedAt: null, + milestones: [], + }; + +/** The fetch script already orders them: open (dated then undated) → + * recently closed. Splitting preserves that order on each side. */ +const open = data.milestones.filter((milestone) => milestone.state === "open"); +const closed = data.milestones.filter((milestone) => milestone.state === "closed"); + +/** Full counts travel with each milestone; items are capped in the script. */ +const overflow = (milestone: RoadmapMilestone) => + Math.max(0, milestone.openIssues + milestone.closedIssues - milestone.items.length); +--- + + +
+
+

{c.title}

+ {c.intro.map((paragraph) =>

{paragraph}

)} +
+ +
+ { + data.fetchedAt && ( +

{c.refreshedAt(formatDate(data.fetchedAt))}

+ ) + } + + { + data.milestones.length === 0 ? ( + + ) : ( + <> + {open.length > 0 && ( +
+

{c.openTitle}

+ +
+ )} + + {closed.length > 0 && ( +
+

{c.closedTitle}

+ +
+ )} + + ) + } +
+
+ + + diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 29ebd24..e020655 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -26,6 +26,7 @@ export const pageKeys = [ "docs", "news", "community", + "roadmap", "compliance", "compare", "about", diff --git a/src/i18n/pages/index.ts b/src/i18n/pages/index.ts index 467664b..e35cd88 100644 --- a/src/i18n/pages/index.ts +++ b/src/i18n/pages/index.ts @@ -9,6 +9,7 @@ import { compliance, type ComplianceContent } from "./compliance"; import { compare, type CompareContent } from "./compare"; import { about, type AboutContent } from "./about"; import { changelog, type ChangelogContent } from "./changelog"; +import { roadmap, type RoadmapContent } from "./roadmap"; /** * FR-8 — the layout reads this registry to stamp every translated page with @@ -24,6 +25,7 @@ export const pageDicts: Record, LocalizedPage string; + /** A milestone with no due date says so — never an invented date. */ + noDate: string; + /** Closed milestone, with the date GitHub recorded. */ + closedOn: (date: string) => string; + /** Progress line: N of M items closed, straight from the repo's counts. */ + progress: (closed: number, total: number) => string; + /** Item state markers. */ + itemOpen: string; + itemDone: string; + pullRequest: string; + /** Overflow beyond the per-milestone item cap. */ + moreOnGitHub: (count: number) => string; + /** Degraded-state copy (fetch failed and no last-known data). */ + empty: string; + viewMilestone: string; + /** The read-of-the-repo / refresh cadence line. */ + refreshedAt: (date: string) => string; +} + +export const roadmap: LocalizedPage = { + en: { + rev: "2026-08-28.1", + title: "keel Roadmap — read from the repo, refreshed hourly", + description: + "keel's roadmap: the open milestones of CodeGateSoftware/keel and the issues under them, read from GitHub at build time. An open milestone is an intention, not a commitment.", + intro: [ + "This page is a read of the project's own working plan — the milestones on GitHub and the issues under them, exactly as the repo tracks them. Every item links to its issue, where discussion already happens; nothing here is voted on, and nothing here can be bought ahead of anything else.", + "An open milestone is an intention, not a commitment, and a date on one is a target, not a promise. Work ships when it is honest to say it shipped — the changelog records what actually landed.", + ], + openTitle: "In progress — open milestones", + closedTitle: "Recently shipped", + dueOn: (date) => `Target date: ${date}`, + noDate: "No date set", + closedOn: (date) => `Shipped ${date}`, + progress: (closed, total) => `${closed} of ${total} items closed`, + itemOpen: "open", + itemDone: "done", + pullRequest: "PR", + moreOnGitHub: (count) => `and ${count} more on GitHub`, + empty: "The roadmap could not be fetched for this build. Read the milestones directly on GitHub.", + viewMilestone: "View this milestone on GitHub", + refreshedAt: (date) => + `Read from the repo's GitHub milestones at build time; refreshed hourly — last read ${date}.`, + }, + + ar: { + rev: "2026-08-28.1", + translatedFromRev: "2026-08-28.1", + title: "خارطةُ طريق كيل — تُقرأ من المستودع وتتجدّد كل ساعة", + description: + "خارطةُ طريق كيل: المراحلُ المفتوحة في CodeGateSoftware/keel والبنودُ تحتها، تُجلب من GitHub وقت البناء. والمرحلةُ المفتوحة نيّةٌ لا التزام.", + intro: [ + "هذه الصفحة قراءةٌ لخطة عمل المشروع نفسه — المراحلُ على GitHub والبنودُ تحتها، تمامًا كما يتتبّعها المستودع. ويرتبط كلُّ بندٍ بقضيّته حيث يجري النقاش أصلًا؛ لا تصويتَ هنا، ولا يمكن شراءُ تقدُّمٍ على أحدٍ هنا.", + "المرحلةُ المفتوحة نيّةٌ لا التزام، والتاريخُ عليها هدفٌ لا وعد. ويُنجَز العمل حين يصحّ قولُ إنه أُنجز — وسجلُّ التغييرات يوثّق ما هبط فعلًا.", + ], + openTitle: "قيدُ العمل — مراحلُ مفتوحة", + closedTitle: "أُنجز مؤخّرًا", + dueOn: (date) => `تاريخٌ مستهدف: ${date}`, + noDate: "لم يُحدَّد تاريخ", + closedOn: (date) => `أُنجزت في ${date}`, + progress: (closed, total) => `أُغلق ${closed} من ${total} بنود`, + itemOpen: "مفتوح", + itemDone: "أُنجز", + pullRequest: "‏PR", + moreOnGitHub: (count) => `و${count} أخرى على GitHub`, + empty: "تعذَّر جلبُ خارطة الطريق في هذا البناء. اقرأ المراحل مباشرةً على GitHub.", + viewMilestone: "شاهِد هذه المرحلة على GitHub", + refreshedAt: (date) => + `تُقرأ خارطةُ الطريق من مراحل GitHub في المستودع وقت البناء؛ وتتجدّد كل ساعة — آخر قراءةٍ ${date}.`, + }, + + fr: { + rev: "2026-08-28.1", + translatedFromRev: "2026-08-28.1", + title: "Feuille de route de keel — lue depuis le dépôt, actualisée toutes les heures", + description: + "La feuille de route de keel : les jalons ouverts de CodeGateSoftware/keel et leurs tickets, lus depuis GitHub à la construction. Un jalon ouvert est une intention, pas un engagement.", + intro: [ + "Cette page est une lecture du plan de travail du projet lui-même — les jalons sur GitHub et les tickets qui s'y rattachent, exactement comme le dépôt les suit. Chaque élément renvoie à son ticket, là où la discussion a déjà lieu ; rien ne se vote ici, et l'on ne peut rien y acheter en passer devant.", + "Un jalon ouvert est une intention, pas un engagement, et sa date est un objectif, pas une promesse. Le travail est livré quand il est honnête de dire qu'il l'est — le journal des versions consigne ce qui a réellement atterri.", + ], + openTitle: "En cours — jalons ouverts", + closedTitle: "Livré récemment", + dueOn: (date) => `Date cible : ${date}`, + noDate: "Aucune date fixée", + closedOn: (date) => `Livré le ${date}`, + progress: (closed, total) => `${closed} éléments sur ${total} fermés`, + itemOpen: "ouvert", + itemDone: "fait", + pullRequest: "PR", + moreOnGitHub: (count) => `et ${count} autres sur GitHub`, + empty: "La feuille de route n'a pas pu être récupérée pour cette construction. Lisez les jalons directement sur GitHub.", + viewMilestone: "Voir ce jalon sur GitHub", + refreshedAt: (date) => + `Lue depuis les jalons GitHub du dépôt à la construction ; actualisée toutes les heures — dernière lecture ${date}.`, + }, +}; diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index d8bde23..8b2d449 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -16,6 +16,7 @@ export const ui = { news: "News", changelog: "Changelog", community: "Community", + roadmap: "Roadmap", compliance: "Compliance", compare: "Compare", about: "About", @@ -122,6 +123,7 @@ export const ui = { news: "الأخبار", changelog: "سجلُّ التغييرات", community: "المجتمع", + roadmap: "خارطة الطريق", compliance: "الامتثال", compare: "مقارنة", about: "حول المشروع", @@ -226,6 +228,7 @@ export const ui = { news: "Actualités", changelog: "Journal des versions", community: "Communauté", + roadmap: "Feuille de route", compliance: "Conformité", compare: "Comparatif", about: "À propos", diff --git a/src/pages/ar/roadmap.astro b/src/pages/ar/roadmap.astro new file mode 100644 index 0000000..9dcea80 --- /dev/null +++ b/src/pages/ar/roadmap.astro @@ -0,0 +1,4 @@ +--- +import RoadmapPage from "../../components/pages/RoadmapPage.astro"; +--- + diff --git a/src/pages/en/roadmap.astro b/src/pages/en/roadmap.astro new file mode 100644 index 0000000..37f9316 --- /dev/null +++ b/src/pages/en/roadmap.astro @@ -0,0 +1,4 @@ +--- +import RoadmapPage from "../../components/pages/RoadmapPage.astro"; +--- + diff --git a/src/pages/fr/roadmap.astro b/src/pages/fr/roadmap.astro new file mode 100644 index 0000000..7280f6d --- /dev/null +++ b/src/pages/fr/roadmap.astro @@ -0,0 +1,4 @@ +--- +import RoadmapPage from "../../components/pages/RoadmapPage.astro"; +--- +