From c44f2c05dc1b9319a9495499e96626f035aba543 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Tue, 29 Sep 2026 00:26:45 +0530 Subject: [PATCH 1/4] refactor(changelog): move the page body into ChangelogClient.jsx Pure move, no behaviour change: page.jsx now re-exports the client component so the next commit can turn page.jsx into a server component that pre-renders the release notes. --- src/app/changelog/ChangelogClient.jsx | 711 +++++++++++++++++++++++++ src/app/changelog/page.jsx | 712 +------------------------- 2 files changed, 712 insertions(+), 711 deletions(-) create mode 100644 src/app/changelog/ChangelogClient.jsx diff --git a/src/app/changelog/ChangelogClient.jsx b/src/app/changelog/ChangelogClient.jsx new file mode 100644 index 0000000..65f4709 --- /dev/null +++ b/src/app/changelog/ChangelogClient.jsx @@ -0,0 +1,711 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import releases from './releases.json' + +// 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) +} + +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. */} + +
    +
    + ) +} diff --git a/src/app/changelog/page.jsx b/src/app/changelog/page.jsx index 65f4709..bf7e8e8 100644 --- a/src/app/changelog/page.jsx +++ b/src/app/changelog/page.jsx @@ -1,711 +1 @@ -'use client' - -import { useEffect, useState } from 'react' -import Link from 'next/link' -import releases from './releases.json' - -// 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) -} - -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. */} - -
    -
    - ) -} +export { default } from './ChangelogClient' From 9234747a1188023c7bcfe64401b021e3b7e0ee79 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Tue, 29 Sep 2026 00:27:16 +0530 Subject: [PATCH 2/4] fix(changelog): render release notes as markdown instead of raw text Release notes were rendered by a small line parser that only knew code fences, ### headings, - bullets, bold and inline code. Tables, links, nested/numbered lists, #### headings, blockquotes and pasted screenshots showed up as raw markdown, text before the first section was dropped, and releases using # or ### section headings lost their sections. Render them with Markdoc and the site's Prose/Fence/AutoLink components, the same renderer the docs use. Bare URLs and pasted screenshots are converted the way GitHub shows them, code fences are marked process=false so a {% %} in a code sample can't break the build, and sections are detected at #, ## and (named) ### levels. A heading-only notice such as v1.14.0's breaking-change warning is kept as a Note. The notes are pre-rendered at build time in a server component, so the browser no longer downloads Markdoc or the raw release bodies: the changelog's page JS drops from 71 kB to 8 kB. --- src/app/changelog/ChangelogClient.jsx | 208 ++---------------- src/app/changelog/page.jsx | 23 +- src/app/changelog/releaseMarkdown.mjs | 294 ++++++++++++++++++++++++++ src/app/changelog/releaseNotes.jsx | 33 +++ 4 files changed, 363 insertions(+), 195 deletions(-) create mode 100644 src/app/changelog/releaseMarkdown.mjs create mode 100644 src/app/changelog/releaseNotes.jsx diff --git a/src/app/changelog/ChangelogClient.jsx b/src/app/changelog/ChangelogClient.jsx index 65f4709..b374aa6 100644 --- a/src/app/changelog/ChangelogClient.jsx +++ b/src/app/changelog/ChangelogClient.jsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import Link from 'next/link' -import releases from './releases.json' // Pagination — client-side only since the page is `'use client'` and // we want every release to remain in the DOM so deep-link anchors @@ -77,190 +76,9 @@ function groupByVersion(items) { 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 = { + 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' }, @@ -279,8 +97,7 @@ function ReleaseCard({ release, isLatest }) { if (tag === release.tag) setExpanded(true) }, [release.tag]) - let sections = parseRelease(release.body) - let sectionTypes = sections.map(s => s.type) + let sections = release.sections return (
    - {sectionTypes.map(type => { - let style = sectionStyles[type] || { label: type, badge: 'bg-slate-800 text-slate-400' } + {sections.map((section, idx) => { + let style = sectionStyles[section.type] || { label: section.label, badge: 'bg-slate-800 text-slate-400' } return ( - - {style.label} + + {section.label} ) })} @@ -342,13 +159,13 @@ function ReleaseCard({ release, isLatest }) { {expanded && (
    {sections.map((section, idx) => { - let style = sectionStyles[section.type] || { label: section.type, color: 'text-slate-300' } + let style = sectionStyles[section.type] || { label: section.label, color: 'text-slate-300' } return (

    - {style.label} + {section.label}

    - + {section.notes}
    ) })} @@ -532,7 +349,10 @@ function alignToPage(target, total) { return Math.min(aligned, total) } -export default function ChangelogPage() { +// 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 diff --git a/src/app/changelog/page.jsx b/src/app/changelog/page.jsx index bf7e8e8..81e4d21 100644 --- a/src/app/changelog/page.jsx +++ b/src/app/changelog/page.jsx @@ -1 +1,22 @@ -export { default } from './ChangelogClient' +import releases from './releases.json' +import { ChangelogClient } from './ChangelogClient' +import { splitReleaseSections } from './releaseMarkdown.mjs' +import { ReleaseNotes } from './releaseNotes' + +// 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 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..0a9eb1d --- /dev/null +++ b/src/app/changelog/releaseMarkdown.mjs @@ -0,0 +1,294 @@ +import Markdoc from '@markdoc/markdoc' + +// Default import: @markdoc/markdoc ships CommonJS, and Node's ESM loader +// (used by the changelog checks) can't see its named exports. +const { nodes: defaultNodes, Tag } = Markdoc + +// 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, so URLs that are already the target +// or text of a markdown link (`](https://…)`, `[https://…]`) are left +// alone. Trailing punctuation is not part of the link. +const BARE_URL = + /(^|\s|(?()[\]]*[^\s<>()[\].,;:!?'"])/g + +// 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 ( + text + .replace(IMG_TAG, imgToMarkdown) + .replace(BARE_URL, (_, lead, url) => `${lead}[${url}](${url})`) + // 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 = /(? { + if (prose.length === 0) return + out.push(mapOutsideInlineCode(prose.join('\n'), fn)) + prose = [] + } + + for (const line of text.split('\n')) { + const marker = /^\s*(`{3,}|~{3,})/.exec(line) + if (fence) { + out.push(line) + if ( + marker && + marker[1][0] === fence[0] && + marker[1].length >= fence.length + ) + fence = null + continue + } + if (marker) { + flush() + fence = marker[1] + out.push(openFence(line)) + continue + } + prose.push(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. +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']) + +// The heading as plain text for the card title and badge: decoration and +// inline markdown ([text](url), `code`, **bold**, a trailing colon) removed. +function headingText(raw) { + return raw + .replace(/[\p{Extended_Pictographic}\uFE0F]/gu, '') + .replace(EMOJI_SHORTCODE, '$1') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[*`]/g, '') + .replace(/\s*:\s*$/, '') + .trim() +} + +// 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, +// and headings inside code blocks are ignored. +export function splitReleaseSections(body) { + if (!body) return [] + + const sections = [] + let current = { type: 'overview', label: 'Overview', lines: [] } + let fence = null + + 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 line of body.replace(/\r\n?/g, '\n').split('\n')) { + const marker = /^\s*(`{3,}|~{3,})/.exec(line) + if (fence) { + current.lines.push(line) + if ( + marker && + marker[1][0] === fence[0] && + marker[1].length >= fence.length + ) + fence = null + continue + } + if (marker) { + fence = marker[1] + current.lines.push(line) + continue + } + + const heading = /^(#{1,3})\s+(.*)$/.exec(line.trim()) + if (heading) { + const raw = heading[2].trim() + const text = headingText(raw) + + // The release title (# Release v1.x.x) is already shown on the card. + if (isTitleHeading(text)) continue + + if (heading[1].length < 3 || SECTION_NAME.test(text)) { + push() + const type = sectionType(text) + current = { + type, + label: text, + 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) || text.split(/\s+/).length < 4 ? '' : raw, + } + continue + } + } + + current.lines.push(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..249f3c0 --- /dev/null +++ b/src/app/changelog/releaseNotes.jsx @@ -0,0 +1,33 @@ +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({ source }) { + // Server component: this runs at build time only. + const ast = Markdoc.parse(normalizeReleaseMarkdown(source)) + 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} + +
    + ) +} From 6e26b1129f20c801216df7683ce42ee8e28176af Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Tue, 29 Sep 2026 11:23:00 +0530 Subject: [PATCH 3/4] fix(changelog): find fences and sections with Markdoc's tokenizer The fence scanner kept a fence open until a closing fence, but in CommonMark a fence inside a list item also ends with the item. v1.27.0 lost its Fixes section to this, and because the same scanner decided where the {% escape and the prose rewrites run, text after such a fence could lose a {% raw %} or keep a bare URL unlinked. Take fence ranges and top-level headings from Markdoc's own tokenizer so the rewrites and the section split always agree with the parser. Validate each release while rendering and fail the build on critical errors, and add utils/check-changelog.mjs (run in prebuild) to render every release and fail on parse errors, lost content or raw markdown in the output. Also stop auto-linking a bare URL that is already inside link text. --- package.json | 3 +- src/app/changelog/page.jsx | 2 +- src/app/changelog/releaseMarkdown.mjs | 166 +++++++++++++++----------- src/app/changelog/releaseNotes.jsx | 14 ++- utils/check-changelog.mjs | 91 ++++++++++++++ 5 files changed, 200 insertions(+), 76 deletions(-) create mode 100644 utils/check-changelog.mjs 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/page.jsx b/src/app/changelog/page.jsx index 81e4d21..8438b08 100644 --- a/src/app/changelog/page.jsx +++ b/src/app/changelog/page.jsx @@ -14,7 +14,7 @@ export default function ChangelogPage() { sections: splitReleaseSections(release.body).map((section) => ({ type: section.type, label: section.label, - notes: , + notes: , })), })) diff --git a/src/app/changelog/releaseMarkdown.mjs b/src/app/changelog/releaseMarkdown.mjs index 0a9eb1d..0d50481 100644 --- a/src/app/changelog/releaseMarkdown.mjs +++ b/src/app/changelog/releaseMarkdown.mjs @@ -1,8 +1,13 @@ import Markdoc from '@markdoc/markdoc' // Default import: @markdoc/markdoc ships CommonJS, and Node's ESM loader -// (used by the changelog checks) can't see its named exports. -const { nodes: defaultNodes, Tag } = Markdoc +// (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). +const tokenizer = new Tokenizer() // GitHub release bodies are GitHub-flavoured markdown. They used to be // rendered by a small line-by-line parser that only knew about fences, @@ -22,12 +27,27 @@ const DECORATIVE_EMOJI = /[🔹🚀🔧🛠💎⚡]\uFE0F?\s*/gu 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, so URLs that are already the target -// or text of a markdown link (`](https://…)`, `[https://…]`) are left -// alone. Trailing punctuation is not part of the link. +// 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 @@ -48,9 +68,7 @@ function imgToMarkdown(tag) { function transformProse(text) { return ( - text - .replace(IMG_TAG, imgToMarkdown) - .replace(BARE_URL, (_, lead, url) => `${lead}[${url}](${url})`) + 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, @@ -84,12 +102,23 @@ function openFence(line) { return line.includes('{%') ? line : `${line} {% process=false %}` } +// Line ranges of every code fence, as Markdoc's tokenizer sees them: +// [first line, line after the last]. A fence left open inside a list item +// ends with the item, not at the end of the document. +function fenceRanges(tokens) { + return tokens + .filter((t) => 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 fence = null let prose = [] + let line = 0 const flush = () => { if (prose.length === 0) return @@ -97,26 +126,14 @@ function mapOutsideCode(text, fn) { prose = [] } - for (const line of text.split('\n')) { - const marker = /^\s*(`{3,}|~{3,})/.exec(line) - if (fence) { - out.push(line) - if ( - marker && - marker[1][0] === fence[0] && - marker[1].length >= fence.length - ) - fence = null - continue - } - if (marker) { - flush() - fence = marker[1] - out.push(openFence(line)) - continue - } - prose.push(line) + 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') @@ -161,16 +178,34 @@ function headingText(raw) { .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, -// and headings inside code blocks are ignored. +// 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 fence = null + let line = 0 const push = () => { const content = current.lines.join('\n').trim() @@ -185,50 +220,35 @@ export function splitReleaseSections(body) { } } - for (const line of body.replace(/\r\n?/g, '\n').split('\n')) { - const marker = /^\s*(`{3,}|~{3,})/.exec(line) - if (fence) { - current.lines.push(line) - if ( - marker && - marker[1][0] === fence[0] && - marker[1].length >= fence.length - ) - fence = null - continue - } - if (marker) { - fence = marker[1] - current.lines.push(line) - continue + for (const heading of topLevelHeadings(tokenizer.tokenize(text))) { + const label = headingText(heading.raw) + const isTitle = isTitleHeading(label) + const isSection = + heading.depth < 3 || (heading.depth === 3 && SECTION_NAME.test(label)) + // Any other heading (a `###` feature title, `####`) stays in the section. + if (!isTitle && !isSection) 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, } - - const heading = /^(#{1,3})\s+(.*)$/.exec(line.trim()) - if (heading) { - const raw = heading[2].trim() - const text = headingText(raw) - - // The release title (# Release v1.x.x) is already shown on the card. - if (isTitleHeading(text)) continue - - if (heading[1].length < 3 || SECTION_NAME.test(text)) { - push() - const type = sectionType(text) - current = { - type, - label: text, - 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) || text.split(/\s+/).length < 4 ? '' : raw, - } - continue - } - } - - current.lines.push(line) } + current.lines.push(...lines.slice(line)) push() return sections diff --git a/src/app/changelog/releaseNotes.jsx b/src/app/changelog/releaseNotes.jsx index 249f3c0..fa8e5fe 100644 --- a/src/app/changelog/releaseNotes.jsx +++ b/src/app/changelog/releaseNotes.jsx @@ -12,9 +12,21 @@ import { const components = { AutoLink, Fence } -export function ReleaseNotes({ source }) { +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, diff --git a/utils/check-changelog.mjs b/utils/check-changelog.mjs new file mode 100644 index 0000000..100dda2 --- /dev/null +++ b/utils/check-changelog.mjs @@ -0,0 +1,91 @@ +#!/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, 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 { + 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') +} + +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}`, + ) + } + + 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() From db3769cd39d7b2b846bd1e1ce58de91d979a3ac1 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Tue, 29 Sep 2026 11:42:25 +0530 Subject: [PATCH 4/4] fix(changelog): read fence and heading positions with Markdoc tags off The tokenizer read the raw release body with Markdoc's {% %} tag rules on, while rendering parses the escaped text where no tag is active. A {% raw %} or {% include %} alone on a line therefore opened a tag at tokenize time, pushed the headings after it off the top level and swallowed the next section, with no validation error to catch it. Turn the tag rules off in the tokenizer used for positions, so fences and sections come from the same structure the final parse sees. Make check-changelog.mjs also fail when a section's rendered content still has a top-level heading that should have been its own section, which catches a swallowed section (it renders as a real heading, not raw markdown). --- src/app/changelog/releaseMarkdown.mjs | 26 ++++++++++++--- utils/check-changelog.mjs | 46 ++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/app/changelog/releaseMarkdown.mjs b/src/app/changelog/releaseMarkdown.mjs index 0d50481..289a01f 100644 --- a/src/app/changelog/releaseMarkdown.mjs +++ b/src/app/changelog/releaseMarkdown.mjs @@ -7,7 +7,18 @@ 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, @@ -146,7 +157,7 @@ export function normalizeReleaseMarkdown(text) { // A heading that only repeats the version ("# Release v1.46.0", // "## **Release - v1.38.0**", "## v1.43.0") is a title, not a section. -function isTitleHeading(header) { +export function isTitleHeading(header) { return /^(release\s*[-–:]?\s*)?v?\d+(\.\d+){1,2}\.?[\w.+-]*$/i.test(header) } @@ -166,9 +177,16 @@ function sectionType(header) { 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. -function headingText(raw) { +export function headingText(raw) { return raw .replace(/[\p{Extended_Pictographic}\uFE0F]/gu, '') .replace(EMOJI_SHORTCODE, '$1') @@ -223,10 +241,8 @@ export function splitReleaseSections(body) { for (const heading of topLevelHeadings(tokenizer.tokenize(text))) { const label = headingText(heading.raw) const isTitle = isTitleHeading(label) - const isSection = - heading.depth < 3 || (heading.depth === 3 && SECTION_NAME.test(label)) // Any other heading (a `###` feature title, `####`) stays in the section. - if (!isTitle && !isSection) continue + if (!isTitle && !isSectionHeading(heading.depth, label)) continue current.lines.push(...lines.slice(line, heading.line)) line = heading.line + 1 diff --git a/utils/check-changelog.mjs b/utils/check-changelog.mjs index 100dda2..51c3740 100644 --- a/utils/check-changelog.mjs +++ b/utils/check-changelog.mjs @@ -1,10 +1,11 @@ #!/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, 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. +// 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' @@ -12,6 +13,9 @@ import { fileURLToPath } from 'node:url' import Markdoc from '@markdoc/markdoc' import { + headingText, + isSectionHeading, + isTitleHeading, normalizeReleaseMarkdown, releaseMarkdocConfig, splitReleaseSections, @@ -36,6 +40,34 @@ function visibleText(html) { .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) @@ -52,6 +84,12 @@ function checkRelease(release) { ) } + 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), )