diff --git a/src/components/CompareEditions.astro b/src/components/CompareEditions.astro new file mode 100644 index 0000000..94826b6 --- /dev/null +++ b/src/components/CompareEditions.astro @@ -0,0 +1,65 @@ +--- +/** + * CompareEditions button - shows a button to compare the current AEP with other editions + */ +import { Icon } from '@astrojs/starlight/components' +import editionsConfig from '../../aep-editions.json' +import { getEditionFromPath, isVersionedPage } from '../utils/versions' + +const currentEdition = getEditionFromPath(editionsConfig, Astro.url.pathname) +const showCompareButton = isVersionedPage(Astro.url.pathname) + +// Extract AEP ID from path +const pathSegments = Astro.url.pathname.split('/').filter(Boolean) +const aepId = pathSegments[pathSegments.length - 1] + +// Only show if we have multiple editions +const hasMultipleEditions = editionsConfig.editions.length > 1 +--- + +{showCompareButton && hasMultipleEditions && ( +
+ + + Compare + +
+)} + + diff --git a/src/components/DiffViewer.astro b/src/components/DiffViewer.astro new file mode 100644 index 0000000..f51a1bf --- /dev/null +++ b/src/components/DiffViewer.astro @@ -0,0 +1,283 @@ +--- +/** + * DiffViewer component - displays side-by-side or unified diff view + */ +import type { DiffLine } from "../utils/diff"; + +interface Props { + diffLines: DiffLine[]; + oldLabel?: string; + newLabel?: string; + viewMode?: "side-by-side" | "unified"; +} + +const { + diffLines, + oldLabel = "Original", + newLabel = "Modified", + viewMode = "side-by-side", +} = Astro.props; + +// Calculate stats +const stats = diffLines.reduce( + (acc, line) => { + if (line.type === "added") acc.additions++; + else if (line.type === "removed") acc.deletions++; + return acc; + }, + { additions: 0, deletions: 0 } +); +--- + +
+
+
+ +{stats.additions} + -{stats.deletions} +
+
+ {oldLabel} + + {newLabel} +
+
+ + { + viewMode === "side-by-side" ? ( +
+
+
{oldLabel}
+ {diffLines.map((line) => ( +
+ {line.oldLineNum && ( + {line.oldLineNum} + )} + {line.type !== "added" && ( + {line.oldContent || ""} + )} +
+ ))} +
+
+
{newLabel}
+ {diffLines.map((line) => ( +
+ {line.newLineNum && ( + {line.newLineNum} + )} + {line.type !== "removed" && ( + {line.newContent || ""} + )} +
+ ))} +
+
+ ) : ( +
+ {diffLines.map((line) => ( +
+ + {line.oldLineNum && {line.oldLineNum}} + {line.newLineNum && {line.newLineNum}} + + + {line.type === "added" ? "+" : line.type === "removed" ? "-" : " "} + + + {line.type === "removed" ? line.oldContent : line.newContent} + +
+ ))} +
+ ) + } +
+ + diff --git a/src/components/EditionBanner.astro b/src/components/EditionBanner.astro index 55cdfd3..49e1cd5 100644 --- a/src/components/EditionBanner.astro +++ b/src/components/EditionBanner.astro @@ -7,12 +7,29 @@ const latestEdition = editionsConfig.editions.find(e => isLatestEdition(e)) const isOlderVersion = !isLatestEdition(currentEdition) const latestUrl = latestEdition ? getVersionedPath(editionsConfig, Astro.url.pathname, latestEdition) : null + +// Extract AEP ID from path for diff link +const pathSegments = Astro.url.pathname.split('/').filter(Boolean) +const aepId = pathSegments[pathSegments.length - 1] +const isAEPPage = /^\d+$/.test(aepId) --- { isOlderVersion && (
- You are viewing {currentEdition.name} + You are viewing {currentEdition.name}. + {isAEPPage && latestEdition && ( + <> + + See what changed + or view {latestEdition.name}. + + )} + {!isAEPPage && latestUrl && ( + <> + View latest edition. + + )}
) } diff --git a/src/components/overrides/ThemeSelect.astro b/src/components/overrides/ThemeSelect.astro index 7f86175..c9290b7 100644 --- a/src/components/overrides/ThemeSelect.astro +++ b/src/components/overrides/ThemeSelect.astro @@ -2,7 +2,9 @@ import Default from '@astrojs/starlight/components/ThemeSelect.astro' import VersionSelect from '../VersionSelect.astro' +import CompareEditions from '../CompareEditions.astro' --- + \ No newline at end of file diff --git a/src/pages/diff/[aepId].astro b/src/pages/diff/[aepId].astro new file mode 100644 index 0000000..c2f0efb --- /dev/null +++ b/src/pages/diff/[aepId].astro @@ -0,0 +1,267 @@ +--- +import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro"; +import DiffViewer from "../../components/DiffViewer.astro"; +import { getAEPContent, getAEPEditions } from "../../utils/aep-content"; +import { computeDiff } from "../../utils/diff"; +import * as fs from "fs"; + +interface Edition { + name: string; + folder: string; +} + +interface EditionsConfig { + editions: Edition[]; +} + +// Load editions config +const editionsConfigPath = "aep-editions.json"; +const editionsConfig: EditionsConfig = JSON.parse( + fs.readFileSync(editionsConfigPath, "utf-8") +); + +const { aepId } = Astro.params; + +// Get query parameters for editions to compare +const url = new URL(Astro.request.url); +const fromEditionName = url.searchParams.get("from"); +const toEditionName = url.searchParams.get("to"); + +// Find editions +const fromEdition = fromEditionName + ? editionsConfig.editions.find((e) => e.name === fromEditionName) + : null; +const toEdition = toEditionName + ? editionsConfig.editions.find((e) => e.name === toEditionName) + : null; + +// Get available editions for this AEP +const availableEditions = await getAEPEditions(aepId!, editionsConfig.editions); + +// If no editions specified, use the first two available editions +let fromEd = fromEdition; +let toEd = toEdition; + +if (!fromEd && availableEditions.length > 0) { + // Default: compare oldest to newest + fromEd = availableEditions[availableEditions.length - 1]; +} + +if (!toEd && availableEditions.length > 0) { + // Default: compare to latest (folder === ".") + toEd = availableEditions.find((e) => e.folder === ".") || availableEditions[0]; +} + +// Validation +if (!fromEd || !toEd) { + throw new Error( + `Cannot compare editions. Available editions for AEP ${aepId}: ${availableEditions.map((e) => e.name).join(", ")}` + ); +} + +if (fromEd.name === toEd.name) { + throw new Error("Cannot compare an edition with itself"); +} + +// Fetch content from both editions +const fromContent = await getAEPContent(aepId!, fromEd); +const toContent = await getAEPContent(aepId!, toEd); + +if (!fromContent) { + throw new Error(`AEP ${aepId} not found in edition ${fromEd.name}`); +} + +if (!toContent) { + throw new Error(`AEP ${aepId} not found in edition ${toEd.name}`); +} + +// Compute diff +const diffLines = computeDiff(fromContent.body, toContent.body); + +// Get AEP title from frontmatter +const aepTitle = + toContent.frontmatter.title || fromContent.frontmatter.title || `AEP-${aepId}`; +--- + + +
+
+

+ {aepTitle} + #{aepId} +

+

+ Comparing editions: + {fromEd.name}{toEd.name} +

+ + { + availableEditions.length > 2 && ( +
+ + + + +
+ ) + } +
+ + + + +
+
+ + + + diff --git a/src/utils/aep-content.ts b/src/utils/aep-content.ts new file mode 100644 index 0000000..133f485 --- /dev/null +++ b/src/utils/aep-content.ts @@ -0,0 +1,136 @@ +/** + * Utility functions for fetching AEP content from different editions. + */ + +import fs from "fs"; +import path from "path"; + +interface Edition { + name: string; + folder: string; +} + +export interface AEPContent { + raw: string; + frontmatter: Record; + body: string; +} + +/** + * Fetches the raw MDX content for an AEP from a specific edition. + */ +export async function getAEPContent( + aepId: string, + edition: Edition, +): Promise { + try { + // Determine the file path based on edition + const basePath = process.cwd(); + let filePath: string; + + if (edition.folder === ".") { + // Latest edition - files are in root of docs + filePath = path.join(basePath, "src", "content", "docs", `${aepId}.mdx`); + } else { + // Non-latest edition - files are in edition subfolder + filePath = path.join( + basePath, + "src", + "content", + "docs", + edition.folder, + `${aepId}.mdx`, + ); + } + + // Check if file exists + if (!fs.existsSync(filePath)) { + return null; + } + + // Read the file + const raw = fs.readFileSync(filePath, "utf-8"); + + // Parse frontmatter (simple extraction) + const { frontmatter, body } = parseMDX(raw); + + return { raw, frontmatter, body }; + } catch (error) { + console.error( + `Error fetching AEP ${aepId} from edition ${edition.name}:`, + error, + ); + return null; + } +} + +/** + * Simple frontmatter parser for MDX files. + */ +function parseMDX(content: string): { + frontmatter: Record; + body: string; +} { + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; + const match = content.match(frontmatterRegex); + + if (!match) { + return { frontmatter: {}, body: content }; + } + + const frontmatterText = match[1]; + const body = match[2]; + + // Parse YAML-like frontmatter (simple key-value pairs) + const frontmatter: Record = {}; + const lines = frontmatterText.split("\n"); + + for (const line of lines) { + const colonIndex = line.indexOf(":"); + if (colonIndex > 0) { + const key = line.substring(0, colonIndex).trim(); + let value = line.substring(colonIndex + 1).trim(); + + // Remove quotes if present + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.substring(1, value.length - 1); + } + + frontmatter[key] = value; + } + } + + return { frontmatter, body }; +} + +/** + * Checks if an AEP exists in a given edition. + */ +export async function aepExistsInEdition( + aepId: string, + edition: Edition, +): Promise { + const content = await getAEPContent(aepId, edition); + return content !== null; +} + +/** + * Gets the list of editions where an AEP exists. + */ +export async function getAEPEditions( + aepId: string, + allEditions: Edition[], +): Promise { + const availableEditions: Edition[] = []; + + for (const edition of allEditions) { + if (await aepExistsInEdition(aepId, edition)) { + availableEditions.push(edition); + } + } + + return availableEditions; +} diff --git a/src/utils/diff.ts b/src/utils/diff.ts new file mode 100644 index 0000000..e0475dc --- /dev/null +++ b/src/utils/diff.ts @@ -0,0 +1,208 @@ +/** + * Utility functions for computing and working with diffs between text content. + */ + +export type DiffLineType = "unchanged" | "added" | "removed" | "modified"; + +export interface DiffLine { + type: DiffLineType; + oldLineNum?: number; + newLineNum?: number; + oldContent?: string; + newContent?: string; +} + +/** + * Computes a line-by-line diff between two strings using a simple LCS algorithm. + */ +export function computeDiff(oldText: string, newText: string): DiffLine[] { + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + + // Compute longest common subsequence for line matching + const lcs = computeLCS(oldLines, newLines); + + const result: DiffLine[] = []; + let oldIdx = 0; + let newIdx = 0; + let oldLineNum = 1; + let newLineNum = 1; + + while (oldIdx < oldLines.length || newIdx < newLines.length) { + const oldLine = oldLines[oldIdx]; + const newLine = newLines[newIdx]; + + // Check if both lines are in LCS (unchanged) + if ( + oldIdx < oldLines.length && + newIdx < newLines.length && + lcs[oldIdx][newIdx] + ) { + result.push({ + type: "unchanged", + oldLineNum: oldLineNum++, + newLineNum: newLineNum++, + oldContent: oldLine, + newContent: newLine, + }); + oldIdx++; + newIdx++; + } + // Line removed from old + else if ( + oldIdx < oldLines.length && + (newIdx >= newLines.length || !lcs[oldIdx][newIdx]) + ) { + result.push({ + type: "removed", + oldLineNum: oldLineNum++, + oldContent: oldLine, + }); + oldIdx++; + } + // Line added in new + else if ( + newIdx < newLines.length && + (oldIdx >= oldLines.length || !lcs[oldIdx][newIdx]) + ) { + result.push({ + type: "added", + newLineNum: newLineNum++, + newContent: newLine, + }); + newIdx++; + } + } + + return result; +} + +/** + * Computes longest common subsequence for line matching. + * Returns a 2D boolean array where lcs[i][j] is true if lines match. + */ +function computeLCS(oldLines: string[], newLines: string[]): boolean[][] { + const m = oldLines.length; + const n = newLines.length; + + // DP table for LCS lengths + const dp: number[][] = Array(m + 1) + .fill(0) + .map(() => Array(n + 1).fill(0)); + + // Compute LCS lengths + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (oldLines[i - 1] === newLines[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + // Backtrack to find which lines are in the LCS + const lcs: boolean[][] = Array(m) + .fill(false) + .map(() => Array(n).fill(false)); + + let i = m; + let j = n; + while (i > 0 && j > 0) { + if (oldLines[i - 1] === newLines[j - 1]) { + lcs[i - 1][j - 1] = true; + i--; + j--; + } else if (dp[i - 1][j] > dp[i][j - 1]) { + i--; + } else { + j--; + } + } + + return lcs; +} + +/** + * Groups consecutive diff lines into hunks for better readability. + * Includes context lines around changes. + */ +export function groupIntoHunks( + diffLines: DiffLine[], + contextLines: number = 3, +): DiffLine[][] { + const hunks: DiffLine[][] = []; + let currentHunk: DiffLine[] = []; + let unchangedCount = 0; + + for (const line of diffLines) { + if (line.type === "unchanged") { + unchangedCount++; + + // If we have a current hunk and too many unchanged lines, close it + if (currentHunk.length > 0 && unchangedCount > contextLines * 2) { + // Add trailing context + for (let i = 0; i < contextLines && i < currentHunk.length; i++) { + if (currentHunk[currentHunk.length - 1 - i].type !== "unchanged") { + break; + } + } + hunks.push([...currentHunk]); + currentHunk = []; + unchangedCount = 1; + } + + currentHunk.push(line); + } else { + // Changed line - include previous context if starting new hunk + if (currentHunk.length === 0 && unchangedCount > 0) { + const contextStart = Math.max( + 0, + diffLines.indexOf(line) - contextLines, + ); + for (let i = contextStart; i < diffLines.indexOf(line); i++) { + currentHunk.push(diffLines[i]); + } + } + + currentHunk.push(line); + unchangedCount = 0; + } + } + + // Add final hunk if exists + if (currentHunk.length > 0) { + hunks.push(currentHunk); + } + + return hunks; +} + +/** + * Generates a summary of changes from diff lines. + */ +export function getDiffSummary(diffLines: DiffLine[]): { + additions: number; + deletions: number; + unchanged: number; +} { + let additions = 0; + let deletions = 0; + let unchanged = 0; + + for (const line of diffLines) { + switch (line.type) { + case "added": + additions++; + break; + case "removed": + deletions++; + break; + case "unchanged": + unchanged++; + break; + } + } + + return { additions, deletions, unchanged }; +}