Files
v10/site/src/utils/docs/routing.ts
T

398 lines
14 KiB
TypeScript

import type { AnySupportedStyle, Sidebar, SupportedFramework } from '@/types/docs';
import { sidebar as defaultSidebar } from '@/config/docs/sidebar';
import { DEFAULT_FRAMEWORK, getDefaultStyle, isValidFramework, isValidStyleForFramework } from '@/types/docs';
import { findFirstGuide, findGuideBySlug, getValidFrameworksForGuide, getValidStylesForGuide, isItemVisible } from './sidebar';
/**
* Build a docs URL from framework, style, and guide slug components.
*/
export function buildDocsUrl(framework: SupportedFramework, style: AnySupportedStyle, guideSlug: string): string {
return `/docs/framework/${framework}/style/${style}/${guideSlug}`;
}
/**
* Input for resolveIndexRedirect
*/
export interface IndexRedirectInput {
preferences: {
framework: string | null;
style: string | null;
};
params: {
framework?: string;
style?: string;
};
}
/**
* Output from resolveIndexRedirect
*/
export interface IndexRedirectResult {
url: string;
selectedFramework: SupportedFramework;
selectedStyle: AnySupportedStyle;
selectedSlug: string;
reason: string;
}
/**
* Resolve redirect for index pages (/docs, /docs/framework/X, /docs/framework/X/style/Y).
* Nothing is pinned - we must select framework, style, AND slug.
*
* Logic:
* 1. If params.framework AND params.style → validate both → find first guide
* 2. If params.framework only → validate → get style from preference (if valid for framework) or default → find first guide
* 3. If neither param → get both from preferences or defaults → find first guide
*
* @param input - The input containing preferences and params
* @param sidebar - Optional sidebar to search (defaults to main sidebar config)
*/
export function resolveIndexRedirect(input: IndexRedirectInput, sidebar: Sidebar = defaultSidebar): IndexRedirectResult {
const { preferences, params } = input;
let selectedFramework: SupportedFramework;
let selectedStyle: AnySupportedStyle;
let reason: string;
// Case 1: Both framework and style in params
if (params.framework && params.style) {
if (!isValidFramework(params.framework)) {
throw new Error(`Invalid framework param: ${params.framework}`);
}
if (!isValidStyleForFramework(params.framework, params.style)) {
throw new Error(`Invalid style param "${params.style}" for framework "${params.framework}"`);
}
selectedFramework = params.framework;
selectedStyle = params.style as AnySupportedStyle;
reason = 'Using validated params.framework and params.style';
} else if (params.framework) {
// Case 2: Only framework in params
if (!isValidFramework(params.framework)) {
throw new Error(`Invalid framework param: ${params.framework}`);
}
selectedFramework = params.framework;
// Try to use style preference if valid for this framework
if (preferences.style && isValidStyleForFramework(selectedFramework, preferences.style)) {
selectedStyle = preferences.style as AnySupportedStyle;
reason = 'Using params.framework and preferences.style';
} else {
selectedStyle = getDefaultStyle(selectedFramework);
reason = 'Using params.framework and default style (preference invalid or missing)';
}
} else {
// Case 3: No params - use preferences or defaults
// Try to use framework preference
if (preferences.framework && isValidFramework(preferences.framework)) {
selectedFramework = preferences.framework;
// Try to use style preference if valid for this framework
if (preferences.style && isValidStyleForFramework(selectedFramework, preferences.style)) {
selectedStyle = preferences.style as AnySupportedStyle;
reason = 'Using preferences.framework and preferences.style';
} else {
selectedStyle = getDefaultStyle(selectedFramework);
reason = 'Using preferences.framework and default style (style preference invalid or missing)';
}
} else {
// Use all defaults
selectedFramework = DEFAULT_FRAMEWORK;
selectedStyle = getDefaultStyle(selectedFramework);
reason = 'Using default framework and default style (no valid preferences)';
}
}
// Find the first guide for the selected framework and style
const selectedSlug = findFirstGuide(selectedFramework, selectedStyle, sidebar);
const url = buildDocsUrl(selectedFramework, selectedStyle, selectedSlug);
return {
url,
selectedFramework,
selectedStyle,
selectedSlug,
reason,
};
}
/**
* Input for resolveFrameworkChange
*/
export interface FrameworkChangeInput {
currentFramework: SupportedFramework;
currentStyle: AnySupportedStyle;
currentSlug: string;
newFramework: SupportedFramework;
}
/**
* Output from resolveFrameworkChange
*/
export interface FrameworkChangeResult {
url: string;
shouldReplace: boolean;
selectedFramework: SupportedFramework;
selectedStyle: AnySupportedStyle;
selectedSlug: string;
slugChanged: boolean;
reason: string;
}
/**
* Resolve URL when user changes framework selector.
* newFramework is PINNED (must keep), slug MAY change if not visible.
*
* Logic:
* 1. framework = newFramework (PINNED)
* 2. If currentStyle valid for newFramework → style = currentStyle
* Else → style = default style for newFramework
* 3. If currentSlug visible in (newFramework, style) → slug = currentSlug, shouldReplace = true
* Else → slug = first guide in (newFramework, style), shouldReplace = false
*
* @param input - The input containing current state and new framework
* @param sidebar - Optional sidebar to search (defaults to main sidebar config)
*/
export function resolveFrameworkChange(input: FrameworkChangeInput, sidebar: Sidebar = defaultSidebar): FrameworkChangeResult {
const { currentSlug, currentStyle, newFramework } = input;
if (!isValidFramework(newFramework)) {
throw new Error(`Invalid framework: ${newFramework}`);
}
const selectedFramework = newFramework; // PINNED
// Determine the style to use
let selectedStyle: AnySupportedStyle;
let styleAdjusted = false;
if (isValidStyleForFramework(newFramework, currentStyle)) {
selectedStyle = currentStyle;
} else {
selectedStyle = getDefaultStyle(newFramework);
styleAdjusted = true;
}
// Determine the slug to use
let selectedSlug: string;
let shouldReplace: boolean;
let slugChanged: boolean;
let reason: string;
const guide = findGuideBySlug(currentSlug, sidebar);
if (guide && isItemVisible(guide, selectedFramework, selectedStyle)) {
// Current slug is visible in the new framework/style combo
selectedSlug = currentSlug;
shouldReplace = true;
slugChanged = false;
reason = styleAdjusted
? 'Changed framework and style (current style invalid), kept slug (visible)'
: 'Changed framework, kept style and slug (both valid)';
} else {
// Current slug is not visible, find first guide
selectedSlug = findFirstGuide(selectedFramework, selectedStyle, sidebar);
shouldReplace = false;
slugChanged = true;
reason = styleAdjusted
? 'Changed framework and style (current style invalid), changed slug (not visible)'
: 'Changed framework, kept style, changed slug (slug not visible)';
}
const url = buildDocsUrl(selectedFramework, selectedStyle, selectedSlug);
return {
url,
shouldReplace,
selectedFramework,
selectedStyle,
selectedSlug,
slugChanged,
reason,
};
}
/**
* Input for resolveStyleChange
*/
export interface StyleChangeInput {
currentFramework: SupportedFramework;
currentStyle: AnySupportedStyle;
currentSlug: string;
newStyle: AnySupportedStyle;
}
/**
* Output from resolveStyleChange
*/
export interface StyleChangeResult {
url: string;
shouldReplace: boolean;
selectedFramework: SupportedFramework;
selectedStyle: AnySupportedStyle;
selectedSlug: string;
slugChanged: boolean;
reason: string;
}
/**
* Resolve URL when user changes style selector.
* newStyle is PINNED (must keep), slug MAY change if not visible.
*
* Logic:
* 1. framework = currentFramework (stays same)
* 2. style = newStyle (PINNED)
* 3. If currentSlug visible in (currentFramework, newStyle) → slug = currentSlug, shouldReplace = true
* Else → slug = first guide in (currentFramework, newStyle), shouldReplace = false
*
* @param input - The input containing current state and new style
* @param sidebar - Optional sidebar to search (defaults to main sidebar config)
*/
export function resolveStyleChange(input: StyleChangeInput, sidebar: Sidebar = defaultSidebar): StyleChangeResult {
const { currentFramework, currentSlug, newStyle } = input;
if (!isValidStyleForFramework(currentFramework, newStyle)) {
throw new Error(`Invalid style "${newStyle}" for framework "${currentFramework}"`);
}
const selectedFramework = currentFramework; // stays same
const selectedStyle = newStyle; // PINNED
// Determine the slug to use
let selectedSlug: string;
let shouldReplace: boolean;
let slugChanged: boolean;
let reason: string;
const guide = findGuideBySlug(currentSlug, sidebar);
if (guide && isItemVisible(guide, selectedFramework, selectedStyle)) {
// Current slug is visible in the new style
selectedSlug = currentSlug;
shouldReplace = true;
slugChanged = false;
reason = 'Changed style, kept slug (visible in new style)';
} else {
// Current slug is not visible, find first guide
selectedSlug = findFirstGuide(selectedFramework, selectedStyle, sidebar);
shouldReplace = false;
slugChanged = true;
reason = 'Changed style, changed slug (slug not visible in new style)';
}
const url = buildDocsUrl(selectedFramework, selectedStyle, selectedSlug);
return {
url,
shouldReplace,
selectedFramework,
selectedStyle,
selectedSlug,
slugChanged,
reason,
};
}
/**
* Input for resolveDocsLinkUrl
*/
export interface DocsLinkInput {
targetSlug: string;
contextFramework: SupportedFramework;
contextStyle: AnySupportedStyle;
}
/**
* Output from resolveDocsLinkUrl
*/
export interface DocsLinkResult {
url: string;
selectedFramework: SupportedFramework;
selectedStyle: AnySupportedStyle;
selectedSlug: string;
priorityLevel: 1 | 2 | 3 | 4;
reason: string;
}
/**
* Resolve the best URL for a guide slug link given current context.
* targetSlug is PINNED (must keep), framework and style MAY change.
*
* Logic (4-level priority cascade):
* 1. slug = targetSlug (PINNED)
* 2. Try to find best (framework, style) that supports targetSlug:
* - Priority 1: If targetSlug visible in (contextFramework, contextStyle) → use both (best UX)
* - Priority 2: If targetSlug visible in (contextFramework, X) for some style X → use (contextFramework, guide's first valid style for contextFramework)
* - Priority 3: If targetSlug visible in (X, contextStyle) for some framework X → use (guide's first framework that supports contextStyle, contextStyle)
* - Priority 4: Use (guide's first valid framework, guide's first valid style for that framework)
*
* @param input - The input containing target slug and context
* @param sidebar - Optional sidebar to search (defaults to main sidebar config)
*/
export function resolveDocsLinkUrl(input: DocsLinkInput, sidebar: Sidebar = defaultSidebar): DocsLinkResult {
const { targetSlug, contextFramework, contextStyle } = input;
const guide = findGuideBySlug(targetSlug, sidebar);
if (!guide) {
throw new Error(`No guide found with slug "${targetSlug}"`);
}
if (!isValidFramework(contextFramework)) {
throw new Error(`Invalid context framework: ${contextFramework}`);
}
if (!isValidStyleForFramework(contextFramework, contextStyle)) {
throw new Error(`Invalid context style "${contextStyle}" for framework "${contextFramework}"`);
}
const selectedSlug = targetSlug; // PINNED
let selectedFramework: SupportedFramework;
let selectedStyle: AnySupportedStyle;
let priorityLevel: 1 | 2 | 3 | 4;
let reason: string;
// Priority 1: Try current framework + current style
const validStylesForContextFramework = getValidStylesForGuide(guide, contextFramework);
if (validStylesForContextFramework.includes(contextStyle as any)) {
selectedFramework = contextFramework;
selectedStyle = contextStyle;
priorityLevel = 1;
reason = 'Priority 1: Kept both framework and style (slug visible in current context)';
} else if (validStylesForContextFramework.length > 0) {
// Priority 2: Try current framework + guide's first valid style for that framework
selectedFramework = contextFramework;
selectedStyle = validStylesForContextFramework[0];
priorityLevel = 2;
reason = 'Priority 2: Kept framework, changed style (slug not visible with current style)';
} else {
// Priority 3: Try guide's first valid framework that supports current style
const validFrameworks = getValidFrameworksForGuide(guide);
const frameworkThatSupportsContextStyle = validFrameworks.find(fw =>
getValidStylesForGuide(guide, fw).includes(contextStyle as any),
);
if (frameworkThatSupportsContextStyle) {
selectedFramework = frameworkThatSupportsContextStyle;
selectedStyle = contextStyle;
priorityLevel = 3;
reason = 'Priority 3: Changed framework, kept style (slug not visible with current framework)';
} else {
// Priority 4: Fallback - use guide's first valid framework + its first valid style
const fallbackFramework = validFrameworks[0];
const fallbackStyle = getValidStylesForGuide(guide, fallbackFramework)[0];
selectedFramework = fallbackFramework;
selectedStyle = fallbackStyle;
priorityLevel = 4;
reason = 'Priority 4: Changed both framework and style (slug not visible in current context)';
}
}
const url = buildDocsUrl(selectedFramework, selectedStyle, selectedSlug);
return {
url,
selectedFramework,
selectedStyle,
selectedSlug,
priorityLevel,
reason,
};
}