diff --git a/package.json b/package.json index 6eb0e9f..dfd08fd 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "private": true, "scripts": { "dev": "next dev", - "prebuild": "node utils/fetch-github-stars.mjs && node utils/generate-changelog-rss.mjs && node utils/generate-llms-full.mjs", + "prebuild": "node utils/fetch-github-stars.mjs && node utils/generate-changelog-rss.mjs && node utils/generate-llms-full.mjs && node utils/check-changelog.mjs", + "check:changelog": "node utils/check-changelog.mjs", "build": "next build", "start": "next start", "lint": "next lint", diff --git a/src/app/changelog/ChangelogClient.jsx b/src/app/changelog/ChangelogClient.jsx new file mode 100644 index 0000000..b374aa6 --- /dev/null +++ b/src/app/changelog/ChangelogClient.jsx @@ -0,0 +1,531 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' + +// Pagination — client-side only since the page is `'use client'` and +// we want every release to remain in the DOM so deep-link anchors +// (e.g. /changelog#v1.30.0) keep working even when the target sits +// past the initial slice. The button advances the window in PAGE_SIZE +// chunks; on hash change we auto-extend to include the target. +const PAGE_SIZE = 20 + +function formatDate(dateStr) { + if (!dateStr) return '' + return new Date(dateStr).toLocaleDateString('en-US', { + year: 'numeric', month: 'long', day: 'numeric', + }) +} + +// Returns a month key like "2026-04" used both for grouping and for +// generating the anchor ID we link to from the right-rail. +function monthKey(dateStr) { + if (!dateStr) return 'unknown' + const d = new Date(dateStr) + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}` +} + +function formatMonthLabel(key) { + if (!key || key === 'unknown') return 'Unknown' + const [y, m] = key.split('-') + const d = new Date(Number(y), Number(m) - 1, 1) + return d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) +} + +// Group releases by month, preserving the chronological order of the +// source array (newest first). +function groupByMonth(items) { + const groups = [] + const seen = new Map() + for (const item of items) { + const key = monthKey(item.date) + if (!seen.has(key)) { + const g = { key, label: formatMonthLabel(key), releases: [] } + seen.set(key, g) + groups.push(g) + } + seen.get(key).releases.push(item) + } + return groups +} + +// Returns a minor-version key like "v1.56" — drops the patch number. +// Releases without a parseable v.. tag fall into +// "other" so the rail still has somewhere to put them. +function versionKey(tag) { + if (!tag) return 'other' + const m = /^v?(\d+)\.(\d+)\.\d+/.exec(tag) + return m ? `v${m[1]}.${m[2]}` : 'other' +} + +// Group releases by minor version line. Same shape as groupByMonth so +// MonthRail can render either set without knowing the difference. +function groupByVersion(items) { + const groups = [] + const seen = new Map() + for (const item of items) { + const key = versionKey(item.tag) + const label = key === 'other' ? 'Other' : key + if (!seen.has(key)) { + const g = { key, label, releases: [] } + seen.set(key, g) + groups.push(g) + } + seen.get(key).releases.push(item) + } + return groups +} + +const sectionStyles = { + overview: { label: 'Overview', color: 'text-slate-300', badge: 'bg-slate-800 text-slate-300' }, + note: { label: 'Note', color: 'text-amber-300', badge: 'bg-amber-500/10 text-amber-300' }, + features: { label: 'Features', color: 'text-violet-400', badge: 'bg-violet-500/10 text-violet-400' }, + enhancements: { label: 'Enhancements', color: 'text-sky-400', badge: 'bg-sky-500/10 text-sky-400' }, + fixes: { label: 'Fixes', color: 'text-amber-400', badge: 'bg-amber-500/10 text-amber-400' }, +} + +function ReleaseCard({ release, isLatest }) { + // Initialise from `isLatest` only — reading `window.location.hash` + // at render time would diverge between server and client and trip + // a hydration mismatch. The deep-link auto-expand happens in the + // effect below, on mount, after hydration is complete. + let [expanded, setExpanded] = useState(isLatest) + + useEffect(() => { + if (typeof window === 'undefined') return + const tag = decodeURIComponent(window.location.hash || '').slice(1) + if (tag === release.tag) setExpanded(true) + }, [release.tag]) + + let sections = release.sections + + return ( +
+
+ +
+ + {release.tag} + + {formatDate(release.date)} + {isLatest && ( + + Latest + + )} +
+ +
+ {sections.map((section, idx) => { + let style = sectionStyles[section.type] || { label: section.label, badge: 'bg-slate-800 text-slate-400' } + return ( + + {section.label} + + ) + })} +
+ + + + {expanded && ( +
+ {sections.map((section, idx) => { + let style = sectionStyles[section.type] || { label: section.label, color: 'text-slate-300' } + return ( +
+

+ {section.label} +

+ {section.notes} +
+ ) + })} +
+ )} + + + View on GitHub + + + + + +
+ ) +} + +// Right-rail timeline. Lists every month that contains a release, with +// a count badge. Active month is the one closest to the top of the +// viewport — same pattern as docs "On this page". Click → smooth-scroll +// to the month anchor; the URL hash updates so the navigation is +// shareable. +// Format a release date for the rail row — short, side-by-side with +// the version tag. Year is included because the rail spans multiple +// years; without it "Sep 2" reads ambiguously between releases. Using +// the apostrophe-year format ("Sep 2 '24") keeps the row compact. +function formatShortDate(dateStr) { + if (!dateStr) return '' + const d = new Date(dateStr) + const md = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + const yr = String(d.getUTCFullYear()).slice(-2) + return `${md} '${yr}` +} + +// Compute a version-line key like "v1.56.x" from a tag like "v1.56.4". +// Note: this groups by ., not just — gofr ships +// patch releases on each minor line, so v1.56.0/v1.56.1/v1.56.2 share +// the same rail group while v1.55.x sits in its own group above. +// Tags that don't parse fall into "other" so the rail still has a +// bucket to put them in. +function versionLineKey(tag) { + const m = tag && /^(v\d+\.\d+)/.exec(tag) + return m ? `${m[1]}.x` : 'other' +} + +// Group releases by version line. Order is preserved from the source +// array (newest first), so the first group is the most recent line. +function groupByVersionLine(items) { + const groups = new Map() + for (const r of items) { + const key = versionLineKey(r.tag) + if (!groups.has(key)) groups.set(key, []) + groups.get(key).push(r) + } + return [...groups.entries()] +} + +// One
group inside the rail. State is local so that user +// toggles stick — passing `open={...}` controlled-style on every render +// would overwrite each user click. The useEffect re-opens the group +// when it becomes the active version line (e.g. user clicked a search +// result that lives in a different line); user-initiated collapses +// in between are preserved. +function ReleaseRailGroup({ + versionLine, + entries, + isActiveLine, + activeTag, + onClick, +}) { + const [open, setOpen] = useState(isActiveLine) + useEffect(() => { + if (isActiveLine) setOpen(true) + }, [isActiveLine]) + + return ( +
setOpen(e.currentTarget.open)} + className="group -ml-px" + > + + + + + {versionLine} + + ({entries.length}) + + +
    + {entries.map((r) => { + const isActive = r.tag === activeTag + const isDim = !isActiveLine && !isActive + return ( +
  1. + onClick?.(e, r.tag)} + className={`-ml-px flex items-center justify-between gap-3 border-l py-1.5 pl-7 pr-2 text-xs transition-colors ${ + isActive + ? 'border-sky-500 font-semibold text-sky-400' + : isDim + ? 'border-transparent text-slate-600 hover:border-slate-700 hover:text-slate-400' + : 'border-transparent text-slate-500 hover:border-slate-600 hover:text-slate-300' + }`} + > + {r.tag} + + {formatShortDate(r.date)} + + +
  2. + ) + })} +
+
+ ) +} + +// Right rail — releases grouped by version line inside native +//
/ blocks. The line containing the active tag (or +// the most recent line as a fallback) is open by default; other +// lines are collapsed and their entries dimmed. Each group manages +// its own open/closed state via ReleaseRailGroup so user toggles +// don't get overwritten by the next render. Clicking a row jumps +// straight to that release card; the page-side handler auto-extends +// pagination if needed. +function ReleaseRail({ items, activeKey, onClick }) { + const grouped = groupByVersionLine(items) + const activeLine = versionLineKey(activeKey) + const fallbackLine = grouped[0]?.[0] + const initialOpenLine = grouped.some(([k]) => k === activeLine) + ? activeLine + : fallbackLine + + return ( + + ) +} + +// Round up `target` to the nearest PAGE_SIZE multiple, capped at the +// total. Keeps the visible window aligned with the chunk boundary +// after a deep-link auto-extend. +function alignToPage(target, total) { + const aligned = Math.ceil(target / PAGE_SIZE) * PAGE_SIZE + return Math.min(aligned, total) +} + +// Interactive shell of the changelog: pagination, deep links, the +// version rail and expand/collapse. Release notes arrive pre-rendered +// from the server (see page.jsx), so no markdown is parsed in the browser. +export function ChangelogClient({ releases }) { + const [shown, setShown] = useState(Math.min(PAGE_SIZE, releases.length)) + + // Body groups by minor-version line (v1.56.x, v1.55.x, …) — the + // axis the rail navigates by. Month is per-release metadata only. + // The rail uses the full release set so every version line is one + // click away regardless of pagination. + const visibleReleases = releases.slice(0, shown) + const groups = groupByVersion(visibleReleases) + // Rail tracks the currently-visible release tag (set by scrollspy). + // Initialised to the latest release so the rail highlights the + // section the user lands on. + const [activeKey, setActiveKey] = useState(releases[0]?.tag) + + // Track which release card is currently in view so the rail + // highlights it. We watch the per-release anchors (id={release.tag}) + // — each card has scroll-mt-24, so a 120px threshold lines up with + // the card's effective top edge after sticky-header offset. + useEffect(() => { + if (typeof window === 'undefined') return + const cards = visibleReleases + .map((r) => document.getElementById(r.tag)) + .filter(Boolean) + + function onScroll() { + const offset = 120 + let current = cards[0] + for (const c of cards) { + if (c.getBoundingClientRect().top - offset <= 0) current = c + else break + } + if (current) setActiveKey(current.id) + } + + onScroll() + window.addEventListener('scroll', onScroll, { passive: true }) + return () => window.removeEventListener('scroll', onScroll) + }, [visibleReleases]) + + // Make hash deep-links work even when the target release sits past + // the initial pagination window. We expand `shown` to include the + // target before scrolling, so /changelog#v1.30.0 still works for an + // older release that wouldn't otherwise be in the DOM yet. + useEffect(() => { + if (typeof window === 'undefined') return + const hash = decodeURIComponent(window.location.hash || '') + if (!hash) return + const tag = hash.slice(1) + const idx = releases.findIndex((r) => r.tag === tag) + if (idx === -1) return + + if (idx >= shown) { + // Auto-extend to include the target. Re-run of this effect + // (after `shown` updates) handles the actual scroll. + setShown(alignToPage(idx + 1, releases.length)) + return + } + + const target = document.getElementById(tag) + if (target) { + // Defer one tick so layout has settled. + setTimeout(() => target.scrollIntoView({ behavior: 'instant', block: 'start' }), 0) + } + }, [shown]) + + return ( +
+
+
+
+

+ Changelog +

+ + + + + RSS + +
+

+ Release notes and version history for GoFr. Pick a version from the right rail or deep-link to a specific tag (e.g. /changelog#{releases[0]?.tag || 'v1.0.0'}). +

+ + {/* Version-grouped release list. Each minor-version line */} + {/* gets a section header (id="version-vMAJ.MIN") so the */} + {/* right rail can link directly to it. */} +
+ {groups.map((group, gIdx) => ( +
+

+ {group.label} + + {group.releases.length} release{group.releases.length === 1 ? '' : 's'} + +

+ {group.releases.map((release, rIdx) => ( + + ))} +
+ ))} +
+ + {/* Pagination footer. "Show more" advances by PAGE_SIZE; */} + {/* once everything is shown the button hides. The "X of Y" */} + {/* counter doubles as a status line so deep-link auto- */} + {/* extends are visible to the user. */} +
+

+ Showing {Math.min(shown, releases.length)} of {releases.length} releases +

+ {shown < releases.length && ( + + )} + + View all releases on GitHub → + +
+
+ + {/* Sticky right rail — hides on small screens. Mirrors the */} + {/* docs "On this page" treatment. With ~28+ months in the */} + {/* index, the rail itself needs to scroll independently of */} + {/* the page so distant months stay reachable. */} + +
+
+ ) +} diff --git a/src/app/changelog/page.jsx b/src/app/changelog/page.jsx index 65f4709..8438b08 100644 --- a/src/app/changelog/page.jsx +++ b/src/app/changelog/page.jsx @@ -1,711 +1,22 @@ -'use client' - -import { useEffect, useState } from 'react' -import Link from 'next/link' import releases from './releases.json' +import { ChangelogClient } from './ChangelogClient' +import { splitReleaseSections } from './releaseMarkdown.mjs' +import { ReleaseNotes } from './releaseNotes' -// Pagination — client-side only since the page is `'use client'` and -// we want every release to remain in the DOM so deep-link anchors -// (e.g. /changelog#v1.30.0) keep working even when the target sits -// past the initial slice. The button advances the window in PAGE_SIZE -// chunks; on hash change we auto-extend to include the target. -const PAGE_SIZE = 20 - -function formatDate(dateStr) { - if (!dateStr) return '' - return new Date(dateStr).toLocaleDateString('en-US', { - year: 'numeric', month: 'long', day: 'numeric', - }) -} - -// Returns a month key like "2026-04" used both for grouping and for -// generating the anchor ID we link to from the right-rail. -function monthKey(dateStr) { - if (!dateStr) return 'unknown' - const d = new Date(dateStr) - return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}` -} - -function formatMonthLabel(key) { - if (!key || key === 'unknown') return 'Unknown' - const [y, m] = key.split('-') - const d = new Date(Number(y), Number(m) - 1, 1) - return d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) -} - -// Group releases by month, preserving the chronological order of the -// source array (newest first). -function groupByMonth(items) { - const groups = [] - const seen = new Map() - for (const item of items) { - const key = monthKey(item.date) - if (!seen.has(key)) { - const g = { key, label: formatMonthLabel(key), releases: [] } - seen.set(key, g) - groups.push(g) - } - seen.get(key).releases.push(item) - } - return groups -} - -// Returns a minor-version key like "v1.56" — drops the patch number. -// Releases without a parseable v.. tag fall into -// "other" so the rail still has somewhere to put them. -function versionKey(tag) { - if (!tag) return 'other' - const m = /^v?(\d+)\.(\d+)\.\d+/.exec(tag) - return m ? `v${m[1]}.${m[2]}` : 'other' -} - -// Group releases by minor version line. Same shape as groupByMonth so -// MonthRail can render either set without knowing the difference. -function groupByVersion(items) { - const groups = [] - const seen = new Map() - for (const item of items) { - const key = versionKey(item.tag) - const label = key === 'other' ? 'Other' : key - if (!seen.has(key)) { - const g = { key, label, releases: [] } - seen.set(key, g) - groups.push(g) - } - seen.get(key).releases.push(item) - } - return groups -} - -// Simple markdown-to-JSX renderer for GitHub release notes -function RenderMarkdown({ text }) { - if (!text) return null - - // Split into lines and process - let lines = text.split('\n') - let elements = [] - let inCodeBlock = false - let codeLines = [] - let codeLang = '' - - for (let i = 0; i < lines.length; i++) { - let line = lines[i] - - // Code block toggle - if (line.trim().startsWith('```')) { - if (inCodeBlock) { - elements.push( -
-            {codeLines.join('\n')}
-          
- ) - codeLines = [] - inCodeBlock = false - } else { - inCodeBlock = true - codeLang = line.trim().replace('```', '') - } - continue - } - - if (inCodeBlock) { - codeLines.push(line) - continue - } - - // Skip top-level release title (# Release v1.x.x) - if (/^#\s+Release\s+v/i.test(line.trim())) continue - if (/^#\s+\*\*v/i.test(line.trim())) continue - - // Skip section headers like ## Features, ## Fixes (we handle these separately) - if (/^##\s+/.test(line.trim())) continue - - // Sub-headers (### Something) - if (/^###\s+/.test(line.trim())) { - let text = line.replace(/^###\s+/, '').replace(/[🔹🚀🔧🛠️]/g, '').trim() - text = renderInline(text) - elements.push( -

- {text} -

- ) - continue - } - - // Empty lines - if (line.trim() === '') continue - - // Bullet points - if (/^\s*[-*]\s+/.test(line)) { - let text = line.replace(/^\s*[-*]\s+/, '') - elements.push( -
  • - {renderInline(text)} -
  • - ) - continue - } - - // Regular paragraph - if (line.trim()) { - elements.push( -

    - {renderInline(line.trim())} -

    - ) - } - } - - return
    {elements}
    -} - -// Render inline markdown (bold, code, links) -function renderInline(text) { - if (!text) return text - - // Remove emoji prefixes and GitHub colon-emoji syntax - text = text.replace(/[🔹🚀🔧🛠️💎⚡]/g, '').trim() - text = text.replace(/:small_blue_diamond:/g, '') - text = text.replace(/:rocket:/g, '') - text = text.replace(/:wrench:/g, '') - text = text.replace(/:hammer_and_wrench:/g, '') - text = text.replace(/:gem:/g, '') - text = text.replace(/:zap:/g, '') - text = text.replace(/:white_check_mark:/g, '') - text = text.replace(/:warning:/g, '') - text = text.replace(/:bug:/g, '') - text = text.replace(/:sparkles:/g, '') - text = text.replace(/:[a-z_]+:/g, '').trim() - - // Split on bold markers and inline code - let parts = [] - let remaining = text - let key = 0 - - while (remaining.length > 0) { - // Bold: **text** - let boldMatch = remaining.match(/\*\*(.*?)\*\*/) - // Inline code: `text` - let codeMatch = remaining.match(/`(.*?)`/) - - // Find the earliest match - let nextBold = boldMatch ? remaining.indexOf(boldMatch[0]) : Infinity - let nextCode = codeMatch ? remaining.indexOf(codeMatch[0]) : Infinity - - if (nextBold === Infinity && nextCode === Infinity) { - parts.push(remaining) - break - } - - if (nextBold <= nextCode && boldMatch) { - if (nextBold > 0) parts.push(remaining.slice(0, nextBold)) - parts.push( - - {boldMatch[1]} - - ) - remaining = remaining.slice(nextBold + boldMatch[0].length) - } else if (codeMatch) { - if (nextCode > 0) parts.push(remaining.slice(0, nextCode)) - parts.push( - - {codeMatch[1]} - - ) - remaining = remaining.slice(nextCode + codeMatch[0].length) - } - } - - return parts.length > 0 ? parts : text -} - -function parseRelease(body) { - if (!body) return [] - - let sections = [] - let currentSection = null - let currentContent = [] - - for (let line of body.split('\n')) { - // Detect section headers: ## 🚀 Features, ## 🔧 Enhancements, ## 🛠️ Fixes - if (/^##\s+/.test(line.trim())) { - if (currentSection) { - sections.push({ type: currentSection, content: currentContent.join('\n') }) - } - let header = line - .replace(/^##\s+/, '') - .replace(/[\p{Extended_Pictographic}\uFE0F]/gu, '') - .trim() - if (/feature/i.test(header)) currentSection = 'features' - else if (/enhancement/i.test(header)) currentSection = 'enhancements' - else if (/improvement/i.test(header)) currentSection = 'enhancements' - else if (/fix/i.test(header)) currentSection = 'fixes' - else currentSection = header.toLowerCase() - currentContent = [] - continue - } - - // Skip release title - if (/^#\s+/.test(line.trim()) && !/^##/.test(line.trim())) continue - - if (currentSection) { - currentContent.push(line) - } - } - - if (currentSection) { - sections.push({ type: currentSection, content: currentContent.join('\n') }) - } - - return sections -} - -const sectionStyles = { - features: { label: 'Features', color: 'text-violet-400', badge: 'bg-violet-500/10 text-violet-400' }, - enhancements: { label: 'Enhancements', color: 'text-sky-400', badge: 'bg-sky-500/10 text-sky-400' }, - fixes: { label: 'Fixes', color: 'text-amber-400', badge: 'bg-amber-500/10 text-amber-400' }, -} - -function ReleaseCard({ release, isLatest }) { - // Initialise from `isLatest` only — reading `window.location.hash` - // at render time would diverge between server and client and trip - // a hydration mismatch. The deep-link auto-expand happens in the - // effect below, on mount, after hydration is complete. - let [expanded, setExpanded] = useState(isLatest) - - useEffect(() => { - if (typeof window === 'undefined') return - const tag = decodeURIComponent(window.location.hash || '').slice(1) - if (tag === release.tag) setExpanded(true) - }, [release.tag]) - - let sections = parseRelease(release.body) - let sectionTypes = sections.map(s => s.type) - - return ( -
    -
    - -
    - - {release.tag} - - {formatDate(release.date)} - {isLatest && ( - - Latest - - )} -
    - -
    - {sectionTypes.map(type => { - let style = sectionStyles[type] || { label: type, badge: 'bg-slate-800 text-slate-400' } - return ( - - {style.label} - - ) - })} -
    - - - - {expanded && ( -
    - {sections.map((section, idx) => { - let style = sectionStyles[section.type] || { label: section.type, color: 'text-slate-300' } - return ( -
    -

    - {style.label} -

    - -
    - ) - })} -
    - )} - - - View on GitHub - - - - - -
    - ) -} - -// Right-rail timeline. Lists every month that contains a release, with -// a count badge. Active month is the one closest to the top of the -// viewport — same pattern as docs "On this page". Click → smooth-scroll -// to the month anchor; the URL hash updates so the navigation is -// shareable. -// Format a release date for the rail row — short, side-by-side with -// the version tag. Year is included because the rail spans multiple -// years; without it "Sep 2" reads ambiguously between releases. Using -// the apostrophe-year format ("Sep 2 '24") keeps the row compact. -function formatShortDate(dateStr) { - if (!dateStr) return '' - const d = new Date(dateStr) - const md = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) - const yr = String(d.getUTCFullYear()).slice(-2) - return `${md} '${yr}` -} - -// Compute a version-line key like "v1.56.x" from a tag like "v1.56.4". -// Note: this groups by ., not just — gofr ships -// patch releases on each minor line, so v1.56.0/v1.56.1/v1.56.2 share -// the same rail group while v1.55.x sits in its own group above. -// Tags that don't parse fall into "other" so the rail still has a -// bucket to put them in. -function versionLineKey(tag) { - const m = tag && /^(v\d+\.\d+)/.exec(tag) - return m ? `${m[1]}.x` : 'other' -} - -// Group releases by version line. Order is preserved from the source -// array (newest first), so the first group is the most recent line. -function groupByVersionLine(items) { - const groups = new Map() - for (const r of items) { - const key = versionLineKey(r.tag) - if (!groups.has(key)) groups.set(key, []) - groups.get(key).push(r) - } - return [...groups.entries()] -} - -// One
    group inside the rail. State is local so that user -// toggles stick — passing `open={...}` controlled-style on every render -// would overwrite each user click. The useEffect re-opens the group -// when it becomes the active version line (e.g. user clicked a search -// result that lives in a different line); user-initiated collapses -// in between are preserved. -function ReleaseRailGroup({ - versionLine, - entries, - isActiveLine, - activeTag, - onClick, -}) { - const [open, setOpen] = useState(isActiveLine) - useEffect(() => { - if (isActiveLine) setOpen(true) - }, [isActiveLine]) - - return ( -
    setOpen(e.currentTarget.open)} - className="group -ml-px" - > - - - - - {versionLine} - - ({entries.length}) - - -
      - {entries.map((r) => { - const isActive = r.tag === activeTag - const isDim = !isActiveLine && !isActive - return ( -
    1. - onClick?.(e, r.tag)} - className={`-ml-px flex items-center justify-between gap-3 border-l py-1.5 pl-7 pr-2 text-xs transition-colors ${ - isActive - ? 'border-sky-500 font-semibold text-sky-400' - : isDim - ? 'border-transparent text-slate-600 hover:border-slate-700 hover:text-slate-400' - : 'border-transparent text-slate-500 hover:border-slate-600 hover:text-slate-300' - }`} - > - {r.tag} - - {formatShortDate(r.date)} - - -
    2. - ) - })} -
    -
    - ) -} - -// Right rail — releases grouped by version line inside native -//
    / blocks. The line containing the active tag (or -// the most recent line as a fallback) is open by default; other -// lines are collapsed and their entries dimmed. Each group manages -// its own open/closed state via ReleaseRailGroup so user toggles -// don't get overwritten by the next render. Clicking a row jumps -// straight to that release card; the page-side handler auto-extends -// pagination if needed. -function ReleaseRail({ items, activeKey, onClick }) { - const grouped = groupByVersionLine(items) - const activeLine = versionLineKey(activeKey) - const fallbackLine = grouped[0]?.[0] - const initialOpenLine = grouped.some(([k]) => k === activeLine) - ? activeLine - : fallbackLine - - return ( - - ) -} - -// Round up `target` to the nearest PAGE_SIZE multiple, capped at the -// total. Keeps the visible window aligned with the chunk boundary -// after a deep-link auto-extend. -function alignToPage(target, total) { - const aligned = Math.ceil(target / PAGE_SIZE) * PAGE_SIZE - return Math.min(aligned, total) -} - +// Release notes are rendered here, on the server, at build time. The +// client component only receives finished markup, so neither the markdown +// renderer nor the raw release bodies are shipped in the page's JS. export default function ChangelogPage() { - const [shown, setShown] = useState(Math.min(PAGE_SIZE, releases.length)) - - // Body groups by minor-version line (v1.56.x, v1.55.x, …) — the - // axis the rail navigates by. Month is per-release metadata only. - // The rail uses the full release set so every version line is one - // click away regardless of pagination. - const visibleReleases = releases.slice(0, shown) - const groups = groupByVersion(visibleReleases) - // Rail tracks the currently-visible release tag (set by scrollspy). - // Initialised to the latest release so the rail highlights the - // section the user lands on. - const [activeKey, setActiveKey] = useState(releases[0]?.tag) - - // Track which release card is currently in view so the rail - // highlights it. We watch the per-release anchors (id={release.tag}) - // — each card has scroll-mt-24, so a 120px threshold lines up with - // the card's effective top edge after sticky-header offset. - useEffect(() => { - if (typeof window === 'undefined') return - const cards = visibleReleases - .map((r) => document.getElementById(r.tag)) - .filter(Boolean) - - function onScroll() { - const offset = 120 - let current = cards[0] - for (const c of cards) { - if (c.getBoundingClientRect().top - offset <= 0) current = c - else break - } - if (current) setActiveKey(current.id) - } - - onScroll() - window.addEventListener('scroll', onScroll, { passive: true }) - return () => window.removeEventListener('scroll', onScroll) - }, [visibleReleases]) - - // Make hash deep-links work even when the target release sits past - // the initial pagination window. We expand `shown` to include the - // target before scrolling, so /changelog#v1.30.0 still works for an - // older release that wouldn't otherwise be in the DOM yet. - useEffect(() => { - if (typeof window === 'undefined') return - const hash = decodeURIComponent(window.location.hash || '') - if (!hash) return - const tag = hash.slice(1) - const idx = releases.findIndex((r) => r.tag === tag) - if (idx === -1) return - - if (idx >= shown) { - // Auto-extend to include the target. Re-run of this effect - // (after `shown` updates) handles the actual scroll. - setShown(alignToPage(idx + 1, releases.length)) - return - } - - const target = document.getElementById(tag) - if (target) { - // Defer one tick so layout has settled. - setTimeout(() => target.scrollIntoView({ behavior: 'instant', block: 'start' }), 0) - } - }, [shown]) - - return ( -
    -
    -
    -
    -

    - Changelog -

    - - - - - RSS - -
    -

    - Release notes and version history for GoFr. Pick a version from the right rail or deep-link to a specific tag (e.g. /changelog#{releases[0]?.tag || 'v1.0.0'}). -

    - - {/* Version-grouped release list. Each minor-version line */} - {/* gets a section header (id="version-vMAJ.MIN") so the */} - {/* right rail can link directly to it. */} -
    - {groups.map((group, gIdx) => ( -
    -

    - {group.label} - - {group.releases.length} release{group.releases.length === 1 ? '' : 's'} - -

    - {group.releases.map((release, rIdx) => ( - - ))} -
    - ))} -
    - - {/* Pagination footer. "Show more" advances by PAGE_SIZE; */} - {/* once everything is shown the button hides. The "X of Y" */} - {/* counter doubles as a status line so deep-link auto- */} - {/* extends are visible to the user. */} -
    -

    - Showing {Math.min(shown, releases.length)} of {releases.length} releases -

    - {shown < releases.length && ( - - )} - - View all releases on GitHub → - -
    -
    - - {/* Sticky right rail — hides on small screens. Mirrors the */} - {/* docs "On this page" treatment. With ~28+ months in the */} - {/* index, the rail itself needs to scroll independently of */} - {/* the page so distant months stay reachable. */} - -
    -
    - ) + const items = releases.map((release) => ({ + tag: release.tag, + date: release.date, + url: release.url, + sections: splitReleaseSections(release.body).map((section) => ({ + type: section.type, + label: section.label, + notes: , + })), + })) + + return } diff --git a/src/app/changelog/releaseMarkdown.mjs b/src/app/changelog/releaseMarkdown.mjs new file mode 100644 index 0000000..289a01f --- /dev/null +++ b/src/app/changelog/releaseMarkdown.mjs @@ -0,0 +1,330 @@ +import Markdoc from '@markdoc/markdoc' + +// Default import: @markdoc/markdoc ships CommonJS, and Node's ESM loader +// (used by utils/check-changelog.mjs) can't see its named exports. +const { nodes: defaultNodes, Tag, Tokenizer } = Markdoc + +// Markdoc's own tokenizer decides where code fences and headings are, so +// the rewriting and section splitting below always agree with the parser +// (a fence inside a list item also ends where the item ends, for example). +// +// Its `{% %}` tag rules are turned off: the text Markdoc finally parses has +// every `{%` in prose escaped and every fence marked process=false, so no +// tag is active in it. Reading the raw text with tags on would let a stray +// `{% include %}` line open a tag and hide the headings after it, while +// the final parse sees them. +const tokenizer = new Tokenizer() +// These are the rule names Markdoc's tag plugin registers (block/core +// "annotations", inline "containers"). They're internal: if a Markdoc +// upgrade renames them, markdown-it throws here and the build fails +// loudly rather than silently tokenizing with tags on. +tokenizer.parser.disable(['annotations', 'containers']) + +// GitHub release bodies are GitHub-flavoured markdown. They used to be +// rendered by a small line-by-line parser that only knew about fences, +// `###` headings, `-` bullets, bold and inline code, so tables, links, +// nested/numbered lists, `####` headings, blockquotes and images all +// showed up as raw markdown. They now go through Markdoc — the same +// renderer the docs use — with the site's own Fence/AutoLink components. + +// Decorative emoji the release template puts in front of headings and +// highlights (🔹 **Title**). The section cards already carry colour and a +// label, so these are dropped, as the previous renderer did. +const DECORATIVE_EMOJI = /[🔹🚀🔧🛠💎⚡]\uFE0F?\s*/gu + +// GitHub colon shortcodes (:rocket:, :small_blue_diamond:). Only whole +// words are removed (after whitespace, before whitespace or punctuation), +// so `host:port` or times are never touched. +const EMOJI_SHORTCODE = /(^|\s):[a-z][a-z0-9_+-]*:(?=[\s,.!?;)\]]|$)/g + +// A bare URL that GitHub would auto-link. It must start the line or follow +// whitespace or an opening parenthesis. Trailing punctuation is not part of +// the link. +const BARE_URL = + /(^|\s|(?()[\]]*[^\s<>()[\].,;:!?'"])/g + +// Existing links ([text](url), ) whose text or target must not +// be auto-linked a second time. +const MARKDOWN_LINK = /!?\[[^\]]*\]\([^)]*\)|\s]*>/g + +function linkBareUrls(text) { + let out = '' + let last = 0 + const link = (part) => + part.replace(BARE_URL, (_, lead, url) => `${lead}[${url}](${url})`) + for (const m of text.matchAll(MARKDOWN_LINK)) { + out += link(text.slice(last, m.index)) + m[0] + last = m.index + m[0].length + } + return out + link(text.slice(last)) +} + +// Raw tags, which GitHub renders for pasted screenshots. +const IMG_TAG = /]*>/gi + +function attr(tag, name) { + const m = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i').exec( + tag, + ) + return m ? m[2] ?? m[3] ?? '' : '' +} + +function imgToMarkdown(tag) { + const src = attr(tag, 'src') + // Only plain https images are kept; anything else stays escaped text. + if (!/^https:\/\//i.test(src)) return tag + const alt = attr(tag, 'alt').replace(/[[\]]/g, '') + return `![${alt}](${src})` +} + +function transformProse(text) { + return ( + linkBareUrls(text.replace(IMG_TAG, imgToMarkdown)) + // Before punctuation the space in front goes too ("launch :rocket:,"). + .replace(EMOJI_SHORTCODE, (m, lead, offset, str) => + /[,.!?;)\]]/.test(str[offset + m.length] ?? '') ? '' : lead, + ) + .replace(DECORATIVE_EMOJI, '') + // `{%` opens a Markdoc tag; release notes never mean that. + .replace(/\{%/g, '\\{%') + ) +} + +// An inline code span opens and closes with backtick runs of the same +// length (``a ` b`` is one span), so a single backtick inside a +// double-backtick span doesn't end it. +const CODE_SPAN = /(? t.type === 'fence' && t.map) + .map((t) => ({ start: t.map[0], end: t.map[1] })) +} + +// Applies fn to prose only: fenced code blocks and inline code spans are +// passed through untouched, so code samples are never rewritten. +function mapOutsideCode(text, fn) { + const lines = text.split('\n') + const fences = fenceRanges(tokenizer.tokenize(text)) + const out = [] + let prose = [] + let line = 0 + + const flush = () => { + if (prose.length === 0) return + out.push(mapOutsideInlineCode(prose.join('\n'), fn)) + prose = [] + } + + for (const fence of fences) { + prose.push(...lines.slice(line, fence.start)) + flush() + out.push(openFence(lines[fence.start])) + out.push(...lines.slice(fence.start + 1, fence.end)) + line = fence.end + } + prose.push(...lines.slice(line)) + flush() + + return out.join('\n') +} + +export function normalizeReleaseMarkdown(text) { + if (!text) return '' + return mapOutsideCode(text.replace(/\r\n?/g, '\n'), transformProse) +} + +// A heading that only repeats the version ("# Release v1.46.0", +// "## **Release - v1.38.0**", "## v1.43.0") is a title, not a section. +export function isTitleHeading(header) { + return /^(release\s*[-–:]?\s*)?v?\d+(\.\d+){1,2}\.?[\w.+-]*$/i.test(header) +} + +// Section names a release uses for its own grouping. Some releases put +// these at `###` rather than `##`, so a `###` heading only starts a section +// when it is exactly one of these ("Bug Fixes & Small Changes" too); any +// other `###` ("Fix Google Pubsub Panic") is a heading inside a section. +const SECTION_NAME = + /^(new\s+)?(features?|enhancements?|improvements?|(bug\s*)?fix(es)?|security(\s+fix(es)?)?|performance|breaking\s+changes?|dependency\s+updates?|deprecations?)(\s*(&|and|\/)\s*[\w\s&/-]+)?$/i + +function sectionType(header) { + if (/\bfeatures?\b/i.test(header)) return 'features' + if (/\b(enhancements?|improvements?)\b/i.test(header)) return 'enhancements' + if (/\bfix(es|ed)?\b/i.test(header)) return 'fixes' + return header.toLowerCase() +} + +const KNOWN_TYPES = new Set(['features', 'enhancements', 'fixes']) + +// Whether a top-level heading starts a section: every `#` and `##`, and a +// `###` only when it is a section name. Exported for check-changelog.mjs, +// which uses it to spot a section heading left inside another section. +export function isSectionHeading(depth, label) { + return depth < 3 || (depth === 3 && SECTION_NAME.test(label)) +} + +// The heading as plain text for the card title and badge: decoration and +// inline markdown ([text](url), `code`, **bold**, a trailing colon) removed. +export function headingText(raw) { + return raw + .replace(/[\p{Extended_Pictographic}\uFE0F]/gu, '') + .replace(EMOJI_SHORTCODE, '$1') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[*`]/g, '') + .replace(/\s*:\s*$/, '') + .trim() +} + +// Top-level `#`-style headings with the line they are on, from Markdoc's +// tokenizer, so headings inside code, list items or quotes never count. +// Setext headings (text underlined with ---) stay part of the content. +function topLevelHeadings(tokens) { + const headings = [] + tokens.forEach((t, i) => { + if (t.type !== 'heading_open' || t.level !== 0 || !t.map) return + if (!t.markup.startsWith('#')) return + headings.push({ + line: t.map[0], + depth: Number(t.tag.slice(1)), + raw: (tokens[i + 1]?.content ?? '').trim(), + }) + }) + return headings +} + +// Splits a release body into its sections (Features, Enhancements, Fixes, +// …), which releases mark with `#`, `##` or `###` headings. Text before the +// first section is kept as an "overview" section instead of being dropped. +export function splitReleaseSections(body) { + if (!body) return [] + + const text = body.replace(/\r\n?/g, '\n') + const lines = text.split('\n') + const sections = [] + let current = { type: 'overview', label: 'Overview', lines: [] } + let line = 0 + + const push = () => { + const content = current.lines.join('\n').trim() + // A section that is only divider lines (`---`, `***`) has nothing to + // show, e.g. the rule some releases put under their title. + if (content.replace(/^\s*([-*_])(\s*\1){2,}\s*$/gm, '').trim()) { + sections.push({ type: current.type, label: current.label, content }) + } else if (current.notice) { + // A heading with no body is a notice ("This version contains + // breaking changes, please use v1.14.1"); show the heading itself. + sections.push({ type: 'note', label: 'Note', content: current.notice }) + } + } + + for (const heading of topLevelHeadings(tokenizer.tokenize(text))) { + const label = headingText(heading.raw) + const isTitle = isTitleHeading(label) + // Any other heading (a `###` feature title, `####`) stays in the section. + if (!isTitle && !isSectionHeading(heading.depth, label)) continue + + current.lines.push(...lines.slice(line, heading.line)) + line = heading.line + 1 + + // The release title (# Release v1.x.x) is already shown on the card. + if (isTitle) continue + + push() + const type = sectionType(label) + current = { + type, + label, + lines: [], + // Only a sentence-like heading is kept as a notice when it has no + // body; empty "What's Changed" style headings are dropped. + notice: + KNOWN_TYPES.has(type) || label.split(/\s+/).length < 4 + ? '' + : heading.raw, + } + } + current.lines.push(...lines.slice(line)) + push() + + return sections +} + +export const releaseMarkdocConfig = { + nodes: { + // Section cards already have a title, so body headings are demoted + // one level. They get no id: the same heading text appears in many + // releases on one page, and duplicate ids would break anchors. + heading: { + ...defaultNodes.heading, + transform(node, cfg) { + const level = Math.min(6, node.attributes.level + 1) + return new Tag(`h${level}`, {}, node.transformChildren(cfg)) + }, + }, + // Wide tables scroll inside the card instead of widening it. + table: { + ...defaultNodes.table, + transform(node, cfg) { + return new Tag('div', { class: 'overflow-x-auto' }, [ + new Tag( + 'table', + node.transformAttributes(cfg), + node.transformChildren(cfg), + ), + ]) + }, + }, + fence: { + render: 'Fence', + attributes: { + language: { type: String }, + // Set by openFence; never rendered. + process: { type: Boolean, render: false }, + // The code itself; declared so validation passes, and passed to + // Fence as children rather than as a prop. + content: { type: String, render: false }, + }, + // With process=false Markdoc keeps the code only in `content` and + // gives the fence no children, so pass it through explicitly. + transform(node, cfg) { + return new Tag('Fence', node.transformAttributes(cfg), [ + node.attributes.content, + ]) + }, + }, + link: { + ...defaultNodes.link, + render: 'AutoLink', + }, + image: { + ...defaultNodes.image, + transform(node, cfg) { + return new Tag('img', { + ...node.transformAttributes(cfg), + loading: 'lazy', + }) + }, + }, + }, +} diff --git a/src/app/changelog/releaseNotes.jsx b/src/app/changelog/releaseNotes.jsx new file mode 100644 index 0000000..fa8e5fe --- /dev/null +++ b/src/app/changelog/releaseNotes.jsx @@ -0,0 +1,45 @@ +import React from 'react' +import Markdoc from '@markdoc/markdoc' + +import { AutoLink } from '@/components/AutoLink' +import { Fence } from '@/components/Fence' +import { Prose } from '@/components/Prose' + +import { + normalizeReleaseMarkdown, + releaseMarkdocConfig, +} from './releaseMarkdown.mjs' + +const components = { AutoLink, Fence } + +export function ReleaseNotes({ tag, source }) { + // Server component: this runs at build time only. + const ast = Markdoc.parse(normalizeReleaseMarkdown(source)) + + // Markdoc renders around what it can't parse and silently drops it, so + // fail the build instead and name the release that needs attention. + const critical = Markdoc.validate(ast, releaseMarkdocConfig).filter( + (e) => e.error.level === 'critical', + ) + if (critical.length > 0) { + const detail = critical + .map((e) => `line ${(e.lines?.[0] ?? 0) + 1}: ${e.error.message}`) + .join('; ') + throw new Error(`changelog: release notes of ${tag} don't parse: ${detail}`) + } + const content = Markdoc.renderers.react( + Markdoc.transform(ast, releaseMarkdocConfig), + React, + { components }, + ) + + // The changelog is always dark, whatever the site theme is, so the + // `dark` class pins the prose styles to their dark variants. + return ( +
    + + {content} + +
    + ) +} diff --git a/utils/check-changelog.mjs b/utils/check-changelog.mjs new file mode 100644 index 0000000..51c3740 --- /dev/null +++ b/utils/check-changelog.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// Renders every release in src/app/changelog/releases.json the way the +// /changelog page does and fails if any of them comes out wrong: Markdoc +// can't parse it, a release loses all its content, a section ends up +// inside another one, or raw markdown (table rows, [text](url), # +// headings) survives outside code. Runs in prebuild, so a regression in +// the release-notes renderer — or a release body it can't handle — shows +// up in CI instead of on gofr.dev. + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import Markdoc from '@markdoc/markdoc' + +import { + headingText, + isSectionHeading, + isTitleHeading, + normalizeReleaseMarkdown, + releaseMarkdocConfig, + splitReleaseSections, +} from '../src/app/changelog/releaseMarkdown.mjs' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(__dirname, '..') +const releasesPath = path.join(repoRoot, 'src/app/changelog/releases.json') + +const RAW_MARKDOWN = [ + ['table row', /\|\s*:?-{3,}:?\s*\|/], + ['markdown link', /\]\((https?:|\/|#)/], + ['heading', /(^|\n)\s*#{1,6}\s+\S/], +] + +// Visible text of rendered HTML, without code, which may legitimately +// contain any of the patterns above. +function visibleText(html) { + return html + .replace(//g, '') + .replace(/[\s\S]*?<\/code>/g, '') + .replace(/<[^>]+>/g, '\n') +} + +// Plain text of a node's inline content (text and inline code). +function nodeText(node) { + if (node.type === 'text' || node.type === 'code') { + return node.attributes.content ?? '' + } + return node.children.map(nodeText).join('') +} + +// Headings that should have started a section of their own (or, for a +// version title, been dropped) but are still at the top level of a +// section's content. They are read from Markdoc's parse of what is +// rendered, not from the splitter, so a splitter that swallows a section +// can't hide it: the swallowed heading renders as a real

    , not as raw +// markdown, and would pass the raw-markdown patterns below. +function swallowedHeadings(ast) { + return ast.children + .filter((node) => node.type === 'heading') + .map((node) => ({ + depth: node.attributes.level, + label: headingText(nodeText(node)), + })) + .filter( + ({ depth, label }) => + isTitleHeading(label) || isSectionHeading(depth, label), + ) + .map(({ depth, label }) => `${'#'.repeat(depth)} ${label}`) +} + +function checkRelease(release) { + const problems = [] + const sections = splitReleaseSections(release.body) + if (sections.length === 0 && /\w/.test(release.body ?? '')) { + problems.push('no content left after splitting into sections') + } + + for (const section of sections) { + const ast = Markdoc.parse(normalizeReleaseMarkdown(section.content)) + for (const e of Markdoc.validate(ast, releaseMarkdocConfig)) { + if (e.error.level !== 'critical') continue + problems.push( + `${section.label}: line ${(e.lines?.[0] ?? 0) + 1}: ${e.error.message}`, + ) + } + + for (const heading of swallowedHeadings(ast)) { + problems.push( + `${section.label}: "${heading}" should be its own section but is inside this one`, + ) + } + + const html = Markdoc.renderers.html( + Markdoc.transform(ast, releaseMarkdocConfig), + ) + const text = visibleText(html) + for (const [what, pattern] of RAW_MARKDOWN) { + if (pattern.test(text)) + problems.push(`${section.label}: raw ${what} in the output`) + } + } + + return problems +} + +function main() { + const releases = JSON.parse(fs.readFileSync(releasesPath, 'utf8')) + let failed = 0 + + for (const release of releases) { + const problems = checkRelease(release) + if (problems.length === 0) continue + failed++ + console.error(`[check-changelog] ${release.tag}:`) + for (const p of problems) console.error(` - ${p}`) + } + + if (failed > 0) { + console.error( + `[check-changelog] ${failed} of ${releases.length} release(s) don't render correctly.`, + ) + process.exit(1) + } + console.log( + `[check-changelog] all ${releases.length} release(s) render cleanly.`, + ) +} + +main()