chore: website tooling (#41)

This commit is contained in:
Darius Cepulis
2025-10-08 15:35:50 -05:00
committed by GitHub
parent d2a0b27272
commit 1bb8166704
53 changed files with 5218 additions and 326 deletions
+30
View File
@@ -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}/`;
}
+177
View File
@@ -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));
}
+81
View File
@@ -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;
}