From cfe6c5d468eef6535eebecc8f6c70598677e2023 Mon Sep 17 00:00:00 2001 From: Matthias Vallentin Date: Thu, 2 Jul 2026 14:13:41 +0200 Subject: [PATCH 1/2] Redirect the changelog to tenzir.com The changelog moves from docs.tenzir.com to the marketing website at https://tenzir.com/changelog, where it is built directly from the tenzir/news repository. Retire the docs-side changelog generator and replace it with a small script that emits static redirect stubs into public/changelog/ for every previously published URL: - Landing page, per-project index and unreleased pages, and the old cross-project timeline pages. - Per-release pages, mapping the dashed docs slugs (v5-28-0) back to the dotted website URLs (v5.28.0) based on the release directory names in tenzir/news. - Farewell Atom feeds that carry a single entry telling subscribers to resubscribe on tenzir.com. Remove the changelog rendering machinery: the sync script, landing and timeline components, the project allow-list, the generated-navigation loader in starlight-site-navigation, and the changelog handling in the llms-txt plugin. The navbar now links to tenzir.com/changelog as a plain external entry. Co-Authored-By: Claude Fable 5 --- .gitignore | 6 +- .markdownlintignore | 1 - .prettierignore | 4 - AGENTS.md | 14 +- astro.config.mjs | 6 +- biome.json | 3 - package.json | 2 +- scripts/generate-changelog-redirects.mjs | 230 ++ scripts/sync-changelog.mjs | 1913 ----------------- src/changelog-landing.ts | 23 - src/changelog-projects.json | 37 - src/components/ChangelogLanding.astro | 64 - src/components/NavbarTopicList.astro | 4 +- src/components/Sidebar.astro | 12 +- src/components/TenzirFooter.astro | 2 +- src/components/Timeline.astro | 332 --- src/components/UnifiedTimeline.astro | 42 - src/content/docs/changelog/index.mdx | 9 - .../starlight-llms-txt/changelog-topics.ts | 16 - .../routes/page-markdown.ts | 12 - .../starlight-llms-txt/sitemap-generator.ts | 24 - .../starlight-site-navigation/index.ts | 122 +- .../starlight-site-navigation/types.ts | 1 - src/search-weights.ts | 7 - 24 files changed, 255 insertions(+), 2631 deletions(-) create mode 100644 scripts/generate-changelog-redirects.mjs delete mode 100644 scripts/sync-changelog.mjs delete mode 100644 src/changelog-landing.ts delete mode 100644 src/changelog-projects.json delete mode 100644 src/components/ChangelogLanding.astro delete mode 100644 src/components/Timeline.astro delete mode 100644 src/components/UnifiedTimeline.astro delete mode 100644 src/content/docs/changelog/index.mdx delete mode 100644 src/plugins/starlight-llms-txt/changelog-topics.ts diff --git a/.gitignore b/.gitignore index db83214ed..f197faab6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,13 +9,9 @@ dist/ node_modules/ -# Changelog sync (generated content) +# Changelog redirects (generated content) .news/ public/changelog/ -src/content/docs/changelog/* -src/changelog-landing.generated.ts -src/sidebar-changelog.generated.ts -src/unified-timeline-data.generated.ts # Docs map and bundle (generated) public/sitemap.md diff --git a/.markdownlintignore b/.markdownlintignore index 31e60ab75..62f901753 100644 --- a/.markdownlintignore +++ b/.markdownlintignore @@ -4,5 +4,4 @@ dist/ bun.lock .github/ public/ -src/content/docs/changelog/ tenzir/ diff --git a/.prettierignore b/.prettierignore index cf2b70a26..aecb6e417 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,10 +3,6 @@ node_modules/ dist/ .astro/ -# Auto-generated changelog -src/content/docs/changelog/ -src/sidebar-changelog.generated.ts -src/unified-timeline-data.generated.ts # Auto-synced API specs src/content/apis/openapi.node.yaml diff --git a/AGENTS.md b/AGENTS.md index 7031f6404..3e0767022 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,14 +72,12 @@ explicit file list, pass NUL-separated paths on standard input to Some content is auto-generated and excluded from linting: -- **Changelog**: Run `bun run generate:changelog` to fetch from `tenzir/news` repo. - The generator only publishes projects listed in `src/changelog-projects.json`, - which is the authoritative source for changelog project inclusion, ordering, - and metadata. Generated files: `src/content/docs/changelog/`, - `src/changelog-landing.generated.ts`, `src/sidebar-changelog.generated.ts`, - and `src/unified-timeline-data.generated.ts`. The tracked - `src/content/docs/changelog/index.mdx` page is a stable shell and should not - change during local builds. +- **Changelog redirects**: The changelog lives at + . Run `bun run generate:changelog` to emit + redirect stubs and farewell Atom feeds into `public/changelog/` so old + docs.tenzir.com changelog URLs forward to tenzir.com. The script derives + the URLs from the `tenzir/news` repo (cloned into the gitignored `.news/` + directory). - **Excalidraw Diagrams**: `bun run dev` and `bun run build` refresh generated SVGs automatically. Use `bun run generate:excalidraw` only when you need a manual refresh. In markdown, reference diagrams as `![alt](foo.excalidraw)`. diff --git a/astro.config.mjs b/astro.config.mjs index f20159a39..cc4c519ee 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -60,9 +60,7 @@ const siteNavigation = [ }, { label: "Changelog", - dropdown: true, - paths: ["/changelog", "/changelog/**"], - childrenFrom: "changelogProjects", + link: "https://tenzir.com/changelog", }, ]; @@ -113,7 +111,7 @@ export default defineConfig({ rel: "alternate", type: "application/atom+xml", title: "Tenzir Changelog", - href: "/changelog/feed.xml", + href: "https://tenzir.com/changelog/feed.xml", }, }, ], diff --git a/biome.json b/biome.json index 57409dfbc..3a09e6fd7 100644 --- a/biome.json +++ b/biome.json @@ -57,9 +57,6 @@ "!**/.vscode/", "!**/*.log", "!**/bun.lock", - "!**/src/content/docs/changelog/", - "!**/src/sidebar-changelog.generated.ts", - "!**/src/unified-timeline-data.generated.ts", "!**/src/content/apis/openapi.*.yaml", "!**/tenzir/", "!**/*.css" diff --git a/package.json b/package.json index bc22e13e1..0ce8a23c4 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "preview": "bun install --silent --frozen-lockfile && astro preview", "astro": "astro", "generate": "bun run generate:changelog && bun run generate:excalidraw", - "generate:changelog": "bun install --silent --frozen-lockfile && node scripts/sync-changelog.mjs", + "generate:changelog": "node scripts/generate-changelog-redirects.mjs", "generate:docs-bundle": "node scripts/generate-docs.mjs", "generate:excalidraw": "bun install --silent --frozen-lockfile && node scripts/generate-excalidraw-svgs.mjs", "generate:excalidraw:placeholders": "node scripts/generate-placeholder-svgs.mjs", diff --git a/scripts/generate-changelog-redirects.mjs b/scripts/generate-changelog-redirects.mjs new file mode 100644 index 000000000..fe9aaed05 --- /dev/null +++ b/scripts/generate-changelog-redirects.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node +/** + * Generate static redirect stubs for the retired docs changelog. + * + * The Tenzir changelog moved to https://tenzir.com/changelog. This script + * replaces the old changelog generator: instead of rendering release pages + * from the tenzir/news repository, it emits redirect stubs into + * `public/changelog/` so that every previously published docs URL forwards + * to its new home on tenzir.com. + * + * It also writes farewell Atom feeds that tell subscribers to resubscribe + * to the feeds on tenzir.com. + * + * Usage: + * node scripts/generate-changelog-redirects.mjs [path/to/local/news/repo] + * + * Without an argument, the script clones (or updates) tenzir/news into the + * gitignored `.news/` directory. + */ + +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const WEBSITE_BASE = "https://tenzir.com/changelog"; + +/** + * Fixed timestamp for the farewell feed entries so rebuilds do not churn + * the feed contents. This is the date the changelog migration shipped. + */ +const MOVED_AT = "2026-07-02T00:00:00Z"; + +/** First year covered by the old cross-project timeline pages. */ +const TIMELINE_START_YEAR = 2014; + +/** + * Clone or update the news repo from GitHub. + */ +function getNewsRepo(localPath) { + if (localPath) { + console.log(`Using local news repo: ${localPath}`); + return path.resolve(localPath); + } + + const newsPath = path.join(process.cwd(), ".news"); + + try { + // Check if already cloned + fs.accessSync(path.join(newsPath, ".git")); + console.log("Updating tenzir/news from GitHub..."); + execSync("git pull --ff-only", { cwd: newsPath, stdio: "inherit" }); + } catch { + console.log("Cloning tenzir/news from GitHub..."); + execSync( + `git clone --depth 1 https://github.com/tenzir/news "${newsPath}"`, + { + stdio: "inherit", + }, + ); + } + + return newsPath; +} + +/** + * Discover changelog projects in the news repo. Only top-level + * `/changelog/config.yaml` files count; nested module changelogs + * (e.g. under `plugins/`) never had their own docs URLs at this level. + */ +function discoverProjects(newsPath) { + const projects = []; + for (const dirent of fs.readdirSync(newsPath, { withFileTypes: true })) { + if (!dirent.isDirectory() || dirent.name.startsWith(".")) { + continue; + } + const changelogDir = path.join(newsPath, dirent.name, "changelog"); + const configPath = path.join(changelogDir, "config.yaml"); + if (!fs.existsSync(configPath)) { + continue; + } + const config = fs.readFileSync(configPath, "utf-8"); + const idMatch = config.match(/^id:\s*["']?([\w.-]+)["']?\s*$/m); + if (!idMatch) { + console.warn(`Skipping ${configPath}: no id field found.`); + continue; + } + const id = idMatch[1]; + const releasesDir = path.join(changelogDir, "releases"); + const versions = fs.existsSync(releasesDir) + ? fs + .readdirSync(releasesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + : []; + projects.push({ id, versions }); + } + return projects.sort((a, b) => a.id.localeCompare(b.id)); +} + +/** + * Render a minimal redirect stub pointing at the new website URL. + */ +function redirectHtml(url) { + return ` + + + + + +Redirecting to ${url} +

The Tenzir changelog has moved. If you are not redirected automatically, +follow ${url}.

+ + +`; +} + +function writeRedirect(outDir, relativeDir, url) { + const dir = path.join(outDir, relativeDir); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "index.html"), redirectHtml(url)); +} + +function escapeXml(value) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * Render a farewell Atom feed with a single entry pointing subscribers at + * the new feed location on tenzir.com. + */ +function farewellFeed({ feedId, title, pageUrl, feedUrl }) { + const message = + "The Tenzir changelog has moved to tenzir.com. This feed is no longer " + + `updated. Please resubscribe to the new feed at ${feedUrl} and browse ` + + `releases at ${pageUrl}.`; + return ` + + ${escapeXml(feedId)} + ${escapeXml(title)} + ${MOVED_AT} + + + ${escapeXml(`${feedId}#moved`)} + The Tenzir changelog has moved + ${MOVED_AT} + + ${escapeXml(message)} + + +`; +} + +function main() { + const newsPath = getNewsRepo(process.argv[2]); + const projects = discoverProjects(newsPath); + if (projects.length === 0) { + throw new Error(`No changelog projects found in ${newsPath}.`); + } + + const outDir = path.join(process.cwd(), "public", "changelog"); + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(outDir, { recursive: true }); + + let stubs = 0; + const emit = (relativeDir, url) => { + writeRedirect(outDir, relativeDir, url); + stubs++; + }; + + // Landing page. + emit(".", `${WEBSITE_BASE}/`); + + // The old cross-project timeline pages have no direct equivalent; send + // them to the changelog landing page. + emit("timeline", `${WEBSITE_BASE}/`); + const currentYear = new Date().getUTCFullYear(); + for (let year = TIMELINE_START_YEAR; year <= currentYear; year++) { + emit(`timeline/${year}`, `${WEBSITE_BASE}/`); + } + + for (const project of projects) { + emit(project.id, `${WEBSITE_BASE}/${project.id}/`); + emit( + `${project.id}/unreleased`, + `${WEBSITE_BASE}/${project.id}/unreleased/`, + ); + for (const version of project.versions) { + // Docs URLs used dashed version slugs (v5.28.0 -> v5-28-0) because + // Starlight slugifies page ids. The website keeps the dots. + const dashed = version.replaceAll(".", "-"); + emit( + `${project.id}/${dashed}`, + `${WEBSITE_BASE}/${project.id}/${version}/`, + ); + } + } + + // Farewell feeds. + fs.writeFileSync( + path.join(outDir, "feed.xml"), + farewellFeed({ + feedId: "https://docs.tenzir.com/changelog/feed.xml", + title: "Tenzir Changelog", + pageUrl: `${WEBSITE_BASE}/`, + feedUrl: `${WEBSITE_BASE}/feed.xml`, + }), + ); + for (const project of projects) { + fs.writeFileSync( + path.join(outDir, `${project.id}.xml`), + farewellFeed({ + feedId: `https://docs.tenzir.com/changelog/${project.id}.xml`, + title: `Tenzir Changelog: ${project.id}`, + pageUrl: `${WEBSITE_BASE}/${project.id}/`, + feedUrl: `${WEBSITE_BASE}/${project.id}.xml`, + }), + ); + } + + console.log( + `Wrote ${stubs} redirect stubs and ${projects.length + 1} farewell feeds to ${outDir}.`, + ); +} + +main(); diff --git a/scripts/sync-changelog.mjs b/scripts/sync-changelog.mjs deleted file mode 100644 index b601c310f..000000000 --- a/scripts/sync-changelog.mjs +++ /dev/null @@ -1,1913 +0,0 @@ -#!/usr/bin/env node - -import { execSync } from "node:child_process"; -import fsSync from "node:fs"; -import fs from "node:fs/promises"; -import { createRequire } from "node:module"; -import path from "node:path"; -import { Feed } from "feed"; -import { remark } from "remark"; -import remarkHtml from "remark-html"; - -// Load shared project config (icons defined once, used by both sync and topics.ts) -const require = createRequire(import.meta.url); -const changelogProjects = require("../src/changelog-projects.json"); -const CHANGELOG_EXPORT_MAX_BUFFER = 64 * 1024 * 1024; - -/** - * Sync changelog from tenzir/news repository. - * - * Usage: - * node sync-changelog.mjs # Clone from GitHub - * node sync-changelog.mjs # Use local clone - */ - -/** - * Parse simple YAML files (config.yaml, manifest.yaml). - * Handles basic key-value pairs and multiline strings (>-). - */ -function parseYaml(content) { - const data = {}; - const lines = content.split("\n"); - let currentKey = null; - let isMultiline = false; - let multilineValue = ""; - let isNestedObject = false; - - for (const line of lines) { - // Handle multiline continuation - if (isMultiline) { - if (line.startsWith(" ") || line.trim() === "") { - // Always fold into a single paragraph (join with spaces) - multilineValue += (multilineValue ? " " : "") + line.trim(); - continue; - } else { - data[currentKey] = multilineValue.trim(); - isMultiline = false; - multilineValue = ""; - } - } - - // Handle nested object properties (indented key-value pairs) - if ( - isNestedObject && - line.startsWith(" ") && - !line.trim().startsWith("-") - ) { - const trimmed = line.trim(); - const nestedColonIndex = trimmed.indexOf(":"); - if (nestedColonIndex > 0) { - const nestedKey = trimmed.slice(0, nestedColonIndex).trim(); - let nestedValue = trimmed.slice(nestedColonIndex + 1).trim(); - // Remove quotes if present - if ( - (nestedValue.startsWith('"') && nestedValue.endsWith('"')) || - (nestedValue.startsWith("'") && nestedValue.endsWith("'")) - ) { - nestedValue = nestedValue.slice(1, -1); - } - data[currentKey][nestedKey] = nestedValue; - continue; - } - } else if (isNestedObject && !line.startsWith(" ") && line.trim() !== "") { - isNestedObject = false; - } - - const colonIndex = line.indexOf(":"); - if (colonIndex > 0 && !line.startsWith(" ") && !line.startsWith("-")) { - const key = line.slice(0, colonIndex).trim(); - let value = line.slice(colonIndex + 1).trim(); - - // Check for multiline indicator (|, |-, >, >-) - all treated as folded - if (value === ">-" || value === ">" || value === "|" || value === "|-") { - currentKey = key; - isMultiline = true; - multilineValue = ""; - continue; - } - - // Handle nested object or array start (empty value) - if (value === "") { - // Look ahead to determine if it's an array or object - const lineIndex = lines.indexOf(line); - const nextLine = lines[lineIndex + 1] || ""; - if (nextLine.trim().startsWith("-")) { - data[key] = []; - } else { - data[key] = {}; - isNestedObject = true; - } - currentKey = key; - continue; - } - - // Remove quotes if present - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - data[key] = value; - currentKey = key; - } else if ( - line.trim().startsWith("-") && - currentKey && - Array.isArray(data[currentKey]) - ) { - const item = line.slice(line.indexOf("-") + 1).trim(); - data[currentKey].push(item); - } - } - - // Handle trailing multiline - if (isMultiline && currentKey) { - data[currentKey] = multilineValue.trim(); - } - - return data; -} - -/** - * Parse semantic version string into components. - */ -function parseVersion(version) { - const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); - if (!match) { - return { major: 0, minor: 0, patch: 0, prerelease: null, raw: version }; - } - return { - major: parseInt(match[1], 10), - minor: parseInt(match[2], 10), - patch: parseInt(match[3], 10), - prerelease: match[4] || null, - raw: version, - }; -} - -/** - * Strip links from intro text to keep timeline rows fully clickable. - */ -function stripIntroLinks(text) { - if (!text) return ""; - let output = text; - output = output.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"); - output = output.replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1"); - output = output.replace(/]*>([\s\S]*?)<\/a>/gi, "$1"); - return output; -} - -/** - * Convert version to slug format (v5.21.2 -> v5-21-2). - */ -function versionToSlug(version) { - return version.replace(/\./g, "-"); -} - -/** - * Check if a dirent is a directory (follows symlinks). - */ -function isDirectoryEntry(basePath, entry) { - if (entry.isDirectory()) return true; - if (entry.isSymbolicLink()) { - return fsSync.statSync(path.join(basePath, entry.name)).isDirectory(); - } - return false; -} - -/** - * Compare versions for sorting (newest first, unreleased at top). - */ -function compareVersions(a, b) { - // Use majorVersion property (Infinity for unreleased) for primary sort - if (a.majorVersion !== b.majorVersion) return b.majorVersion - a.majorVersion; - if (a.minorVersion !== b.minorVersion) return b.minorVersion - a.minorVersion; - if (a.patchVersion !== b.patchVersion) return b.patchVersion - a.patchVersion; - - return 0; -} - -/** - * Read all projects from the news repo. - * Projects have structure: {project}/changelog/config.yaml - * Projects may have modules configured via glob pattern. - */ -async function readProjects(newsRepoPath) { - const projects = []; - const entries = await fs.readdir(newsRepoPath, { withFileTypes: true }); - - for (const entry of entries) { - // Skip hidden entries and non-directories - if (entry.name.startsWith(".") || !isDirectoryEntry(newsRepoPath, entry)) { - continue; - } - - // Config is at {project}/changelog/config.yaml - const configPath = path.join( - newsRepoPath, - entry.name, - "changelog", - "config.yaml", - ); - try { - const configContent = await fs.readFile(configPath, "utf-8"); - const config = parseYaml(configContent); - - if (config.id) { - const project = { - id: config.id, - name: config.name || config.id, - description: config.description || "", - repository: config.repository || "", - components: config.components || [], - dirName: entry.name, - modules: [], // Will be populated if modules field exists - }; - - // Check for modules configuration - if (config.modules) { - project.modules = await discoverModules( - newsRepoPath, - entry.name, - config.modules, - ); - } - - projects.push(project); - } - } catch { - // Skip directories without config.yaml - } - } - - return projects; -} - -/** - * Discover module changelogs based on glob pattern. - * @param {string} newsRepoPath - Base path to news repo - * @param {string} projectDir - Project directory name - * @param {string} modulesGlob - Glob pattern from config (e.g., "../plugins/wildcard/changelog") - */ -async function discoverModules(newsRepoPath, projectDir, modulesGlob) { - const modules = []; - const changelogRoot = path.join(newsRepoPath, projectDir, "changelog"); - - // Resolve the glob pattern relative to changelog root - // Pattern like "../plugins/*/changelog" resolves to {project}/plugins/*/changelog - const resolvedPattern = path.resolve(changelogRoot, modulesGlob); - - // Extract the base path and wildcard part - // e.g., /path/to/news/claude-plugins/plugins/*/changelog - const parts = resolvedPattern.split("*"); - if (parts.length !== 2) { - console.warn(` Warning: Unsupported glob pattern: ${modulesGlob}`); - return modules; - } - - const basePath = parts[0]; // /path/to/news/claude-plugins/plugins/ - const suffix = parts[1]; // /changelog - - try { - const dirEntries = await fs.readdir(basePath, { withFileTypes: true }); - - for (const dirEntry of dirEntries) { - // Skip hidden entries and non-directories - if ( - dirEntry.name.startsWith(".") || - !isDirectoryEntry(basePath, dirEntry) - ) { - continue; - } - - const moduleConfigPath = path.join( - basePath, - dirEntry.name, - suffix.replace(/^\//, ""), - "config.yaml", - ); - - try { - const configContent = await fs.readFile(moduleConfigPath, "utf-8"); - const config = parseYaml(configContent); - - if (config.id) { - // Compute changelog path relative to news repo - const moduleChangelogDir = path.join( - basePath, - dirEntry.name, - suffix.replace(/^\//, ""), - ); - modules.push({ - id: config.id, - name: config.name || config.id, - description: config.description || "", - repository: config.repository || "", - components: config.components || [], - changelogPath: path.relative(newsRepoPath, moduleChangelogDir), - }); - } - } catch { - // Skip modules without valid config - } - } - } catch { - console.warn(` Warning: Could not read modules from ${basePath}`); - } - - // Sort modules alphabetically by id - modules.sort((a, b) => a.id.localeCompare(b.id)); - - return modules; -} - -/** - * Read all releases for a changelog using bulk JSON export. - * Uses tenzir-ship's bulk export feature for efficiency. - * @param {string} newsRepoPath - Base path to news repo - * @param {string} changelogPath - Path to changelog directory (relative to newsRepoPath) - * @param {string} projectName - Name for release titles - */ -async function readAllReleases(newsRepoPath, changelogPath, projectName) { - const fullChangelogPath = path.join(newsRepoPath, changelogPath); - const releases = []; - - try { - // Bulk export all releases as JSON (includes unreleased) - const jsonOutput = execSync( - "uvx tenzir-ship show all --release --json --explicit-links", - { - cwd: fullChangelogPath, - encoding: "utf-8", - maxBuffer: CHANGELOG_EXPORT_MAX_BUFFER, - stdio: ["pipe", "pipe", "pipe"], - }, - ).trim(); - - if (!jsonOutput) return releases; - - const allReleases = JSON.parse(jsonOutput); - if (!Array.isArray(allReleases)) return releases; - - for (const release of allReleases) { - const version = release.version; - const isUnreleased = version === "Unreleased" || !version; - - // Extract unique components from all entries - const entries = release.entries || []; - const components = [ - ...new Set(entries.flatMap((e) => e.components || [])), - ]; - const entryTypes = [...new Set(entries.map((e) => e.type))]; - - if (isUnreleased) { - // Only include unreleased if there are entries - if (entries.length === 0) continue; - - releases.push({ - version: "Unreleased", - slug: "unreleased", - title: "Unreleased", - created: release.created || "", - intro: release.intro || "", - entries, - majorVersion: Infinity, // Sort to top - minorVersion: 0, - patchVersion: 0, - isUnreleased: true, - components, - entryTypes, - entryCount: entries.length, - }); - } else { - const parsed = parseVersion(version); - releases.push({ - version, - slug: versionToSlug(version), - title: release.title || `${projectName} ${version}`, - created: release.created || "", - intro: release.intro || "", - entries, - majorVersion: parsed.major, - minorVersion: parsed.minor, - patchVersion: parsed.patch, - components, - entryTypes, - entryCount: entries.length, - modules: release.modules || null, // Module versions for parent releases - }); - } - } - } catch (err) { - throw new Error(`Bulk export failed for ${changelogPath}: ${err.message}`); - } - - // Sort releases by version (newest first, unreleased at top due to Infinity) - releases.sort(compareVersions); - - return releases; -} - -/** - * Entry type configuration for rendering. - */ -const entryTypeConfig = { - breaking: { emoji: "๐Ÿ’ฅ", heading: "Breaking Changes", order: 0 }, - feature: { emoji: "๐Ÿš€", heading: "Features", order: 1 }, - change: { emoji: "๐Ÿ”ง", heading: "Changes", order: 2 }, - bugfix: { emoji: "๐Ÿž", heading: "Bug Fixes", order: 3 }, -}; - -/** - * Format author for display. - * Handles multiple formats: - * - Markdown link: "[@handle](url)" - passed through unchanged - * - Object: { handle: "mavam", url: "..." } - converted to link - * - String handle: "mavam" - prefixed with @ - * - String name: "Full Name" - used as-is - */ -function formatAuthor(author) { - // Handle string format (including markdown links from --explicit-links) - if (typeof author === "string") { - // Already a markdown link - pass through unchanged - if (author.startsWith("[") && author.includes("](")) { - return author; - } - // Name with spaces - use as-is - if (author.includes(" ")) { - return author; - } - // Handle - add @ prefix - return `@${author}`; - } - - // Handle object format: { handle: "mavam", url: "https://github.com/mavam" } - if (typeof author === "object" && author !== null) { - const handle = author.handle || author.name || "unknown"; - const url = author.url; - // If we have a URL, create a markdown link - if (url) { - const displayName = handle.includes(" ") ? handle : `@${handle}`; - return `[${displayName}](${url})`; - } - // No URL - just format the handle - if (handle.includes(" ")) { - return handle; - } - return `@${handle}`; - } - - return String(author); -} - -/** - * Format date for display (YYYY-MM-DD -> readable format). - */ -function formatDate(dateStr) { - if (!dateStr) return ""; - // Handle ISO datetime strings - const date = new Date(dateStr); - if (Number.isNaN(date.getTime())) return ""; - return date.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); -} - -/** - * Convert Markdown auto-links to bare URLs for MDX compatibility. - * Auto-links like are valid Markdown but MDX - * parses them as JSX tags, causing build failures. - */ -function escapeForMdx(markdown) { - return markdown.replace(/<(https?:\/\/[^>]+)>/g, "$1"); -} - -/** - * Render a single changelog entry as markdown. - */ -function renderEntry(entry) { - let md = `### ${entry.title}\n\n`; - - // Build details line: date ยท authors ยท PRs ยท components (badges) - const detailParts = []; - - // Date - if (entry.created) { - const dateStr = formatDate(entry.created); - if (dateStr) { - detailParts.push(dateStr); - } - } - - // Authors - if (entry.authors && entry.authors.length > 0) { - const formatted = entry.authors.map(formatAuthor).join(", "); - detailParts.push(formatted); - } - - // PRs (may be numbers, strings, or objects from --explicit-links) - if (entry.prs && entry.prs.length > 0) { - const prLinks = entry.prs - .map((pr) => { - // Object format from --explicit-links: { number: 123, url: "..." } - if (typeof pr === "object" && pr !== null) { - const num = pr.number || pr.id || "?"; - const url = pr.url; - return url ? `[#${num}](${url})` : `#${num}`; - } - // Already a markdown link string - pass through - if (typeof pr === "string" && pr.startsWith("[")) { - return pr; - } - // Just a number or string - format as #N - return `#${pr}`; - }) - .join(", "); - detailParts.push(prLinks); - } - - // Components as badges (last) - if (entry.components && entry.components.length > 0) { - const badges = entry.components - .map((c) => `${c}`) - .join(" "); - detailParts.push(badges); - } - - if (detailParts.length > 0) { - md += `

${detailParts.join(" ยท ")}

\n\n`; - } - - // Add body - if (entry.body) { - md += `${escapeForMdx(entry.body)}\n\n`; - } - - return md; -} - -/** - * Generate release notes content from JSON entries. - */ -function generateNotesFromEntries(entries) { - if (!entries || entries.length === 0) return ""; - - // Group entries by type - const grouped = {}; - for (const entry of entries) { - const type = entry.type || "change"; - if (!grouped[type]) { - grouped[type] = []; - } - grouped[type].push(entry); - } - - // Sort types by configured order - const sortedTypes = Object.keys(grouped).sort((a, b) => { - const orderA = entryTypeConfig[a]?.order ?? 99; - const orderB = entryTypeConfig[b]?.order ?? 99; - return orderA - orderB; - }); - - // Render each section - let md = ""; - for (const type of sortedTypes) { - const config = entryTypeConfig[type] || { emoji: "๐Ÿ“", heading: "Other" }; - md += `## ${config.emoji} ${config.heading}\n\n`; - - for (const entry of grouped[type]) { - md += renderEntry(entry); - } - } - - return md; -} - -/** - * Generate MDX content for a release. - * @param {object} project - Project or module object - * @param {object} release - Release object - * @param {object} options - Optional settings - * @param {string} [options.topicId] - Topic ID for frontmatter - * @param {string} [options.sidebarLabel] - Custom sidebar label (defaults to version) - * @param {Array} [options.moduleReleases] - Module releases to include after parent content - */ -function generateMdxContent( - project, - release, - { topicId, sidebarLabel, moduleReleases } = {}, -) { - const topicLine = topicId ? `\ntopic: ${topicId}` : ""; - const label = sidebarLabel || release.version; - - // Determine if we need LinkCard import (for download link) - const hasDownloadLink = project.repository && !release.isUnreleased; - const imports = hasDownloadLink - ? `\nimport LinkCard from '@components/LinkCard.astro';\n` - : ""; - - const frontmatter = `--- -title: "${project.name} ${release.version}" -pagefind: false -sidebar: - label: "${label}"${topicLine} ---- -${imports} -`; - - // Add intro paragraph if available - let content = ""; - if (release.intro) { - content += `${release.intro}\n\n`; - } - - // Generate content from entries if available, otherwise fall back to notes - if (release.entries && release.entries.length > 0) { - content += generateNotesFromEntries(release.entries); - } else if (release.notes) { - content += release.notes; - } - - // Add module releases if provided (for parent releases with modules) - // Uses compact bullet format: "- ๐Ÿš€ Title โ€” _@author_" - if (moduleReleases && moduleReleases.length > 0) { - content += "\n---\n\n"; - for (const modRelease of moduleReleases) { - content += `## ${modRelease.name} ${modRelease.version}\n\n`; - if (modRelease.entries && modRelease.entries.length > 0) { - for (const entry of modRelease.entries) { - const emoji = entryTypeConfig[entry.type]?.emoji || "๐Ÿ“"; - const authors = entry.authors?.map(formatAuthor).join(" and ") || ""; - const authorSuffix = authors ? ` โ€” _${authors}_` : ""; - content += `- ${emoji} ${entry.title}${authorSuffix}\n`; - } - content += "\n"; - } - } - } - - // Add download link if repository is specified (not for unreleased) - if (hasDownloadLink) { - const repoUrl = project.repository.startsWith("http") - ? project.repository - : `https://github.com/${project.repository}`; - const downloadUrl = `${repoUrl}/releases/tag/${release.version}`; - content += `\n\n`; - } - - return frontmatter + content; -} - -/** - * Group versions by major version. - * Unreleased entries are included at the top of the latest major version group. - */ -function groupVersionsByMajor(releases) { - const groups = new Map(); - const unreleased = []; - - for (const release of releases) { - if (release.isUnreleased) { - unreleased.push(release); - } else { - const major = release.majorVersion; - if (!groups.has(major)) { - groups.set(major, []); - } - groups.get(major).push(release); - } - } - - // Convert to array sorted by major version (newest first) - const result = Array.from(groups.entries()) - .sort((a, b) => b[0] - a[0]) - .map(([major, versions]) => ({ - label: `v${major}.x`, - major, - versions, - })); - - // Add unreleased entries to the top of the latest major version group - if (unreleased.length > 0 && result.length > 0) { - result[0].versions = [...unreleased, ...result[0].versions]; - } - - return result; -} - -/** - * Build sidebar items for a set of releases. - * @param {string} basePath - URL path prefix (e.g., "changelog/mcp") - * @param {string} projectName - Name for version group labels - * @param {Array} releases - Array of release objects - */ -function buildSidebarItems(basePath, projectName, releases) { - const sidebarItems = []; - const versionGroups = groupVersionsByMajor(releases); - - for (const group of versionGroups) { - // Version groups are collapsible, first one expanded - const isFirst = sidebarItems.length === 0; - sidebarItems.push({ - label: `${projectName} ${group.label}`, - collapsed: !isFirst, - items: group.versions.map((v) => `${basePath}/${v.slug}`), - }); - } - - return sidebarItems; -} - -/** - * Generate unified timeline entries across all projects. - * Returns released entries sorted by date (newest first). - * Unreleased entries are excluded from the unified timeline. - */ -function generateUnifiedTimelineEntries( - projects, - projectVersions, - moduleVersions, -) { - const entries = []; - - for (const project of projects) { - const releases = projectVersions[project.id] || []; - const icon = changelogProjects[project.id]?.icon || "document"; - const color = changelogProjects[project.id]?.color || ""; - - // Add project releases (skip unreleased) - for (const release of releases) { - if (release.isUnreleased) continue; - - let description = stripIntroLinks(release.intro || ""); - if (description.length > 300) { - description = `${description.slice(0, 297)}...`; - } - - entries.push({ - version: release.version, - date: formatDate(release.created), - href: `/changelog/${project.id}/${release.slug}`, - description: description || undefined, - // Store raw date for sorting - _sortDate: release.created || "", - project: { - id: project.id, - name: project.name, - icon, - color, - }, - }); - } - - // Add module releases (skip unreleased) - if (project.modules && project.modules.length > 0) { - for (const mod of project.modules) { - const moduleReleases = moduleVersions[`${project.id}/${mod.id}`] || []; - for (const release of moduleReleases) { - if (release.isUnreleased) continue; - - let description = stripIntroLinks(release.intro || ""); - if (description.length > 300) { - description = `${description.slice(0, 297)}...`; - } - - entries.push({ - version: release.version, - date: formatDate(release.created), - href: `/changelog/${project.id}/${mod.id}/${release.slug}`, - description: description || undefined, - _sortDate: release.created || "", - project: { - id: mod.id, - name: mod.name, - icon, - color, - parentName: project.name, - }, - }); - } - } - } - } - - // Sort by date (newest first) - entries.sort((a, b) => new Date(b._sortDate) - new Date(a._sortDate)); - - // Remove internal sort field and clean up undefined descriptions - return entries.map(({ _sortDate, description, ...entry }) => { - if (description) { - return { ...entry, description }; - } - return entry; - }); -} - -/** - * Group timeline entries by year. - * Returns a Map with year as key and entries for that year as value. - * Years are sorted newest first. - */ -function groupEntriesByYear(entries) { - const yearGroups = new Map(); - - for (const entry of entries) { - // Extract year from date string (format: "Dec 19, 2024") - const match = entry.date.match(/\d{4}/); - const year = match ? match[0] : "Unknown"; - - if (!yearGroups.has(year)) { - yearGroups.set(year, []); - } - yearGroups.get(year).push(entry); - } - - // Sort by year descending and return as array of [year, entries] tuples - return Array.from(yearGroups.entries()).sort((a, b) => b[0] - a[0]); -} - -/** - * Generate unified timeline TypeScript file. - */ -function generateUnifiedTimelineFile(entries) { - return `// This file is auto-generated by scripts/sync-changelog.mjs -// Do not edit manually. - -import type { StarlightIcon } from "@astrojs/starlight/components/Icons"; - -export interface UnifiedTimelineEntry { - version: string; - date: string; - href: string; - description?: string; - project: { - id: string; - name: string; - icon: StarlightIcon; - color: string; - parentName?: string; - }; -} - -export const unifiedTimelineEntries: UnifiedTimelineEntry[] = ${JSON.stringify(entries, null, 2)}; -`; -} - -/** - * Generate changelog landing page data. - */ -function generateLandingDataFile(latestTimelineYear, projectCards) { - return `// This file is auto-generated by scripts/sync-changelog.mjs -// Do not edit manually. - -export const latestTimelineYear = ${JSON.stringify(latestTimelineYear)}; - -export const changelogLandingProjects = ${JSON.stringify(projectCards, null, 2)}; -`; -} - -/** - * Generate MDX content for a year-specific timeline page. - */ -function generateYearTimelineContent(year, entries) { - const entriesJson = JSON.stringify(entries, null, 2); - - return `--- -title: "${year}" -pagefind: false -sidebar: - label: "${year}" -topic: changelog-timeline -tableOfContents: false ---- - -import Timeline from '@components/Timeline.astro'; - -All changes across Tenzir projects released in ${year}. - - -`; -} - -// ============================================================================= -// Atom Feed Generation -// ============================================================================= - -const SITE_URL = "https://docs.tenzir.com"; - -const FEED_AUTHOR = { - name: "Tenzir", - link: "https://tenzir.com", -}; - -/** - * Escape special HTML characters. - */ -function escapeHtml(str) { - if (!str) return ""; - return str.replace(/&/g, "&").replace(//g, ">"); -} - -// Remark processor for converting markdown to HTML (sanitized for feeds) -const remarkProcessor = remark().use(remarkHtml, { sanitize: true }); - -/** - * Convert markdown to HTML for feed content using remark. - */ -function markdownToHtml(md) { - if (!md) return ""; - const result = remarkProcessor.processSync(md); - return String(result).trim(); -} - -/** - * Generate HTML content for a single release entry. - */ -function generateEntryHtml(release) { - let html = ""; - - // Intro paragraph - if (release.intro) { - html += `${markdownToHtml(release.intro)}\n`; - } - - // Group entries by type - if (release.entries && release.entries.length > 0) { - const grouped = {}; - for (const entry of release.entries) { - const type = entry.type || "change"; - if (!grouped[type]) grouped[type] = []; - grouped[type].push(entry); - } - - // Sort by configured order - const sortedTypes = Object.keys(grouped).sort((a, b) => { - const orderA = entryTypeConfig[a]?.order ?? 99; - const orderB = entryTypeConfig[b]?.order ?? 99; - return orderA - orderB; - }); - - for (const type of sortedTypes) { - const config = entryTypeConfig[type] || { heading: "Other" }; - html += `\n

${config.heading}

\n`; - - for (const entry of grouped[type]) { - html += `\n

${escapeHtml(entry.title)}

\n`; - - // Metadata line - const metaParts = []; - if (entry.created) { - metaParts.push(formatDate(entry.created)); - } - if (entry.authors && entry.authors.length > 0) { - const authors = entry.authors - .map((a) => { - if (typeof a === "string" && a.startsWith("[")) { - // Parse markdown link: [@handle](url) - const match = a.match(/\[([^\]]+)\]\(([^)]+)\)/); - if (match) return `${match[1]}`; - } - const handle = typeof a === "string" ? a : a.handle || a.name; - const url = typeof a === "object" ? a.url : null; - const display = handle.includes(" ") ? handle : `@${handle}`; - return url ? `${display}` : display; - }) - .join(", "); - metaParts.push(authors); - } - if (entry.prs && entry.prs.length > 0) { - const prs = entry.prs - .map((pr) => { - if (typeof pr === "string" && pr.startsWith("[")) { - const match = pr.match(/\[([^\]]+)\]\(([^)]+)\)/); - if (match) return `${match[1]}`; - } - if (typeof pr === "object") { - return pr.url - ? `#${pr.number}` - : `#${pr.number}`; - } - return `#${pr}`; - }) - .join(", "); - metaParts.push(prs); - } - if (metaParts.length > 0) { - html += `

${metaParts.join(" ยท ")}

\n`; - } - - // Entry body - if (entry.body) { - html += `${markdownToHtml(entry.body)}\n`; - } - } - } - } - - return html; -} - -/** - * Generate Atom feed XML for a project. - * @param {object} project - Project object - * @param {Array} releases - Release objects (newest first) - * @param {object} options - Optional settings - * @param {number} [options.limit=20] - Maximum entries in feed - */ -function generateAtomFeed(project, releases, { limit = 20 } = {}) { - const releasedOnly = releases.filter((r) => !r.isUnreleased); - const feedReleases = releasedOnly.slice(0, limit); - - if (feedReleases.length === 0) return null; - - const projectPath = `changelog/${project.id}`; - const feedUrl = `${SITE_URL}/${projectPath}.xml`; - const siteUrl = `${SITE_URL}/${projectPath}`; - - const feed = new Feed({ - title: `${project.name} Changelog`, - description: `Release notes and changelog for ${project.name}`, - id: siteUrl, - link: siteUrl, - language: "en", - favicon: `${SITE_URL}/favicon.svg`, - updated: new Date(feedReleases[0].created), - generator: "Tenzir Changelog", - feedLinks: { - atom: feedUrl, - }, - author: FEED_AUTHOR, - }); - - for (const release of feedReleases) { - const entryUrl = `${SITE_URL}/changelog/${project.id}/${release.slug}`; - - feed.addItem({ - title: `${project.name} ${release.version}`, - id: entryUrl, - link: entryUrl, - description: stripIntroLinks(release.intro || ""), - content: generateEntryHtml(release), - date: new Date(release.created), - published: new Date(release.created), - }); - } - - return feed.atom1(); -} - -/** - * Generate unified Atom feed across all projects. - */ -function generateUnifiedAtomFeed( - projects, - projectVersions, - moduleVersions, - { limit = 50 } = {}, -) { - // Collect all released entries with dates - const allEntries = []; - - for (const project of projects) { - const releases = projectVersions[project.id] || []; - - for (const release of releases) { - if (release.isUnreleased) continue; - allEntries.push({ - project, - release, - path: `changelog/${project.id}/${release.slug}`, - sortDate: new Date(release.created), - }); - } - - // Include module releases - if (project.modules) { - for (const mod of project.modules) { - const modReleases = moduleVersions[`${project.id}/${mod.id}`] || []; - for (const release of modReleases) { - if (release.isUnreleased) continue; - allEntries.push({ - project: { ...mod, parentName: project.name }, - release, - path: `changelog/${project.id}/${mod.id}/${release.slug}`, - sortDate: new Date(release.created), - }); - } - } - } - } - - // Sort by date (newest first) and limit - allEntries.sort((a, b) => b.sortDate - a.sortDate); - const feedEntries = allEntries.slice(0, limit); - - if (feedEntries.length === 0) return null; - - const feedUrl = `${SITE_URL}/changelog/feed.xml`; - const siteUrl = `${SITE_URL}/changelog`; - - const feed = new Feed({ - title: "Tenzir Changelog", - description: "Release notes across all Tenzir projects", - id: siteUrl, - link: siteUrl, - language: "en", - favicon: `${SITE_URL}/favicon.svg`, - updated: feedEntries[0].sortDate, - generator: "Tenzir Changelog", - feedLinks: { - atom: feedUrl, - }, - author: FEED_AUTHOR, - }); - - for (const entry of feedEntries) { - const { project, release, path } = entry; - const entryUrl = `${SITE_URL}/${path}`; - - // Title includes parent name for modules - const titlePrefix = project.parentName - ? `${project.parentName}: ${project.name}` - : project.name; - - // Category for project identification - const categoryLabel = project.parentName - ? `${project.parentName}: ${project.name}` - : project.name; - - feed.addItem({ - title: `${titlePrefix} ${release.version}`, - id: entryUrl, - link: entryUrl, - description: stripIntroLinks(release.intro || ""), - content: generateEntryHtml(release), - date: new Date(release.created), - published: new Date(release.created), - category: [{ name: categoryLabel }], - }); - } - - return feed.atom1(); -} - -/** - * Generate and write all Atom feeds. - */ -async function generateFeeds( - projects, - projectVersions, - moduleVersions, - outputDir, -) { - await fs.mkdir(outputDir, { recursive: true }); - const generated = []; - - // Per-project feeds - for (const project of projects) { - const releases = projectVersions[project.id] || []; - const feed = generateAtomFeed(project, releases); - if (feed) { - const filename = `${project.id}.xml`; - await fs.writeFile(path.join(outputDir, filename), feed); - generated.push(filename); - } - } - - // Unified feed - const unifiedFeed = generateUnifiedAtomFeed( - projects, - projectVersions, - moduleVersions, - ); - if (unifiedFeed) { - await fs.writeFile(path.join(outputDir, "feed.xml"), unifiedFeed); - generated.push("feed.xml"); - } - - return generated; -} - -/** - * Remove stale feed files that are no longer generated. - */ -async function removeStaleFeeds(outputDir, generatedFeeds) { - const validFeeds = new Set(generatedFeeds); - const existingEntries = await fs.readdir(outputDir, { withFileTypes: true }); - - for (const entry of existingEntries) { - if (!entry.isFile() || !entry.name.endsWith(".xml")) { - continue; - } - if (validFeeds.has(entry.name)) { - continue; - } - await fs.rm(path.join(outputDir, entry.name), { force: true }); - } -} - -// ============================================================================= -// Sidebar Generation -// ============================================================================= - -/** - * Generate TypeScript sidebar file content with topics. - * Icons come from changelog-projects.json. - */ -function generateSidebarFile( - projects, - projectVersions, - moduleVersions, - timelineYears = [], -) { - const topics = []; - const topicParents = {}; - - for (const project of projects) { - const releases = projectVersions[project.id] || []; - const hasModules = project.modules.length > 0; - const sidebarItems = []; - - if (hasModules) { - // Add parent project releases as flat list (no major version grouping) - // to avoid excessive nesting with modules - if (releases.length > 0) { - sidebarItems.push({ - label: project.name, - collapsed: true, - items: releases.map((r) => `changelog/${project.id}/${r.slug}`), - }); - } - - // Add each module with flat version list - for (const mod of project.modules) { - const moduleReleases = moduleVersions[`${project.id}/${mod.id}`] || []; - if (moduleReleases.length === 0) continue; - - sidebarItems.push({ - label: mod.name, - collapsed: true, - items: moduleReleases.map( - (r) => `changelog/${project.id}/${mod.id}/${r.slug}`, - ), - }); - } - } else { - // Use standard version grouping for regular projects - sidebarItems.push( - ...buildSidebarItems(`changelog/${project.id}`, project.name, releases), - ); - } - - // Get icon and color from changelog-projects.json - const icon = changelogProjects[project.id]?.icon || "pen"; - const color = changelogProjects[project.id]?.color; - - const topicEntry = { - label: project.name, - id: `changelog-${project.id}`, - link: `changelog/${project.id}`, - icon, - items: sidebarItems, - }; - if (color) topicEntry.color = color; - topics.push(topicEntry); - - topicParents[project.name] = "Changelog"; - } - - // Add timeline topic with year-based sidebar items - if (timelineYears.length > 0) { - const timelineTopic = { - label: "Timeline", - id: "changelog-timeline", - link: "changelog/timeline", - icon: "seti:clock", - items: timelineYears.map((year) => `changelog/timeline/${year}`), - }; - topics.unshift(timelineTopic); // Add at the beginning - topicParents.Timeline = "Changelog"; - } - - // Generate topic path mappings for starlight-sidebar-topics - const topicPaths = {}; - for (const project of projects) { - const topicId = `changelog-${project.id}`; - topicPaths[topicId] = [ - `/changelog/${project.id}`, - `/changelog/${project.id}/**/*`, - ]; - } - - // Add timeline topic paths - if (timelineYears.length > 0) { - topicPaths["changelog-timeline"] = [ - "/changelog/timeline", - "/changelog/timeline/**/*", - ]; - } - - return `// This file is auto-generated by scripts/sync-changelog.mjs -// Do not edit manually. - -// Individual project topics (children of Changelog) -export const changelogTopics = ${JSON.stringify(topics, null, 2)}; - -// Parent mappings for the dropdown -export const changelogTopicParents = ${JSON.stringify(topicParents, null, 2)}; - -// Topic path mappings for starlight-sidebar-topics -export const changelogTopicPaths = ${JSON.stringify(topicPaths, null, 2)}; -`; -} - -/** - * Clone or update news repo from GitHub. - */ -function getNewsRepo(localPath) { - if (localPath) { - console.log(`Using local news repo: ${localPath}`); - return path.resolve(localPath); - } - - const newsPath = path.join(process.cwd(), ".news"); - - try { - // Check if already cloned - fsSync.accessSync(path.join(newsPath, ".git")); - console.log("Updating tenzir/news from GitHub..."); - execSync("git pull --ff-only", { cwd: newsPath, stdio: "inherit" }); - } catch { - console.log("Cloning tenzir/news from GitHub..."); - execSync( - `git clone --depth 1 https://github.com/tenzir/news "${newsPath}"`, - { - stdio: "inherit", - }, - ); - } - - return newsPath; -} - -/** - * Generate Timeline entries array for an index page. - */ -function generateTimelineEntries(releases, basePath) { - const entries = []; - - for (const r of releases) { - let description = stripIntroLinks(r.intro || ""); - if (description.length > 300) { - description = `${description.slice(0, 297)}...`; - } - // Escape quotes and newlines for JSON - description = description - .replace(/\\/g, "\\\\") - .replace(/"/g, '\\"') - .replace(/\n/g, " "); - - const entry = { - version: r.isUnreleased ? "Unreleased" : r.version, - date: r.isUnreleased ? "" : formatDate(r.created), - href: `${basePath}/${r.slug}`, - }; - - if (description) { - entry.description = description; - } - - entries.push(entry); - } - - return entries; -} - -/** - * Generate index MDX content for a changelog (project or module). - * @param {string} name - Page title - * @param {string} description - Page description - * @param {object} options - Optional settings - * @param {string} [options.topicId] - Topic ID for frontmatter - * @param {boolean} [options.useCardGrid] - Wrap content in CardGrid component (for module lists) - * @param {string} [options.cardContent] - Card content for CardGrid mode - * @param {string} [options.prefixContent] - Content to render before the CardGrid (full width) - * @param {Array} [options.timelineEntries] - Timeline entries for release list - * @param {string} [options.repository] - GitHub repository path (e.g., "tenzir/tenzir") - * @param {string} [options.feedUrl] - RSS feed URL for the project - */ -function generateIndexContent( - name, - description, - { - topicId, - useCardGrid, - cardContent, - prefixContent, - timelineEntries, - repository, - feedUrl, - } = {}, -) { - const topicLine = topicId ? `\ntopic: ${topicId}` : ""; - - // Build imports based on what's needed - const importLines = []; - const starlightComponents = []; - - // Use LinkCard for GitHub + RSS cards when we have a repository - const useResourceCards = - repository && (timelineEntries?.length > 0 || useCardGrid); - - if (useCardGrid || useResourceCards) { - importLines.push(`import LinkCard from '@components/LinkCard.astro';`); - starlightComponents.push("CardGrid"); - } - if (timelineEntries && timelineEntries.length > 0) { - importLines.push(`import Timeline from '@components/Timeline.astro';`); - } - if (starlightComponents.length > 0) { - importLines.push( - `import { ${starlightComponents.join(", ")} } from '@astrojs/starlight/components';`, - ); - } - const imports = importLines.join("\n"); - - // Build body content - let body = ""; - - // Description paragraph - if (description) { - body += `

${description}

\n\n`; - } - - // GitHub + RSS Feed cards - if (useResourceCards) { - const repoUrl = repository.startsWith("http") - ? repository - : `https://github.com/${repository}/releases`; - body += `\n`; - body += ` \n`; - if (feedUrl) { - body += ` \n`; - } - body += `\n\n`; - } - - // Main content: either CardGrid or Timeline - if (useCardGrid && cardContent) { - // Add section heading before releases - body += `## Releases\n\n`; - // Prefix content (e.g., root project card before module grid) - if (prefixContent) { - body += prefixContent; - } - body += `\n${cardContent}\n`; - } else if (timelineEntries && timelineEntries.length > 0) { - // Add section heading before timeline - body += `## Releases\n\n`; - // Generate Timeline component with entries - const entriesJson = JSON.stringify(timelineEntries, null, 2); - body += ``; - } else if (prefixContent) { - body += prefixContent; - } else { - body += "No releases available yet."; - } - - return `--- -title: ${name} -description: "${description || ""}" -pagefind: false -sidebar: - hidden: true${topicLine} -tableOfContents: false ---- - -${imports} - -${body} -`; -} - -/** - * Write MDX files for a list of releases. - * @param {string} dir - Output directory - * @param {object} entity - Project or module object - * @param {Array} releases - Release objects - * @param {object} options - Optional settings passed to generateMdxContent - * @returns {number} Number of files written. - */ -async function writeReleaseMdxFiles(dir, entity, releases, options = {}) { - let count = 0; - for (const release of releases) { - const mdxPath = path.join(dir, `${release.slug}.mdx`); - const mdxContent = generateMdxContent(entity, release, options); - await fs.writeFile(mdxPath, mdxContent); - count++; - } - return count; -} - -/** - * Main sync function. - */ -async function syncChangelog(newsRepoPath) { - const docsRoot = process.cwd(); - const changelogContentDir = path.join(docsRoot, "src/content/docs/changelog"); - const srcDir = path.join(docsRoot, "src"); - - // Helper for inline progress updates (only in TTY mode) - const isTTY = process.stdout.isTTY; - const writeProgress = (text, isFinal = false) => { - if (!isTTY && !isFinal) return; // Skip intermediate progress in CI - if (isTTY) { - process.stdout.clearLine?.(0); - process.stdout.cursorTo?.(0); - } - process.stdout.write(isFinal && !isTTY ? text : text); - }; - - console.log(`Syncing from: ${newsRepoPath}`); - - // Only generate projects that are configured in src/changelog-projects.json. - // The JSON file is the authoritative source for project ordering and metadata. - const configOrder = Object.keys(changelogProjects); - const configuredProjectIds = new Set(configOrder); - const discoveredProjects = await readProjects(newsRepoPath); - const skippedProjects = discoveredProjects.filter( - (project) => !configuredProjectIds.has(project.id), - ); - const projects = discoveredProjects.filter((project) => - configuredProjectIds.has(project.id), - ); - const missingConfiguredProjects = configOrder.filter( - (projectId) => !projects.some((project) => project.id === projectId), - ); - - projects.sort( - (a, b) => configOrder.indexOf(a.id) - configOrder.indexOf(b.id), - ); - - console.log(`Found ${projects.length} configured projects\n`); - if (skippedProjects.length > 0) { - console.log( - `Skipping unconfigured projects: ${skippedProjects.map((p) => p.id).join(", ")}\n`, - ); - } - if (missingConfiguredProjects.length > 0) { - console.log( - `Configured but missing from news repo: ${missingConfiguredProjects.join(", ")}\n`, - ); - } - - // Clean stale project directories before regenerating - // Preserve: index.mdx (git-tracked landing page shell, not regenerated) - // Preserve: timeline/ (generated separately at end of sync) - const existingEntries = await fs.readdir(changelogContentDir, { - withFileTypes: true, - }); - const validProjectIds = new Set(projects.map((p) => p.id)); - - for (const entry of existingEntries) { - // Skip non-directories (includes the tracked landing page shell) - if (!entry.isDirectory()) { - continue; - } - // Skip timeline directory (regenerated separately later in sync) - if (entry.name === "timeline") { - continue; - } - // Remove project directories not in current news repo - if (!validProjectIds.has(entry.name)) { - const stalePath = path.join(changelogContentDir, entry.name); - console.log(` Removing stale project: ${entry.name}`); - await fs.rm(stalePath, { recursive: true, force: true }); - } - } - - // Read releases for each project and their modules - const projectVersions = {}; - const moduleVersions = {}; - let totalPages = 0; - - for (const project of projects) { - const hasModules = project.modules.length > 0; - const topicId = `changelog-${project.id}`; - - // Show project being processed - writeProgress(` ${project.id}: reading releases...`); - - // Read all releases via bulk export - const changelogPath = path.join(project.dirName, "changelog"); - const releases = await readAllReleases( - newsRepoPath, - changelogPath, - project.name, - ); - projectVersions[project.id] = releases; - - // Clean and create project directory - const projectDir = path.join(changelogContentDir, project.id); - await fs.rm(projectDir, { recursive: true, force: true }); - await fs.mkdir(projectDir, { recursive: true }); - - // Log project completion - const moduleInfo = hasModules ? ` + ${project.modules.length} modules` : ""; - writeProgress( - ` ${project.id}: ${releases.length} releases${moduleInfo}\n`, - true, - ); - - // Read all module releases first (needed for parent release pages) - if (hasModules) { - for (const mod of project.modules) { - writeProgress(` ${mod.id}: reading...`); - const moduleReleases = await readAllReleases( - newsRepoPath, - mod.changelogPath, - mod.name, - ); - moduleVersions[`${project.id}/${mod.id}`] = moduleReleases; - writeProgress( - ` ${mod.id}: ${moduleReleases.length} releases\n`, - true, - ); - } - } - - // Write release MDX files for the parent project - // For projects with modules, include module releases in parent release pages - for (const release of releases) { - const mdxPath = path.join(projectDir, `${release.slug}.mdx`); - const releaseOptions = {}; - - // If this release has module versions, gather their releases - if (release.modules && hasModules) { - const modReleases = []; - for (const mod of project.modules) { - const modVersion = release.modules[mod.id]; - if (modVersion) { - const allModReleases = - moduleVersions[`${project.id}/${mod.id}`] || []; - const matchingRelease = allModReleases.find( - (r) => r.version === modVersion, - ); - if (matchingRelease) { - modReleases.push({ - name: mod.name, - version: matchingRelease.version, - entries: matchingRelease.entries, - }); - } - } - } - if (modReleases.length > 0) { - releaseOptions.moduleReleases = modReleases; - } - } - - const mdxContent = generateMdxContent(project, release, releaseOptions); - await fs.writeFile(mdxPath, mdxContent); - totalPages++; - } - - // Process modules if present - write module release pages - if (hasModules) { - for (const mod of project.modules) { - const moduleReleases = moduleVersions[`${project.id}/${mod.id}`] || []; - - // Create module directory and write release files - const moduleDir = path.join(projectDir, mod.id); - await fs.mkdir(moduleDir, { recursive: true }); - totalPages += await writeReleaseMdxFiles( - moduleDir, - mod, - moduleReleases, - { topicId }, - ); - - // Generate module index with Timeline - const moduleTimelineEntries = generateTimelineEntries( - moduleReleases, - `/changelog/${project.id}/${mod.id}`, - ); - const moduleIndexContent = generateIndexContent( - mod.name, - mod.description, - { - topicId, - timelineEntries: moduleTimelineEntries, - }, - ); - await fs.writeFile( - path.join(moduleDir, "index.mdx"), - moduleIndexContent, - ); - } - } - - // Generate project index - const indexPath = path.join(projectDir, "index.mdx"); - if (hasModules) { - // Module cards for parent project index - const moduleCardsList = project.modules - .map((mod) => { - const modReleases = moduleVersions[`${project.id}/${mod.id}`] || []; - const latestRelease = modReleases.find((r) => !r.isUnreleased); - const version = latestRelease?.version || ""; - return ``; - }) - .join("\n\n"); - - // Add parent project card before modules (full width, outside grid) - // Shows latest release, or unreleased if no releases exist yet - let prefixCards = ""; - const latestParentRelease = releases.find((r) => !r.isUnreleased); - const parentUnreleased = releases.find((r) => r.isUnreleased); - const projectIcon = changelogProjects[project.id]?.icon || "document"; - const projectColor = changelogProjects[project.id]?.color; - const colorAttr = projectColor ? `\n color="${projectColor}"` : ""; - - if (latestParentRelease) { - prefixCards += `\n\n`; - } else if (parentUnreleased) { - // Only show unreleased card if there are no releases yet - prefixCards += `\n\n`; - } - - const indexContent = generateIndexContent( - project.name, - project.description, - { - useCardGrid: true, - cardContent: moduleCardsList, - prefixContent: prefixCards || undefined, - repository: changelogProjects[project.id]?.repository, - feedUrl: `/changelog/${project.id}.xml`, - }, - ); - await fs.writeFile(indexPath, indexContent); - } else { - // Regular project: show Timeline - const timelineEntries = generateTimelineEntries( - releases, - `/changelog/${project.id}`, - ); - const indexContent = generateIndexContent( - project.name, - project.description, - { - timelineEntries, - repository: changelogProjects[project.id]?.repository, - feedUrl: `/changelog/${project.id}.xml`, - }, - ); - await fs.writeFile(indexPath, indexContent); - } - } - - // Generate unified timeline data - const unifiedEntries = generateUnifiedTimelineEntries( - projects, - projectVersions, - moduleVersions, - ); - const unifiedTimelinePath = path.join( - srcDir, - "unified-timeline-data.generated.ts", - ); - await fs.writeFile( - unifiedTimelinePath, - generateUnifiedTimelineFile(unifiedEntries), - ); - - // Generate year-based timeline pages - const yearGroups = groupEntriesByYear(unifiedEntries); - const timelineYears = yearGroups.map(([year]) => year); - const timelineDir = path.join(changelogContentDir, "timeline"); - await fs.rm(timelineDir, { recursive: true, force: true }); - await fs.mkdir(timelineDir, { recursive: true }); - - for (const [year, entries] of yearGroups) { - const yearContent = generateYearTimelineContent(year, entries); - await fs.writeFile(path.join(timelineDir, `${year}.mdx`), yearContent); - } - - // Generate timeline index page (mirrors the original timeline.mdx) - const timelineIndexContent = `--- -title: Timeline -description: "A chronological view of all changes across all Tenzir projects." -pagefind: false -sidebar: - hidden: true -topic: changelog-timeline -tableOfContents: false ---- - -import UnifiedTimeline from "@components/UnifiedTimeline.astro"; -import LinkCard from "@components/LinkCard.astro"; -import { CardGrid } from "@astrojs/starlight/components"; - -Browse all release updates across Tenzir projects in reverse-chronological -order. Use the toggle to include unreleased changes that are in development. - - - - - - -## Releases - - -`; - await fs.writeFile(path.join(timelineDir, "index.mdx"), timelineIndexContent); - - console.log( - ` timeline: ${timelineYears.length} years (${timelineYears.join(", ")})`, - ); - - // Generate main changelog landing page data - const latestTimelineYear = timelineYears[0] || "2025"; - const projectCards = projects - .map((project) => { - const releases = projectVersions[project.id] || []; - const latestRelease = releases.find((r) => !r.isUnreleased); - const version = latestRelease?.version || ""; - return { - title: project.name, - description: project.description, - href: `/changelog/${project.id}`, - icon: changelogProjects[project.id]?.icon || "document", - color: changelogProjects[project.id]?.color, - meta: version, - }; - }) - .filter((project) => project.title); - const landingDataPath = path.join(srcDir, "changelog-landing.generated.ts"); - await fs.writeFile( - landingDataPath, - generateLandingDataFile(latestTimelineYear, projectCards), - ); - - // Generate sidebar and topics configuration - const sidebarFilePath = path.join(srcDir, "sidebar-changelog.generated.ts"); - const sidebarContent = generateSidebarFile( - projects, - projectVersions, - moduleVersions, - timelineYears, - ); - await fs.writeFile(sidebarFilePath, sidebarContent); - - // Generate Atom feeds - const feedsDir = path.join(docsRoot, "public/changelog"); - const generatedFeeds = await generateFeeds( - projects, - projectVersions, - moduleVersions, - feedsDir, - ); - await removeStaleFeeds(feedsDir, generatedFeeds); - console.log( - ` feeds: ${generatedFeeds.length} (${generatedFeeds.join(", ")})`, - ); - - console.log( - `\nGenerated ${totalPages} pages, ${unifiedEntries.length} timeline entries`, - ); -} - -// CLI entry point -const args = process.argv.slice(2); -const localPath = args[0] || null; - -const newsRepoPath = getNewsRepo(localPath); - -syncChangelog(newsRepoPath).catch((err) => { - console.error("Error syncing changelog:", err); - process.exit(1); -}); diff --git a/src/changelog-landing.ts b/src/changelog-landing.ts deleted file mode 100644 index 49e6ac300..000000000 --- a/src/changelog-landing.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { StarlightIcon } from "@astrojs/starlight/components/Icons"; - -export interface ChangelogLandingProject { - title: string; - description: string; - href: string; - icon: StarlightIcon; - color?: string; - meta: string; -} - -const modules = import.meta.glob<{ - latestTimelineYear: string; - changelogLandingProjects: ChangelogLandingProject[]; -}>("./changelog-landing.generated.ts", { eager: true }); - -const generated = Object.values(modules)[0]; - -export const latestTimelineYear = - generated?.latestTimelineYear ?? String(new Date().getFullYear()); - -export const changelogLandingProjects = - generated?.changelogLandingProjects ?? []; diff --git a/src/changelog-projects.json b/src/changelog-projects.json deleted file mode 100644 index d0af5cb42..000000000 --- a/src/changelog-projects.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "tenzir": { - "icon": "seti:webpack", - "color": "blue", - "repository": "tenzir/tenzir" - }, - "platform": { - "icon": "seti:db", - "color": "red", - "repository": "tenzir/platform" - }, - "tenzir-test": { - "icon": "approve-check", - "color": "green", - "repository": "tenzir/test" - }, - "tenzir-ship": { - "icon": "rocket", - "color": "purple", - "repository": "tenzir/ship" - }, - "tenzir-skills": { - "icon": "puzzle", - "color": "orange", - "repository": "tenzir/skills" - }, - "tree-sitter-tql": { - "icon": "sun", - "color": "yellow", - "repository": "tenzir/tree-sitter-tql" - }, - "tenzir-helm-charts": { - "icon": "server", - "color": "lightblue", - "repository": "tenzir/helm-charts" - } -} diff --git a/src/components/ChangelogLanding.astro b/src/components/ChangelogLanding.astro deleted file mode 100644 index fe2c5e4b1..000000000 --- a/src/components/ChangelogLanding.astro +++ /dev/null @@ -1,64 +0,0 @@ ---- -import LinkButton from "@components/LinkButton.astro"; -import LinkCard from "@components/LinkCard.astro"; -import { - changelogLandingProjects, - latestTimelineYear, -} from "../changelog-landing"; - -const hasGeneratedLanding = changelogLandingProjects.length > 0; ---- - -{ - hasGeneratedLanding ? ( -
-

- Welcome to the Tenzir changelog hub. Here you can find release notes, - feature updates, and behind-the-scenes improvements across our - projects. -

- -
- - View all changes chronologically - - - RSS Feed - -
- -
- {changelogLandingProjects.map((project) => ( - - ))} -
- -
- -

- For general release announcements and deeper dives into selected - features, check out our blog or - join the conversation on{" "} - Discord. -

-
- ) : ( -

- The changelog is populated during CI builds. Run{" "} - bun run generate:changelog locally to fetch release notes - from the tenzir/news{" "} - repository. -

- ) -} diff --git a/src/components/NavbarTopicList.astro b/src/components/NavbarTopicList.astro index 624b3c42f..6192e0975 100644 --- a/src/components/NavbarTopicList.astro +++ b/src/components/NavbarTopicList.astro @@ -1,5 +1,5 @@ --- -import { pathWithBase } from "@utils/base"; +import { hrefWithBase } from "@utils/base"; const { primarySections } = Astro.locals.siteNavigation; --- @@ -11,7 +11,7 @@ const { primarySections } = Astro.locals.siteNavigation; primarySections.map((section) => (
  • {section.label} diff --git a/src/components/Sidebar.astro b/src/components/Sidebar.astro index 44a156c95..b9c54a555 100644 --- a/src/components/Sidebar.astro +++ b/src/components/Sidebar.astro @@ -3,7 +3,6 @@ import MobileMenuFooter from "virtual:starlight/components/MobileMenuFooter"; import { Badge } from "@astrojs/starlight/components"; import SidebarPersister from "@astrojs/starlight/components/SidebarPersister.astro"; import { pathWithBase } from "@utils/base"; -import changelogProjects from "../changelog-projects.json"; import { getIconComponent } from "../utils/icon-registry"; import SidebarSublist from "./SidebarSublist.astro"; import TopicDropdown from "./TopicDropdown.astro"; @@ -12,21 +11,12 @@ const { activePrimarySection, sectionSwitcherSections } = Astro.locals.siteNavigation; const { sidebar } = Astro.locals.starlightRoute; -const getSectionColor = (link: string | undefined): string | undefined => { - if (!link) return undefined; - const normalized = link.replace(/^\/|\/$/g, ""); - if (!normalized.startsWith("changelog/")) return undefined; - const projectId = normalized.slice("changelog/".length); - return changelogProjects[projectId as keyof typeof changelogProjects]?.color; -}; - const useDropdown = !!activePrimarySection?.dropdown; const dropdownItems = useDropdown ? sectionSwitcherSections.map((section) => ({ label: section.label, link: section.link, icon: typeof section.icon === "string" ? section.icon : undefined, - color: getSectionColor(section.link), isCurrent: section.isCurrent, })) : []; @@ -44,7 +34,7 @@ const dropdownItems = useDropdown
  • {IconComponent && ( -
    +
    )} diff --git a/src/components/TenzirFooter.astro b/src/components/TenzirFooter.astro index 0c28d4285..4d6f7749b 100644 --- a/src/components/TenzirFooter.astro +++ b/src/components/TenzirFooter.astro @@ -34,7 +34,7 @@ const footerSections: Record = { { text: "Events", href: "https://tenzir.com/events" }, { text: "Sheets & Briefs", href: "https://tenzir.com/resources" }, { text: "Documentation", href: "/" }, - { text: "Changelog", href: "/changelog" }, + { text: "Changelog", href: "https://tenzir.com/changelog" }, ], Company: [ { text: "About Tenzir", href: "https://tenzir.com/company/about" }, diff --git a/src/components/Timeline.astro b/src/components/Timeline.astro deleted file mode 100644 index 50d64fc11..000000000 --- a/src/components/Timeline.astro +++ /dev/null @@ -1,332 +0,0 @@ ---- -import { Icon } from "@astrojs/starlight/components"; -import type { StarlightIcon } from "@astrojs/starlight/components/Icons"; -import { remark } from "remark"; -import remarkHtml from "remark-html"; - -/** - * Timeline component for changelog pages. - * - * Structure: - * [dot] [content: header + description] - * - * The connecting line between entries is drawn using a pseudo-element - * on each entry (except the last), positioned absolutely to span from - * below the current dot to the top of the next dot. - * - * When showProjectInfo is true, displays project icon and name: - * [dot] [icon] Project Name > Module v1.0.0 Dec 21, 2025 - */ -interface ProjectInfo { - icon?: StarlightIcon; - color?: string; - name: string; - parentName?: string; -} - -interface TimelineEntry { - version: string; - date: string; - href: string; - description?: string; - project?: ProjectInfo; -} - -interface Props { - entries: TimelineEntry[]; - showProjectInfo?: boolean; -} - -const { entries, showProjectInfo = false } = Astro.props; - -async function renderMarkdown(text: string): Promise { - const result = await remark().use(remarkHtml).process(text); - return String(result); -} - -const renderedEntries = await Promise.all( - entries.map(async (entry) => ({ - ...entry, - renderedDescription: entry.description - ? await renderMarkdown(entry.description) - : undefined, - })), -); - -const isLastEntry = (index: number) => index === renderedEntries.length - 1; ---- - -
    - - diff --git a/src/components/UnifiedTimeline.astro b/src/components/UnifiedTimeline.astro deleted file mode 100644 index 5c359f4a3..000000000 --- a/src/components/UnifiedTimeline.astro +++ /dev/null @@ -1,42 +0,0 @@ ---- -import type { StarlightIcon } from "@astrojs/starlight/components/Icons"; -/** - * UnifiedTimeline component for the /changelog/timeline page. - * - * Displays all changelog entries from all projects in reverse-chronological - * order, with a toggle to include/exclude unreleased entries. - */ -import Timeline from "./Timeline.astro"; - -interface TimelineEntry { - version: string; - date: string; - href: string; - description?: string; - project?: { - id: string; - name: string; - icon: StarlightIcon; - color: string; - parentName?: string; - }; -} - -// Use import.meta.glob to gracefully handle missing generated file -const modules = import.meta.glob<{ unifiedTimelineEntries: TimelineEntry[] }>( - "../unified-timeline-data.generated.ts", - { eager: true }, -); -const generated = Object.values(modules)[0]; -const entries = generated?.unifiedTimelineEntries ?? []; ---- - - - - - - diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx deleted file mode 100644 index 0badf0e95..000000000 --- a/src/content/docs/changelog/index.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: Changelog -template: splash -pagefind: false ---- - -import ChangelogLanding from '@components/ChangelogLanding.astro'; - - diff --git a/src/plugins/starlight-llms-txt/changelog-topics.ts b/src/plugins/starlight-llms-txt/changelog-topics.ts deleted file mode 100644 index 11dfb6eee..000000000 --- a/src/plugins/starlight-llms-txt/changelog-topics.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface ChangelogTopic { - label: string; - link?: string; -} - -interface ChangelogModule { - changelogTopics: ChangelogTopic[]; -} - -const modules = import.meta.glob( - "../../sidebar-changelog.generated.ts", - { eager: true }, -); - -export const changelogTopics: ChangelogTopic[] = - Object.values(modules)[0]?.changelogTopics ?? []; diff --git a/src/plugins/starlight-llms-txt/routes/page-markdown.ts b/src/plugins/starlight-llms-txt/routes/page-markdown.ts index b6f308d1d..e55f040f0 100644 --- a/src/plugins/starlight-llms-txt/routes/page-markdown.ts +++ b/src/plugins/starlight-llms-txt/routes/page-markdown.ts @@ -20,7 +20,6 @@ import { markdownUrlForDocPath, normalizeDocPathForMarkdown, } from "../../../utils/llm-markdown-path"; -import { changelogTopics } from "../changelog-topics"; import { entryToSimpleMarkdown } from "../entry-to-markdown"; import type { SidebarItem } from "../types"; import { ensureTrailingSlash, isDefaultLocale } from "../utils"; @@ -73,17 +72,6 @@ function extractChildLinks(docId: string, baseUrl: string): ChildLink[] | null { .filter((link): link is ChildLink => link !== null); } - // Check for changelog section - if (docId === "changelog" || docId === "changelog/index") { - const projects = changelogTopics.filter( - (t) => t.label !== "Timeline" && t.link, - ); - return projects.map((t) => ({ - title: t.label, - url: childUrl(t.link), - })); - } - // For nested pages, look for matching parent in sidebar for (const [, sidebarItems] of Object.entries(SECTION_SIDEBARS)) { const children = findChildrenForPath(sidebarItems, docId, baseUrl); diff --git a/src/plugins/starlight-llms-txt/sitemap-generator.ts b/src/plugins/starlight-llms-txt/sitemap-generator.ts index ea0c7f869..af84e6871 100644 --- a/src/plugins/starlight-llms-txt/sitemap-generator.ts +++ b/src/plugins/starlight-llms-txt/sitemap-generator.ts @@ -11,8 +11,6 @@ import { tutorials, } from "../../sidebar"; import { markdownUrlForDocPath } from "../../utils/llm-markdown-path"; -// Changelog topics -import { changelogTopics } from "./changelog-topics"; import { entryToSimpleMarkdown } from "./entry-to-markdown"; import { extractDescription } from "./excerpt-utils"; import type { SidebarItem } from "./types"; @@ -30,7 +28,6 @@ Start here to build understanding of a particular topic.`, Start here when you need detailed information about building blocks.`, integrations: `Turn-key packages and native connectors for security tools. Start here to connect Tenzir with Splunk, Elastic, CrowdStrike, etc.`, - changelog: `Release notes and version history for all Tenzir projects.`, }; /** @@ -321,24 +318,6 @@ function formatSection( return output; } -/** - * Format changelog section with shallow depth (project links only). - */ -function formatChangelogSection(baseUrl: string): string { - let output = `## [Changelog](${baseUrl}/changelog.md)\n\n`; - output += `${SECTION_DESCRIPTIONS.changelog}\n\n`; - - // Extract main project topics (skip Timeline) - const projects = changelogTopics.filter((t) => t.label !== "Timeline"); - - for (const project of projects) { - output += `- [${project.label}](${markdownUrlForDocPath(baseUrl, project.link)})\n`; - } - - output += "\n"; - return output; -} - /** * Generate the sitemap markdown content. */ @@ -421,8 +400,5 @@ and manage them via the Tenzir Platform.`); segments.push(sectionOutput.trim()); } - // Add changelog section - segments.push(formatChangelogSection(baseUrl).trim()); - return `${segments.join("\n\n")}\n`; } diff --git a/src/plugins/starlight-site-navigation/index.ts b/src/plugins/starlight-site-navigation/index.ts index 3b6799db8..bee6afb2f 100644 --- a/src/plugins/starlight-site-navigation/index.ts +++ b/src/plugins/starlight-site-navigation/index.ts @@ -1,4 +1,3 @@ -import fsSync from "node:fs"; import { fileURLToPath } from "node:url"; import type { @@ -7,7 +6,6 @@ import type { } from "@astrojs/starlight/types"; import type { ViteUserConfig } from "astro"; -import changelogProjects from "../../changelog-projects.json"; import * as sidebars from "../../sidebar"; import { isSection } from "../../sidebar-sections"; import type { @@ -23,19 +21,6 @@ interface SharedSiteNavigationData { sections: ResolvedSiteNavigationSection[]; } -interface GeneratedChangelogTopic { - id: string; - label: string; - link: string; - icon?: string; - items: SidebarItems; -} - -interface GeneratedChangelogNavigation { - topics: GeneratedChangelogTopic[]; - paths: Record; -} - export default function starlightSiteNavigation( userConfig: SiteNavigationUserSectionConfig[], ): StarlightPlugin { @@ -97,12 +82,9 @@ function resolveUserConfig( const resolvedLink = normalizeLink( section.link ?? deriveLink(section.label), ); - const resolvedChildren = [ - ...(section.children ?? []).flatMap((child) => - resolveUserConfig([child], sectionTrail), - ), - ...resolveChildrenSource(section.childrenFrom), - ]; + const resolvedChildren = (section.children ?? []).flatMap((child) => + resolveUserConfig([child], sectionTrail), + ); return { label: section.label, @@ -121,95 +103,6 @@ function resolveUserConfig( }); } -function resolveChildrenSource( - source: SiteNavigationUserSectionConfig["childrenFrom"], -): SiteNavigationSectionConfig[] { - switch (source) { - case undefined: - return []; - case "changelogProjects": - return resolveChangelogChildren(); - default: - throw new Error(`Unknown site navigation children source: ${source}`); - } -} - -function resolveChangelogChildren(): SiteNavigationSectionConfig[] { - const generated = loadGeneratedChangelogNavigation(); - if (generated) { - return generated.topics.map((topic) => ({ - label: topic.label, - link: normalizeLink(topic.link), - icon: topic.icon, - sidebar: topic.items, - paths: (generated.paths[topic.id] ?? [topic.link]).map( - normalizePathPattern, - ), - })); - } - - return Object.keys(changelogProjects).map((projectId) => ({ - label: humanizeProjectId(projectId), - link: `/changelog/${projectId}`, - icon: changelogProjects[projectId as keyof typeof changelogProjects]?.icon, - paths: [`/changelog/${projectId}`, `/changelog/${projectId}/**`], - })); -} - -function humanizeProjectId(projectId: string) { - return projectId - .split("-") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -let cachedGeneratedChangelogNavigation: - | GeneratedChangelogNavigation - | null - | undefined; - -function loadGeneratedChangelogNavigation() { - if (cachedGeneratedChangelogNavigation !== undefined) { - return cachedGeneratedChangelogNavigation; - } - - const generatedPath = fileURLToPath( - new URL("../../sidebar-changelog.generated.ts", import.meta.url), - ); - if (!fsSync.existsSync(generatedPath)) { - cachedGeneratedChangelogNavigation = null; - return cachedGeneratedChangelogNavigation; - } - - const source = fsSync.readFileSync(generatedPath, "utf-8"); - const topics = extractJsonExport( - source, - "changelogTopics", - ); - const paths = extractJsonExport>( - source, - "changelogTopicPaths", - ); - - cachedGeneratedChangelogNavigation = { - topics, - paths, - }; - return cachedGeneratedChangelogNavigation; -} - -function extractJsonExport(source: string, exportName: string): T { - const match = source.match( - new RegExp(`export const ${exportName} = ([\\s\\S]*?);\\n`), - ); - if (!match) { - throw new Error( - `Failed to read ${exportName} from sidebar-changelog.generated.ts. Re-run bun run generate:changelog.`, - ); - } - return JSON.parse(match[1]) as T; -} - function resolveSidebar({ explicitSidebarName, fallbackSidebarName, @@ -353,7 +246,14 @@ function slugify(value: string) { .replace(/^-|-$/g, ""); } +function isExternalLink(link: string) { + return /^https?:\/\//.test(link); +} + function normalizeLink(link: string) { + if (isExternalLink(link)) { + return link; + } if (!link || link === "/") { return "/"; } @@ -368,7 +268,7 @@ function normalizePathPattern(path: string) { } function createId(section: SiteNavigationSectionConfig) { - if (section.link && section.link !== "/") { + if (section.link && section.link !== "/" && !isExternalLink(section.link)) { return section.link.replace(/^\/+|\/+$/g, "").replace(/\//g, "."); } return section.label diff --git a/src/plugins/starlight-site-navigation/types.ts b/src/plugins/starlight-site-navigation/types.ts index c25243e8b..ade20ea1a 100644 --- a/src/plugins/starlight-site-navigation/types.ts +++ b/src/plugins/starlight-site-navigation/types.ts @@ -19,7 +19,6 @@ export interface SiteNavigationUserSectionConfig { paths?: string[]; dropdown?: boolean; children?: SiteNavigationUserSectionConfig[]; - childrenFrom?: "changelogProjects"; } export interface SiteNavigationSectionConfig { diff --git a/src/search-weights.ts b/src/search-weights.ts index 6f6fcb42d..bce721d68 100644 --- a/src/search-weights.ts +++ b/src/search-weights.ts @@ -88,13 +88,6 @@ export const searchWeights: SearchWeightConfig[] = [ weight: 3.0, metadata: { type: "Explanation" }, }, - - // Changelog pages - lowest priority - { - pattern: "changelog/", - weight: 1.0, - metadata: { type: "Changelog" }, - }, ]; /** From 9768062fcc247738cbc8a3944f5c94ca56356aab Mon Sep 17 00:00:00 2001 From: Matthias Vallentin Date: Thu, 2 Jul 2026 14:13:51 +0200 Subject: [PATCH 2/2] Point content links at the website changelog Update the remaining internal /changelog links in guides and reference pages to their https://tenzir.com/changelog equivalents. The redirect stubs would catch them, but direct links are cleaner. Co-Authored-By: Claude Fable 5 --- src/content/docs/guides/contribution/documentation.mdx | 2 +- src/content/docs/guides/node-setup/deploy-a-node/index.mdx | 6 +++--- src/content/docs/reference/node/helm-chart.mdx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/content/docs/guides/contribution/documentation.mdx b/src/content/docs/guides/contribution/documentation.mdx index aca9d78b8..9caad6ab8 100644 --- a/src/content/docs/guides/contribution/documentation.mdx +++ b/src/content/docs/guides/contribution/documentation.mdx @@ -16,7 +16,7 @@ https://github.com/tenzir/docs. We use [Astro](https://astro.build) with | `bun run dev` | Start local dev server at `localhost:4321` | | `bun run build` | Build production site to `./dist/` | | `bun run preview` | Preview build locally before deploying | -| `bun run generate:changelog` | Sync changelog from [tenzir/news](https://github.com/tenzir/news) | +| `bun run generate:changelog` | Generate changelog redirect stubs to [tenzir.com/changelog](https://tenzir.com/changelog) | | `bun run generate:docs-map` | Generate documentation map for AI agents | | `bun run generate:excalidraw` | Convert `.excalidraw` diagrams to SVG | diff --git a/src/content/docs/guides/node-setup/deploy-a-node/index.mdx b/src/content/docs/guides/node-setup/deploy-a-node/index.mdx index ce297ddd2..8960f8317 100644 --- a/src/content/docs/guides/node-setup/deploy-a-node/index.mdx +++ b/src/content/docs/guides/node-setup/deploy-a-node/index.mdx @@ -281,7 +281,7 @@ To update your Tenzir node to a new version, you need to update the **Container ``` Replace `v..` with the desired version (e.g., `v4.30.3`). -Check our [changelog](/changelog/) for the latest version. +Check our [changelog](https://tenzir.com/changelog/tenzir/) for the latest version. @@ -369,7 +369,7 @@ Follow these steps for manual deployment through the AWS console: ``` Each node version has its own image tag. Replace `v..` - with the desired version. Check the [changelog](/changelog/) for + with the desired version. Check the [changelog](https://tenzir.com/changelog/tenzir/) for the latest version. 4. Return to your cluster, navigate to the _Tasks_ tab, and click _Run new @@ -515,7 +515,7 @@ image: tag: v6.2.0 ``` -See the [Tenzir changelog](/changelog/tenzir/) for the list of available +See the [Tenzir changelog](https://tenzir.com/changelog/tenzir/) for the list of available releases. Bumping the tag and re-running `helm upgrade` becomes your version-bump workflow. To roll back, set the previous tag and `helm upgrade` again. diff --git a/src/content/docs/reference/node/helm-chart.mdx b/src/content/docs/reference/node/helm-chart.mdx index 512c08f62..931e3976a 100644 --- a/src/content/docs/reference/node/helm-chart.mdx +++ b/src/content/docs/reference/node/helm-chart.mdx @@ -182,7 +182,7 @@ image: tag: v6.2.0 ``` -See the [Tenzir changelog](/changelog/tenzir/) for available releases. +See the [Tenzir changelog](https://tenzir.com/changelog/tenzir/) for available releases. ## See also