From 109808a027eb1881fc691a7d81cd21502f9a87d7 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 2 Mar 2026 18:36:53 -0500 Subject: [PATCH 1/2] Typespec package index --- website/src/components/header/header.astro | 3 + .../react-pages/packages.module.css | 204 ++++++++++++++++++ .../src/components/react-pages/packages.tsx | 184 ++++++++++++++++ website/src/pages/packages.astro | 8 + 4 files changed, 399 insertions(+) create mode 100644 website/src/components/react-pages/packages.module.css create mode 100644 website/src/components/react-pages/packages.tsx create mode 100644 website/src/pages/packages.astro diff --git a/website/src/components/header/header.astro b/website/src/components/header/header.astro index 455a7feccd7..8d1c56ddc04 100644 --- a/website/src/components/header/header.astro +++ b/website/src/components/header/header.astro @@ -220,6 +220,9 @@ const { noLinks } = Astro.props; Blog + + Packages + Community diff --git a/website/src/components/react-pages/packages.module.css b/website/src/components/react-pages/packages.module.css new file mode 100644 index 00000000000..fdc382db373 --- /dev/null +++ b/website/src/components/react-pages/packages.module.css @@ -0,0 +1,204 @@ +.content { + background-color: var(--colorNeutralBackground3); + min-height: 100%; + padding: 20px; + box-sizing: border-box; +} + +.list { + max-width: 960px; + margin: auto; +} + +.intro { + text-align: center; + margin-bottom: 20px; +} + +.intro h1 { + color: var(--colorNeutralForeground1); + font-size: 28px; + font-weight: 600; + line-height: 140%; + margin: 0 0 8px; +} + +.intro p { + color: var(--colorNeutralForeground3); + font-size: 20px; + line-height: 140%; + margin: 0; +} + +.search-input { + width: 100%; + padding: 10px 16px; + font-size: 1rem; + border: 1px solid var(--colorNeutralStroke1); + border-radius: 4px; + background-color: var(--colorNeutralBackground1); + color: var(--colorNeutralForeground1); + margin-bottom: 16px; + box-sizing: border-box; + outline: none; +} + +.search-input:focus { + border-color: var(--colorBrandForeground1); +} + +.count { + font-size: 0.85rem; + color: var(--colorNeutralForeground3); + margin-bottom: 16px; +} + +.package-grid { + display: flex; + flex-wrap: wrap; + gap: 1rem; + list-style-type: none; + margin: 0; + padding: 0; +} + +.package-item { + width: 100%; +} + +.card-link { + display: block; + border-radius: 0; + color: var(--colorNeutralForeground1); + text-decoration: none; +} + +.card-link:hover > .card > .card-bg { + border-color: var(--colorBrandForeground1); +} + +.card { + display: block; + position: relative; + overflow: hidden; +} + +.card-bg { + position: absolute; + width: 100%; + height: 100%; + background-color: var(--colorNeutralBackground1); + border: 1px solid var(--colorNeutralStroke3); + border-radius: 0; + box-sizing: border-box; +} + +.card-content { + position: relative; + z-index: 1; + padding: 20px; +} + +.card-header { + display: flex; + align-items: center; + margin-bottom: 4px; +} + +.avatar { + width: 16px; + height: 16px; + border-radius: 50%; + flex-shrink: 0; + object-fit: cover; +} + +.card-title-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + min-width: 0; +} + +.package-name { + font-size: 1.1rem; + font-weight: 600; + color: var(--colorBrandForeground1); +} + +.version-badge { + font-size: 0.75rem; + padding: 2px 10px; + border-radius: 12px; + background-color: var(--colorNeutralBackground4); + color: var(--colorNeutralForeground2); +} + +.description { + color: var(--colorNeutralForeground2); + font-size: 0.9rem; + margin: 4px 0 10px; + line-height: 1.4; +} + +.keywords { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.keyword { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 3px; + background-color: var(--colorNeutralBackground4); + color: var(--colorNeutralForeground3); +} + +.meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 0.8rem; + color: var(--colorNeutralForeground3); +} + +.loading { + text-align: center; + padding: 3rem; + color: var(--colorNeutralForeground2); + font-size: 1.1rem; +} + +.error { + text-align: center; + padding: 3rem; +} + +.error p { + color: var(--colorPaletteRedForeground1); + margin-bottom: 1rem; +} + +.retry-button { + padding: 8px 24px; + border: 1px solid var(--colorNeutralStroke1); + border-radius: 4px; + background-color: var(--colorNeutralBackground1); + color: var(--colorNeutralForeground1); + cursor: pointer; + font-size: 0.9rem; +} + +.retry-button:hover { + background-color: var(--colorNeutralBackground2); +} + +@media only screen and (min-width: 640px) { + .package-grid { + gap: 1.25rem; + } +} diff --git a/website/src/components/react-pages/packages.tsx b/website/src/components/react-pages/packages.tsx new file mode 100644 index 00000000000..892d0bd1ba9 --- /dev/null +++ b/website/src/components/react-pages/packages.tsx @@ -0,0 +1,184 @@ +import { formatDistanceToNow } from "date-fns"; +import { useEffect, useMemo, useState } from "react"; +import style from "./packages.module.css"; + +interface NpmPackage { + name: string; + description: string; + version: string; + publisherUsername: string; + publisherEmail: string; + date: string; + link: string; + keywords: string[]; +} + +interface NpmSearchResult { + objects: Array<{ + package: { + name: string; + description?: string; + version: string; + author?: { name?: string }; + publisher?: { username?: string; email?: string }; + date: string; + keywords?: string[]; + links: { npm?: string }; + }; + }>; + total: number; +} + +async function fetchNpmPackages(): Promise { + const response = await fetch( + "https://registry.npmjs.org/-/v1/search?text=keywords:typespec&size=250", + ); + if (!response.ok) { + throw new Error(`Failed to fetch packages: ${response.statusText}`); + } + const data: NpmSearchResult = await response.json(); + return data.objects.map((obj) => ({ + name: obj.package.name, + description: obj.package.description ?? "", + version: obj.package.version, + publisherUsername: obj.package.publisher?.username ?? "unknown", + publisherEmail: obj.package.publisher?.email ?? "", + date: obj.package.date, + link: obj.package.links.npm ?? `https://www.npmjs.com/package/${obj.package.name}`, + keywords: obj.package.keywords ?? [], + })); +} + +async function md5(message: string): Promise { + const msgBuffer = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer); + // Gravatar also accepts SHA-256 hashes + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function PackageCard({ pkg }: { pkg: NpmPackage }) { + const timeAgo = formatDistanceToNow(new Date(pkg.date), { addSuffix: true }); + const [avatarUrl, setAvatarUrl] = useState(null); + + useEffect(() => { + if (pkg.publisherEmail) { + md5(pkg.publisherEmail.trim().toLowerCase()).then((hash) => { + setAvatarUrl(`https://www.gravatar.com/avatar/${hash}?s=72&d=retro`); + }); + } + }, [pkg.publisherEmail]); + + return ( +
  • + +
  • + ); +} + +export const PackageList = () => { + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(""); + + const load = () => { + setLoading(true); + setError(null); + fetchNpmPackages() + .then(setPackages) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + load(); + }, []); + + const filtered = useMemo(() => { + if (!filter) return packages; + const lowerFilter = filter.toLowerCase(); + return packages.filter( + (p) => + p.name.toLowerCase().includes(lowerFilter) || + p.description.toLowerCase().includes(lowerFilter), + ); + }, [packages, filter]); + + return ( +
    +
    +

    TypeSpec Packages

    +

    + Packages on npm tagged with the typespec keyword. +

    +
    +
    + setFilter(e.target.value)} + className={style["search-input"]} + /> + {loading &&
    Loading packages...
    } + {error && ( +
    +

    Failed to load packages: {error}

    + +
    + )} + {!loading && !error && ( + <> +
    + {filtered.length} package{filtered.length !== 1 ? "s" : ""} found +
    +
      + {filtered.map((pkg) => ( + + ))} +
    + + )} +
    +
    + ); +}; diff --git a/website/src/pages/packages.astro b/website/src/pages/packages.astro new file mode 100644 index 00000000000..7c76c9e1ea2 --- /dev/null +++ b/website/src/pages/packages.astro @@ -0,0 +1,8 @@ +--- +import BaseLayout from "../layouts/base-layout.astro"; +import { PackageList } from "../components/react-pages/packages"; +--- + + + + From 3dee47fe66b39c8b5a2d28aa30151c6182287b0e Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 3 Mar 2026 08:16:29 -0500 Subject: [PATCH 2/2] first party --- website/src/components/react-pages/packages.module.css | 9 +++++++++ website/src/components/react-pages/packages.tsx | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/website/src/components/react-pages/packages.module.css b/website/src/components/react-pages/packages.module.css index fdc382db373..3e9abc2d9a2 100644 --- a/website/src/components/react-pages/packages.module.css +++ b/website/src/components/react-pages/packages.module.css @@ -135,6 +135,15 @@ color: var(--colorNeutralForeground2); } +.first-party-badge { + font-size: 0.75rem; + padding: 2px 10px; + border-radius: 12px; + background-color: var(--colorBrandBackground); + color: var(--colorNeutralForegroundOnBrand); + font-weight: 600; +} + .description { color: var(--colorNeutralForeground2); font-size: 0.9rem; diff --git a/website/src/components/react-pages/packages.tsx b/website/src/components/react-pages/packages.tsx index 892d0bd1ba9..cfea75ec959 100644 --- a/website/src/components/react-pages/packages.tsx +++ b/website/src/components/react-pages/packages.tsx @@ -70,6 +70,9 @@ function PackageCard({ pkg }: { pkg: NpmPackage }) { } }, [pkg.publisherEmail]); + const isFirstParty = + pkg.name.startsWith("@typespec/") || pkg.name.startsWith("@azure-tools/"); + return (
  • {pkg.name} v{pkg.version} + {isFirstParty && ✓ Official} {pkg.description &&

    {pkg.description}

    }