Skip to content
Open
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
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
120 changes: 120 additions & 0 deletions src/components/docs/DocsSourceActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"use client";

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",
klaude: "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;
};

export function DocsSourceActions({
filePath,
projectId,
pageTitle,
}: 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="flex gap-2">
<ActionLink href={safeEditUrl}>Compose a PR</ActionLink>
<ActionLink href={sourceUrl}>View Source</ActionLink>
<ActionLink href={issueUrl}>Open Issue</ActionLink>
</div>
);
}

function ActionLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 min-w-[120px] justify-center rounded-md border px-3 py-1.5 text-sm font-medium
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
transition-all duration-150"
>
{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',
klaude: 'https://github.com/kubestellar/klaude/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' },
'klaude': { repo: 'kubestellar/klaude', 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/klaude/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
30 changes: 26 additions & 4 deletions src/components/master-page/AboutSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect } from "react";
import { GridLines, StarField } from "../index";
import { useTranslations } from "next-intl";
import { DocsSourceActions } from "@/components/docs/DocsSourceActions";
import Link from "next/link";

export default function AboutSection() {
Expand Down Expand Up @@ -127,7 +128,7 @@ export default function AboutSection() {
<div className="feature-card relative group perspective">
<div className="card-3d-container relative transition-all duration-500 group-hover:rotate-y-10 w-full h-full transform-style-3d">
<div className="absolute -inset-0.5 bg-gradient-to-r from-primary-600 to-purple-600 rounded-xl blur opacity-30 group-hover:opacity-90 transition duration-500"></div>
<div className="relative bg-gray-800/50 backdrop-blur-md rounded-xl shadow-lg p-8 transition-all duration-300 transform group-hover:translate-y-[-8px] group-hover:shadow-xl border border-gray-700/50 h-full">
<div className="relative bg-gray-800/50 backdrop-blur-md rounded-xl shadow-lg p-8 transition-all duration-300 transform group-hover:translate-y-[-8px] group-hover:shadow-xl border border-gray-700/50 h-full flex flex-col justify-between h-full">
{/* Icon with animation */}
<div className="w-16 h-16 rounded-full bg-gradient-to-r from-blue-500 to-purple-600 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
<svg
Expand Down Expand Up @@ -175,6 +176,13 @@ export default function AboutSection() {
</svg>
</div>
</Link>
<div className="mt-4 pt-3 border-t border-gray-700/40">
<DocsSourceActions
filePath="direct/architecture.md"
projectId="kubestellar"
pageTitle={t("card1Title")}
/>
</div>
</div>
</div>
</div>
Expand All @@ -183,7 +191,7 @@ export default function AboutSection() {
<div className="feature-card relative group perspective">
<div className="card-3d-container relative transition-all duration-500 group-hover:rotate-y-10 w-full h-full transform-style-3d">
<div className="absolute -inset-0.5 bg-gradient-to-r from-primary-600 to-purple-600 rounded-xl blur opacity-30 group-hover:opacity-90 transition duration-500"></div>
<div className="relative bg-gray-800/50 backdrop-blur-md rounded-xl shadow-lg p-8 transition-all duration-300 transform group-hover:translate-y-[-8px] group-hover:shadow-xl border border-gray-700/50">
<div className="relative bg-gray-800/50 backdrop-blur-md rounded-xl shadow-lg p-8 transition-all duration-300 transform group-hover:translate-y-[-8px] group-hover:shadow-xl border border-gray-700/50 flex flex-col justify-between h-full">
{/* Icon with animation */}
<div className="w-16 h-16 rounded-full bg-gradient-to-r from-blue-500 to-purple-600 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
<svg
Expand Down Expand Up @@ -231,6 +239,13 @@ export default function AboutSection() {
</svg>
</div>
</Link>
<div className="mt-4 pt-3 border-t border-gray-700/40">
<DocsSourceActions
filePath="direct/wds.md"
projectId="kubestellar"
pageTitle={t("card2Title")}
/>
</div>
</div>
</div>
</div>
Expand All @@ -239,7 +254,7 @@ export default function AboutSection() {
<div className="feature-card relative group perspective">
<div className="card-3d-container relative transition-all duration-500 group-hover:rotate-y-10 w-full h-full transform-style-3d">
<div className="absolute -inset-0.5 bg-gradient-to-r from-primary-600 to-purple-600 rounded-xl blur opacity-30 group-hover:opacity-90 transition duration-500"></div>
<div className="relative bg-gray-800/50 backdrop-blur-md rounded-xl shadow-lg p-8 transition-all duration-300 transform group-hover:translate-y-[-8px] group-hover:shadow-xl border border-gray-700/50">
<div className="relative bg-gray-800/50 backdrop-blur-md rounded-xl shadow-lg p-8 transition-all duration-300 transform group-hover:translate-y-[-8px] group-hover:shadow-xl border border-gray-700/50 flex flex-col justify-between h-full">
{/* Icon with animation */}
<div className="w-16 h-16 rounded-full bg-gradient-to-r from-blue-500 to-purple-600 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
<svg
Expand Down Expand Up @@ -287,11 +302,18 @@ export default function AboutSection() {
</svg>
</div>
</Link>
<div className="mt-4 pt-3 border-t border-gray-700/40">
<DocsSourceActions
filePath="direct/binding.md"
projectId="kubestellar"
pageTitle={t("card3Title")}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
}
}