diff --git a/src/pages/docs/changelog.md.ts b/src/pages/docs/changelog.md.ts new file mode 100644 index 00000000000..0d97730a480 --- /dev/null +++ b/src/pages/docs/changelog.md.ts @@ -0,0 +1,26 @@ +import type { APIRoute } from "astro" +import { renderChangelogIndexMarkdown } from "~/utils/changelogMarkdown" +import { fetchMajorReleases } from "~/utils/fetchChangelogVersions" + +// Same limit as the getStaticPaths in src/pages/docs/changelog/[tag].astro, so +// the index lists exactly the release pages that get built. +const CHANGELOG_RELEASES = 150 + +/** + * Serves the changelog index page (`/docs/changelog`) as raw Markdown at + * `/docs/changelog.md`. + * + * The release notes themselves live behind `/docs/changelog/{tag}.md`; this + * index gives AI agents and other tools a single entry point to discover them + * instead of returning a 404. + */ +export const GET: APIRoute = async () => { + const releases = await fetchMajorReleases(CHANGELOG_RELEASES) + + return new Response(renderChangelogIndexMarkdown(releases), { + status: 200, + headers: { + "Content-Type": "text/markdown; charset=utf-8", + }, + }) +} diff --git a/src/pages/docs/changelog/[tag].md.ts b/src/pages/docs/changelog/[tag].md.ts new file mode 100644 index 00000000000..fc7420e3852 --- /dev/null +++ b/src/pages/docs/changelog/[tag].md.ts @@ -0,0 +1,42 @@ +import type { APIRoute, GetStaticPaths } from "astro" +import { renderReleaseMarkdown } from "~/utils/changelogMarkdown" +import { + fetchMajorReleases, + type GitHubRelease, +} from "~/utils/fetchChangelogVersions" + +// Same limit as the getStaticPaths in src/pages/docs/changelog/[tag].astro, so +// every release page that gets built also gets a Markdown endpoint. +const CHANGELOG_RELEASES = 150 + +export const getStaticPaths = (async () => { + const releases = await fetchMajorReleases(CHANGELOG_RELEASES) + return releases.map((release: GitHubRelease) => ({ + params: { tag: release.tag_name }, + props: { release }, + })) +}) satisfies GetStaticPaths + +/** + * Serves a release notes page (`/docs/changelog/{tag}`) as raw Markdown at + * `/docs/changelog/{tag}.md`. + * + * The catch-all route `docs/[...docsPath].md.ts` only emits paths for the + * `docs` content collection, and changelog pages are built from the GitHub + * releases API instead of Markdown files in this repo. Without this endpoint + * every `/docs/changelog/*.md` URL 404s, which breaks the "append .md to any + * kestra.io/docs/* URL" contract advertised in DocsLayout and llms-full.txt. + */ +export const GET: APIRoute = ({ props }) => { + const release = (props as { release?: GitHubRelease }).release + if (!release) { + return new Response("Not found", { status: 404 }) + } + + return new Response(renderReleaseMarkdown(release), { + status: 200, + headers: { + "Content-Type": "text/markdown; charset=utf-8", + }, + }) +} diff --git a/src/utils/changelogMarkdown.test.ts b/src/utils/changelogMarkdown.test.ts new file mode 100644 index 00000000000..f096482e778 --- /dev/null +++ b/src/utils/changelogMarkdown.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest" +import type { GitHubRelease } from "~/utils/fetchChangelogVersions" +import { + renderChangelogIndexMarkdown, + renderReleaseMarkdown, +} from "~/utils/changelogMarkdown" + +// Shaped like the GitHub releases payload after fetchMajorReleases(): the body is +// already Markdown with commit links rewritten. +const release = (over: Partial = {}): GitHubRelease => ({ + tag_name: "v1.3.35", + name: "v1.3.35", + body: "## Changelog\n\n### 🐛 Bug Fixes", + published_at: "2026-08-25T09:12:33Z", + draft: false, + prerelease: false, + ...over, +}) + +describe("renderReleaseMarkdown", () => { + it("prefixes the release body with the release name as an h1", () => { + expect(renderReleaseMarkdown(release())).toBe( + "# v1.3.35\n\n## Changelog\n\n### 🐛 Bug Fixes", + ) + }) + + it("falls back to the tag when the release has no name", () => { + const md = renderReleaseMarkdown(release({ name: undefined })) + expect(md.startsWith("# v1.3.35\n\n")).toBe(true) + }) + + it("does not emit 'undefined' when the body is missing", () => { + const md = renderReleaseMarkdown( + release({ body: undefined as unknown as string }), + ) + expect(md).toBe("# v1.3.35\n\n") + }) +}) + +describe("renderChangelogIndexMarkdown", () => { + it("links every release to its page with the publication date", () => { + const md = renderChangelogIndexMarkdown([ + release(), + release({ tag_name: "v1.0.57", name: "v1.0.57" }), + ]) + + expect(md).toContain( + "- [v1.3.35](https://kestra.io/docs/changelog/v1.3.35) — 2026-08-25", + ) + expect(md).toContain( + "- [v1.0.57](https://kestra.io/docs/changelog/v1.0.57) — 2026-08-25", + ) + expect(md.startsWith("# Release Notes\n")).toBe(true) + }) + + it("omits the separator when a release has no publication date", () => { + const md = renderChangelogIndexMarkdown([ + release({ published_at: undefined as unknown as string }), + ]) + + expect(md).toContain( + "- [v1.3.35](https://kestra.io/docs/changelog/v1.3.35)\n", + ) + expect(md).not.toContain("—") + }) + + it("still renders a usable page when the release list is empty", () => { + const md = renderChangelogIndexMarkdown([]) + expect(md).toContain("# Release Notes") + expect(md).not.toContain("- [") + }) +}) diff --git a/src/utils/changelogMarkdown.ts b/src/utils/changelogMarkdown.ts new file mode 100644 index 00000000000..9c04dc81fc6 --- /dev/null +++ b/src/utils/changelogMarkdown.ts @@ -0,0 +1,31 @@ +import type { GitHubRelease } from "~/utils/fetchChangelogVersions" + +export const CHANGELOG_BASE_URL = "https://kestra.io/docs/changelog" + +/** Renders a single release as the Markdown served at `/docs/changelog/{tag}.md`. */ +export function renderReleaseMarkdown(release: GitHubRelease): string { + const title = release.name || release.tag_name + return `# ${title}\n\n${release.body ?? ""}` +} + +/** Renders the release index served at `/docs/changelog.md`. */ +export function renderChangelogIndexMarkdown( + releases: GitHubRelease[], +): string { + const lines = releases.map((release) => { + const title = release.name || release.tag_name + const url = `${CHANGELOG_BASE_URL}/${release.tag_name}` + const date = release.published_at?.slice(0, 10) + return date ? `- [${title}](${url}) — ${date}` : `- [${title}](${url})` + }) + + return [ + "# Release Notes", + "", + "Stay up to date with the latest Kestra releases and updates.", + "Append `.md` to any release URL below to retrieve its notes as plain Markdown.", + "", + ...lines, + "", + ].join("\n") +}