Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions content/docs/changelog.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
title: "Changelog"
description: "Track recent updates and additions to the Superwall documentation."
icon: "History"
---

<ChangelogTimeline />
1 change: 1 addition & 0 deletions content/docs/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"web-checkout",
"integrations",
"support",
"changelog",

"---SDKs---",
"ios",
Expand Down
2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"generate:md": "bun scripts/generate-md-files.ts",
"generate:llm": "bun scripts/generate-llm-files.ts",
"generate:title-map": "bun scripts/generate-title-map.ts",
"build:prep": "bun run generate:title-map && bun run generate:llm && bun run generate:md && bun run copy:docs-images",
"generate:changelog": "bun scripts/generate-changelog.ts",
"build:prep": "bun run generate:changelog && bun run generate:title-map && bun run generate:llm && bun run generate:md && bun run copy:docs-images",
"build": "bun run build:prep && fumadocs-mdx && bun run build:next",
"build:next": "NEXT_PRIVATE_STANDALONE=true NEXT_PRIVATE_OUTPUT_TRACE_ROOT=$PWD next build --webpack",
"build:cf": "bun run build && opennextjs-cloudflare build -- --skipNextBuild",
Expand Down
267 changes: 267 additions & 0 deletions scripts/generate-changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { getChangedFilesSince, type GitFileChange } from './utils/git-history';
import {
categorizeFile,
getCategoryOrder,
} from './utils/changelog-categorizer';
import {
generateDescriptions,
hasApiKey,
type DescriptionRequest,
} from './utils/llm-descriptions';

// Configuration
const SINCE_DATE = new Date('2025-12-01');
const OUTPUT_FILE = path.join(process.cwd(), 'src/lib/changelog-entries.json');
const MONTHS_TO_KEEP = 3;

interface ChangelogEntry {
/** Unique key: path:commitHash */
key: string;
/** Relative path from content/docs */
path: string;
/** Page title from frontmatter */
title: string;
/** One-line description of the change */
description: string;
/** Top-level category */
category: string;
/** Subcategory (optional) */
subcategory?: string;
/** Full docs URL */
url: string;
/** ISO date string of the change */
date: string;
/** Type of change */
changeType: 'added' | 'modified';
}

interface ChangelogData {
/** When the changelog was last updated */
lastUpdated: string;
/** All changelog entries (newest first) */
entries: ChangelogEntry[];
}

/**
* Get the cutoff date for pruning old entries (first day of the month, N months ago).
*/
function getCutoffDate(monthsToKeep: number): Date {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth() - monthsToKeep + 1, 1);
}

/**
* Filter entries to only keep those within the retention period.
*/
function pruneOldEntries(entries: ChangelogEntry[]): ChangelogEntry[] {
const cutoff = getCutoffDate(MONTHS_TO_KEEP);
return entries.filter((entry) => new Date(entry.date) >= cutoff);
}

/**
* Load existing changelog data.
*/
function loadExistingChangelog(): ChangelogData {
try {
if (fs.existsSync(OUTPUT_FILE)) {
const content = fs.readFileSync(OUTPUT_FILE, 'utf-8');
return JSON.parse(content);
}
} catch (error) {
console.warn('Warning: Failed to load existing changelog:', error);
}
return { lastUpdated: '', entries: [] };
}

/**
* Extract frontmatter from an MDX file.
*/
function extractFrontmatter(
filePath: string
): { title: string } | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}

const content = fs.readFileSync(filePath, 'utf-8');
const { data } = matter(content);

if (!data.title) {
return null;
}

return { title: data.title };
} catch (error) {
console.warn(`Warning: Failed to parse ${filePath}:`, error);
return null;
}
}

/**
* Convert a file path to a docs URL.
*/
function filePathToUrl(relativePath: string): string {
const normalizedPath = relativePath.replace(/\\/g, '/');
const cleanPath = normalizedPath
.replace(/^content\/docs\//, '')
.replace(/\.mdx?$/, '')
.replace(/\/index$/, '');
return `/docs/${cleanPath}`;
}

/**
* Main generator function.
*/
async function main() {
console.log('📝 Generating documentation changelog...');
console.log(` Since: ${SINCE_DATE.toISOString().split('T')[0]}`);

// Load existing changelog
const existing = loadExistingChangelog();
const existingKeys = new Set(existing.entries.map((e) => e.key));
console.log(` Existing entries: ${existingKeys.size}`);

// Get changed files from git
const changes = getChangedFilesSince(SINCE_DATE);
console.log(` Found ${changes.length} changed files in git history`);

// Find NEW changes (not already in changelog)
const newChanges: Array<{
change: GitFileChange;
frontmatter: { title: string };
categoryInfo: ReturnType<typeof categorizeFile>;
key: string;
}> = [];

for (const change of changes) {
const key = `${change.path}:${change.commitHash}`;

// Skip if already in changelog
if (existingKeys.has(key)) {
continue;
}

const absolutePath = path.join(process.cwd(), change.path);
const frontmatter = extractFrontmatter(absolutePath);

if (!frontmatter) {
continue;
}

const normalizedPath = change.path.replace(/\\/g, '/');
const categoryInfo = categorizeFile(normalizedPath);

newChanges.push({
change,
frontmatter,
categoryInfo,
key,
});
}

console.log(` New entries to generate: ${newChanges.length}`);

if (newChanges.length === 0) {
console.log('✅ Changelog is up to date, no new entries');
return;
}

// Check for API key - required for generating new entries
if (!hasApiKey()) {
console.log('\n⚠️ No ANTHROPIC_API_KEY found. Skipping changelog generation.');
console.log(' To generate descriptions for new entries, add ANTHROPIC_API_KEY to .env.local');
console.log(` ${newChanges.length} new entries were not added.\n`);
return;
}

// Generate descriptions for new changes only
const descriptionRequests: DescriptionRequest[] = newChanges.map((item) => ({
filePath: item.change.path,
commitHash: item.change.commitHash,
changeType: item.change.changeType,
title: item.frontmatter.title,
category: item.categoryInfo.category,
subcategory: item.categoryInfo.subcategory,
}));

const descriptions = await generateDescriptions(descriptionRequests);

// Build new entries
const newEntries: ChangelogEntry[] = newChanges.map((item) => {
const cacheKey = `${item.change.path}:${item.change.commitHash}`;
const descResult = descriptions.get(cacheKey);
const normalizedPath = item.change.path.replace(/\\/g, '/');
const relativePath = normalizedPath.replace(/^content\/docs\//, '');

return {
key: item.key,
path: relativePath,
title: item.frontmatter.title,
description: descResult?.description || '',
category: item.categoryInfo.category,
subcategory: item.categoryInfo.subcategory,
url: filePathToUrl(normalizedPath),
date: item.change.date.toISOString(),
changeType: item.change.changeType,
};
});

// Merge: new entries + existing entries
const mergedEntries = [...newEntries, ...existing.entries];

// Prune entries older than 3 months
const prunedEntries = pruneOldEntries(mergedEntries);
const prunedCount = mergedEntries.length - prunedEntries.length;

// Sort by date (newest first), then by category order
prunedEntries.sort((a, b) => {
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime();
if (dateCompare !== 0) return dateCompare;
return getCategoryOrder(a.category) - getCategoryOrder(b.category);
});

// Build final changelog data
const changelogData: ChangelogData = {
lastUpdated: new Date().toISOString(),
entries: prunedEntries,
};

// Ensure output directory exists
const outputDir = path.dirname(OUTPUT_FILE);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}

// Write output
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(changelogData, null, 2), 'utf-8');

console.log(`✅ Changelog updated: ${path.relative(process.cwd(), OUTPUT_FILE)}`);
console.log(` Added ${newEntries.length} new entries`);
if (prunedCount > 0) {
console.log(` Pruned ${prunedCount} entries older than ${MONTHS_TO_KEEP} months`);
}
console.log(` Total entries: ${prunedEntries.length}`);

// Show category breakdown for new entries
if (newEntries.length > 0) {
const categoryCount = new Map<string, number>();
for (const entry of newEntries) {
const count = categoryCount.get(entry.category) || 0;
categoryCount.set(entry.category, count + 1);
}

console.log(' New entries by category:');
for (const [category, count] of categoryCount.entries()) {
console.log(` - ${category}: ${count}`);
}
}
}

main().catch((error) => {
console.error('❌ Changelog generation failed:', error);
process.exit(1);
});
Loading