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
@@ -0,0 +1,17 @@
---
interface Props {
date: Date;
}
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
</time>
@@ -0,0 +1,15 @@
---
import { type SupportedFramework } from '@/types/docs';
interface Props {
frameworks?: SupportedFramework[];
}
const { frameworks } = Astro.props;
const { framework } = Astro.params;
// Only render if current framework matches, or if no frameworks specified (all frameworks)
const shouldRender = !frameworks || frameworks.includes(framework as SupportedFramework);
---
{shouldRender && <slot />}
+143
View File
@@ -0,0 +1,143 @@
import type { AnySupportedStyle, SupportedFramework } from '@/types/docs';
import { navigate } from 'astro:transitions/client';
import { getAvailableStyles, getDefaultStyle, SUPPORTED_FRAMEWORKS } from '@/types/docs';
import { findFirstGuide, findGuideBySlug, getValidStylesForGuide } from '@/utils/docs/sidebar';
interface SelectorProps {
currentFramework: SupportedFramework;
currentStyle: AnySupportedStyle;
}
/**
* Extract the current guide slug from the docs URL.
* URL format: /docs/framework/{framework}/style/{style}/{slug}/
* @returns The guide slug (everything after the style parameter)
*/
function getCurrentGuideSlug(): string {
const pathParts = window.location.pathname.split('/').filter(Boolean);
const styleIndex = pathParts.indexOf('style');
const slugParts = pathParts.slice(styleIndex + 2); // Everything after the style value
return slugParts.join('/');
}
export function Selectors({ currentFramework, currentStyle }: SelectorProps) {
const handleFrameworkChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const newFramework = event.target.value as SupportedFramework;
// Get current guide slug from URL
const currentGuideSlug = getCurrentGuideSlug();
// Find the current guide in the sidebar
const currentGuide = findGuideBySlug(currentGuideSlug);
if (!currentGuide) {
// No current guide found, redirect to first guide of new framework
const firstGuide = findFirstGuide(newFramework, getDefaultStyle(newFramework));
if (firstGuide) {
navigate(`/docs/framework/${newFramework}/style/${getDefaultStyle(newFramework)}/${firstGuide}/`);
} else {
navigate('/docs/');
}
return;
}
// Check if guide is available for the new framework
if (currentGuide.frameworks && !currentGuide.frameworks.includes(newFramework)) {
// Guide not available in new framework, redirect to first guide
const firstGuide = findFirstGuide(newFramework, getDefaultStyle(newFramework));
if (firstGuide) {
navigate(`/docs/framework/${newFramework}/style/${getDefaultStyle(newFramework)}/${firstGuide}/`);
} else {
navigate('/docs/');
}
return;
}
// Get valid styles for this guide in the new framework
const validStyles = getValidStylesForGuide(currentGuide, newFramework);
if (validStyles.length === 0) {
// Guide not available in new framework, go to first guide
const firstGuide = findFirstGuide(newFramework, getDefaultStyle(newFramework));
if (firstGuide) {
navigate(`/docs/framework/${newFramework}/style/${getDefaultStyle(newFramework)}/${firstGuide}/`);
} else {
navigate('/docs/');
}
return;
}
// Pick best style: current if still valid, otherwise first valid
const newStyle = validStyles.some((s) => s === currentStyle) ? currentStyle : validStyles[0];
// Navigate to same guide with adjusted framework/style
navigate(`/docs/framework/${newFramework}/style/${newStyle}/${currentGuideSlug}/`);
};
const handleStyleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const newStyle = event.target.value as AnySupportedStyle;
// Get current guide slug from URL
const currentGuideSlug = getCurrentGuideSlug();
// Find the current guide in the sidebar
const currentGuide = findGuideBySlug(currentGuideSlug);
if (!currentGuide) {
// No current guide found, redirect to first guide of new style
const firstGuide = findFirstGuide(currentFramework, newStyle);
if (firstGuide) {
navigate(`/docs/framework/${currentFramework}/style/${newStyle}/${firstGuide}/`);
} else {
navigate('/docs/');
}
return;
}
// Check if guide is valid for current framework and new style
const validStyles = getValidStylesForGuide(currentGuide, currentFramework);
if (!validStyles.some((s) => s === newStyle)) {
// Guide not available for new style, go to first guide
const firstGuide = findFirstGuide(currentFramework, newStyle);
if (firstGuide) {
navigate(`/docs/framework/${currentFramework}/style/${newStyle}/${firstGuide}/`);
} else {
navigate('/docs/');
}
return;
}
// Guide supports the new style, navigate to it
navigate(`/docs/framework/${currentFramework}/style/${newStyle}/${currentGuideSlug}/`);
};
const availableStyles = getAvailableStyles(currentFramework);
return (
<div className="mb-4">
<div>
<label htmlFor="framework-select">Framework:</label>
<select id="framework-select" value={currentFramework} onChange={handleFrameworkChange}>
{SUPPORTED_FRAMEWORKS.map((fw) => (
<option key={fw} value={fw}>
{fw}
</option>
))}
</select>
</div>
<div>
<label htmlFor="style-select">Style:</label>
<select id="style-select" value={currentStyle} onChange={handleStyleChange}>
{availableStyles.map((st) => (
<option key={st} value={st}>
{st}
</option>
))}
</select>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
---
import {
type SupportedFramework,
type AnySupportedStyle,
type Guide,
type Section,
isSection,
} from '@/types/docs';
type Props = {
item: Guide | Section;
framework: SupportedFramework;
style: AnySupportedStyle;
docTitles: Map<string, string>;
};
const { item, framework, style, docTitles } = Astro.props;
---
{
isSection(item) ? (
<details open>
<summary>{item.sidebarLabel}</summary>
{item.contents.map((contentItem) => (
<Astro.self
item={contentItem}
framework={framework}
style={style}
docTitles={docTitles}
/>
))}
</details>
) : (
<div class="sidebar-guide">
<a href={`/docs/framework/${framework}/style/${style}/${item.slug}/`}>
{item.sidebarLabel || docTitles.get(item.slug) || item.slug}
</a>
</div>
)
}
@@ -0,0 +1,15 @@
---
import type { AnySupportedStyle } from '@/types/docs';
interface Props {
styles?: AnySupportedStyle[];
}
const { styles } = Astro.props;
const { style } = Astro.params;
// Only render if current style matches, or if no styles specified (all styles)
const shouldRender = !styles || styles.includes(style as AnySupportedStyle);
---
{shouldRender && <slot />}
+16
View File
@@ -0,0 +1,16 @@
import type { Sidebar } from '@/types/docs';
export const sidebar: Sidebar = [
{
sidebarLabel: 'Concepts',
contents: [
{ slug: 'concepts/everyone' },
{ slug: 'concepts/react-only', frameworks: ['react'] },
{ slug: 'concepts/tailwind-only', styles: ['tailwind'] },
],
},
{
sidebarLabel: 'How-To Guides',
contents: [{ slug: 'how-to/everyone' }],
},
];
+5
View File
@@ -0,0 +1,5 @@
// Place any global data in this file.
// You can import this data from anywhere in your site by using the `import` keyword.
export const SITE_TITLE = 'Video.js 10';
export const SITE_DESCRIPTION = 'Modern video player framework with multi-platform support';
+128
View File
@@ -0,0 +1,128 @@
import { defineCollection, reference, z } from 'astro:content';
import { file } from 'astro/loaders';
import { simpleGit } from 'simple-git';
import { globWithParser } from './utils/globWithParser';
const git = simpleGit();
/**
* Extract date from filename in format: YYYY-MM-DD-slug.{md,mdx}
* Throws an error if the filename doesn't match the expected pattern
*/
function extractDateFromFilename(id: string): Date {
const match = id.match(/^(\d{4})-(\d{2})-(\d{2})-/);
if (!match) {
throw new Error(
`Filename "${id}" must follow format: YYYY-MM-DD-slug.{md,mdx}`,
);
}
const [, year, month, day] = match;
return new Date(`${year}-${month}-${day}`);
}
/**
* Get the last modified date of a file from git history
* Returns null if git command fails or file is not in git history
*/
async function getGitLastModifiedDate(filePath: string): Promise<Date | null> {
try {
const log = await git.log({ file: filePath, maxCount: 1 });
if (!log.latest) return null;
return new Date(log.latest.date);
} catch {
return null;
}
}
const blog = defineCollection({
// Load Markdown and MDX files in the `src/content/blog/` directory.
loader: globWithParser({
base: './src/content/blog',
pattern: '**/*.{md,mdx}',
generateId: ({ entry }) => {
// Remove date prefix and extension from slug (e.g., "2022-07-08-first-post.md" -> "first-post")
return entry.replace(/^\d{4}-\d{2}-\d{2}-/, '').replace(/\.mdx?$/, '');
},
parser: async (entry, originalEntry) => {
// Extract pubDate from original filename (before date prefix was removed)
const pubDate = extractDateFromFilename(originalEntry);
// Get updatedDate from git history (last modification date)
const filePath = `website/src/content/blog/${originalEntry}`;
const updatedDate = await getGitLastModifiedDate(filePath);
// Return transformed entry with added fields
return {
...entry,
data: {
...entry.data,
pubDate,
...(updatedDate &&
updatedDate.getTime() !== pubDate.getTime()
? { updatedDate }
: {}),
},
};
},
}),
// Type-check frontmatter using a schema
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
updatedDate: z.coerce.date().optional(),
heroImage: image().optional(),
authors: z.array(reference('authors')),
}),
});
const docs = defineCollection({
loader: globWithParser({
base: './src/content/docs',
pattern: '**/*.{md,mdx}',
parser: async (entry, originalEntry) => {
// Get updatedDate from git history
const filePath = `website/src/content/docs/${originalEntry}`;
const updatedDate = await getGitLastModifiedDate(filePath);
// Return transformed entry with added field if updatedDate exists
return {
...entry,
data: {
...entry.data,
...(updatedDate ? { updatedDate } : {}),
},
};
},
}),
schema: z.object({
title: z.string(),
description: z.string(),
updatedDate: z.coerce.date().optional(),
}),
});
const authors = defineCollection({
loader: file('./src/content/authors.json'),
schema: z.object({
name: z.string(),
shortName: z.string(),
bio: z.string().optional(),
avatar: z.string().optional(),
socialLinks: z
.object({
x: z.string().optional(),
bluesky: z.string().optional(),
mastodon: z.string().optional(),
github: z.string().optional(),
linkedin: z.string().optional(),
website: z.string().optional(),
})
.optional(),
}),
});
export const collections = { blog, docs, authors };
+31
View File
@@ -0,0 +1,31 @@
[
{
"id": "john-doe",
"name": "John Doe",
"shortName": "John",
"bio": "Software engineer and video technology enthusiast. Loves building web applications.",
"avatar": "https://placecats.com/neo/150/150",
"socialLinks": {
"x": "https://x.com/johndoe",
"github": "https://github.com/johndoe"
}
},
{
"id": "jane-smith",
"name": "Jane Smith",
"shortName": "Jane",
"bio": "Frontend developer passionate about accessibility and user experience.",
"avatar": "https://placecats.com/millie/150/150",
"socialLinks": {
"github": "https://github.com/janesmith",
"linkedin": "https://linkedin.com/in/janesmith",
"website": "https://janesmith.dev"
}
},
{
"id": "alex-johnson",
"name": "Alex Johnson",
"shortName": "Alex",
"bio": "Full-stack developer with a focus on media streaming technologies."
}
]
@@ -0,0 +1,15 @@
---
title: 'First post'
description: 'Lorem ipsum dolor sit amet'
authors: [john-doe]
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.
@@ -0,0 +1,15 @@
---
title: 'Second post'
description: 'Lorem ipsum dolor sit amet'
authors: [jane-smith, alex-johnson]
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.
@@ -0,0 +1,15 @@
---
title: 'Third post'
description: 'Lorem ipsum dolor sit amet'
authors: [alex-johnson]
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.
@@ -0,0 +1,24 @@
---
title: 'Using MDX'
description: 'Lorem ipsum dolor sit amet'
authors: [jane-smith]
---
This theme comes with the [@astrojs/mdx](https://docs.astro.build/en/guides/integrations-guide/mdx/) integration installed and configured in your `astro.config.mjs` config file. If you prefer not to use MDX, you can disable support by removing the integration from your config file.
## Why MDX?
MDX is a special flavor of Markdown that supports embedded JavaScript & JSX syntax. This unlocks the ability to [mix JavaScript and UI Components into your Markdown content](https://docs.astro.build/en/guides/markdown-content/#mdx-features) for things like interactive charts or alerts.
If you have existing content authored in MDX, this integration will hopefully make migrating to Astro a breeze.
## Example
Here is how you import and use a UI component inside of MDX.
When you open this page in the browser, you should see the clickable button below.
## More Links
- [MDX Syntax Documentation](https://mdxjs.com/docs/what-is-mdx)
- [Astro Usage Documentation](https://docs.astro.build/en/guides/markdown-content/#markdown-and-mdx-pages)
- **Note:** [Client Directives](https://docs.astro.build/en/reference/directives-reference/#client-directives) are still required to create interactive components. Otherwise, all components in your MDX will render as static HTML (no JavaScript) by default.
@@ -0,0 +1,211 @@
---
title: 'Markdown Style Guide'
description: 'Here is a sample of some basic Markdown syntax that can be used when writing Markdown content in Astro.'
authors: [john-doe, jane-smith]
---
Here is a sample of some basic Markdown syntax that can be used when writing Markdown content in Astro.
## Headings
The following HTML `<h1>``<h6>` elements represent six levels of section headings. `<h1>` is the highest section level while `<h6>` is the lowest.
# H1
## H2
### H3
#### H4
##### H5
###### H6
## Paragraph
Xerum, quo qui aut unt expliquam qui dolut labo. Aque venitatiusda cum, voluptionse latur sitiae dolessi aut parist aut dollo enim qui voluptate ma dolestendit peritin re plis aut quas inctum laceat est volestemque commosa as cus endigna tectur, offic to cor sequas etum rerum idem sintibus eiur? Quianimin porecus evelectur, cum que nis nust voloribus ratem aut omnimi, sitatur? Quiatem. Nam, omnis sum am facea corem alique molestrunt et eos evelece arcillit ut aut eos eos nus, sin conecerem erum fuga. Ri oditatquam, ad quibus unda veliamenimin cusam et facea ipsamus es exerum sitate dolores editium rerore eost, temped molorro ratiae volorro te reribus dolorer sperchicium faceata tiustia prat.
Itatur? Quiatae cullecum rem ent aut odis in re eossequodi nonsequ idebis ne sapicia is sinveli squiatum, core et que aut hariosam ex eat.
## Images
### Syntax
```markdown
![Alt text](./full/or/relative/path/of/image)
```
### Output
## Blockquotes
The blockquote element represents content that is quoted from another source, optionally with a citation which must be within a `footer` or `cite` element, and optionally with in-line changes such as annotations and abbreviations.
### Blockquote without attribution
#### Syntax
```markdown
> Tiam, ad mint andaepu dandae nostion secatur sequo quae.
> **Note** that you can use _Markdown syntax_ within a blockquote.
```
#### Output
> Tiam, ad mint andaepu dandae nostion secatur sequo quae.
> **Note** that you can use _Markdown syntax_ within a blockquote.
### Blockquote with attribution
#### Syntax
```markdown
> Don't communicate by sharing memory, share memory by communicating.<br>
> — <cite>Rob Pike[^1]</cite>
```
#### Output
> Don't communicate by sharing memory, share memory by communicating.<br>
> — <cite>Rob Pike[^1]</cite>
[^1]: The above quote is excerpted from Rob Pike's [talk](https://www.youtube.com/watch?v=PAAkCSZUG1c) during Gopherfest, November 18, 2015.
## Tables
### Syntax
```markdown
| Italics | Bold | Code |
| --------- | -------- | ------ |
| _italics_ | **bold** | `code` |
```
### Output
| Italics | Bold | Code |
| --------- | -------- | ------ |
| _italics_ | **bold** | `code` |
## Code Blocks
### Syntax
we can use 3 backticks ``` in new line and write snippet and close with 3 backticks on new line and to highlight language specific syntax, write one word of language name after first 3 backticks, for eg. html, javascript, css, markdown, typescript, txt, bash
````markdown
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Example HTML5 Document</title>
</head>
<body>
<p>Test</p>
</body>
</html>
```
````
### Output
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Example HTML5 Document</title>
</head>
<body>
<p>Test</p>
</body>
</html>
```
## List Types
### Ordered List
#### Syntax
```markdown
1. First item
2. Second item
3. Third item
```
#### Output
1. First item
2. Second item
3. Third item
### Unordered List
#### Syntax
```markdown
- List item
- Another item
- And another item
```
#### Output
- List item
- Another item
- And another item
### Nested list
#### Syntax
```markdown
- Fruit
- Apple
- Orange
- Banana
- Dairy
- Milk
- Cheese
```
#### Output
- Fruit
- Apple
- Orange
- Banana
- Dairy
- Milk
- Cheese
## Other Elements — abbr, sub, sup, kbd, mark
### Syntax
```markdown
<abbr title="Graphics Interchange Format">GIF</abbr> is a bitmap image format.
H<sub>2</sub>O
X<sup>n</sup> + Y<sup>n</sup> = Z<sup>n</sup>
Press <kbd>CTRL</kbd> + <kbd>ALT</kbd> + <kbd>Delete</kbd> to end the session.
Most <mark>salamanders</mark> are nocturnal, and hunt for insects, worms, and other small creatures.
```
### Output
<abbr title="Graphics Interchange Format">GIF</abbr> is a bitmap image format.
H<sub>2</sub>O
X<sup>n</sup> + Y<sup>n</sup> = Z<sup>n</sup>
Press <kbd>CTRL</kbd> + <kbd>ALT</kbd> + <kbd>Delete</kbd> to end the session.
Most <mark>salamanders</mark> are nocturnal, and hunt for insects, worms, and other small creatures.
@@ -0,0 +1,53 @@
---
title: 'Concepts for Everyone'
description: 'Core concepts that apply to all frameworks and styling approaches'
---
import FrameworkCase from '@/components/docs/FrameworkCase.astro';
import StyleCase from '@/components/docs/StyleCase.astro';
# Concepts for Everyone
This guide is available to all users regardless of their framework or styling choice.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. This is placeholder content to demonstrate the docs structure.
## Framework-Specific Content
<FrameworkCase frameworks={["react"]}>
### React-Specific Section
This content only appears when viewing the React version of the docs.
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
### HTML-Specific Section
This content only appears when viewing the HTML version of the docs.
</FrameworkCase>
## Style-Specific Content
<StyleCase styles={["css"]}>
### CSS Styling
This content only appears when viewing the CSS styling approach.
</StyleCase>
<StyleCase styles={["tailwind"]}>
### Tailwind Styling
This content only appears when viewing the Tailwind styling approach.
</StyleCase>
## Content for All
This appears for all frameworks (no frameworks prop specified).
@@ -0,0 +1,10 @@
---
title: 'React-Specific Concepts'
description: 'Concepts that are specific to React implementations'
---
# React-Specific Concepts
This guide is only available when using the React framework.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. This content demonstrates React-specific features.
@@ -0,0 +1,10 @@
---
title: 'Tailwind Styling Concepts'
description: 'Concepts specific to using Tailwind CSS for styling'
---
# Tailwind Styling Concepts
This guide is only available when using Tailwind CSS.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. This content demonstrates Tailwind-specific styling approaches.
@@ -0,0 +1,12 @@
---
title: 'How-To Guide for Everyone'
description: 'Step-by-step instructions that work for all frameworks and styles'
---
# How-To Guide for Everyone
This how-to guide is available to all users regardless of their framework or styling choice.
1. First step with placeholder content
2. Second step with more placeholder content
3. Third step to demonstrate the structure
+71
View File
@@ -0,0 +1,71 @@
---
import '@/styles/global.css';
import type { ImageMetadata } from 'astro';
import { ClientRouter } from 'astro:transitions';
import { SITE_TITLE } from '@/consts';
interface Props {
title: string;
description: string;
image?: ImageMetadata;
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { title, description, image } = Astro.props;
---
<html lang="en">
<head>
<!-- Enable Astro's client-side routing for SPA-like navigation between pages -->
<ClientRouter />
<!-- Global Metadata -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="sitemap" href="/sitemap-index.xml" />
<link rel="alternate" type="application/rss+xml" title={SITE_TITLE} href={new URL('rss.xml', Astro.site)} />
<meta name="generator" content={Astro.generator} />
<!-- Font preloads -->
<link rel="preload" href="/fonts/atkinson-regular.woff" as="font" type="font/woff" crossorigin />
<link rel="preload" href="/fonts/atkinson-bold.woff" as="font" type="font/woff" crossorigin />
<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL} />
<!-- Primary Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
{image && <meta property="og:image" content={new URL(image.src, Astro.url)} />}
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
{image && <meta property="twitter:image" content={new URL(image.src, Astro.url)} />}
<slot name="head" />
</head>
<body>
<nav class="px-4 py-4 md:px-8">
<a href="/" class="mr-4">Home</a>
<a href="/docs" class="mr-4">Docs</a>
<a href="/blog">Blog</a>
</nav>
<div class="px-4 md:px-8">
<slot />
</div>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
---
import type { CollectionEntry } from 'astro:content';
import { Image } from 'astro:assets';
import FormattedDate from '@/components/FormattedDate.astro';
import Base from './Base.astro';
type Props = CollectionEntry<'blog'>['data'] & {
authors: CollectionEntry<'authors'>[];
};
const { title, description, pubDate, updatedDate, heroImage, authors } = Astro.props;
---
<Base title={title} description={description} image={heroImage}>
<main class="py-8">
<article class="max-w-4xl mx-auto">
<div class="hero-image mb-8">
{heroImage && <Image width={1020} height={510} src={heroImage} alt="" />}
</div>
<div class="prose">
<div class="title mb-8">
<div class="date mb-4">
<FormattedDate date={pubDate} />
{
updatedDate && (
<div class="last-updated-on">
Last updated on <FormattedDate date={updatedDate} />
</div>
)
}
</div>
<h1 class="mb-4">{title}</h1>
<div class="authors mb-4">
By {authors.map((author, index) => (
<>
<a href={`/blog/authors/${author.id}`}>{author.data.name}</a>
{index < authors.length - 1 && ', '}
</>
))}
</div>
<hr />
</div>
<slot />
</div>
</article>
</main>
</Base>
+49
View File
@@ -0,0 +1,49 @@
---
import type { CollectionEntry } from 'astro:content';
import { getCollection } from 'astro:content';
import { Selectors } from '@/components/docs/Selectors';
import SidebarItem from '@/components/docs/SidebarItem.astro';
import { type AnySupportedStyle, type SupportedFramework } from '@/types/docs';
import { filterSidebar } from '@/utils/docs/sidebar';
import Base from './Base.astro';
type Props = {
doc: CollectionEntry<'docs'>;
framework: SupportedFramework;
style: AnySupportedStyle;
};
const { doc, framework, style } = Astro.props;
const filteredSidebar = filterSidebar(framework, style);
// Fetch all docs to get their titles
const allDocs = await getCollection('docs');
const docTitles = new Map(allDocs.map((d) => [d.id, d.data.title]));
---
<Base title={doc.data.title} description={doc.data.description}>
<div class="grid grid-cols-[theme(spacing.64)_1fr] gap-4 max-w-7xl mx-auto p-4 docs-container">
<aside>
<Selectors client:load currentFramework={framework} currentStyle={style} />
<nav>
{
filteredSidebar.map((item) => (
<SidebarItem item={item} framework={framework} style={style} docTitles={docTitles} />
))
}
</nav>
</aside>
<main>
<article class="max-w-4xl">
<h1 class="text-3xl font-bold mb-4">{doc.data.title}</h1>
<p class="text-gray-600 mb-8">{doc.data.description}</p>
<div class="prose">
<slot />
</div>
</article>
</main>
</div>
</Base>
+21
View File
@@ -0,0 +1,21 @@
---
import { type CollectionEntry, getCollection, getEntries, render } from 'astro:content';
import BlogPost from '@/layouts/BlogPost.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
props: post,
}));
}
type Props = CollectionEntry<'blog'>;
const post = Astro.props;
const { Content } = await render(post);
const authors = await getEntries(post.data.authors);
---
<BlogPost {...post.data} authors={authors}>
<Content />
</BlogPost>
@@ -0,0 +1,104 @@
---
import { type CollectionEntry, getCollection, getEntry } from 'astro:content';
import FormattedDate from '@/components/FormattedDate.astro';
import { SITE_TITLE } from '@/consts';
import Base from '@/layouts/Base.astro';
export async function getStaticPaths() {
const authors = await getCollection('authors');
return authors.map((author) => ({
params: { author: author.id },
props: { author },
}));
}
type Props = {
author: CollectionEntry<'authors'>;
};
const { author } = Astro.props;
// Get all blog posts by this author
const allPosts = await getCollection('blog');
const authorPosts = allPosts.filter((post) =>
post.data.authors.some((a) => a.id === author.id),
);
const sortedPosts = authorPosts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
---
<Base
title={`${author.data.name} - ${SITE_TITLE}`}
description={author.data.bio || `Posts by ${author.data.name}`}
>
<main class="py-8">
<div class="max-w-4xl mx-auto">
<article class="mb-12">
<div class="flex items-start gap-6 mb-6">
{author.data.avatar && <img src={author.data.avatar} alt={author.data.name} width="150" height="150" class="rounded" />}
<div>
<h1 class="text-3xl font-bold mb-2">{author.data.name}</h1>
{author.data.bio && <p class="text-gray-600 mb-4">{author.data.bio}</p>}
{
author.data.socialLinks && (
<ul class="flex gap-4 flex-wrap">
{author.data.socialLinks.x && (
<li>
<a href={author.data.socialLinks.x} class="hover:underline">X</a>
</li>
)}
{author.data.socialLinks.bluesky && (
<li>
<a href={author.data.socialLinks.bluesky} class="hover:underline">Bluesky</a>
</li>
)}
{author.data.socialLinks.mastodon && (
<li>
<a href={author.data.socialLinks.mastodon} class="hover:underline">Mastodon</a>
</li>
)}
{author.data.socialLinks.github && (
<li>
<a href={author.data.socialLinks.github} class="hover:underline">GitHub</a>
</li>
)}
{author.data.socialLinks.linkedin && (
<li>
<a href={author.data.socialLinks.linkedin} class="hover:underline">LinkedIn</a>
</li>
)}
{author.data.socialLinks.website && (
<li>
<a href={author.data.socialLinks.website} class="hover:underline">Website</a>
</li>
)}
</ul>
)
}
</div>
</div>
</article>
<section>
<h2 class="text-2xl font-bold mb-6">Posts by {author.data.shortName}</h2>
<ul class="space-y-6">
{
sortedPosts.map((post) => (
<li class="border-b pb-6 last:border-b-0">
<a href={`/blog/${post.id}`} class="block hover:opacity-80">
<h3 class="text-xl font-semibold mb-2">{post.data.title}</h3>
<p class="text-gray-600 mb-2">{post.data.description}</p>
<p class="text-sm text-gray-500">
<FormattedDate date={post.data.pubDate} />
</p>
</a>
</li>
))
}
</ul>
</section>
</div>
</main>
</Base>
@@ -0,0 +1,31 @@
---
import { getCollection } from 'astro:content';
import { SITE_TITLE } from '@/consts';
import Base from '@/layouts/Base.astro';
const authors = await getCollection('authors');
---
<Base title={`Authors - ${SITE_TITLE}`} description="All blog authors">
<main class="py-8">
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold mb-8">Authors</h1>
<ul class="space-y-6">
{
authors.map((author) => (
<li class="border-b pb-6 last:border-b-0">
<a href={`/blog/authors/${author.id}`} class="flex items-start gap-4 hover:opacity-80">
{author.data.avatar && <img src={author.data.avatar} alt={author.data.name} width="50" height="50" class="rounded" />}
<div>
<h2 class="text-xl font-semibold mb-1">{author.data.name}</h2>
{author.data.bio && <p class="text-gray-600">{author.data.bio}</p>}
</div>
</a>
</li>
))
}
</ul>
</div>
</main>
</Base>
+49
View File
@@ -0,0 +1,49 @@
---
import { Image } from 'astro:assets';
import { getCollection, getEntries } from 'astro:content';
import FormattedDate from '@/components/FormattedDate.astro';
import { SITE_DESCRIPTION, SITE_TITLE } from '@/consts';
import Base from '@/layouts/Base.astro';
const posts = (await getCollection('blog')).sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
// Resolve author references for all posts
const postsWithAuthors = await Promise.all(
posts.map(async (post) => ({
...post,
authors: await getEntries(post.data.authors),
}))
);
---
<Base title={SITE_TITLE} description={SITE_DESCRIPTION}>
<main class="py-8">
<section class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold mb-8">Blog</h1>
<ul class="space-y-8">
{
postsWithAuthors.map((post) => (
<li class="border-b pb-8 last:border-b-0">
<a href={`/blog/${post.id}`}>
{post.data.heroImage && <Image width={720} height={360} src={post.data.heroImage} alt="" class="mb-4" />}
<h4 class="title text-2xl font-semibold mb-2">{post.data.title}</h4>
<p class="date text-gray-600 mb-2">
<FormattedDate date={post.data.pubDate} />
</p>
<p class="authors text-sm">
By {post.authors.map((author: any, index: number) => (
<>
<a href={`/blog/authors/${author.id}`} class="hover:underline">{author.data.name}</a>
{index < post.authors.length - 1 && ', '}
</>
))}
</p>
</a>
</li>
))
}
</ul>
</section>
</main>
</Base>
@@ -0,0 +1,16 @@
---
import {
SUPPORTED_FRAMEWORKS,
type SupportedFramework,
getDefaultStyle,
} from '@/types/docs';
import { getDocsRedirectUrl } from '@/utils/docs/redirects';
export function getStaticPaths() {
return SUPPORTED_FRAMEWORKS.map((framework) => ({ params: { framework } }));
}
const { framework } = Astro.params as { framework: SupportedFramework };
return Astro.redirect(getDocsRedirectUrl(framework, getDefaultStyle(framework)));
---
@@ -0,0 +1,64 @@
---
import type { AnySupportedStyle, SupportedFramework } from '@/types/docs';
import type { CollectionEntry } from 'astro:content';
import { getCollection, render } from 'astro:content';
import DocsLayout from '@/layouts/Docs.astro';
import { getAvailableStyles, SUPPORTED_FRAMEWORKS } from '@/types/docs';
import { filterSidebar, getAllGuideSlugs } from '@/utils/docs/sidebar';
export async function getStaticPaths() {
const docs = await getCollection('docs');
const paths = [];
// Build a map of allowed slugs for each framework/style combination
const allowedSlugsMap = new Map<string, Set<string>>();
for (const framework of SUPPORTED_FRAMEWORKS) {
const availableStyles = getAvailableStyles(framework);
for (const style of availableStyles) {
const key = `${framework}-${style}`;
const filteredSidebar = filterSidebar(framework, style);
allowedSlugsMap.set(key, new Set(getAllGuideSlugs(filteredSidebar)));
}
}
// Generate paths using the precomputed map
for (const framework of SUPPORTED_FRAMEWORKS) {
const availableStyles = getAvailableStyles(framework);
for (const style of availableStyles) {
const key = `${framework}-${style}`;
const allowedSlugs = allowedSlugsMap.get(key)!;
// Only generate paths for docs that are visible in the sidebar
for (const doc of docs) {
if (allowedSlugs.has(doc.id)) {
paths.push({
params: {
framework,
style,
slug: doc.id,
},
props: { doc, framework, style },
});
}
}
}
}
return paths;
}
type Props = {
doc: CollectionEntry<'docs'>;
framework: SupportedFramework;
style: AnySupportedStyle;
};
const { doc, framework, style } = Astro.props;
const { Content } = await render(doc);
---
<DocsLayout doc={doc} framework={framework} style={style}>
<Content />
</DocsLayout>
@@ -0,0 +1,28 @@
---
import {
SUPPORTED_FRAMEWORKS,
type SupportedFramework,
type AnySupportedStyle,
getAvailableStyles,
} from '@/types/docs';
import { getDocsRedirectUrl } from '@/utils/docs/redirects';
export function getStaticPaths() {
const paths = [];
for (const framework of SUPPORTED_FRAMEWORKS) {
const availableStyles = getAvailableStyles(framework);
for (const style of availableStyles) {
paths.push({ params: { framework, style } });
}
}
return paths;
}
const { framework, style } = Astro.params as {
framework: SupportedFramework;
style: AnySupportedStyle;
};
return Astro.redirect(getDocsRedirectUrl(framework, style));
---
+7
View File
@@ -0,0 +1,7 @@
---
import { getDefaultStyle } from '@/types/docs';
import { getDocsRedirectUrl } from '@/utils/docs/redirects';
const defaultFramework = 'html';
return Astro.redirect(getDocsRedirectUrl(defaultFramework, getDefaultStyle(defaultFramework)));
---
+32
View File
@@ -0,0 +1,32 @@
---
import { SITE_DESCRIPTION, SITE_TITLE } from '@/consts';
import Base from '@/layouts/Base.astro';
---
<Base title={SITE_TITLE} description={SITE_DESCRIPTION}>
<main class="py-8">
<div class="max-w-4xl mx-auto">
<h1 class="text-4xl font-bold mb-4">Video.js 10</h1>
<p class="text-lg text-gray-600 mb-8">
A modern, platform-agnostic video player framework built for the web.
Video.js 10 provides a unified API across HTML, React, and React Native.
</p>
<h2 class="text-2xl font-bold mb-4">Quick Links</h2>
<ul class="space-y-2 mb-8">
<li><a href="/docs/" class="hover:underline">Documentation</a> - Get started with Video.js 10</li>
<li><a href="/blog/" class="hover:underline">Blog</a> - Latest news and updates</li>
<li><a href="https://github.com/videojs/video.js" class="hover:underline">GitHub</a> - Source code and contributions</li>
</ul>
<h2 class="text-2xl font-bold mb-4">Features</h2>
<ul class="space-y-2">
<li>Multi-platform support: HTML, React, and React Native</li>
<li>Modular architecture with pluggable engines</li>
<li>Framework-agnostic state management</li>
<li>Comprehensive component library</li>
<li>TypeScript-first with strict type safety</li>
</ul>
</div>
</main>
</Base>
+16
View File
@@ -0,0 +1,16 @@
import { getCollection } from 'astro:content';
import rss from '@astrojs/rss';
import { SITE_DESCRIPTION, SITE_TITLE } from '@/consts';
export async function GET(context) {
const posts = await getCollection('blog');
return rss({
title: SITE_TITLE,
description: SITE_DESCRIPTION,
site: context.site,
items: posts.map((post) => ({
...post.data,
link: `/blog/${post.id}/`,
})),
});
}
+1
View File
@@ -0,0 +1 @@
@import 'tailwindcss';
+54
View File
@@ -0,0 +1,54 @@
export const FRAMEWORK_STYLES = {
html: ['css', 'tailwind'],
react: ['css', 'tailwind', 'styled-components'],
} as const;
export const SUPPORTED_FRAMEWORKS = Object.keys(
FRAMEWORK_STYLES,
) as (keyof typeof FRAMEWORK_STYLES)[];
export type SupportedFramework = keyof typeof FRAMEWORK_STYLES;
export type SupportedStyle<F extends SupportedFramework> =
(typeof FRAMEWORK_STYLES)[F][number];
export type AnySupportedStyle = SupportedStyle<SupportedFramework>;
/**
* Get the available styles for a given framework
*/
export function getAvailableStyles<F extends SupportedFramework>(
framework: F,
): readonly SupportedStyle<F>[] {
return FRAMEWORK_STYLES[framework];
}
/**
* Get the default style for a given framework (first available style)
*/
export function getDefaultStyle<F extends SupportedFramework>(
framework: F,
): SupportedStyle<F> {
return FRAMEWORK_STYLES[framework][0];
}
export type Guide = {
slug: string;
sidebarLabel?: string; // defaults to guide title
frameworks?: SupportedFramework[];
styles?: AnySupportedStyle[];
};
export type Section = {
sidebarLabel: string;
frameworks?: SupportedFramework[];
styles?: AnySupportedStyle[];
contents: Array<Guide | Section>;
};
export type Sidebar = Array<Guide | Section>;
/**
* Type guard to check if an item is a Section (vs a Guide)
*/
export function isSection(item: Guide | Section): item is Section {
return 'contents' in item;
}
+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;
}