Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/(home)/overview/_pages/page.en.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import {
Cloud,
Container,
GeminiIcon,
GoIcon,
OpenAIIcon,
PythonIcon,
RustIcon,
TSIcon,
} from '@/components/ui/icon';
import SteelLogo from '@/public/images/logo.png';
Expand Down Expand Up @@ -160,6 +162,18 @@ export default function HomePage() {
title="Steel Python SDK"
description="Python SDK for building applications on Steel."
/>
<SmallCard
icon={<RustIcon />}
href="/steel-rust-sdk"
title="Steel Rust SDK"
description="Rust SDK for building applications on Steel."
/>
<SmallCard
icon={<GoIcon />}
href="/steel-go-sdk"
title="Steel Go SDK"
description="Go SDK for building applications on Steel."
/>
</Cards>
</div>
<div className="flex flex-col">
Expand Down
2 changes: 1 addition & 1 deletion components/docskit/code.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function MultiCode({ group, className }: { group: CodeGroup; className?:

return (
<Tabs
value={currentTitle}
value={current.title}
onValueChange={setCurrentTitle}
className={cn(CODEBLOCK, className)}
style={style}
Expand Down
2 changes: 2 additions & 0 deletions components/mdx/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { docskit } from '@/components/docskit/components';
import { FAQ, FAQItem } from '@/components/faq';
import { IntegrationGrid } from '@/components/integration-grid';
import { OrderedList, UnorderedList } from '@/components/lists';
import { Tabs as LangTabs } from '@/components/mdx/lang-tabs';
import { RecipeCard, RecipeGrid } from '@/components/recipe-card';
import { RecipeJsonLd } from '@/components/recipe-jsonld';
import { RecipeMeta } from '@/components/recipe-meta';
Expand Down Expand Up @@ -47,6 +48,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
...FilesComponents,
...StepsComponents,
...TabsComponents,
Tabs: LangTabs,
IntegrationGrid,
RecipeCard,
RecipeGrid,
Expand Down
56 changes: 56 additions & 0 deletions components/mdx/lang-tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use client';

import { Tabs as FumaTabs, TabsList, TabsTrigger } from 'fumadocs-ui/components/tabs';
import type { ComponentProps, ReactElement, SVGProps } from 'react';
import { Children, cloneElement, isValidElement } from 'react';
import { GoIcon, PythonIcon, RustIcon, TSIcon } from '@/components/ui/icon';

type IconComponent = (props: SVGProps<SVGSVGElement>) => ReactElement;

const LANG_ICONS: Record<string, { Icon: IconComponent; className: string }> = {
typescript: { Icon: TSIcon, className: '!size-[13px]' },
python: { Icon: PythonIcon, className: '!size-[15px]' },
go: { Icon: GoIcon, className: '!size-[18px]' },
rust: { Icon: RustIcon, className: '!size-[15px]' },
};

function escapeValue(value: string) {
return value.toLowerCase().replace(/\s/, '-');
}

export function Tabs({
items,
defaultIndex = 0,
children,
...props
}: ComponentProps<typeof FumaTabs>) {
if (!items) {
return <FumaTabs {...props}>{children}</FumaTabs>;
}

const values = items.map(escapeValue);
const tabs = Children.toArray(children).filter(isValidElement) as ReactElement<{
id?: string;
value?: string;
}>[];
const withValues = tabs.map((child, i) =>
cloneElement(child, { value: child.props.value ?? child.props.id ?? values[i] }),
);

return (
<FumaTabs {...props} defaultValue={values[defaultIndex]}>
<TabsList>
{items.map((item, i) => {
const icon = LANG_ICONS[values[i]];
return (
<TabsTrigger key={item} value={values[i]} className="cursor-pointer !text-[13px]">
{icon ? <icon.Icon className={icon.className} /> : null}
{item}
</TabsTrigger>
);
})}
</TabsList>
{withValues}
</FumaTabs>
);
}
77 changes: 59 additions & 18 deletions components/recipe-card.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import Link from 'next/link';
import type { ReactNode } from 'react';
import type { ReactNode, SVGProps } from 'react';
import { GoIcon, PythonIcon, RustIcon, TSIcon } from '@/components/ui/icon';
import { cn } from '@/lib/utils';

interface RecipeCardProps {
slug: string;
title: string;
description: string;
topics: string[];
languages?: string[];
date?: string;
}

// Per-language icon with optical-size tuning so the marks read as one
// consistent size inside the chip (TS fills its box; the Go mark is
// wide-and-short).
const LANG_ICONS: Record<
string,
{ Icon: (p: SVGProps<SVGSVGElement>) => ReactNode; size: string }
> = {
TypeScript: { Icon: TSIcon, size: 'size-3' },
Python: { Icon: PythonIcon, size: 'size-3.5' },
Go: { Icon: GoIcon, size: 'size-4' },
Rust: { Icon: RustIcon, size: 'size-3.5' },
};

// Render a registry date (YYYY-MM-DD) as the short English month-day-year
// form ("Apr 23, 2026"). Falls back to the raw string if parsing fails.
function formatDate(iso: string): string {
Expand All @@ -34,14 +49,26 @@ function topicSlug(topic: string): string {
.replace(/^-|-$/g, '');
}

export function RecipeCard({ slug, title, description, topics, date }: RecipeCardProps) {
export function RecipeCard({
slug,
title,
description,
topics,
languages,
date,
}: RecipeCardProps) {
// "Stretched link" pattern: an absolute overlay <Link> covers the whole
// card, so clicking anywhere navigates to the recipe. Inner contents are
// pointer-events-none by default; interactive children (title, topic
// pills) re-enable pointer events and sit above the overlay so they stay
// clickable as distinct links.
return (
<article className="group not-prose relative flex flex-col rounded-lg border border-border bg-card p-5 transition-colors hover:border-muted-foreground">
<article
className={cn(
'group not-prose relative flex flex-col rounded-lg border bg-card p-5 transition-colors',
'border-border hover:border-muted-foreground',
)}
>
<Link
href={`/cookbook/${slug}`}
className="absolute inset-0 z-0 rounded-lg"
Expand All @@ -59,21 +86,35 @@ export function RecipeCard({ slug, title, description, topics, date }: RecipeCar
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<div className="pointer-events-none mt-auto flex flex-wrap items-end justify-between gap-3 pt-4">
{topics.length > 0 ? (
<div className="flex flex-wrap gap-2">
{topics.map((topic) => (
<Link
key={topic}
href={`/cookbook/topics/${topicSlug(topic)}`}
className="pointer-events-auto relative z-10 rounded-full bg-yellow-50 dark:bg-yellow-50/5 px-2.5 py-0.5 font-mono text-xs uppercase text-stone-400 transition-colors hover:text-primary"
>
{topic}
</Link>
))}
</div>
) : (
<span />
)}
<div className="flex flex-wrap items-center gap-2">
{languages && languages.length > 0 && (
<div className="flex -space-x-1.5">
{languages.map((language) => {
const icon = LANG_ICONS[language];
if (!icon) return null;
return (
<span
key={language}
title={language}
aria-label={language}
className="inline-flex size-5 items-center justify-center rounded-full border-2 border-background bg-card text-muted-foreground"
>
<icon.Icon className={icon.size} />
</span>
);
})}
</div>
)}
{topics.map((topic) => (
<Link
key={topic}
href={`/cookbook/topics/${topicSlug(topic)}`}
className="pointer-events-auto relative z-10 rounded-full bg-yellow-50 dark:bg-yellow-50/5 px-2.5 py-0.5 font-mono text-xs uppercase text-stone-400 transition-colors hover:text-primary"
>
{topic}
</Link>
))}
</div>
{date && (
<time
dateTime={date}
Expand Down
38 changes: 35 additions & 3 deletions components/recipe-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@ interface Recipe {
title: string;
description: string;
topics: string[];
languages?: string[];
date?: string;
}

interface Props {
recipes: Recipe[];
featured?: Recipe[];
}

// Client-side filter over the Home page's recipe list. Matches tokens in
// the query against any of title/description/topics/slug (substring
// match). Global ⌘K search still covers the full docs index.
export function RecipeSearch({ recipes }: Props) {
export function RecipeSearch({ recipes, featured }: Props) {
const inputId = useId();
const [query, setQuery] = useState('');

Expand All @@ -31,19 +33,30 @@ export function RecipeSearch({ recipes }: Props) {
.filter(Boolean);
if (tokens.length === 0) return recipes;
return recipes.filter((recipe) => {
const haystack = [recipe.title, recipe.description, recipe.slug, ...(recipe.topics ?? [])]
const haystack = [
recipe.title,
recipe.description,
recipe.slug,
...(recipe.topics ?? []),
...(recipe.languages ?? []),
]
.join(' ')
.toLowerCase();
return tokens.every((token) => haystack.includes(token));
});
}, [recipes, query]);

// Featured + All sections only make sense on the unfiltered view; once the
// user is searching, collapse to a single focused result list.
const isIdle = query.trim().length === 0;
const showFeatured = isIdle && !!featured && featured.length > 0;

return (
<div className="not-prose">
<label htmlFor={inputId} className="sr-only">
Filter recipes
</label>
<div className="relative mb-4">
<div className="relative mb-8">
<SearchIcon
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
Expand All @@ -59,6 +72,25 @@ export function RecipeSearch({ recipes }: Props) {
spellCheck={false}
/>
</div>

{showFeatured && featured && (
<section className="mb-10">
<h2 className="font-mono text-xs uppercase tracking-wide text-muted-foreground">
Featured
</h2>
<RecipeGrid>
{featured.map((r) => (
<RecipeCard key={r.slug} {...r} />
))}
</RecipeGrid>
</section>
)}

{isIdle && (
<h2 className="font-mono text-xs uppercase tracking-wide text-muted-foreground">
All
</h2>
)}
{filtered.length > 0 ? (
<RecipeGrid>
{filtered.map((r) => (
Expand Down
Loading
Loading