mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
chore: website tooling (#41)
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
type SupportedFramework,
|
||||
type SupportedStyle,
|
||||
} from '@/types/docs';
|
||||
import { findFirstGuide } from '@/utils/docs/sidebar';
|
||||
|
||||
/**
|
||||
* Get the redirect URL for a given framework and style combination.
|
||||
* Finds the first available guide and returns the full docs URL.
|
||||
* Throws an error if no guide is available (fails at build time).
|
||||
*
|
||||
* @param framework - The framework to redirect to
|
||||
* @param style - The style to redirect to
|
||||
* @returns The full docs URL to redirect to
|
||||
* @throws Error if no guide is available for the given framework/style
|
||||
*/
|
||||
export function getDocsRedirectUrl<F extends SupportedFramework>(
|
||||
framework: F,
|
||||
style: SupportedStyle<F>,
|
||||
): string {
|
||||
const firstGuide = findFirstGuide(framework, style);
|
||||
|
||||
if (!firstGuide) {
|
||||
throw new Error(
|
||||
`No guide available for framework "${framework}" and style "${style}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return `/docs/framework/${framework}/style/${style}/${firstGuide}/`;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
type SupportedFramework,
|
||||
type SupportedStyle,
|
||||
type AnySupportedStyle,
|
||||
getAvailableStyles,
|
||||
type Guide,
|
||||
type Section,
|
||||
type Sidebar,
|
||||
isSection,
|
||||
} from '@/types/docs';
|
||||
import { sidebar } from '@/config/docs/sidebar';
|
||||
|
||||
/**
|
||||
* Check if an item (Guide or Section) should be shown based on framework and style.
|
||||
* If no frameworks are specified, the item is visible to all frameworks.
|
||||
* If no styles are specified, the item is visible to all styles.
|
||||
*
|
||||
* @param item - The guide or section to check
|
||||
* @param framework - The currently selected framework
|
||||
* @param style - The currently selected style
|
||||
* @returns true if the item should be visible
|
||||
*/
|
||||
function isItemVisible(
|
||||
item: Guide | Section,
|
||||
framework: SupportedFramework,
|
||||
style: AnySupportedStyle,
|
||||
): boolean {
|
||||
const frameworkMatch =
|
||||
!item.frameworks || item.frameworks.includes(framework);
|
||||
const styleMatch = !item.styles || item.styles.includes(style);
|
||||
return frameworkMatch && styleMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter sidebar items based on selected framework and style.
|
||||
* Recursively filters sections and guides to only include
|
||||
* those that are visible for the given framework and style combination.
|
||||
* Removes empty sections after filtering.
|
||||
*
|
||||
* @param framework - The framework to filter for
|
||||
* @param style - The style to filter for
|
||||
* @param sidebarToFilter - Optional sidebar to filter (defaults to main sidebar config)
|
||||
* @returns A new filtered sidebar with only visible content
|
||||
*/
|
||||
export function filterSidebar(
|
||||
framework: SupportedFramework,
|
||||
style: AnySupportedStyle,
|
||||
sidebarToFilter: Sidebar = sidebar,
|
||||
): Sidebar {
|
||||
return sidebarToFilter
|
||||
.filter((item) => isItemVisible(item, framework, style))
|
||||
.map((item) => {
|
||||
if (isSection(item)) {
|
||||
const filteredContents = filterSidebar(framework, style, item.contents);
|
||||
return {
|
||||
...item,
|
||||
contents: filteredContents,
|
||||
};
|
||||
}
|
||||
// It's a Guide, return as-is
|
||||
return item;
|
||||
})
|
||||
.filter((item) => {
|
||||
// Remove sections with no contents after filtering
|
||||
if (isSection(item)) {
|
||||
return item.contents.length > 0;
|
||||
}
|
||||
// Keep all guides
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first guide in the sidebar that matches the framework and style.
|
||||
* Recursively searches through sections and guides in order,
|
||||
* returning the slug of the first visible guide found.
|
||||
*
|
||||
* @param framework - The framework to match
|
||||
* @param style - The style to match
|
||||
* @param sidebarToSearch - Optional sidebar to search (defaults to main sidebar config)
|
||||
* @returns The slug of the first visible guide, or null if none found
|
||||
*/
|
||||
export function findFirstGuide(
|
||||
framework: SupportedFramework,
|
||||
style: AnySupportedStyle,
|
||||
sidebarToSearch: Sidebar = sidebar,
|
||||
): string | null {
|
||||
for (const item of sidebarToSearch) {
|
||||
if (!isItemVisible(item, framework, style)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSection(item)) {
|
||||
// Recursively search section contents
|
||||
const guide = findFirstGuide(framework, style, item.contents);
|
||||
if (guide) return guide;
|
||||
} else {
|
||||
// It's a Guide, return its slug
|
||||
return item.slug;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all guide slugs from a sidebar (recursively).
|
||||
* This function extracts ALL slugs from the provided sidebar structure,
|
||||
* including those in nested sections. It does not perform any filtering.
|
||||
* Typically used with an already-filtered sidebar to get allowed slugs.
|
||||
*
|
||||
* @param sidebarToExtract - Optional sidebar to extract from (defaults to main sidebar config)
|
||||
* @returns An array of all guide slugs found in the sidebar
|
||||
*/
|
||||
export function getAllGuideSlugs(sidebarToExtract: Sidebar = sidebar): string[] {
|
||||
const slugs: string[] = [];
|
||||
|
||||
for (const item of sidebarToExtract) {
|
||||
if (isSection(item)) {
|
||||
// Recursively get slugs from section contents
|
||||
slugs.push(...getAllGuideSlugs(item.contents));
|
||||
} else {
|
||||
// It's a Guide, add its slug
|
||||
slugs.push(item.slug);
|
||||
}
|
||||
}
|
||||
|
||||
return slugs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a guide by its slug in the sidebar (recursively).
|
||||
*
|
||||
* @param slug - The slug to find
|
||||
* @param sidebarToSearch - Optional sidebar to search (defaults to main sidebar config)
|
||||
* @returns The guide object if found, null otherwise
|
||||
*/
|
||||
export function findGuideBySlug(
|
||||
slug: string,
|
||||
sidebarToSearch: Sidebar = sidebar,
|
||||
): Guide | null {
|
||||
for (const item of sidebarToSearch) {
|
||||
if (isSection(item)) {
|
||||
// Recursively search section contents
|
||||
const guide = findGuideBySlug(slug, item.contents);
|
||||
if (guide) return guide;
|
||||
} else if (item.slug === slug) {
|
||||
// Found the guide
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid styles for a guide in a specific framework.
|
||||
* Returns the intersection of styles the framework supports and styles the guide supports.
|
||||
* If the guide has no style restrictions (styles is undefined), returns all framework styles.
|
||||
*
|
||||
* @param guide - The guide to check
|
||||
* @param framework - The framework to check against
|
||||
* @returns Array of valid styles for this guide in this framework
|
||||
*/
|
||||
export function getValidStylesForGuide<F extends SupportedFramework>(
|
||||
guide: Guide,
|
||||
framework: F,
|
||||
): readonly SupportedStyle<F>[] {
|
||||
const frameworkStyles = getAvailableStyles(framework);
|
||||
|
||||
// If guide has no style restrictions, all framework styles are valid
|
||||
if (!guide.styles) {
|
||||
return frameworkStyles;
|
||||
}
|
||||
|
||||
// Return intersection of framework styles and guide styles
|
||||
return frameworkStyles.filter((s) => guide.styles!.includes(s));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { glob, type ParseDataOptions } from 'astro/loaders';
|
||||
|
||||
/**
|
||||
* Parser function that runs before Astro's schema validation.
|
||||
* Receives the entry and the original filename (before generateId transforms it).
|
||||
*/
|
||||
type Parser = <TData extends Record<string, unknown>>(
|
||||
options: ParseDataOptions<TData>,
|
||||
originalEntry: string,
|
||||
) => Promise<ParseDataOptions<TData>>;
|
||||
|
||||
type GlobWithParserOptions = Parameters<typeof glob>[0] & {
|
||||
parser: Parser;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps Astro's glob loader to provide a parser function that has access to both
|
||||
* the transformed entry and the original filename.
|
||||
*
|
||||
* This is useful when using generateId to transform entry IDs (e.g., for clean URLs)
|
||||
* but still needing the original filename to extract metadata (e.g., dates from filenames).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* loader: globWithParser({
|
||||
* base: './src/content/blog',
|
||||
* pattern: '**\/*.md',
|
||||
* generateId: ({ entry }) => entry.replace(/^\d{4}-\d{2}-\d{2}-/, ''),
|
||||
* parser: async (entry, originalEntry) => {
|
||||
* // entry.id = "my-post", originalEntry = "2024-01-01-my-post.md"
|
||||
* const date = extractDateFromFilename(originalEntry);
|
||||
* entry.data.pubDate = date;
|
||||
* return entry;
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function globWithParser({
|
||||
parser,
|
||||
generateId,
|
||||
...globOptions
|
||||
}: GlobWithParserOptions) {
|
||||
/**
|
||||
* Store mapping of transformed IDs to original entry filenames.
|
||||
* Created per-invocation to avoid memory leaks across builds.
|
||||
* This is needed because generateId transforms the entry name (e.g., removes date prefix),
|
||||
* but we need access to the original filename in the parser (e.g., to extract date from filename).
|
||||
*/
|
||||
const entryMap = new Map<string, string>();
|
||||
|
||||
// Wrap generateId to capture the original entry name before transformation
|
||||
// This allows us to maintain a mapping from the transformed ID back to the original filename
|
||||
const wrappedGenerateId = generateId
|
||||
? (ctx: Parameters<NonNullable<typeof generateId>>[0]) => {
|
||||
const newId = generateId(ctx);
|
||||
// Store mapping: transformed ID -> original filename
|
||||
entryMap.set(newId, ctx.entry);
|
||||
return newId;
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Create the base glob loader with our wrapped generateId
|
||||
const loader = glob({ ...globOptions, generateId: wrappedGenerateId });
|
||||
const originalLoad = loader.load;
|
||||
|
||||
// Intercept the load function to inject our custom parser
|
||||
// This allows us to provide both the transformed entry and original filename to the parser
|
||||
loader.load = async ({ parseData, ...rest }) => {
|
||||
return originalLoad({
|
||||
parseData: async (entry) => {
|
||||
// Retrieve the original filename from our map, falling back to entry.id if not found
|
||||
const originalEntry = entryMap.get(entry.id) || entry.id;
|
||||
// Call user's parser with both the transformed entry and original filename
|
||||
return parseData(await parser(entry, originalEntry));
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
};
|
||||
|
||||
return loader;
|
||||
}
|
||||
Reference in New Issue
Block a user