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 ae6247f7b..8aa5c54e8 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..6d6cc5fb2
--- /dev/null
+++ b/apps/www/src/content/docs/(overview)/changelog.mdx
@@ -0,0 +1,8 @@
+---
+title: Changelog
+description: The pull requests in each Apsara release, newest first.
+---
+
+Each entry is a merged pull request. A `!` after the type marks a breaking change. For the steps a breaking change needs, see [Upgrading](/docs/upgrading).
+
+
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..79d2d6631
--- /dev/null
+++ b/apps/www/src/lib/remark-changelog.ts
@@ -0,0 +1,172 @@
+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']);
+// Older subjects put the `!` before or after the parenthesized scope.
+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 {
+ /** `null` for the changes after the latest release. */
+ tag: string | null;
+ 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, bangBefore, parenScopes, bangAfter, bracketScopes, text, pr] =
+ match;
+ // Some older subjects write `BREAKING CHANGE` as the scope or in the summary.
+ const scopes = (bracketScopes ?? parenScopes ?? '')
+ .split(',')
+ .map(scope => scope.trim())
+ .filter(scope => scope && scope !== 'BREAKING CHANGE');
+ const summary = text.replace(/^BREAKING CHANGE:?\s*/, '');
+ const breaking =
+ Boolean(bangBefore || bangAfter) ||
+ subject.includes('BREAKING CHANGE') ||
+ 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 | null, string][] = [[null, `${tags[0]}..HEAD`]];
+ 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 ?? 'Unreleased' }]
+ };
+ if (!release.tag) return [heading, list];
+ 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());
+ };
+}