Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/www/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/www/source.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -37,6 +38,7 @@ export default defineConfig({
remarkPlugins: () => {
return [
remarkGfm,
remarkChangelog,
[remarkHeading, { generateToc: false }],
[remarkImage, { useImport: undefined }],
// remarkCodeTab,
Expand Down
6 changes: 6 additions & 0 deletions apps/www/src/content/docs/(overview)/changelog.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: Changelog
description: Changelogs for each Apsara release.
---

<Changelog />
2 changes: 1 addition & 1 deletion apps/www/src/content/docs/(overview)/meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"title": "Overview",
"pages": ["..."]
"pages": ["...", "changelog"]
}
2 changes: 2 additions & 0 deletions apps/www/src/lib/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
163 changes: 163 additions & 0 deletions apps/www/src/lib/remark-changelog.ts
Original file line number Diff line number Diff line change
@@ -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, `<type>: [<scopes>] <summary> (#<pr>)` or
* `<type>(<scopes>): <summary> (#<pr>)`. 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 `<Changelog />` 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());
};
}
Loading