feat(docs): changelog page overhaul

This commit is contained in:
Melvin Chia
2025-12-30 22:07:35 +08:00
parent 57be3bb0ab
commit 8ea871963d
9 changed files with 178 additions and 43 deletions

View File

@@ -0,0 +1,68 @@
import fs from 'node:fs'
import path from 'node:path'
import type { Plugin } from 'vite'
const VIRTUAL_MODULE_ID = 'virtual:mdx-list-counts'
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID
function countListItems(mdxSource: string): number {
const listItemRegex = /^[\t ]*- /gm
const matches = mdxSource.match(listItemRegex)
return matches ? matches.length : 0
}
function mdxListCountsPlugin(): Plugin {
return {
name: 'vite-plugin-mdx-list-counts',
resolveId(id) {
if (id === VIRTUAL_MODULE_ID) {
return RESOLVED_VIRTUAL_MODULE_ID
}
},
load(id) {
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
const versionsDir = path.resolve(
__dirname,
'../src/contents/04.progress/versions'
)
const counts: Record<string, number> = {}
// Read all MDX files recursively
function readMdxFiles(dir: string, basePath = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
const relativePath = path.join(basePath, entry.name)
if (entry.isDirectory()) {
readMdxFiles(fullPath, relativePath)
} else if (entry.name.endsWith('.mdx')) {
const content = fs.readFileSync(fullPath, 'utf-8')
const key = `../versions/${relativePath.replace(/\\/g, '/')}`
counts[key] = countListItems(content)
}
}
}
readMdxFiles(versionsDir)
return `export default ${JSON.stringify(counts)};`
}
},
handleHotUpdate({ file, server }) {
// Invalidate the virtual module when any MDX file changes
if (file.endsWith('.mdx') && file.includes('versions')) {
const module = server.moduleGraph.getModuleById(
RESOLVED_VIRTUAL_MODULE_ID
)
if (module) {
server.moduleGraph.invalidateModule(module)
return [module]
}
}
}
}
}
export default mdxListCountsPlugin

View File

@@ -3,6 +3,7 @@ import { useEffect } from 'react'
import Scrollbars from 'react-custom-scrollbars'
import { useLocation } from 'shared'
import { BLACKLISTED_PAGES } from '../Rightbar'
import NavigationBar from './components/NavigationBar'
function Boilerplate({ children }: { children: React.ReactNode }) {
@@ -40,7 +41,9 @@ function Boilerplate({ children }: { children: React.ReactNode }) {
/>
)}
>
<div className="flex h-full w-full min-w-0 flex-col sm:pl-8 lg:w-[calc(100%-20rem)]">
<div
className={`flex h-full w-full min-w-0 flex-col sm:pl-8 ${BLACKLISTED_PAGES.some(page => location.pathname.startsWith(page)) ? '' : 'lg:w-[calc(100%-20rem)]'}`}
>
{children}
<NavigationBar />
<hr className="border-bg-200 dark:border-bg-800 my-12 border-t-[1.5px]" />

View File

@@ -3,6 +3,8 @@ import _ from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { useLocation } from 'shared'
export const BLACKLISTED_PAGES = ['/progress/changelog']
function Rightbar() {
const [allSections, setAllSections] = useState<string[]>([])
@@ -135,6 +137,10 @@ function Rightbar() {
}, 1000) // 1 second delay to allow scroll to finish
}
if (BLACKLISTED_PAGES.some(page => location.pathname.startsWith(page))) {
return null
}
return (
<aside className="fixed top-20 right-0 hidden h-full min-h-0 w-80 overflow-y-auto p-12 lg:block">
<h2 className="text-lg font-semibold">On This Page</h2>

View File

@@ -15,15 +15,8 @@ Unlike traditional software development projects which adapt Semantic versioning
```plaintext
dev <year>w<week>
```
</Alert>
<Alert className="mt-6" type="warning">
This page only covers changes made from version 25w33 onwards. For earlier
versions, please refer to the legacy
[CHANGELOG.md](https://github.com/lifeforge-app/lifeforge/blob/main/CHANGELOG.md).
Also, minor changes may not be documented here.
</Alert>
---

View File

@@ -1,10 +1,19 @@
import mdxListCounts from 'virtual:mdx-list-counts'
import { components } from '@/components/MdxComponents'
import Version from './Version'
const compiledModules = import.meta.glob('../versions/**/*.mdx', {
eager: true
}) as Record<
string,
{ default: React.ComponentType<{ components: typeof components }> }
>
const entries = Object.entries(
Object.groupBy(
Object.entries(import.meta.glob('../versions/**/*.mdx', { eager: true })),
Object.entries(compiledModules),
([path]) => path.match(/\.\.\/versions\/(\d+)\/.*/)![1]
)
)
@@ -14,22 +23,28 @@ const entries = Object.entries(
versions: modules!
.map(([path, module]) => ({
week: Number(path.match(/\.\.\/versions\/\d+\/(\d+)\.mdx/)![1]),
Component: (module as any).default
Component: module.default,
liCount: mdxListCounts[path] ?? 0
}))
.sort((a, b) => b.week - a.week)
}))
function ChangelogEntries() {
return (
<>
<div className="divide-bg-500/20 divide-y-[1.5px]">
{entries.map(({ year, versions }) =>
versions.map(({ week, Component }) => (
<Version key={`${year}-week-${week}`} week={week} year={year}>
versions.map(({ week, Component, liCount }) => (
<Version
key={`${year}-week-${week}`}
liCount={liCount}
week={week}
year={year}
>
<Component components={components} />
</Version>
))
)}
</>
</div>
)
}

View File

@@ -1,7 +1,8 @@
import { Icon } from '@iconify/react'
import dayjs from 'dayjs'
import weekOfYear from 'dayjs/plugin/weekOfYear'
import React from 'react'
import { Card } from 'lifeforge-ui'
import { useRef, useState } from 'react'
dayjs.extend(weekOfYear)
@@ -9,47 +10,87 @@ function Version({
prefix = 'dev',
year = dayjs().year(),
week,
liCount,
children
}: {
prefix?: string
year?: number
week: number
liCount?: number
children: React.ReactNode
}) {
const [collapsed, setCollapsed] = useState(true)
const [debouncedCollapsed, setDebouncedCollapsed] = useState(true)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const version = `${prefix} ${dayjs().year(year).format('YY')}w${week.toString().padStart(2, '0')}`
const startOfWeek = dayjs()
.year(year)
.week(week)
.startOf('week')
.format('DD MMM YYYY')
// Start from January 4th of the year (guaranteed to be in week 1 per ISO 8601)
// then add the weeks offset
const weekDate = dayjs(`${year}-01-04`).week(week)
const endOfWeek = dayjs()
.year(year)
.week(week)
.endOf('week')
.format('DD MMM YYYY')
const startOfWeek = weekDate.startOf('week').format('DD MMM YYYY')
const endOfWeek = weekDate.endOf('week').format('DD MMM YYYY')
function toggleCollapsed() {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
if (collapsed) {
// Expanding: immediately show content, then animate
setDebouncedCollapsed(false)
setCollapsed(false)
} else {
// Collapsing: animate first, then hide content after animation completes
setCollapsed(true)
timeoutRef.current = setTimeout(() => {
setDebouncedCollapsed(true)
}, 200) // Match the duration-200 animation
}
}
return (
<section
id={`${prefix}-${dayjs().year(year).format('YY')}-w-${week.toString().padStart(2, '0')}`}
>
<header className="space-y-2">
<div className="flex items-center gap-3 text-2xl font-semibold sm:text-3xl">
<div className="bg-custom-500/20 text-custom-500 rounded-lg p-3">
<Icon className="size-10" icon="tabler:history" />
</div>
<div>
<Card
className="overflow-y-hidden p-0!"
id={`${prefix}-${dayjs().year(year).format('YY')}-w-${week.toString().padStart(2, '0')}`}
>
<header
className="hover:bg-bg-100 dark:hover:bg-bg-800/50 flex cursor-pointer items-center justify-between gap-4 p-4 transition-colors select-none"
onClick={toggleCollapsed}
>
<div>
<h2 className="block">{version}</h2>
<span className="text-bg-500 block text-base">
<h2 className="text-xl font-medium sm:text-2xl">{version}</h2>
<span className="text-bg-500 text-sm">{liCount} entries</span>
</div>
<div className="flex items-center gap-3">
<span className="text-bg-500 hidden text-sm sm:block">
{startOfWeek} - {endOfWeek}
</span>
<Icon
className={`text-bg-500 size-5 shrink-0 transition-transform duration-500 ${
!collapsed ? 'rotate-180' : ''
}`}
icon="tabler:chevron-down"
/>
</div>
</header>
<div
className={`grid px-4 transition-all duration-200 ${
collapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'
}`}
>
{!debouncedCollapsed && (
<div className="overflow-hidden pb-8">{children}</div>
)}
</div>
</header>
{children}
<hr className="border-bg-200 dark:border-bg-800 mt-8 mb-4 border-t-[1.5px] sm:mt-12 sm:mb-8" />
</section>
</Card>
</div>
)
}

View File

@@ -1 +1,6 @@
/// <reference types="vite/client" />
declare module 'virtual:mdx-list-counts' {
const counts: Record<string, number>
export default counts
}

View File

@@ -7,5 +7,8 @@
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
"include": [
"vite.config.ts",
"plugins/**/*.ts"
]
}

View File

@@ -2,17 +2,18 @@ import mdx, { Options } from '@mdx-js/rollup'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import path from 'node:path'
import rehypeHighlight from 'rehype-highlight'
import remarkGfm from 'remark-gfm'
import { defineConfig } from 'vite'
import mdxListCountsPlugin from './plugins/mdxListCountsPlugin'
const options: Options = {
remarkPlugins: [remarkGfm]
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), mdx(options), tailwindcss()],
plugins: [react(), mdx(options), tailwindcss(), mdxListCountsPlugin()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')