diff --git a/apps/web/content/changelog/1.0.0-nightly.1.mdx b/apps/web/content/changelog/1.0.0-nightly.1.mdx index bca52d989c..fec68a6980 100644 --- a/apps/web/content/changelog/1.0.0-nightly.1.mdx +++ b/apps/web/content/changelog/1.0.0-nightly.1.mdx @@ -1,5 +1,5 @@ --- -created: "2025-11-18" +created: "2025-11-18T07:50:16Z" --- - Initial release. diff --git a/apps/web/content/changelog/1.0.0-nightly.2.mdx b/apps/web/content/changelog/1.0.0-nightly.2.mdx index 81296cb875..cdc136cb9b 100644 --- a/apps/web/content/changelog/1.0.0-nightly.2.mdx +++ b/apps/web/content/changelog/1.0.0-nightly.2.mdx @@ -1,5 +1,5 @@ --- -created: "2025-11-22" +created: "2025-11-18T09:03:33Z" --- - Fixed app icon padding. diff --git a/apps/web/content/changelog/1.0.0-nightly.3.mdx b/apps/web/content/changelog/1.0.0-nightly.3.mdx new file mode 100644 index 0000000000..5c80325dff --- /dev/null +++ b/apps/web/content/changelog/1.0.0-nightly.3.mdx @@ -0,0 +1,5 @@ +--- +created: "2025-11-19T00:11:42Z" +--- + +- Fix invalid titap editor panic when rendering empty note. diff --git a/apps/web/src/changelog.ts b/apps/web/src/changelog.ts new file mode 100644 index 0000000000..5663f7acf4 --- /dev/null +++ b/apps/web/src/changelog.ts @@ -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 = {}; + const preByBase: Record = {}; + 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 = {}; + 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); +} diff --git a/apps/web/src/routes/_view/changelog/$slug.tsx b/apps/web/src/routes/_view/changelog/$slug.tsx index 1b5e8970d2..8d5b1a1ac1 100644 --- a/apps/web/src/routes/_view/changelog/$slug.tsx +++ b/apps/web/src/routes/_view/changelog/$slug.tsx @@ -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; @@ -77,6 +75,19 @@ function Component() { + {diffUrl && ( +
+ + + View diff on GitHub + +
+ )}
diff --git a/apps/web/src/routes/_view/changelog/index.tsx b/apps/web/src/routes/_view/changelog/index.tsx index 25a5f3c3ba..d7fa60f343 100644 --- a/apps/web/src/routes/_view/changelog/index.tsx +++ b/apps/web/src/routes/_view/changelog/index.tsx @@ -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 }; }, @@ -66,7 +64,7 @@ function ChangelogCard({ changelog, isFirst, }: { - changelog: Changelog; + changelog: ChangelogWithMeta; isFirst: boolean; }) { return ( diff --git a/apps/web/src/scripts/versioning.ts b/apps/web/src/scripts/versioning.ts index 5635528831..4f8ae06f7b 100644 --- a/apps/web/src/scripts/versioning.ts +++ b/apps/web/src/scripts/versioning.ts @@ -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 @@ -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 { @@ -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 { + 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);