mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
[Docs][Site] + [Dashboard][UI] - Adds docs to Stack Companion (#869)
<!--
Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md
-->
<!-- ELLIPSIS_HIDDEN -->
----
> [!IMPORTANT]
> Adds a unified documentation widget to the dashboard, enabling in-app
viewing and switching of documentation types with platform-specific
adaptations.
>
> - **Behavior**:
> - Adds `UnifiedDocsWidget` to `stack-companion.tsx` for viewing docs
within the dashboard.
> - Supports platform switching, back navigation, sidebar toggle,
loading/error states, and external opening.
> - Adapts content based on current page across dashboard, docs, and
API.
> - **Documentation**:
> - Adds embedded routes/layouts in `docs/src/app` for `api-embed`,
`dashboard-embed`, and `docs-embed`.
> - Implements `EmbeddedLinkInterceptor` and `PlatformChangeNotifier`
for link handling and platform change notifications.
> - Updates `generate-docs.js` to include dashboard docs generation.
> - **Configuration**:
> - Adds `NEXT_PUBLIC_STACK_DOCS_BASE_URL` to `.env.development` and
`env.tsx`.
> - Configures CORS headers in `next.config.mjs` for dashboard
embedding.
> - **Misc**:
> - Updates styling in `global.css` to support embedded content.
> - Adds `EmbeddedLink` component for MDX link handling in
`mdx-components.tsx`.
>
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for 5760b90ea6. You can
[customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this
summary. It will automatically update as commits are pushed.</sup>
----
<!-- ELLIPSIS_HIDDEN -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Unified embedded docs viewer added to the dashboard with multi-type
support, navigation controls, back navigation, and external-open
behavior
* In-iframe link interception and MDX embedded-link support for seamless
embedded navigation
* **Style**
* Improved CSS for embedded content: scrollbar hiding, overflow
handling, responsive media and code blocks
* **Chores**
* Added dashboard docs collection, embed routes/layouts, CORS headers,
and env config for docs embedding
* **UX**
* Consolidated account UI in mobile header; improved auth panel
open/close animations
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
parent
40d878d304
commit
3538638406
@ -3,6 +3,7 @@ NEXT_PUBLIC_STACK_API_URL=# enter your stack endpoint here, For local developmen
|
||||
NEXT_PUBLIC_STACK_PROJECT_ID=internal
|
||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=# enter your Stack publishable client key here. For local development, just enter a random string, then run `pnpm db:reset`
|
||||
STACK_SECRET_SERVER_KEY=# enter your Stack secret client key here. For local development, do the same as above
|
||||
NEXT_PUBLIC_STACK_DOCS_BASE_URL=# enter the base URL of the docs here
|
||||
NEXT_PUBLIC_STACK_EXTRA_REQUEST_HEADERS=# a list of extra request headers to add to all Stack Auth API requests, as a JSON record
|
||||
NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY=# enter your Stripe publishable key here
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
NEXT_PUBLIC_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02
|
||||
NEXT_PUBLIC_STACK_DOCS_BASE_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}04
|
||||
|
||||
NEXT_PUBLIC_STACK_PROJECT_ID=internal
|
||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
|
||||
|
||||
@ -9,6 +9,7 @@ import packageJson from '../../package.json';
|
||||
import { FeedbackForm } from './feedback-form';
|
||||
import { ChangelogWidget } from './stack-companion/changelog-widget';
|
||||
import { FeatureRequestBoard } from './stack-companion/feature-request-board';
|
||||
import { UnifiedDocsWidget } from './stack-companion/unified-docs-widget';
|
||||
|
||||
type StackCompanionProps = {
|
||||
className?: string,
|
||||
@ -362,26 +363,7 @@ export function StackCompanion({ className, onExpandedChange }: StackCompanionPr
|
||||
}
|
||||
`}</style>
|
||||
{activeItem === 'docs' && (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => window.open('https://docs.stack-auth.com', '_blank')}
|
||||
className="w-full bg-muted/30 hover:bg-muted/50 rounded-lg p-4 text-center transition-colors cursor-pointer group"
|
||||
>
|
||||
<BookOpen className="h-6 w-6 mx-auto mb-2 text-blue-600 group-hover:text-blue-700" />
|
||||
<p className="text-xs text-foreground group-hover:text-blue-600 font-medium">
|
||||
Access Stack Auth Documentation
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Click to open docs.stack-auth.com
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground italic">
|
||||
Interactive dashboard docs coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UnifiedDocsWidget isActive={true} />
|
||||
)}
|
||||
|
||||
{activeItem === 'feedback' && (
|
||||
|
||||
@ -0,0 +1,404 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, BookOpen, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getPublicEnvVar } from '../../lib/env';
|
||||
|
||||
type UnifiedDocsWidgetProps = {
|
||||
isActive: boolean,
|
||||
};
|
||||
|
||||
type DocContent = {
|
||||
title: string,
|
||||
url: string,
|
||||
type: 'dashboard' | 'docs' | 'api',
|
||||
};
|
||||
|
||||
type DocType = 'dashboard' | 'docs' | 'api';
|
||||
|
||||
const PROD_DOCS_ORIGIN = 'https://docs.stack-auth.com';
|
||||
const LOCAL_DOCS_ORIGIN = 'http://localhost:8104';
|
||||
|
||||
const isLocalHostname = (hostname: string): boolean => {
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
};
|
||||
|
||||
const isAllowedDocsUrl = (url: URL): boolean => {
|
||||
if (isLocalHostname(url.hostname)) {
|
||||
// Permit localhost/127.0.0.1 for local development regardless of port
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:') return false;
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (hostname === 'docs.stack-auth.com') return true;
|
||||
return hostname.endsWith('.stack-auth.com');
|
||||
};
|
||||
|
||||
const isLocalEnvironment = (): boolean => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return isLocalHostname(window.location.hostname);
|
||||
}
|
||||
|
||||
return process.env.NODE_ENV === 'development';
|
||||
};
|
||||
|
||||
// Get the docs base URL from environment variable with fallback
|
||||
const getDocsBaseUrl = (): string => {
|
||||
const fallbackOrigin = isLocalEnvironment() ? LOCAL_DOCS_ORIGIN : PROD_DOCS_ORIGIN;
|
||||
|
||||
// Use centralized environment variable system
|
||||
const docsBaseUrl = getPublicEnvVar('NEXT_PUBLIC_STACK_DOCS_BASE_URL');
|
||||
if (docsBaseUrl) {
|
||||
try {
|
||||
const parsedUrl = new URL(docsBaseUrl);
|
||||
|
||||
if (isAllowedDocsUrl(parsedUrl)) {
|
||||
return parsedUrl.origin;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
'[UnifiedDocsWidget] Ignoring untrusted docs base URL from env:',
|
||||
parsedUrl.origin
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[UnifiedDocsWidget] Invalid docs base URL provided via env:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback logic for when env var is not set
|
||||
return fallbackOrigin;
|
||||
};
|
||||
|
||||
// Route patterns for matching dashboard pages
|
||||
const DASHBOARD_ROUTE_PATTERNS = [
|
||||
// Main dashboard routes (match your actual dashboard URLs)
|
||||
// Users
|
||||
{ pattern: /\/users(?:\/.*)?$/, docPage: 'users' },
|
||||
{ pattern: /\/auth-methods(?:\/.*)?$/, docPage: 'auth-methods' },
|
||||
|
||||
// Teams
|
||||
{ pattern: /\/teams(?:\/.*)?$/, docPage: 'orgs-and-teams' },
|
||||
{ pattern: /\/team-permissions(?:\/.*)?$/, docPage: 'team-permissions' },
|
||||
|
||||
// Emails
|
||||
{ pattern: /\/emails(?:\/.*)?$/, docPage: 'emails' },
|
||||
|
||||
// Payments
|
||||
// TODO: Add Docs for payments here
|
||||
|
||||
// Configuration
|
||||
{ pattern: /\/domains(?:\/.*)?$/, docPage: 'domains' },
|
||||
{ pattern: /\/webhooks(?:\/.*)?$/, docPage: 'webhooks' },
|
||||
{ pattern: /\/api-keys(?:\/.*)?$/, docPage: 'stack-auth-keys' },
|
||||
{ pattern: /\/project-settings(?:\/.*)?$/, docPage: 'project-settings' },
|
||||
];
|
||||
|
||||
// Get the dashboard page name from the current pathname
|
||||
const getDashboardPage = (path: string): string => {
|
||||
// Normalize the path by removing the projects/<projectId> prefix
|
||||
const normalizedPath = path.replace(/^\/projects\/[^/]+/, '');
|
||||
|
||||
// Find the first matching pattern
|
||||
for (const { pattern, docPage } of DASHBOARD_ROUTE_PATTERNS) {
|
||||
if (pattern.test(normalizedPath)) {
|
||||
return docPage;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to overview for root dashboard or unmatched routes
|
||||
return 'overview';
|
||||
};
|
||||
|
||||
// Get documentation URL and title for the current page and doc type
|
||||
const DASHBOARD_TO_DOCS_MAP = new Map<string, { path: string, title: string }>([
|
||||
['overview', { path: 'overview', title: 'Stack Auth Overview' }],
|
||||
['users', { path: 'getting-started/users', title: 'User Management' }],
|
||||
['auth-methods', { path: 'concepts/auth-providers', title: 'Authentication Providers' }],
|
||||
['orgs-and-teams', { path: 'concepts/orgs-and-teams', title: 'Teams & Organizations' }],
|
||||
['team-permissions', { path: 'concepts/permissions#team-permissions', title: 'Team Permissions' }],
|
||||
['emails', { path: 'concepts/emails', title: 'Emails' }],
|
||||
['domains', { path: 'getting-started/production#domains', title: 'Domains' }],
|
||||
['webhooks', { path: 'concepts/webhooks', title: 'Webhooks' }],
|
||||
['stack-auth-keys', { path: 'getting-started/setup#update-api-keys', title: 'Stack Auth Keys' }],
|
||||
['project-settings', { path: 'getting-started/production#enabling-production-mode', title: 'Project Configuration' }],
|
||||
]);
|
||||
|
||||
const getDocContentForPath = (path: string, docType: DocType): DocContent => {
|
||||
switch (docType) {
|
||||
case 'dashboard': {
|
||||
const page = getDashboardPage(path);
|
||||
|
||||
const docMapping = DASHBOARD_TO_DOCS_MAP.get(page);
|
||||
if (!docMapping) {
|
||||
throw new Error(`No documentation mapping found for dashboard page: ${page}`);
|
||||
}
|
||||
const url = `${getDocsBaseUrl()}/docs-embed/${docMapping.path}`;
|
||||
const title = docMapping.title;
|
||||
return { title, url, type: 'dashboard' };
|
||||
}
|
||||
case 'docs': {
|
||||
// Default to getting started for main docs
|
||||
const url = `${getDocsBaseUrl()}/docs-embed/getting-started/setup`;
|
||||
const title = 'Stack Auth Documentation';
|
||||
return { title, url, type: 'docs' };
|
||||
}
|
||||
case 'api': {
|
||||
// Default to overview for API docs
|
||||
const url = `${getDocsBaseUrl()}/api-embed/overview`;
|
||||
const title = 'API Reference';
|
||||
return { title, url, type: 'api' };
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown doc type: ${docType}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function UnifiedDocsWidget({ isActive }: UnifiedDocsWidgetProps) {
|
||||
const pathname = usePathname();
|
||||
const [selectedDocType, setSelectedDocType] = useState<DocType>('dashboard');
|
||||
const [docContent, setDocContent] = useState<DocContent | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSwitchPrompt, setShowSwitchPrompt] = useState(false);
|
||||
const [currentPageDoc, setCurrentPageDoc] = useState<string>('');
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [iframeRef, setIframeRef] = useState<HTMLIFrameElement | null>(null);
|
||||
|
||||
// Load documentation when the component becomes active, doc type changes, or pathname changes
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
const newPageDoc = getDashboardPage(pathname);
|
||||
|
||||
// If this is the first time opening or doc type changed
|
||||
if (!docContent || docContent.type !== selectedDocType) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCurrentPageDoc(newPageDoc);
|
||||
setCanGoBack(false);
|
||||
|
||||
try {
|
||||
const page = getDashboardPage(pathname);
|
||||
console.log('Debug mapping:', {
|
||||
pathname,
|
||||
normalizedPath: pathname.replace(/^\/projects\/[^/]+/, ''),
|
||||
detectedPage: page
|
||||
});
|
||||
const content = getDocContentForPath(pathname, selectedDocType);
|
||||
console.log('Loading docs:', { page, url: content.url });
|
||||
setDocContent(content);
|
||||
} catch (err) {
|
||||
console.error('Failed to load documentation:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load documentation');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
// If we already have content loaded but user switched to a different dashboard page (only relevant for dashboard docs)
|
||||
else if (selectedDocType === 'dashboard' && currentPageDoc !== newPageDoc) {
|
||||
setShowSwitchPrompt(true);
|
||||
}
|
||||
}
|
||||
}, [isActive, pathname, selectedDocType, docContent, currentPageDoc]);
|
||||
|
||||
// Listen for navigation state updates from the embedded docs iframe
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
let expectedOrigin: string | null = null;
|
||||
try {
|
||||
expectedOrigin = new URL(getDocsBaseUrl()).origin;
|
||||
} catch (error) {
|
||||
console.debug('Unable to resolve docs origin', error);
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (expectedOrigin && event.origin !== expectedOrigin) return;
|
||||
if (!event.data || typeof event.data !== 'object') return;
|
||||
|
||||
const data = event.data as { type?: unknown, canGoBack?: unknown };
|
||||
if (data.type === 'DOCS_HISTORY_STATE' && typeof data.canGoBack === 'boolean') {
|
||||
setCanGoBack(data.canGoBack);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
// Handle iframe load events
|
||||
const handleIframeLoad = (event: React.SyntheticEvent<HTMLIFrameElement>) => {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setIframeRef(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleIframeError = () => {
|
||||
setError('Failed to load documentation');
|
||||
setLoading(false);
|
||||
setCanGoBack(false);
|
||||
};
|
||||
|
||||
// Handle switching to current page's documentation
|
||||
const handleSwitchToDocs = () => {
|
||||
const newPageDoc = getDashboardPage(pathname);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCurrentPageDoc(newPageDoc);
|
||||
setShowSwitchPrompt(false);
|
||||
setCanGoBack(false);
|
||||
|
||||
try {
|
||||
const content = getDocContentForPath(pathname, selectedDocType);
|
||||
setDocContent(content);
|
||||
} catch (err) {
|
||||
console.error('Failed to load documentation:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load documentation');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle dismissing the switch prompt
|
||||
const handleDismissSwitch = () => {
|
||||
setShowSwitchPrompt(false);
|
||||
};
|
||||
|
||||
// Handle back button click
|
||||
const handleGoBack = () => {
|
||||
if (!iframeRef?.contentWindow) {
|
||||
setCanGoBack(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCanGoBack(false);
|
||||
|
||||
try {
|
||||
const src = iframeRef.getAttribute('src') ?? docContent?.url ?? getDocsBaseUrl();
|
||||
const targetOrigin = new URL(src, window.location.href).origin;
|
||||
iframeRef.contentWindow.postMessage(
|
||||
{ type: 'NAVIGATE_BACK' },
|
||||
targetOrigin
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Failed to request docs back navigation', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
{/* Switch Prompt for Dashboard Docs */}
|
||||
{showSwitchPrompt && selectedDocType === 'dashboard' && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-3">
|
||||
<div className="text-blue-800 dark:text-blue-200 text-xs">
|
||||
<strong>Page changed:</strong> Switch to docs for this page?
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={handleSwitchToDocs}
|
||||
className="text-xs bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded"
|
||||
>
|
||||
Switch
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismissSwitch}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
Keep current
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 mb-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="text-red-600 dark:text-red-400 text-xs">
|
||||
<strong>Failed to load docs:</strong> {error}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCanGoBack(false);
|
||||
try {
|
||||
const content = getDocContentForPath(pathname, selectedDocType);
|
||||
setDocContent(content);
|
||||
} catch (err) {
|
||||
console.error('Retry failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load documentation');
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
className="mt-2 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content - Iframe */}
|
||||
{docContent && !error && (
|
||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||
{/* Loading Overlay */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<div className="text-xs text-muted-foreground">Loading documentation...</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header with controls */}
|
||||
<div className="pb-2 mb-3 border-b space-y-2">
|
||||
{/* Top row: back button, title, external link */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs font-medium rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Go back to previous page"
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<BookOpen className="h-3 w-3 text-muted-foreground" />
|
||||
<h4 className="text-xs font-medium text-muted-foreground">{docContent.title}</h4>
|
||||
</div>
|
||||
<a
|
||||
href={docContent.url.replace('/docs-embed/', '/docs/').replace('/api-embed/', '/api/')} // Convert embed URLs to full URLs
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Open in new tab"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Iframe */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<iframe
|
||||
key={docContent.url} // Force iframe reload when URL changes
|
||||
src={docContent.url}
|
||||
className="w-full h-full border-0 rounded-md"
|
||||
onLoad={handleIframeLoad}
|
||||
onError={handleIframeError}
|
||||
title={docContent.title}
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -20,6 +20,7 @@ const _inlineEnvVars = {
|
||||
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS: process.env.NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS,
|
||||
NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY,
|
||||
NEXT_PUBLIC_STACK_PORT_PREFIX: process.env.NEXT_PUBLIC_STACK_PORT_PREFIX,
|
||||
NEXT_PUBLIC_STACK_DOCS_BASE_URL: process.env.NEXT_PUBLIC_STACK_DOCS_BASE_URL,
|
||||
|
||||
|
||||
// TODO: NEXT_PUBLIC_BROWSER_STACK_API_URL should be renamed to NEXT_PUBLIC_STACK_BROWSER_API_URL
|
||||
@ -59,6 +60,7 @@ const _postBuildEnvVars = {
|
||||
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS",
|
||||
NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY",
|
||||
NEXT_PUBLIC_STACK_PORT_PREFIX: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_PORT_PREFIX",
|
||||
NEXT_PUBLIC_STACK_DOCS_BASE_URL: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_DOCS_BASE_URL",
|
||||
} satisfies typeof _inlineEnvVars;
|
||||
|
||||
// If this is not replaced with "true", then we will not use inline env vars
|
||||
|
||||
1
docs/.gitignore
vendored
1
docs/.gitignore
vendored
@ -29,6 +29,7 @@ next-env.d.ts
|
||||
|
||||
# ignore generated API content
|
||||
/content/api/
|
||||
/content/dashboard/
|
||||
/public/openapi/
|
||||
/openapi/
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { api, docs } from '@/.source';
|
||||
import { api, dashboard, docs } from '@/.source';
|
||||
import { loader } from 'fumadocs-core/source';
|
||||
import { attachFile } from 'fumadocs-openapi/server';
|
||||
import { icons } from 'lucide-react';
|
||||
@ -38,3 +38,13 @@ export const apiSource = loader({
|
||||
},
|
||||
icon: createIconResolver(),
|
||||
});
|
||||
|
||||
// Dashboard source for /dashboard routes
|
||||
export const dashboardSource = loader({
|
||||
baseUrl: '/dashboard',
|
||||
source: dashboard.toFumadocsSource(),
|
||||
pageTree: {
|
||||
attachFile,
|
||||
},
|
||||
icon: createIconResolver(),
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { createMDX } from 'fumadocs-mdx/next';
|
||||
|
||||
const withMDX = createMDX();
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_STACK_DASHBOARD_URL || 'http://localhost:8101';
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
@ -9,6 +10,46 @@ const config = {
|
||||
// Temporarily disable ESLint during builds for Vercel deployment
|
||||
ignoreDuringBuilds: false,
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
// Allow CORS for dashboard routes to be accessed by the dashboard app
|
||||
source: '/dashboard/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Access-Control-Allow-Origin',
|
||||
value: dashboardUrl, // Dashboard app origin
|
||||
},
|
||||
{
|
||||
key: 'Access-Control-Allow-Methods',
|
||||
value: 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
},
|
||||
{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value: 'Content-Type, Authorization',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Allow CORS for embedded dashboard routes to be accessed by the dashboard app
|
||||
source: '/dashboard-embed/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Access-Control-Allow-Origin',
|
||||
value: dashboardUrl, // Dashboard app origin
|
||||
},
|
||||
{
|
||||
key: 'Access-Control-Allow-Methods',
|
||||
value: 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
},
|
||||
{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value: 'Content-Type, Authorization',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
// Include OpenAPI files in output tracing for Vercel deployments
|
||||
outputFileTracingIncludes: {
|
||||
'/**/*': ['./content/**/*', './openapi/**/*'],
|
||||
|
||||
@ -27,6 +27,17 @@ export const api = defineDocs({
|
||||
},
|
||||
});
|
||||
|
||||
// Separate collection for Dashboard content
|
||||
export const dashboard = defineDocs({
|
||||
dir: './content/dashboard',
|
||||
docs: {
|
||||
schema: frontmatterSchema,
|
||||
},
|
||||
meta: {
|
||||
schema: metaSchema,
|
||||
},
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
mdxOptions: {
|
||||
// MDX options
|
||||
|
||||
24
docs/src/app/api-embed/[[...slug]]/page.tsx
Normal file
24
docs/src/app/api-embed/[[...slug]]/page.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { getEmbeddedMDXComponents } from '@/mdx-components';
|
||||
import { apiSource } from 'lib/source';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function ApiEmbedPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug?: string[] }>,
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const page = apiSource.getPage(slug ?? []);
|
||||
|
||||
if (!page) redirect("/");
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<div className="p-6 prose prose-neutral dark:prose-invert max-w-none overflow-x-hidden">
|
||||
<div className="w-full">
|
||||
<MDX components={getEmbeddedMDXComponents()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
docs/src/app/api-embed/layout.tsx
Normal file
16
docs/src/app/api-embed/layout.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { EmbeddedLinkInterceptor } from '@/components/embedded-link-interceptor';
|
||||
|
||||
// Embedded layout for API docs - no navbar, optimized for iframe
|
||||
export default function ApiEmbedLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-fd-background">
|
||||
<EmbeddedLinkInterceptor />
|
||||
{/* Main content area - no header, no padding, prevent horizontal overflow */}
|
||||
<main className="overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-hide">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
docs/src/app/dashboard-embed/[[...slug]]/page.tsx
Normal file
24
docs/src/app/dashboard-embed/[[...slug]]/page.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { getEmbeddedMDXComponents } from '@/mdx-components';
|
||||
import { dashboardSource } from 'lib/source';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function DashboardEmbedPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug?: string[] }>,
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const page = dashboardSource.getPage(slug ?? []);
|
||||
|
||||
if (!page) redirect("/");
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<div className="p-6 prose prose-neutral dark:prose-invert max-w-none overflow-x-hidden">
|
||||
<div className="w-full">
|
||||
<MDX components={getEmbeddedMDXComponents()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
docs/src/app/dashboard-embed/layout.tsx
Normal file
16
docs/src/app/dashboard-embed/layout.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { EmbeddedLinkInterceptor } from '@/components/embedded-link-interceptor';
|
||||
|
||||
// Embedded layout for dashboard docs - no navbar, optimized for iframe
|
||||
export default function DashboardEmbedLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-fd-background">
|
||||
<EmbeddedLinkInterceptor />
|
||||
{/* Main content area - no header, no padding, prevent horizontal overflow */}
|
||||
<main className="h-screen overflow-hidden">
|
||||
<div className="h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
docs/src/app/docs-embed/[[...slug]]/page.tsx
Normal file
43
docs/src/app/docs-embed/[[...slug]]/page.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import {
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsPage,
|
||||
DocsTitle,
|
||||
} from '@/components/layouts/page';
|
||||
import { getEmbeddedMDXComponents } from '@/mdx-components';
|
||||
import { source } from 'lib/source';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function DocsEmbedPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug?: string[] }>,
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
|
||||
// If no slug provided, redirect to overview
|
||||
if (!slug || slug.length === 0) {
|
||||
redirect('/docs-embed/overview');
|
||||
}
|
||||
|
||||
const page = source.getPage(slug);
|
||||
|
||||
if (!page) {
|
||||
// Redirect to overview if page not found
|
||||
redirect('/docs-embed/overview');
|
||||
}
|
||||
|
||||
const MDXContent = page.data.body;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
{page.data.description && page.data.description.trim() && (
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
)}
|
||||
<DocsBody>
|
||||
<MDXContent components={getEmbeddedMDXComponents()} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
32
docs/src/app/docs-embed/layout.tsx
Normal file
32
docs/src/app/docs-embed/layout.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { EmbeddedDocsMessageBridge } from '@/components/embedded-docs-message-bridge';
|
||||
import { DocsHeaderWrapper } from '@/components/layouts/docs-header-wrapper';
|
||||
import { DynamicDocsLayout } from '@/components/layouts/docs-layout-router';
|
||||
import { DocsLayoutWrapper } from '@/components/layouts/docs-layout-wrapper';
|
||||
import { SidebarProvider } from '@/components/layouts/sidebar-context';
|
||||
import { source } from 'lib/source';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Embedded layout for main docs - includes full header and sidebar for iframe
|
||||
export default function DocsEmbedLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<DocsLayoutWrapper>
|
||||
<EmbeddedDocsMessageBridge />
|
||||
{/* Docs Header Wrapper - provides navigation and platform selector */}
|
||||
<DocsHeaderWrapper
|
||||
showSearch={false}
|
||||
pageTree={source.pageTree}
|
||||
/>
|
||||
|
||||
{/* Docs Layout Content - with full sidebar */}
|
||||
<div>
|
||||
<DynamicDocsLayout
|
||||
tree={source.pageTree}
|
||||
>
|
||||
{children}
|
||||
</DynamicDocsLayout>
|
||||
</div>
|
||||
</DocsLayoutWrapper>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@ -200,6 +200,59 @@ button:not(.chat-gradient-active)::before {
|
||||
transition: mask 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Hide scrollbars for embedded iframe content */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
|
||||
/* Prevent horizontal overflow in embedded iframe content */
|
||||
.prose {
|
||||
/* Only break words when absolutely necessary, not all the time */
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
/* Remove aggressive word-break that was breaking normal text */
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
/* Prevent code blocks from causing horizontal scroll */
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
/* Only break very long unbreakable strings, not normal code */
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
/* Make tables responsive */
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
/* Ensure images don't overflow */
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Only break URLs and very long unbreakable strings */
|
||||
.prose a[href*="://"] {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Break very long strings that have no spaces (like tokens, hashes, etc.) */
|
||||
.prose code:not(pre code) {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Ensure EnhancedAPIPage components fill their container */
|
||||
.api-content-container {
|
||||
width: 100%;
|
||||
|
||||
@ -211,7 +211,12 @@ export function AuthPanel() {
|
||||
</div>
|
||||
|
||||
{/* Mobile Auth Panel */}
|
||||
<div className="md:hidden fixed inset-0 z-50 flex flex-col bg-fd-background">
|
||||
<div
|
||||
className={`md:hidden fixed inset-0 z-50 flex flex-col bg-fd-background transition-all duration-300 ease-out ${
|
||||
isAuthOpen ? 'translate-x-0 opacity-100 pointer-events-auto' : 'translate-x-full opacity-0 pointer-events-none'
|
||||
}`}
|
||||
aria-hidden={!isAuthOpen}
|
||||
>
|
||||
{/* Mobile Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-fd-border bg-fd-background">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
134
docs/src/components/embedded-docs-message-bridge.tsx
Normal file
134
docs/src/components/embedded-docs-message-bridge.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
type HistoryStateMessage = {
|
||||
type: 'DOCS_HISTORY_STATE',
|
||||
canGoBack: boolean,
|
||||
pathname: string,
|
||||
};
|
||||
|
||||
type ParentMessage =
|
||||
| { type: 'NAVIGATE_BACK' }
|
||||
| { type: string }; // Allow for future message types
|
||||
|
||||
const getAllowedParentOrigins = (): string[] => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return ['http://localhost:8101'];
|
||||
}
|
||||
|
||||
return ['https://app.stack-auth.com'];
|
||||
};
|
||||
|
||||
const resolveParentOrigin = (allowedOrigins: string[]): string | null => {
|
||||
try {
|
||||
if (typeof document !== 'undefined' && document.referrer) {
|
||||
const origin = new URL(document.referrer).origin;
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return origin;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Unable to derive parent origin from referrer', error);
|
||||
}
|
||||
|
||||
return allowedOrigins[0] ?? null;
|
||||
};
|
||||
|
||||
export function EmbeddedDocsMessageBridge() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const allowedOrigins = useMemo(getAllowedParentOrigins, []);
|
||||
const parentOrigin = useMemo(
|
||||
() => resolveParentOrigin(allowedOrigins),
|
||||
[allowedOrigins]
|
||||
);
|
||||
|
||||
const historyRef = useRef<string[]>([]);
|
||||
const indexRef = useRef<number>(-1);
|
||||
|
||||
const notifyParent = useCallback((canGoBack: boolean) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (window.parent === window) return;
|
||||
if (!parentOrigin) return;
|
||||
|
||||
const currentPath =
|
||||
historyRef.current[indexRef.current] ?? pathname;
|
||||
|
||||
const message: HistoryStateMessage = {
|
||||
type: 'DOCS_HISTORY_STATE',
|
||||
canGoBack,
|
||||
pathname: currentPath,
|
||||
};
|
||||
|
||||
try {
|
||||
window.parent.postMessage(message, parentOrigin);
|
||||
} catch (error) {
|
||||
console.debug('Failed to post history state to parent', error);
|
||||
}
|
||||
}, [parentOrigin, pathname]);
|
||||
|
||||
// Track navigation within the embedded docs to maintain a local history stack
|
||||
useEffect(() => {
|
||||
const history = historyRef.current;
|
||||
|
||||
if (indexRef.current === -1) {
|
||||
history.push(pathname);
|
||||
indexRef.current = history.length - 1;
|
||||
notifyParent(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath = history[indexRef.current];
|
||||
|
||||
if (pathname === currentPath) {
|
||||
notifyParent(indexRef.current > 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (indexRef.current < history.length - 1) {
|
||||
history.splice(indexRef.current + 1);
|
||||
}
|
||||
|
||||
history.push(pathname);
|
||||
indexRef.current = history.length - 1;
|
||||
notifyParent(indexRef.current > 0);
|
||||
}, [notifyParent, pathname]);
|
||||
|
||||
// Listen for commands from the parent (e.g., back navigation)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.source !== window.parent) return;
|
||||
if (!allowedOrigins.includes(event.origin)) return;
|
||||
if (!event.data) return;
|
||||
if (typeof event.data !== 'object') return;
|
||||
|
||||
const messageData = event.data as ParentMessage;
|
||||
if (messageData.type === 'NAVIGATE_BACK') {
|
||||
if (indexRef.current > 0) {
|
||||
const nextIndex = indexRef.current - 1;
|
||||
const targetPath = historyRef.current[nextIndex];
|
||||
|
||||
if (!targetPath) {
|
||||
notifyParent(false);
|
||||
return;
|
||||
}
|
||||
|
||||
indexRef.current = nextIndex;
|
||||
router.push(targetPath);
|
||||
} else {
|
||||
notifyParent(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [allowedOrigins, notifyParent, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
158
docs/src/components/embedded-link-interceptor.tsx
Normal file
158
docs/src/components/embedded-link-interceptor.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
// Map regular doc routes to embedded routes and resolve relative paths
|
||||
const getEmbeddedUrl = (href: string, currentPath: string): string => {
|
||||
// Ignore absolute-URL schemes (http:, https:, mailto:, tel:, javascript:, etc.)
|
||||
if (/^[a-zA-Z][a-zA-Z+\-.]*:/.test(href)) return href;
|
||||
|
||||
// Preserve query/hash
|
||||
const [pathAndQuery, hash = ''] = href.split('#', 2);
|
||||
const [rawPath, query = ''] = pathAndQuery.split('?', 2);
|
||||
let cleanPath = rawPath;
|
||||
|
||||
// Strip .md/.mdx
|
||||
if (cleanPath.endsWith('.md')) cleanPath = cleanPath.slice(0, -3);
|
||||
else if (cleanPath.endsWith('.mdx')) cleanPath = cleanPath.slice(0, -4);
|
||||
|
||||
// Remove leading ./ (relative indicator)
|
||||
if (cleanPath.startsWith('./')) cleanPath = cleanPath.slice(2);
|
||||
|
||||
// Already an embedded URL?
|
||||
if (
|
||||
cleanPath.startsWith('/docs-embed') ||
|
||||
cleanPath.startsWith('/api-embed') ||
|
||||
cleanPath.startsWith('/dashboard-embed')
|
||||
) {
|
||||
return withSuffix(cleanPath);
|
||||
}
|
||||
|
||||
// Absolute roots -> embedded roots
|
||||
if (cleanPath === '/docs' || cleanPath.startsWith('/docs/')) {
|
||||
return withSuffix(cleanPath.replace(/^\/docs(?=\/|$)/, '/docs-embed'));
|
||||
}
|
||||
if (cleanPath === '/api' || cleanPath.startsWith('/api/')) {
|
||||
return withSuffix(cleanPath.replace(/^\/api(?=\/|$)/, '/api-embed'));
|
||||
}
|
||||
if (cleanPath === '/dashboard' || cleanPath.startsWith('/dashboard/')) {
|
||||
return withSuffix(cleanPath.replace(/^\/dashboard(?=\/|$)/, '/dashboard-embed'));
|
||||
}
|
||||
|
||||
// Relative paths -> resolve against current embedded section (if present)
|
||||
if (!cleanPath.startsWith('/') && !cleanPath.startsWith('#')) {
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
if (parts.length >= 1 && parts[0].endsWith('-embed')) {
|
||||
const embedType = parts[0];
|
||||
const section = parts[1] ?? '';
|
||||
const base = section ? `/${embedType}/${section}/` : `/${embedType}/`;
|
||||
return withSuffix(normalizePath(base + cleanPath));
|
||||
}
|
||||
}
|
||||
|
||||
// Other absolute paths -> treat as relative to current embedded section
|
||||
if (cleanPath.startsWith('/')) {
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
if (parts.length >= 1 && parts[0].endsWith('-embed')) {
|
||||
const embedType = parts[0];
|
||||
const section = parts[1] ?? '';
|
||||
const base = section ? `/${embedType}/${section}/` : `/${embedType}/`;
|
||||
return withSuffix(normalizePath(base + (cleanPath.startsWith('/') ? cleanPath.slice(1) : cleanPath)));
|
||||
}
|
||||
}
|
||||
|
||||
return withSuffix(cleanPath);
|
||||
|
||||
function withSuffix(p: string) {
|
||||
const q = query ? `?${query}` : '';
|
||||
const h = hash ? `#${hash}` : '';
|
||||
return `${p}${q}${h}`;
|
||||
}
|
||||
function normalizePath(p: string) {
|
||||
const segs = p.split('/').filter(Boolean);
|
||||
const out: string[] = [];
|
||||
for (const s of segs) {
|
||||
if (s === '.') continue;
|
||||
if (s === '..') {
|
||||
out.pop();
|
||||
continue;
|
||||
}
|
||||
out.push(s);
|
||||
}
|
||||
return '/' + out.join('/');
|
||||
}
|
||||
};
|
||||
|
||||
export function EmbeddedLinkInterceptor() {
|
||||
const router = useRouter();
|
||||
|
||||
// Function to check if a URL exists
|
||||
const checkUrlExists = useCallback(async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Find the closest anchor tag
|
||||
const anchor = target.closest('a');
|
||||
if (!anchor) return;
|
||||
|
||||
const href = anchor.getAttribute('href');
|
||||
if (!href) return;
|
||||
|
||||
if (anchor.target === '_blank' || anchor.hasAttribute('download')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith('javascript:') || href.startsWith('data:') || href.startsWith('blob:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept internal links that need to be rewritten OR relative links
|
||||
if (
|
||||
href.startsWith('/docs/') ||
|
||||
href.startsWith('/api/') ||
|
||||
href.startsWith('/dashboard/') ||
|
||||
(!/^[a-zA-Z][a-zA-Z+\-.]*:/.test(href) && !href.startsWith('#'))
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const embeddedHref = getEmbeddedUrl(href, currentPath);
|
||||
const navigate = () => router.push(embeddedHref);
|
||||
|
||||
// Check if the URL exists before navigating (async operation)
|
||||
runAsynchronously(async () => {
|
||||
const urlExists = await checkUrlExists(embeddedHref);
|
||||
if (!urlExists) {
|
||||
console.warn(`Embedded link not found, navigating anyway: ${embeddedHref}`);
|
||||
}
|
||||
navigate();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add click listener to document
|
||||
document.addEventListener('click', handleClick);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [checkUrlExists, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -91,7 +91,7 @@ type AccordionContextType = {
|
||||
|
||||
const AccordionContext = createContext<AccordionContextType | null>(null);
|
||||
|
||||
function AccordionProvider({ children }: { children: ReactNode }) {
|
||||
export function AccordionProvider({ children }: { children: ReactNode }) {
|
||||
const [accordionState, setAccordionStateInternal] = useState<Record<string, boolean>>({});
|
||||
|
||||
const setAccordionState = (key: string, isOpen: boolean) => {
|
||||
@ -105,7 +105,7 @@ function AccordionProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function useAccordionState(key: string, defaultValue: boolean) {
|
||||
export function useAccordionState(key: string, defaultValue: boolean) {
|
||||
const context = useContext(AccordionContext);
|
||||
if (!context) {
|
||||
throw new Error('useAccordionState must be used within AccordionProvider');
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { CustomSearchDialog } from '@/components/layout/custom-search-dialog';
|
||||
import { SearchInputToggle } from '@/components/layout/custom-search-toggle';
|
||||
import { type NavLink } from '@/lib/navigation-utils';
|
||||
import { UserButton } from '@stackframe/stack';
|
||||
import { UserButton, useUser } from '@stackframe/stack';
|
||||
import { Key, Menu, Sparkles, TableOfContents, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
@ -202,6 +202,41 @@ function StackAuthLogo() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Account menu wrapper to keep the UserButton styling consistent
|
||||
* in the mobile navigation.
|
||||
*/
|
||||
function DocsAccountMenu({
|
||||
className,
|
||||
}: {
|
||||
className?: string,
|
||||
}) {
|
||||
const user = useUser();
|
||||
const isSignedIn = Boolean(user);
|
||||
const displayName = user?.displayName ?? 'Stack Auth';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-4 rounded-xl border border-fd-border/80 bg-fd-muted/30 px-4 py-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-fd-foreground">
|
||||
{isSignedIn ? displayName : 'Account'}
|
||||
</p>
|
||||
<p className="text-xs text-fd-muted-foreground">
|
||||
{isSignedIn ? 'Manage your Stack Auth profile and settings.' : 'Sign in to manage your Stack Auth account.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<UserButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SHARED HEADER COMPONENT
|
||||
*
|
||||
@ -378,6 +413,13 @@ export function SharedHeader({
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Top-level Navigation */}
|
||||
<div>
|
||||
{/* User Authentication */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-fd-foreground mb-4">Account</h2>
|
||||
<DocsAccountMenu />
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold text-fd-foreground mb-4">Navigation</h2>
|
||||
<div className="space-y-2">
|
||||
{navLinks.map((link, index) => {
|
||||
@ -403,13 +445,6 @@ export function SharedHeader({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Authentication */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-fd-foreground mb-4">Account</h2>
|
||||
<div className="flex justify-center">
|
||||
<UserButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
{sidebarContent && (
|
||||
|
||||
105
docs/src/components/mdx/embedded-link.tsx
Normal file
105
docs/src/components/mdx/embedded-link.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
type EmbeddedLinkProps = ComponentProps<'a'> & {
|
||||
isEmbedded?: boolean,
|
||||
};
|
||||
|
||||
// Map regular doc routes to embedded routes
|
||||
const getEmbeddedUrl = (href: string, currentPath?: string): string => {
|
||||
// Handle hash-only links (like #section)
|
||||
if (href.startsWith('#')) {
|
||||
return href;
|
||||
}
|
||||
|
||||
// Handle external links (http://, https://, mailto:, etc.)
|
||||
if (href.includes('://') || href.startsWith('mailto:')) {
|
||||
return href;
|
||||
}
|
||||
|
||||
// Handle absolute paths
|
||||
if (href.startsWith('/')) {
|
||||
// Already embedded - leave as is
|
||||
if (href.startsWith('/docs-embed/') || href.startsWith('/api-embed/') || href.startsWith('/dashboard-embed/')) {
|
||||
return href;
|
||||
}
|
||||
|
||||
// Convert regular doc routes to embedded routes
|
||||
if (href.startsWith('/docs/')) {
|
||||
return href.replace('/docs/', '/docs-embed/');
|
||||
}
|
||||
if (href.startsWith('/api/')) {
|
||||
return href.replace('/api/', '/api-embed/');
|
||||
}
|
||||
if (href.startsWith('/dashboard/')) {
|
||||
return href.replace('/dashboard/', '/dashboard-embed/');
|
||||
}
|
||||
|
||||
// Other absolute paths - leave as is
|
||||
return href;
|
||||
}
|
||||
|
||||
// Handle relative links (like ./setup.mdx or users.mdx)
|
||||
// These need to be resolved relative to the current embedded path
|
||||
if (currentPath && (currentPath.startsWith('/docs-embed/') || currentPath.startsWith('/api-embed/') || currentPath.startsWith('/dashboard-embed/'))) {
|
||||
// Remove .mdx extension if present
|
||||
const cleanHref = href.replace(/\.mdx?$/, '');
|
||||
|
||||
const hashIndex = cleanHref.indexOf('#');
|
||||
const queryIndex = cleanHref.indexOf('?');
|
||||
const splitIndex = hashIndex !== -1 && queryIndex !== -1
|
||||
? Math.min(hashIndex, queryIndex)
|
||||
: (hashIndex !== -1 ? hashIndex : queryIndex);
|
||||
const pathPart = splitIndex !== -1 ? cleanHref.substring(0, splitIndex) : cleanHref;
|
||||
const suffix = splitIndex !== -1 ? cleanHref.substring(splitIndex) : '';
|
||||
|
||||
// Get current directory
|
||||
const currentDir = currentPath.substring(0, currentPath.lastIndexOf('/'));
|
||||
|
||||
// Resolve relative path
|
||||
let resolvedPath: string;
|
||||
if (pathPart.startsWith('./')) {
|
||||
resolvedPath = `${currentDir}/${pathPart.substring(2)}`;
|
||||
} else if (pathPart.startsWith('../')) {
|
||||
// Go up one directory
|
||||
const parentDir = currentDir.substring(0, currentDir.lastIndexOf('/'));
|
||||
resolvedPath = `${parentDir}/${pathPart.substring(3)}`;
|
||||
} else {
|
||||
// Same directory
|
||||
resolvedPath = `${currentDir}/${pathPart}`;
|
||||
}
|
||||
return resolvedPath + suffix;
|
||||
}
|
||||
|
||||
// Fallback - return as is
|
||||
return href;
|
||||
};
|
||||
|
||||
export function EmbeddedLink({ href, isEmbedded, children, ...restProps }: EmbeddedLinkProps) {
|
||||
const currentPath = usePathname();
|
||||
|
||||
// Explicitly type props to exclude already-destructured properties
|
||||
const props = restProps as Omit<ComponentProps<'a'>, 'href' | 'children' | 'isEmbedded'>;
|
||||
|
||||
// If not embedded or no href, use regular link behavior
|
||||
if (!isEmbedded || !href) {
|
||||
return <a href={href} {...props}>{children}</a>;
|
||||
}
|
||||
|
||||
const embeddedHref = getEmbeddedUrl(href, currentPath);
|
||||
|
||||
// For internal links, use Next.js Link for better performance
|
||||
if (embeddedHref.startsWith('/')) {
|
||||
return (
|
||||
<Link href={embeddedHref} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// For external links, use regular anchor tag
|
||||
return <a href={embeddedHref} {...props}>{children}</a>;
|
||||
}
|
||||
@ -2,6 +2,7 @@ import * as CodeBlock from 'fumadocs-ui/components/codeblock';
|
||||
import { TypeTable } from 'fumadocs-ui/components/type-table';
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
// OpenAPI sources
|
||||
import { APIPage } from 'fumadocs-openapi/ui';
|
||||
@ -13,6 +14,7 @@ import { Card, CardGroup, Info } from './components/mdx';
|
||||
import ApiSequenceDiagram from './components/mdx/api-sequence-diagram';
|
||||
import { AuthCard } from './components/mdx/auth-card';
|
||||
import { DynamicCodeblock } from './components/mdx/dynamic-code-block';
|
||||
import { EmbeddedLink } from './components/mdx/embedded-link';
|
||||
import { PlatformCodeblock } from './components/mdx/platform-codeblock';
|
||||
|
||||
import { AsideSection, CollapsibleMethodSection, CollapsibleTypesSection, MethodAside, MethodContent, MethodLayout, MethodSection, MethodTitle } from './components/ui/method-layout';
|
||||
@ -94,3 +96,12 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
|
||||
img: (props) => <ImageZoom {...(props as any)} />,
|
||||
} as MDXComponents;
|
||||
}
|
||||
|
||||
// MDX components for embedded mode - includes link rewriting
|
||||
export function getEmbeddedMDXComponents(components?: MDXComponents): MDXComponents {
|
||||
return {
|
||||
...getMDXComponents(components),
|
||||
// Override the link component to use embedded URLs
|
||||
a: (props: ComponentProps<'a'>) => <EmbeddedLink {...props} isEmbedded={true} />,
|
||||
} as MDXComponents;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user