diff --git a/apps/www/package.json b/apps/www/package.json
index b5ae77297..199a6aff0 100644
--- a/apps/www/package.json
+++ b/apps/www/package.json
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "prebuild": "cd ../.. && npm run build:apsara",
+ "prebuild": "cd ../.. && (git rev-parse -q --verify refs/tags/v1.0.0 >/dev/null || git fetch -q --tags $(test \"$(git rev-parse --is-shallow-repository)\" = true && echo --unshallow) https://github.com/raystack/apsara.git || true) && npm run build:apsara",
"dev": "next dev",
"build": "next build",
"start": "next start",
diff --git a/apps/www/source.config.ts b/apps/www/source.config.ts
index a64771a59..c9f674a79 100644
--- a/apps/www/source.config.ts
+++ b/apps/www/source.config.ts
@@ -13,6 +13,7 @@ import {
metaSchema
} from 'fumadocs-mdx/config';
import { remarkAutoTypeTable } from 'fumadocs-typescript';
+import { remarkChangelog } from '@/lib/remark-changelog';
import { generator, transform } from '@/lib/type-table';
import { SourceSchema, TagSchema } from '@/lib/types';
@@ -37,6 +38,7 @@ export default defineConfig({
remarkPlugins: () => {
return [
remarkGfm,
+ remarkChangelog,
[remarkHeading, { generateToc: false }],
[remarkImage, { useImport: undefined }],
// remarkCodeTab,
diff --git a/apps/www/src/content/docs/(overview)/changelog.mdx b/apps/www/src/content/docs/(overview)/changelog.mdx
new file mode 100644
index 000000000..c8c12616d
--- /dev/null
+++ b/apps/www/src/content/docs/(overview)/changelog.mdx
@@ -0,0 +1,6 @@
+---
+title: Changelog
+description: Changelogs for each Apsara release.
+---
+
+
diff --git a/apps/www/src/content/docs/(overview)/meta.json b/apps/www/src/content/docs/(overview)/meta.json
index d149ba563..21dbd2e4e 100644
--- a/apps/www/src/content/docs/(overview)/meta.json
+++ b/apps/www/src/content/docs/(overview)/meta.json
@@ -1,4 +1,4 @@
{
"title": "Overview",
- "pages": ["..."]
+ "pages": ["...", "changelog"]
}
diff --git a/apps/www/src/lib/ai.ts b/apps/www/src/lib/ai.ts
index 17b888388..f8d6624e6 100644
--- a/apps/www/src/lib/ai.ts
+++ b/apps/www/src/lib/ai.ts
@@ -7,11 +7,13 @@ import remarkGfm from 'remark-gfm';
import remarkMdx from 'remark-mdx';
import { docs } from '@/lib/source';
import { remarkTypeTableToMd } from './remark';
+import { remarkChangelog } from './remark-changelog';
import { generator, transform } from './type-table';
const processor = remark()
.use(remarkMdx)
.use(remarkGfm)
+ .use(remarkChangelog)
.use(remarkAutoTypeTable, { generator, options: { transform } })
.use(remarkTypeTableToMd);
diff --git a/apps/www/src/lib/remark-changelog.ts b/apps/www/src/lib/remark-changelog.ts
new file mode 100644
index 000000000..6e2a8f7a8
--- /dev/null
+++ b/apps/www/src/lib/remark-changelog.ts
@@ -0,0 +1,163 @@
+import { execFileSync } from 'node:child_process';
+import type { Link, List, Paragraph, Root, RootContent } from 'mdast';
+
+const RELEASES_URL = 'https://github.com/raystack/apsara/releases';
+const PULL_URL = 'https://github.com/raystack/apsara/pull';
+
+// Releases before v1.0.0 are the Radix-based library.
+const FIRST_TAG = 'v1.0.0';
+const TYPES = new Set(['feat', 'fix', 'refactor']);
+const SUBJECT =
+ /^(\w+)(!?)(?:\(([^)]*)\))?:\s*(?:\[([^\]]*)\]\s*)?(.+?)\s*\(#(\d+)\)$/;
+const BREAKING_FOOTER = /^BREAKING[ -]CHANGE:/m;
+// Separates commits and the subject from the body in the `git log` output.
+const COMMIT_END = '\x1e';
+const BODY_START = '\x1f';
+
+interface ChangelogEntry {
+ type: string;
+ breaking: boolean;
+ scopes: string[];
+ summary: string;
+ pr: number;
+}
+
+interface ChangelogRelease {
+ tag: string;
+ entries: ChangelogEntry[];
+}
+
+/**
+ * Reads a squash-merged commit, `: [] (#)` or
+ * `(): (#)`. A `!` after the type or a
+ * `BREAKING CHANGE:` footer marks it as breaking.
+ */
+function parseCommit(commit: string): ChangelogEntry | undefined {
+ const [subject, body = ''] = commit.trim().split(BODY_START);
+ const match = SUBJECT.exec(subject);
+ if (!match || !TYPES.has(match[1])) return undefined;
+ const [, type, bang, parenScopes, bracketScopes, summary, pr] = match;
+ const scopes = (bracketScopes ?? parenScopes ?? '')
+ .split(',')
+ .map(scope => scope.trim())
+ .filter(Boolean);
+ const breaking = Boolean(bang) || BREAKING_FOOTER.test(body);
+ return { type, breaking, scopes, summary, pr: Number(pr) };
+}
+
+function git(args: string[]) {
+ return execFileSync('git', args, {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ maxBuffer: 64 * 1024 * 1024
+ });
+}
+
+/**
+ * Returns the feat, fix and refactor pull requests of each release after
+ * v1.0.0, newest first, or `null` when the build has no git history or tags.
+ */
+function getChangelog(): ChangelogRelease[] | null {
+ try {
+ const tags = git(['tag', '--merged', 'HEAD', '--sort=-v:refname'])
+ .split('\n')
+ .filter(tag => /^v\d+\.\d+\.\d+$/.test(tag));
+ const first = tags.indexOf(FIRST_TAG);
+ if (first === -1) return null;
+ const ranges: [string, string][] = [];
+ for (let index = 0; index < first; index += 1)
+ ranges.push([tags[index], `${tags[index + 1]}..${tags[index]}`]);
+ return ranges
+ .map(([tag, range]) => ({
+ tag,
+ entries: git([
+ 'log',
+ '--no-merges',
+ `--format=%s${BODY_START}%b${COMMIT_END}`,
+ range
+ ])
+ .split(COMMIT_END)
+ .flatMap(commit => parseCommit(commit) ?? [])
+ }))
+ .filter(release => release.entries.length > 0);
+ } catch {
+ return null;
+ }
+}
+
+function link(url: string, value: string): Link {
+ return { type: 'link', url, children: [{ type: 'text', value }] };
+}
+
+function entryItem(entry: ChangelogEntry): Paragraph {
+ const scopes = entry.scopes.length > 0 ? `[${entry.scopes.join(', ')}] ` : '';
+ return {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'text',
+ value: `${entry.type}${entry.breaking ? '!' : ''}: ${scopes}${entry.summary} (`
+ },
+ link(`${PULL_URL}/${entry.pr}`, `#${entry.pr}`),
+ { type: 'text', value: ')' }
+ ]
+ };
+}
+
+function changelogNodes(): RootContent[] {
+ const releases = getChangelog();
+ if (!releases)
+ return [
+ {
+ type: 'paragraph',
+ children: [
+ { type: 'text', value: 'See ' },
+ link(RELEASES_URL, 'GitHub Releases'),
+ { type: 'text', value: '.' }
+ ]
+ }
+ ];
+
+ return releases.flatMap((release): RootContent[] => {
+ const list: List = {
+ type: 'list',
+ ordered: false,
+ spread: false,
+ children: release.entries.map(entry => ({
+ type: 'listItem',
+ spread: false,
+ children: [entryItem(entry)]
+ }))
+ };
+ // The table of contents links each heading, so the heading holds no link.
+ const heading: RootContent = {
+ type: 'heading',
+ depth: 2,
+ children: [{ type: 'text', value: release.tag }]
+ };
+ return [
+ heading,
+ {
+ type: 'paragraph',
+ children: [
+ link(`${RELEASES_URL}/tag/${release.tag}`, 'Release on GitHub')
+ ]
+ },
+ list
+ ];
+ });
+}
+
+/**
+ * Replaces `` with a heading and a list of pull requests for each
+ * release. The plugin writes Markdown, so the headings reach the table of
+ * contents, search and the page's Markdown output.
+ */
+export function remarkChangelog() {
+ return (tree: Root) => {
+ const index = tree.children.findIndex(
+ node => node.type === 'mdxJsxFlowElement' && node.name === 'Changelog'
+ );
+ if (index !== -1) tree.children.splice(index, 1, ...changelogNodes());
+ };
+}