diff --git a/src/components/docs/DocsLayout.tsx b/src/components/docs/DocsLayout.tsx index f3c67042..42756a57 100644 --- a/src/components/docs/DocsLayout.tsx +++ b/src/components/docs/DocsLayout.tsx @@ -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; @@ -71,7 +71,11 @@ export function DocsLayout({ children, pageMap, toc, metadata, filePath, project {/* Edit page icon - top right */} {filePath && projectId && (
- +
)} diff --git a/src/components/docs/DocsSourceActions.tsx b/src/components/docs/DocsSourceActions.tsx new file mode 100644 index 00000000..e17600ef --- /dev/null +++ b/src/components/docs/DocsSourceActions.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useSharedConfig } from "@/hooks/useSharedConfig"; +import type { ProjectId } from "@/config/versions"; + +const STATIC_EDIT_BASE_URLS: Record = { + 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 | 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 ( +
+ Compose a PR + View Source + Open Issue +
+ ); +} + +function ActionLink({ + href, + children, +}: { + href: string; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/components/docs/EditPageLink.tsx b/src/components/docs/EditPageLink.tsx index e7c58e3d..100e029d 100644 --- a/src/components/docs/EditPageLink.tsx +++ b/src/components/docs/EditPageLink.tsx @@ -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 = { + 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 = { - 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 { @@ -111,31 +33,32 @@ function isValidGitHubEditUrl(url: string): boolean { } } -export function EditPageLink({ filePath, projectId, variant = 'full' }: EditPageLinkProps) { - const { config } = useSharedConfig(); - const [currentBranch, setCurrentBranch] = useState('main'); +//refactoring helper function to build the GitHub edit URL +export function buildGitHubEditUrl( + filePath: string, + projectId: ProjectId, + editBaseUrls?: Record +): 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; diff --git a/src/components/master-page/AboutSection.tsx b/src/components/master-page/AboutSection.tsx index d017fe2d..b259e553 100644 --- a/src/components/master-page/AboutSection.tsx +++ b/src/components/master-page/AboutSection.tsx @@ -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() { @@ -127,7 +128,7 @@ export default function AboutSection() {
-
+
{/* Icon with animation */}
+
+ +
@@ -183,7 +191,7 @@ export default function AboutSection() {
-
+
{/* Icon with animation */}
+
+ +
@@ -239,7 +254,7 @@ export default function AboutSection() {
-
+
{/* Icon with animation */}
+
+ +
@@ -294,4 +316,4 @@ export default function AboutSection() {
); -} +} \ No newline at end of file