[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:
Madison 2025-10-30 20:01:30 -05:00 committed by GitHub
parent 40d878d304
commit 3538638406
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1141 additions and 32 deletions

View File

@ -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

View File

@ -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

View File

@ -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' && (

View File

@ -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>
);
}

View File

@ -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
View File

@ -29,6 +29,7 @@ next-env.d.ts
# ignore generated API content
/content/api/
/content/dashboard/
/public/openapi/
/openapi/

View File

@ -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(),
});

View File

@ -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/**/*'],

View File

@ -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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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%;

View File

@ -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">

View 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;
}

View 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;
}

View File

@ -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');

View File

@ -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 && (

View 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>;
}

View File

@ -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;
}