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/web/content/changelog/1.0.0-nightly.1.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
created: "2025-11-18"
created: "2025-11-18T07:50:16Z"
---

- Initial release.
2 changes: 1 addition & 1 deletion apps/web/content/changelog/1.0.0-nightly.2.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
created: "2025-11-22"
created: "2025-11-18T09:03:33Z"
---

- Fixed app icon padding.
5 changes: 5 additions & 0 deletions apps/web/content/changelog/1.0.0-nightly.3.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
created: "2025-11-19T00:11:42Z"
---

- Fix invalid titap editor panic when rendering empty note.
140 changes: 140 additions & 0 deletions apps/web/src/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { allChangelogs, type Changelog } from "content-collections";
import semver from "semver";

export type ChangelogWithMeta = Changelog & {
beforeVersion: string | null;
newerSlug: string | null;
olderSlug: string | null;
};

function buildChangelogMeta(): ChangelogWithMeta[] {
const parsed = allChangelogs
.map((doc) => {
const version = semver.parse(doc.version);
if (!version) {
return null;
}

return { doc, version };
})
.filter(
(
entry,
): entry is {
doc: Changelog;
version: semver.SemVer;
} => entry !== null,
);

parsed.sort((a, b) => semver.compare(a.version, b.version));

const stableByMajorMinor: Record<string, number[]> = {};
const preByBase: Record<string, number[]> = {};
const allStableAsc: number[] = [];

parsed.forEach((entry, idx) => {
const v = entry.version;
const majorMinor = `${v.major}.${v.minor}`;
const base = `${v.major}.${v.minor}.${v.patch}`;

if (v.prerelease.length === 0) {
allStableAsc.push(idx);
(stableByMajorMinor[majorMinor] ??= []).push(idx);
} else {
(preByBase[base] ??= []).push(idx);
}
});

const stablePos: Record<number, number> = {};
allStableAsc.forEach((idx, pos) => {
stablePos[idx] = pos;
});

const withMeta: ChangelogWithMeta[] = parsed.map(({ doc }) => ({
...doc,
beforeVersion: null,
newerSlug: null,
olderSlug: null,
}));

// Pre-releases: chain within same base
Object.values(preByBase).forEach((indices) => {
indices.forEach((idx, j) => {
if (j === 0) {
withMeta[idx].beforeVersion = null;
return;
}

const prevIdx = indices[j - 1];
withMeta[idx].beforeVersion = parsed[prevIdx].doc.version;
});
});

// Stable releases: previous stable within same major/minor, or earliest pre
Object.entries(stableByMajorMinor).forEach(([_, indicesForMm]) => {
indicesForMm.forEach((idxInParsed, posInMm) => {
const entry = parsed[idxInParsed];
const v = entry.version;
const base = `${v.major}.${v.minor}.${v.patch}`;

if (posInMm > 0) {
const prevIdx = indicesForMm[posInMm - 1];
withMeta[idxInParsed].beforeVersion = parsed[prevIdx].doc.version;
return;
}

const preIndices = preByBase[base];
if (preIndices && preIndices.length > 0) {
const firstPreIdx = preIndices[0];
withMeta[idxInParsed].beforeVersion = parsed[firstPreIdx].doc.version;
return;
}

const globalPos = stablePos[idxInParsed];
if (globalPos > 0) {
const prevGlobalIdx = allStableAsc[globalPos - 1];
withMeta[idxInParsed].beforeVersion = parsed[prevGlobalIdx].doc.version;
}
});
});

// Navigation: compute newer/older in descending order (newest first)
const descOrder = parsed
.map((entry, idx) => ({ idx, version: entry.version }))
.sort((a, b) => semver.rcompare(a.version, b.version))
.map((entry) => entry.idx);

descOrder.forEach((idxInParsed, position) => {
const newerIdx = position > 0 ? descOrder[position - 1] : undefined;
const olderIdx =
position < descOrder.length - 1 ? descOrder[position + 1] : undefined;

withMeta[idxInParsed].newerSlug =
newerIdx !== undefined ? parsed[newerIdx].doc.slug : null;
withMeta[idxInParsed].olderSlug =
olderIdx !== undefined ? parsed[olderIdx].doc.slug : null;
});

// Return in descending order to match UI expectations
return descOrder.map((idx) => withMeta[idx]);
}

let cache: ChangelogWithMeta[] | null = null;

function getAllChangelogMeta(): ChangelogWithMeta[] {
if (!cache) {
cache = buildChangelogMeta();
}

return cache;
}

export function getChangelogList(): ChangelogWithMeta[] {
return getAllChangelogMeta();
}

export function getChangelogBySlug(
slug: string,
): ChangelogWithMeta | undefined {
return getAllChangelogMeta().find((entry) => entry.slug === slug);
}
49 changes: 30 additions & 19 deletions apps/web/src/routes/_view/changelog/$slug.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,38 @@
import { MDXContent } from "@content-collections/mdx/react";
import { Icon } from "@iconify-icon/react";
import { createFileRoute, Link, notFound } from "@tanstack/react-router";
import { allChangelogs } from "content-collections";
import { useCallback } from "react";
import semver from "semver";

import { getChangelogBySlug } from "@/changelog";

export const Route = createFileRoute("/_view/changelog/$slug")({
component: Component,
loader: async ({ params }) => {
const changelog = allChangelogs.find(
(changelog) => changelog.slug === params.slug,
);
const changelog = getChangelogBySlug(params.slug);
if (!changelog) {
throw notFound();
}

const sortedChangelogs = [...allChangelogs].sort((a, b) =>
semver.rcompare(a.version, b.version),
);

const currentIndex = sortedChangelogs.findIndex(
(c) => c.slug === changelog.slug,
);
const nextChangelog =
currentIndex > 0 ? sortedChangelogs[currentIndex - 1] : null;
const prevChangelog =
currentIndex < sortedChangelogs.length - 1
? sortedChangelogs[currentIndex + 1]
const nextChangelog = changelog.newerSlug
? (getChangelogBySlug(changelog.newerSlug) ?? null)
: null;
const prevChangelog = changelog.olderSlug
? (getChangelogBySlug(changelog.olderSlug) ?? null)
: null;

const beforeVersion = changelog.beforeVersion;
const diffUrl =
beforeVersion != null
? `https://github.com/fastrepl/hyprnote/compare/desktop_v${beforeVersion}...desktop_v${changelog.version}`
: null;

return { changelog, nextChangelog, prevChangelog };
return { changelog, nextChangelog, prevChangelog, diffUrl };
},
});

function Component() {
const { changelog, nextChangelog, prevChangelog } = Route.useLoaderData();
const { changelog, nextChangelog, prevChangelog, diffUrl } =
Route.useLoaderData();

const isLatest = nextChangelog === null;

Expand Down Expand Up @@ -77,6 +75,19 @@ function Component() {
</h1>

<button onClick={handleDownload}>download</button>
{diffUrl && (
<div className="mt-4">
<a
href={diffUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-neutral-600 hover:text-stone-600 transition-colors"
>
<Icon icon="mdi:github" />
<span>View diff on GitHub</span>
</a>
</div>
)}
</header>

<article className="prose prose-stone prose-headings:font-serif prose-headings:font-semibold prose-h1:text-4xl prose-h1:mt-12 prose-h1:mb-6 prose-h2:text-3xl prose-h2:mt-10 prose-h2:mb-5 prose-h3:text-2xl prose-h3:mt-8 prose-h3:mb-4 prose-h4:text-xl prose-h4:mt-6 prose-h4:mb-3 prose-a:text-stone-600 prose-a:underline prose-a:decoration-dotted hover:prose-a:text-stone-800 prose-code:bg-stone-50 prose-code:border prose-code:border-neutral-200 prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:font-mono prose-code:text-stone-700 prose-pre:bg-stone-50 prose-pre:border prose-pre:border-neutral-200 prose-pre:rounded-sm prose-img:rounded-sm prose-img:border prose-img:border-neutral-200 prose-img:my-8 max-w-none">
Expand Down
10 changes: 4 additions & 6 deletions apps/web/src/routes/_view/changelog/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { Icon } from "@iconify-icon/react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { allChangelogs, type Changelog } from "content-collections";
import semver from "semver";

import { type ChangelogWithMeta, getChangelogList } from "@/changelog";

export const Route = createFileRoute("/_view/changelog/")({
component: Component,
loader: async () => {
const changelogs = [...allChangelogs].sort((a, b) =>
semver.rcompare(a.version, b.version),
);
const changelogs = getChangelogList();

return { changelogs };
},
Expand Down Expand Up @@ -66,7 +64,7 @@ function ChangelogCard({
changelog,
isFirst,
}: {
changelog: Changelog;
changelog: ChangelogWithMeta;
isFirst: boolean;
}) {
return (
Expand Down
93 changes: 85 additions & 8 deletions apps/web/src/scripts/versioning.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";

type VersionChannel = "stable" | "nightly" | "staging";

// https://docs.crabnebula.dev/cloud/cli/upload-assets/#public-platform---public-platform
Expand All @@ -24,13 +27,23 @@ type GithubTagResponse = {
};
};

type GithubCommitResponse = {
sha: string;
commit: {
author: {
date: string;
};
};
};

type GithubTagInfo = {
tag: string;
version: string;
sha: string;
createdAt: string;
};

export async function fetchGithubDesktopTags(options?: {
async function fetchGithubDesktopTags(options?: {
signal?: AbortSignal;
token?: string;
}): Promise<GithubTagInfo[]> {
Expand Down Expand Up @@ -58,11 +71,75 @@ export async function fetchGithubDesktopTags(options?: {

const data = (await response.json()) as GithubTagResponse[];

return data
.filter((tag) => tag.name.startsWith("desktop_v"))
.map((tag) => ({
tag: tag.name,
version: tag.name.replace(/^desktop_v/, ""),
sha: tag.commit.sha,
}));
const filteredTags = data.filter(
(tag) =>
tag.name.startsWith("desktop_v1") &&
!tag.name.includes("1.0.0-nightly.0"),
);

const tagsWithDates = await Promise.all(
filteredTags.map(async (tag) => {
const commitResponse = await fetch(
`https://api.github.com/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/commits/${tag.commit.sha}`,
{
headers,
signal: options?.signal,
},
);

if (!commitResponse.ok) {
throw new Error(
`Failed to fetch commit ${tag.commit.sha}: ${commitResponse.status} ${commitResponse.statusText}`,
);
}

const commitData = (await commitResponse.json()) as GithubCommitResponse;

return {
tag: tag.name,
version: tag.name.replace(/^desktop_v/, ""),
sha: tag.commit.sha,
createdAt: commitData.commit.author.date,
};
}),
);

return tagsWithDates;
}

function updateCreatedField(content: string, date: string): string {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);

if (!frontmatterMatch) {
return `---\ncreated: "${date}"\n---\n${content}`;
}

const [, frontmatter, body] = frontmatterMatch;
const hasCreated = /^created:/m.test(frontmatter);

const updatedFrontmatter = hasCreated
? frontmatter.replace(/^created:.*$/m, `created: "${date}"`)
: `${frontmatter}\ncreated: "${date}"`;

return `---\n${updatedFrontmatter}\n---\n${body.trimEnd()}\n`;
}

async function updateChangelogFiles(tags: GithubTagInfo[]): Promise<void> {
const changelogDir = join(import.meta.dirname, "../../content/changelog");
await mkdir(changelogDir, { recursive: true });

await Promise.all(
tags.map(async (tag) => {
const filePath = join(changelogDir, `${tag.version}.mdx`);
const datetime = tag.createdAt;

const content = await readFile(filePath, "utf-8").catch(() => "");
const updated = updateCreatedField(content, datetime);

await writeFile(filePath, updated, "utf-8");
console.log(`Updated ${tag.version}.mdx with created date: ${datetime}`);
}),
);
}

fetchGithubDesktopTags().then(updateChangelogFiles);
Loading