Skip to content
Open
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
8 changes: 8 additions & 0 deletions apps/www/src/content/docs/(overview)/changelog.mdx
Original file line number Diff line number Diff line change
@@ -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).

<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
172 changes: 172 additions & 0 deletions apps/www/src/lib/remark-changelog.ts
Original file line number Diff line number Diff line change
@@ -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, `<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, 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 `<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