Skip to content
8 changes: 6 additions & 2 deletions src/components/docs/DocsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { DocsSidebar } from './DocsSidebar';
import { TableOfContents } from './TableOfContents';
import { MobileTOC } from './MobileTOC';
import { MobileHeader } from './MobileSidebarToggle';
import { EditPageLink } from './EditPageLink';
import { useDocsMenu } from './DocsProvider';
import type { ProjectId } from '@/config/versions';
import { DocsSourceActions } from '@/components/docs/DocsSourceActions';

interface TOCItem {
id: string;
Expand Down Expand Up @@ -71,7 +71,11 @@ export function DocsLayout({ children, pageMap, toc, metadata, filePath, project
{/* Edit page icon - top right */}
{filePath && projectId && (
<div className="shrink-0 ml-2">
<EditPageLink filePath={filePath} projectId={projectId} variant="icon" />
<DocsSourceActions
filePath={filePath}
projectId={projectId}
pageTitle={metadata?.title ?? 'Documentation'}
/>
</div>
)}
</div>
Expand Down
160 changes: 160 additions & 0 deletions src/components/docs/DocsSourceActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"use client";

import { useSharedConfig } from "@/hooks/useSharedConfig";
import type { ProjectId } from "@/config/versions";
import { GitPullRequest, FileCode, AlertCircle } from "lucide-react";


const STATIC_EDIT_BASE_URLS: Record<ProjectId, string> = {
kubestellar: "https://github.com/kubestellar/docs/edit/main/docs/content",
a2a: "https://github.com/kubestellar/a2a/edit/main/docs",
kubeflex: "https://github.com/kubestellar/kubeflex/edit/main/docs",
"multi-plugin": "https://github.com/kubestellar/kubectl-multi-plugin/edit/main/docs",
"kubestellar-mcp": "https://github.com/kubestellar/kubectl-claude/edit/main/docs",
console: 'https://github.com/kubestellar/console/edit/main/docs',
};

// Prevent path traversal
function sanitizeFilePath(filePath: string): string {
return filePath.replace(/\.\./g, "").replace(/^\/+/, "");
}

// Force fork-based editing for ALL users (including admins)
function buildGitHubEditUrl(
filePath: string,
projectId: ProjectId,
editBaseUrls?: Record<string, string>
): string | null {
const baseUrl =
editBaseUrls?.[projectId] ?? STATIC_EDIT_BASE_URLS[projectId];

if (!baseUrl) return null;

return `${baseUrl}/${sanitizeFilePath(filePath)}?fork=true`;
}

// Validate GitHub edit URL
function isValidGitHubEditUrl(url: string): boolean {
try {
const parsed = new URL(url);
return (
parsed.protocol === "https:" &&
parsed.hostname === "github.com" &&
parsed.pathname.includes("/edit/")
);
} catch {
return false;
}
}

// Convert edit β†’ blob
function buildSourceUrl(editUrl: string): string {
const url = new URL(editUrl);
url.search = "";
url.pathname = url.pathname.replace("/edit/", "/blob/");
return url.toString();
}

// Build GitHub issue link
function buildIssueUrl(pageTitle: string, sourceUrl: string): string {
return `https://github.com/kubestellar/docs/issues/new?title=${encodeURIComponent(
`Docs: ${pageTitle}`
)}&body=${encodeURIComponent(`Source file:\n${sourceUrl}`)}`;
}

type DocsSourceActionsProps = {
filePath: string;
projectId: ProjectId;
pageTitle: string;
variant?: "full" | "compact";
};

export function DocsSourceActions({
filePath,
projectId,
pageTitle,
variant = "full",
}: DocsSourceActionsProps) {
const { config } = useSharedConfig();

const editUrl = buildGitHubEditUrl(
filePath,
projectId,
config?.editBaseUrls
);

if (!editUrl || !isValidGitHubEditUrl(editUrl)) return null;

const safeEditUrl = new URL(editUrl).href;
const sourceUrl = buildSourceUrl(safeEditUrl);
const issueUrl = buildIssueUrl(pageTitle, sourceUrl);

return (
<div
className={
variant === "compact"
? "flex gap-2"
: "flex flex-wrap gap-2"
}
>
<ActionLink
href={safeEditUrl}
compact={variant === "compact"}
title="Compose a PR"
>
<GitPullRequest className="h-4 w-4" />
{variant === "full" && "Compose a PR"}
</ActionLink>

<ActionLink
href={sourceUrl}
compact={variant === "compact"}
title="View Source"
>
<FileCode className="h-4 w-4" />
{variant === "full" && "View Source"}
</ActionLink>

<ActionLink
href={issueUrl}
compact={variant === "compact"}
title="Open Issue"
>
<AlertCircle className="h-4 w-4" />
{variant === "full" && "Open Issue"}
</ActionLink>
</div>
);
}

function ActionLink({
href,
children,
compact,
title,
}: {
href: string;
children: React.ReactNode;
compact?: boolean;
title?: string;
}) {
return (
<a
href={href}
title={title}
target="_blank"
rel="noopener noreferrer"
className={[
"inline-flex items-center justify-center rounded-md border font-medium transition-all duration-150",
compact
? "h-11 w-11 border-gray-700 bg-gray-900 text-gray-100 hover:bg-gray-800"
: "min-w-[150px] gap-2 px-3 py-1.5 text-sm",
"border-gray-300 text-gray-800 bg-white dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100",
"hover:bg-gray-100 hover:border-gray-400 dark:hover:bg-gray-800 dark:hover:border-gray-500",
"focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600",
].join(" ")}
>
{children}
</a>
);
}
137 changes: 30 additions & 107 deletions src/components/docs/EditPageLink.tsx
Original file line number Diff line number Diff line change
@@ -1,101 +1,23 @@
"use client";

import { useState, useEffect } from 'react';
import { useSharedConfig, getVersionsForProject, VersionInfo } from '@/hooks/useSharedConfig';
import { getProjectVersions as getStaticProjectVersions } from '@/config/versions';
import { useSharedConfig } from '@/hooks/useSharedConfig';
import type { ProjectId } from '@/config/versions';

const STATIC_EDIT_BASE_URLS: Record<ProjectId, string> = {
kubestellar: 'https://github.com/kubestellar/docs/edit/main/docs/content',
a2a: 'https://github.com/kubestellar/a2a/edit/main/docs',
kubeflex: 'https://github.com/kubestellar/kubeflex/edit/main/docs',
"multi-plugin": 'https://github.com/kubestellar/kubectl-multi-plugin/edit/main/docs',
"kubestellar-mcp": 'https://github.com/kubestellar/kubectl-claude/edit/main/docs',
console: 'https://github.com/kubestellar/console/edit/main/docs',
};

interface EditPageLinkProps {
filePath: string;
projectId: ProjectId;
variant?: 'full' | 'icon';
}

// Version entry with key from the versions config
type VersionEntry = { key: string } & VersionInfo;

// Convert branch name to Netlify slug format (e.g., docs/0.29.0 -> docs-0-29-0)
function branchToSlug(branch: string): string {
return branch.replace(/\//g, '-').replace(/\./g, '-');
}

// Detect current branch from hostname (for kubestellar docs repo)
function detectCurrentBranch(versions: VersionEntry[]): string {
if (typeof window === 'undefined') return 'main';

const hostname = window.location.hostname;

// Production site uses the "latest" version's branch
if (hostname === 'kubestellar.io' || hostname === 'www.kubestellar.io') {
const latestVersion = versions.find(v => v.key === 'latest');
return latestVersion?.branch || 'main';
}

// Netlify branch deploys: {branch-slug}--{site-name}.netlify.app
const branchDeployMatch = hostname.match(/^(.+)--[\w-]+\.netlify\.app$/);
if (branchDeployMatch) {
const branchSlug = branchDeployMatch[1];

// Main branch deploy
if (branchSlug === 'main') {
return 'main';
}

// Deploy previews go to main
if (branchSlug.startsWith('deploy-preview-')) {
return 'main';
}

// Match branch slug to version branch (e.g., docs-0-29-0 -> docs/0.29.0)
for (const version of versions) {
if (branchSlug === branchToSlug(version.branch)) {
return version.branch;
}
}
}

return 'main';
}

// Source repos for each project (used when on main branch)
// Projects not listed here have their docs in the docs repo itself
const SOURCE_REPOS: Record<string, { repo: string; docsPath: string }> = {
a2a: { repo: 'kubestellar/a2a', docsPath: 'docs' },
kubeflex: { repo: 'kubestellar/kubeflex', docsPath: 'docs' },
'multi-plugin': { repo: 'kubestellar/kubectl-multi-plugin', docsPath: 'docs' },
'kubestellar-mcp': { repo: 'kubestellar/kubestellar-mcp', docsPath: 'docs' },
};

// Projects whose docs live in the docs repo itself (not a separate source repo)
const DOCS_REPO_PROJECTS = ['console'];

// Build edit URL for a project, using correct branch
function buildEditBaseUrl(projectId: ProjectId, branch: string): string {
// KubeStellar docs always live in docs repo
if (projectId === 'kubestellar') {
return `https://github.com/kubestellar/docs/edit/${branch}/docs/content`;
}

// Projects whose docs live in the docs repo itself (e.g., console)
if (DOCS_REPO_PROJECTS.includes(projectId)) {
return `https://github.com/kubestellar/docs/edit/${branch}/docs/content/${projectId}`;
}

// For other projects: version branches are in docs repo, main goes to source repo
if (branch !== 'main' && branch.startsWith('docs/')) {
// Version branch in docs repo (e.g., docs/kubestellar-mcp/0.6.0)
return `https://github.com/kubestellar/docs/edit/${branch}/docs/content/${projectId}`;
}

// Main branch - link to source repo
const source = SOURCE_REPOS[projectId];
if (source) {
return `https://github.com/${source.repo}/edit/main/${source.docsPath}`;
}

return '';
}

// Validate that URL is a safe GitHub edit URL to prevent XSS
function isValidGitHubEditUrl(url: string): boolean {
try {
Expand All @@ -111,31 +33,32 @@ function isValidGitHubEditUrl(url: string): boolean {
}
}

export function EditPageLink({ filePath, projectId, variant = 'full' }: EditPageLinkProps) {
const { config } = useSharedConfig();
const [currentBranch, setCurrentBranch] = useState<string>('main');
//refactoring helper function to build the GitHub edit URL
export function buildGitHubEditUrl(
filePath: string,
projectId: ProjectId,
editBaseUrls?: Record<string, string>
): string | null {
const baseUrl =
editBaseUrls?.[projectId] ?? STATIC_EDIT_BASE_URLS[projectId];

// Get versions to detect current branch
const versions = config
? getVersionsForProject(config, projectId)
: getStaticProjectVersions(projectId);
if (!baseUrl) return null;

// Detect current branch from hostname on client-side mount
useEffect(() => {
const detected = detectCurrentBranch(versions);
setCurrentBranch(detected);
}, [versions]);
const sanitizedFilePath = filePath.replace(/\.\./g, "").replace(/^\/+/, "");
return `${baseUrl}/${sanitizedFilePath}`;
}

// Build edit URL with correct branch
const editBaseUrl = buildEditBaseUrl(projectId, currentBranch);
export function EditPageLink({ filePath, projectId, variant = 'full' }: EditPageLinkProps) {
const { config } = useSharedConfig();

if (!editBaseUrl) return null;
const editUrl = buildGitHubEditUrl(
filePath,
projectId,
config?.editBaseUrls
);

// Sanitize filePath to prevent path traversal
const sanitizedFilePath = filePath.replace(/\.\./g, '').replace(/^\/+/, '');
if (!editUrl) return null;

// Construct the full edit URL
const editUrl = `${editBaseUrl}/${sanitizedFilePath}`;

// Validate URL before rendering to prevent XSS
if (!isValidGitHubEditUrl(editUrl)) return null;
Expand Down
Loading