Add live product catalog to docs site

Espressif product data fetched at build time via Content Layer API
and rendered as browsable Starlight pages. Alpine.js provides
client-side filtering by series, type, connectivity, and status.

- Products content collection with inline API loader + Zod schema
- Filterable browse page at /products/ with card grid
- Individual spec sheet pages at /products/[id]/ (94 products)
- Separate CSS with dark/light theme support
- Sidebar section linking to catalog
- Cross-references to MCP tool reference docs
This commit is contained in:
Ryan Malloy 2026-02-25 16:02:43 -07:00
parent dab9c6848e
commit f8dfa90fba
10 changed files with 1513 additions and 3 deletions

View File

@ -1,5 +1,6 @@
import { defineConfig } from "astro/config";
import starlight from "@astrojs/starlight";
import alpinejs from "@astrojs/alpinejs";
import tailwindcss from "@tailwindcss/vite";
import sitemap from "@astrojs/sitemap";
@ -8,6 +9,7 @@ export default defineConfig({
telemetry: false,
devToolbar: { enabled: false },
integrations: [
alpinejs(),
starlight({
title: "mcesptool",
description:
@ -20,7 +22,7 @@ export default defineConfig({
href: "https://git.supported.systems/MCP/mcesptool",
},
],
customCss: ["./src/styles/global.css"],
customCss: ["./src/styles/global.css", "./src/styles/products.css"],
sidebar: [
{
label: "Tutorials",
@ -82,12 +84,22 @@ export default defineConfig({
label: "Server Tools",
slug: "reference/server-tools",
},
{
label: "Product Catalog",
slug: "reference/product-catalog",
},
],
},
{ label: "Resources", slug: "reference/resources" },
{ label: "Configuration", slug: "reference/configuration" },
],
},
{
label: "Product Catalog",
items: [
{ label: "Browse Products", link: "/products/" },
],
},
{
label: "Explanation",
autogenerate: { directory: "explanation" },

View File

@ -8,14 +8,26 @@
"name": "mcesptool-docs",
"version": "0.0.1",
"dependencies": {
"@astrojs/alpinejs": "^0.4.9",
"@astrojs/sitemap": "^3.3.1",
"@astrojs/starlight": "^0.37.6",
"@tailwindcss/vite": "^4.1.3",
"alpinejs": "^3.15.8",
"astro": "^5.7.10",
"sharp": "^0.33.0",
"tailwindcss": "^4.1.3"
}
},
"node_modules/@astrojs/alpinejs": {
"version": "0.4.9",
"resolved": "https://registry.npmjs.org/@astrojs/alpinejs/-/alpinejs-0.4.9.tgz",
"integrity": "sha512-fvKBAugn7yIngEKfdk6vL3ZlcVKtQvFXCZznG28OikGanKN5W+PkRPIdKaW/0gThRU2FyCemgzyHgyFjsH8dTA==",
"license": "MIT",
"peerDependencies": {
"@types/alpinejs": "^3.0.0",
"alpinejs": "^3.0.0"
}
},
"node_modules/@astrojs/compiler": {
"version": "2.13.1",
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz",
@ -2026,6 +2038,13 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@types/alpinejs": {
"version": "3.13.11",
"resolved": "https://registry.npmjs.org/@types/alpinejs/-/alpinejs-3.13.11.tgz",
"integrity": "sha512-3KhGkDixCPiLdL3Z/ok1GxHwLxEWqQOKJccgaQL01wc0EVM2tCTaqlC3NIedmxAXkVzt/V6VTM8qPgnOHKJ1MA==",
"license": "MIT",
"peer": true
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@ -2125,6 +2144,21 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.1.5"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@ -2146,6 +2180,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/alpinejs": {
"version": "3.15.8",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.8.tgz",
"integrity": "sha512-zxIfCRTBGvF1CCLIOMQOxAyBuqibxSEwS6Jm1a3HGA9rgrJVcjEWlwLcQTVGAWGS8YhAsTRLVrtQ5a5QT9bSSQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/ansi-align": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",

View File

@ -9,9 +9,11 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/alpinejs": "^0.4.9",
"@astrojs/sitemap": "^3.3.1",
"@astrojs/starlight": "^0.37.6",
"@tailwindcss/vite": "^4.1.3",
"alpinejs": "^3.15.8",
"astro": "^5.7.10",
"sharp": "^0.33.0",
"tailwindcss": "^4.1.3"

View File

@ -1,7 +1,140 @@
import { defineCollection } from "astro:content";
import { defineCollection, z } from "astro:content";
import { docsLoader } from "@astrojs/starlight/loaders";
import { docsSchema } from "@astrojs/starlight/schema";
function parseMemoryField(value: string | number | null | undefined): [number, string] {
if (typeof value === "number") return [value, ""];
if (!value || ["N/A", "NA", "-", "\u2014"].includes(value)) return [0, ""];
const parts = value.split(",").map((s) => s.trim());
const size = parseInt(parts[0], 10);
return [isNaN(size) ? 0 : size, parts[1] || ""];
}
function parseSramKb(value: string | number | null | undefined): number {
if (typeof value === "number") return value;
if (!value || ["N/A", "NA", "-", "\u2014"].includes(value)) return 0;
const n = parseInt(value.split(",")[0].trim(), 10);
return isNaN(n) ? 0 : n;
}
function hasCapability(value: string | null | undefined): boolean {
if (!value) return false;
const v = value.trim().toLowerCase();
return !["", "n/a", "na", "-", "\u2014", "no", "0"].includes(v);
}
function normalizeStatus(value: string): string {
const mapping: Record<string, string> = {
"mass production": "Mass Production",
nrnd: "NRND",
eol: "EOL",
replaced: "Replaced",
sample: "Sample",
};
return mapping[value.trim().toLowerCase()] || value.trim();
}
const products = defineCollection({
loader: async () => {
const resp = await fetch(
"https://products.espressif.com/api/user/products?language=en",
);
if (!resp.ok) {
throw new Error(`Espressif API returned ${resp.status}`);
}
const data = await resp.json();
const results: any[] = data.results || [];
return results.map((p: any) => {
const [flashMb, flashType] = parseMemoryField(p.flash);
const [psramMb, psramType] = parseMemoryField(p.psram);
const id = (p.name || "unknown")
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return {
id,
name: p.name || "",
name_new: p.nameNew || "",
type: p.type || "",
series: p.seriesName || "",
status: normalizeStatus(p.status || ""),
mpn: p.mpn || "",
dimensions: p.dimensions || "",
wifi: p.wifi || "",
wifi6: p.wifi6 || "",
bluetooth: p.bluetooth || "",
thread_zigbee: p.threadZigbee || "",
pins: String(p.pins || ""),
frequency_mhz: String(p.freq || ""),
sram_kb: parseSramKb(p.sram),
rom_kb: String(p.rom || ""),
flash_mb: flashMb,
flash_type: flashType,
psram_mb: psramMb,
psram_type: psramType,
gpio: parseInt(String(p.gpio || 0), 10) || 0,
operating_temp: p.operatingTemp || "",
voltage_range: p.voltageRange || "",
antenna: p.antenna || "",
size_type: p.sizeType || "",
release_time: p.releaseTime || "",
idf_supports: p.idfSupports || "",
spq: String(p.spq || ""),
moq: String(p.moq || ""),
lead_time: p.leadTime || "",
eccn_code: p.eccnCode || "",
hs_code: p.hsCode || "",
replaced_by: p.replacedByName || "",
has_wifi: hasCapability(p.wifi),
has_bluetooth: hasCapability(p.bluetooth),
has_thread_zigbee: hasCapability(p.threadZigbee),
};
});
},
schema: z.object({
name: z.string(),
name_new: z.string(),
type: z.string(),
series: z.string(),
status: z.string(),
mpn: z.string(),
dimensions: z.string(),
wifi: z.string(),
wifi6: z.string(),
bluetooth: z.string(),
thread_zigbee: z.string(),
pins: z.string(),
frequency_mhz: z.string(),
sram_kb: z.number(),
rom_kb: z.string(),
flash_mb: z.number(),
flash_type: z.string(),
psram_mb: z.number(),
psram_type: z.string(),
gpio: z.number(),
operating_temp: z.string(),
voltage_range: z.string(),
antenna: z.string(),
size_type: z.string(),
release_time: z.string(),
idf_supports: z.string(),
spq: z.string(),
moq: z.string(),
lead_time: z.string(),
eccn_code: z.string(),
hs_code: z.string(),
replaced_by: z.string(),
has_wifi: z.boolean(),
has_bluetooth: z.boolean(),
has_thread_zigbee: z.boolean(),
}),
});
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
products,
};

View File

@ -103,6 +103,16 @@ All tools follow a consistent pattern: they return a JSON object with a `success
| `esp_list_tools` | List all tools by category |
| `esp_health_check` | Environment health check |
### Product Catalog (5 tools)
| Tool | Description |
|------|-------------|
| `esp_product_search` | Search products by series, type, connectivity, memory, GPIO, temp, status |
| `esp_product_info` | Full spec sheet for a product (fuzzy-matched by name or MPN) |
| `esp_chip_compare` | Side-by-side comparison of 2-4 products |
| `esp_product_recommend` | Recommendations based on use case description |
| `esp_product_availability` | Filter by production status, lead time, and MOQ |
## Additional References
<CardGrid>

View File

@ -0,0 +1,326 @@
---
title: Product Catalog
description: Chip and module discovery, comparison, and procurement planning
---
import { Aside } from '@astrojs/starlight/components';
The Product Catalog component provides 5 tools for discovering Espressif chips and modules, comparing specifications, getting recommendations, and checking availability. Data is fetched from [Espressif's public product API](https://products.espressif.com/) and cached in-memory.
<Aside type="note">
The catalog is **lazy-loaded** on first use and cached with a 24-hour TTL. The initial call may take a few seconds while the catalog is fetched. Subsequent calls within the same server session are instant.
</Aside>
<Aside type="tip">
Product lookups use **fuzzy matching** on name and MPN (Manufacturer Part Number). You don't need the exact product name -- partial matches and close spellings work. For example, `"S3-WROOM"` will match `"ESP32-S3-WROOM-1-N16R8"`.
</Aside>
---
## esp_product_search
Search the Espressif product catalog by specs, capabilities, or keyword. All filters are optional and combine with AND logic. Returns matching products with key specs.
### Parameters
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `series` | `str \| None` | `None` | Chip family filter, e.g. `"ESP32-S3"`, `"ESP32-C6"`. |
| `product_type` | `str \| None` | `None` | `"SoC"` or `"Module"`. |
| `wifi` | `bool \| None` | `None` | Filter to products with (or without) Wi-Fi. |
| `bluetooth` | `bool \| None` | `None` | Filter to products with (or without) Bluetooth. |
| `thread_zigbee` | `bool \| None` | `None` | Filter to products with (or without) Thread/Zigbee (802.15.4). |
| `min_flash_mb` | `int \| None` | `None` | Minimum flash size in MB. |
| `min_psram_mb` | `int \| None` | `None` | Minimum PSRAM size in MB. |
| `min_sram_kb` | `int \| None` | `None` | Minimum SRAM size in KB. |
| `min_gpio` | `int \| None` | `None` | Minimum GPIO pin count. |
| `max_temp_c` | `int \| None` | `None` | Required upper operating temperature (e.g. `105` for industrial). |
| `status` | `str \| None` | `None` | Production status: `"Mass Production"`, `"NRND"`, `"EOL"`, `"Sample"`. |
| `antenna` | `str \| None` | `None` | Antenna type: `"PCB"`, `"IPEX"`, etc. |
| `keyword` | `str \| None` | `None` | Fuzzy search on product name or MPN. |
### Example
```python
result = await client.call_tool("esp_product_search", {
"series": "ESP32-S3",
"product_type": "Module",
"wifi": True,
"min_flash_mb": 8,
"status": "Mass Production"
})
```
### Return Value
```json
{
"count": 5,
"filters_applied": {
"series": "ESP32-S3",
"product_type": "Module",
"wifi": true,
"min_flash_mb": 8,
"status": "Mass Production"
},
"products": [
{
"name": "ESP32-S3-WROOM-1-N8",
"type": "Module",
"series": "ESP32-S3",
"status": "Mass Production",
"wifi": true,
"bluetooth": true,
"thread_zigbee": false,
"flash_mb": 8,
"psram_mb": 0,
"sram_kb": 512,
"gpio": 36,
"antenna": "PCB"
}
]
}
```
The `filters_applied` object echoes back only the filters that were actually provided, making it easy to confirm what was searched.
---
## esp_product_info
Get the full specification sheet for a specific Espressif product. The product is looked up by name or MPN using fuzzy matching.
### Parameters
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `name` | `str` | -- | Product name or MPN, e.g. `"ESP32-S3-WROOM-1"`. **Required**. Fuzzy-matched. |
### Example
```python
result = await client.call_tool("esp_product_info", {
"name": "ESP32-S3-WROOM-1"
})
```
### Return Value
```json
{
"name": "ESP32-S3-WROOM-1-N16R8",
"name_new": "",
"type": "Module",
"series": "ESP32-S3",
"status": "Mass Production",
"mpn": "ESP32-S3-WROOM-1-N16R8",
"dimensions": "18x25.5x3.1",
"wifi": "802.11 b/g/n",
"wifi6": "",
"bluetooth": "BLE 5.0",
"thread_zigbee": "",
"pins": "38",
"frequency_mhz": "240",
"sram_kb": 512,
"rom_kb": "384",
"flash_mb": 16,
"flash_type": "Quad",
"psram_mb": 8,
"psram_type": "Octal",
"gpio": 36,
"operating_temp": "-40~85C",
"voltage_range": "3.0~3.6V",
"antenna": "PCB",
"size_type": "18x25.5",
"release_time": "2021-12",
"idf_supports": ">=v4.4",
"spq": "1400",
"moq": "1400",
"lead_time": "8-10 weeks",
"eccn_code": "5A992.c",
"ccatsz_code": "",
"hs_code": "8542390000",
"pre_firmware": "",
"replaced_by": ""
}
```
If no product matches, the response includes an `error` field and a `suggestion` to try `esp_product_search` with a keyword.
---
## esp_chip_compare
Compare 2-4 Espressif products side-by-side. The response highlights fields that differ between the products and groups fields that are identical, making it easy to spot the key differences between chip families or module variants.
### Parameters
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `products` | `list[str]` | -- | List of 2-4 product names to compare. **Required**. Each name is fuzzy-matched. |
### Example
```python
result = await client.call_tool("esp_chip_compare", {
"products": ["ESP32-S3-WROOM-1-N16R8", "ESP32-C6-WROOM-1-N8"]
})
```
### Return Value
```json
{
"products_compared": ["ESP32-S3-WROOM-1-N16R8", "ESP32-C6-WROOM-1-N8"],
"differences": {
"series": {
"ESP32-S3-WROOM-1-N16R8": "ESP32-S3",
"ESP32-C6-WROOM-1-N8": "ESP32-C6"
},
"flash_mb": {
"ESP32-S3-WROOM-1-N16R8": 16,
"ESP32-C6-WROOM-1-N8": 8
},
"thread_zigbee": {
"ESP32-S3-WROOM-1-N16R8": "",
"ESP32-C6-WROOM-1-N8": "802.15.4"
}
},
"common": {
"type": "Module",
"status": "Mass Production",
"antenna": "PCB"
},
"full_specs": { }
}
```
The `differences` object maps each differing field to a dict of `{product_name: value}`. The `common` object contains fields where all compared products have the same value. `full_specs` includes the complete detail record for each product.
---
## esp_product_recommend
Get chip or module recommendations for a use case. Describe what you're building and any constraints, and this tool filters the catalog to matching products ranked by fit.
### Parameters
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `use_case` | `str` | -- | What you're building, e.g. `"battery-powered BLE sensor"`. **Required**. |
| `constraints` | `str \| None` | `None` | Technical constraints, e.g. `"needs PSRAM, temp to 105C"`. |
| `prefer_module` | `bool` | `True` | Prefer modules over bare SoCs. Falls back to SoCs if no modules match. |
### Example
```python
result = await client.call_tool("esp_product_recommend", {
"use_case": "battery-powered BLE sensor with 8MB flash",
"constraints": "needs PSRAM, industrial temperature"
})
```
### Return Value
```json
{
"use_case": "battery-powered BLE sensor with 8MB flash",
"constraints_parsed": {
"wifi": false,
"bluetooth": true,
"thread_zigbee": false,
"psram_required": true,
"industrial_temp": true,
"min_flash_mb": 8,
"min_psram_mb": null,
"prefer_module": true
},
"count": 3,
"recommendations": [
{
"name": "ESP32-S3-WROOM-1-N8R8",
"type": "Module",
"series": "ESP32-S3",
"status": "Mass Production",
"wifi": true,
"bluetooth": true,
"thread_zigbee": false,
"flash_mb": 8,
"psram_mb": 8,
"sram_kb": 512,
"gpio": 36,
"antenna": "PCB"
}
]
}
```
The `constraints_parsed` object shows how the natural-language inputs were interpreted into filter criteria. Up to 15 recommendations are returned.
<Aside type="caution">
Recommendations only include products in **Mass Production** status. NRND, EOL, and Sample parts are excluded to avoid suggesting products that may be difficult to source.
</Aside>
---
## esp_product_availability
Check product availability and procurement details. Filter by production status, lead time, and minimum order quantity to find products you can actually source.
### Parameters
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `series` | `str \| None` | `None` | Chip family filter, e.g. `"ESP32-S3"`. |
| `status` | `str` | `"Mass Production"` | Production status filter. |
| `max_lead_weeks` | `int \| None` | `None` | Maximum acceptable lead time in weeks. |
| `max_moq` | `int \| None` | `None` | Maximum acceptable minimum order quantity. |
### Example
```python
result = await client.call_tool("esp_product_availability", {
"series": "ESP32-C3",
"max_lead_weeks": 12,
"max_moq": 500
})
```
### Return Value
```json
{
"count": 4,
"filters": {
"series": "ESP32-C3",
"status": "Mass Production",
"max_lead_weeks": 12,
"max_moq": 500
},
"products": [
{
"name": "ESP32-C3-WROOM-02-N4",
"type": "Module",
"series": "ESP32-C3",
"status": "Mass Production",
"wifi": true,
"bluetooth": true,
"thread_zigbee": false,
"flash_mb": 4,
"psram_mb": 0,
"sram_kb": 400,
"gpio": 22,
"antenna": "PCB",
"lead_time": "8-10 weeks",
"moq": "200",
"spq": "200",
"mpn": "ESP32-C3-WROOM-02-N4",
"replaced_by": ""
}
]
}
```
<Aside type="tip">
Use this tool to identify alternatives for EOL or NRND parts. Set `status` to `"NRND"` or `"EOL"` and check the `replaced_by` field for Espressif's suggested replacements.
</Aside>

View File

@ -5,7 +5,7 @@ description: Real-time MCP resources exposed by mcesptool
import { Aside } from '@astrojs/starlight/components';
mcesptool exposes 3 MCP resources that provide real-time server state without requiring tool invocations. Resources are read-only and can be accessed by any MCP client using the `read_resource` protocol method.
mcesptool exposes 4 MCP resources that provide real-time server state without requiring tool invocations. Resources are read-only and can be accessed by any MCP client using the `read_resource` protocol method.
<Aside type="tip">
Resources are lighter-weight than tools -- they do not run subprocess commands or interact with hardware. Use them for dashboard displays, status polling, or client-side configuration checks.
@ -158,3 +158,65 @@ result = await client.read_resource("esp://capabilities")
<Aside type="note">
The `esp_chip_support` list reflects what esptool can communicate with. QEMU emulation supports a smaller subset -- see the [QEMU Manager supported chips table](/reference/qemu-manager/#supported-chips).
</Aside>
---
## esp://products/catalog
Complete Espressif product catalog with summarized specs for every SoC and module. Data is fetched from [Espressif's public product API](https://products.espressif.com/) and cached in-memory with a 24-hour TTL.
For filtered queries, use the [Product Catalog tools](/reference/product-catalog/) instead.
### URI
```
esp://products/catalog
```
### Example
```python
result = await client.read_resource("esp://products/catalog")
```
### Response
```json
[
{
"name": "ESP32-S3-WROOM-1-N16R8",
"type": "Module",
"series": "ESP32-S3",
"status": "Mass Production",
"wifi": true,
"bluetooth": true,
"thread_zigbee": false,
"flash_mb": 16,
"psram_mb": 8,
"sram_kb": 512,
"gpio": 36,
"antenna": "PCB"
}
]
```
### Fields
| Field | Type | Description |
|-------|------|-------------|
| `name` | `str` | Product name as listed in Espressif's catalog. |
| `type` | `str` | `"SoC"` or `"Module"`. |
| `series` | `str` | Chip family, e.g. `"ESP32-S3"`, `"ESP32-C6"`. |
| `status` | `str` | Production status: `"Mass Production"`, `"NRND"`, `"EOL"`, `"Sample"`. |
| `wifi` | `bool` | Whether the product supports Wi-Fi. |
| `bluetooth` | `bool` | Whether the product supports Bluetooth/BLE. |
| `thread_zigbee` | `bool` | Whether the product supports Thread/Zigbee (802.15.4). |
| `flash_mb` | `int` | Integrated flash size in MB (0 if none). |
| `psram_mb` | `int` | Integrated PSRAM size in MB (0 if none). |
| `sram_kb` | `int` | Internal SRAM size in KB. |
| `gpio` | `int` | Number of GPIO pins. |
| `antenna` | `str` | Antenna type (e.g. `"PCB"`, `"IPEX"`, or empty for SoCs). |
<Aside type="note">
The catalog resource returns the full, unfiltered product list. For large catalogs (200+ products) this can be a significant payload. Use the [search](/reference/product-catalog/#esp_product_search) and [availability](/reference/product-catalog/#esp_product_availability) tools for targeted queries.
</Aside>

View File

@ -0,0 +1,238 @@
---
import type { GetStaticPaths } from "astro";
import { getCollection } from "astro:content";
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
export const getStaticPaths: GetStaticPaths = async () => {
const products = await getCollection("products");
return products.map((product) => ({
params: { id: product.id },
props: { product },
}));
};
const { product } = Astro.props;
const d = product.data;
function statusClass(status: string): string {
const map: Record<string, string> = {
"Mass Production": "badge-mass-production",
Sample: "badge-sample",
NRND: "badge-nrnd",
EOL: "badge-eol",
Replaced: "badge-replaced",
};
return map[status] || "";
}
function displayValue(val: string | number | undefined | null): string {
if (val === undefined || val === null || val === "" || val === 0) return "\u2014";
return String(val);
}
function memoryDisplay(mb: number, type: string): string {
if (mb === 0) return "\u2014";
return `${mb} MB${type ? ` (${type})` : ""}`;
}
---
<StarlightPage
frontmatter={{
title: d.name,
description: `${d.name} — ${d.type} from the ${d.series} family. Full specifications, memory, connectivity, and procurement details.`,
}}
>
<a href="/products/" class="product-back-link">&larr; Back to catalog</a>
<div class="product-detail-header">
<div class="product-detail-badges">
{d.series && <span class="badge badge-series">{d.series}</span>}
{d.type && (
<span class={`badge ${d.type === "SoC" ? "badge-soc" : "badge-module"}`}>
{d.type}
</span>
)}
{d.status && <span class={`badge ${statusClass(d.status)}`}>{d.status}</span>}
{d.antenna && <span class="conn-badge">{d.antenna}</span>}
</div>
</div>
{d.name_new && d.name_new !== d.name && (
<p style="font-size: 0.875rem; color: var(--sl-color-gray-3); margin-block-end: 1.5rem;">
Also known as: <strong style="color: var(--sl-color-white);">{d.name_new}</strong>
</p>
)}
<div class="product-spec-grid">
<div class="spec-section">
<h3>Memory</h3>
<table class="spec-table">
<tbody>
<tr>
<th>SRAM</th>
<td>{d.sram_kb > 0 ? `${d.sram_kb} KB` : "\u2014"}</td>
</tr>
<tr>
<th>ROM</th>
<td>{displayValue(d.rom_kb) !== "\u2014" ? `${d.rom_kb} KB` : "\u2014"}</td>
</tr>
<tr>
<th>Flash</th>
<td>{memoryDisplay(d.flash_mb, d.flash_type)}</td>
</tr>
<tr>
<th>PSRAM</th>
<td>{memoryDisplay(d.psram_mb, d.psram_type)}</td>
</tr>
</tbody>
</table>
</div>
<div class="spec-section">
<h3>Connectivity</h3>
<table class="spec-table">
<tbody>
<tr>
<th>Wi-Fi</th>
<td>
{d.has_wifi ? (
<span class="conn-badge conn-badge-wifi">{d.wifi}</span>
) : "\u2014"}
</td>
</tr>
{d.wifi6 && (
<tr>
<th>Wi-Fi 6</th>
<td><span class="conn-badge conn-badge-wifi">{d.wifi6}</span></td>
</tr>
)}
<tr>
<th>Bluetooth</th>
<td>
{d.has_bluetooth ? (
<span class="conn-badge conn-badge-bt">{d.bluetooth}</span>
) : "\u2014"}
</td>
</tr>
<tr>
<th>Thread / Zigbee</th>
<td>
{d.has_thread_zigbee ? (
<span class="conn-badge conn-badge-thread">{d.thread_zigbee}</span>
) : "\u2014"}
</td>
</tr>
</tbody>
</table>
</div>
<div class="spec-section">
<h3>Physical</h3>
<table class="spec-table">
<tbody>
<tr>
<th>Pins</th>
<td>{displayValue(d.pins)}</td>
</tr>
<tr>
<th>GPIO</th>
<td>{d.gpio > 0 ? d.gpio : "\u2014"}</td>
</tr>
<tr>
<th>Frequency</th>
<td>{displayValue(d.frequency_mhz) !== "\u2014" ? `${d.frequency_mhz} MHz` : "\u2014"}</td>
</tr>
<tr>
<th>Dimensions</th>
<td>{displayValue(d.dimensions)}</td>
</tr>
<tr>
<th>Antenna</th>
<td>{displayValue(d.antenna)}</td>
</tr>
<tr>
<th>Package</th>
<td>{displayValue(d.size_type)}</td>
</tr>
<tr>
<th>Operating Temp</th>
<td>{displayValue(d.operating_temp)}</td>
</tr>
<tr>
<th>Voltage Range</th>
<td>{displayValue(d.voltage_range)}</td>
</tr>
</tbody>
</table>
</div>
<div class="spec-section">
<h3>Procurement</h3>
<table class="spec-table">
<tbody>
<tr>
<th>MPN</th>
<td>{displayValue(d.mpn)}</td>
</tr>
<tr>
<th>MOQ</th>
<td>{displayValue(d.moq)}</td>
</tr>
<tr>
<th>SPQ</th>
<td>{displayValue(d.spq)}</td>
</tr>
<tr>
<th>Lead Time</th>
<td>{displayValue(d.lead_time)}</td>
</tr>
<tr>
<th>ECCN</th>
<td>{displayValue(d.eccn_code)}</td>
</tr>
<tr>
<th>HS Code</th>
<td>{displayValue(d.hs_code)}</td>
</tr>
</tbody>
</table>
</div>
</div>
{(d.release_time || d.idf_supports || d.replaced_by) && (
<div class="spec-section" style="margin-block-end: 2rem;">
<h3>Lifecycle</h3>
<table class="spec-table">
<tbody>
{d.release_time && (
<tr>
<th>Release Date</th>
<td>{d.release_time}</td>
</tr>
)}
{d.idf_supports && (
<tr>
<th>IDF Support</th>
<td>{d.idf_supports}</td>
</tr>
)}
{d.replaced_by && (
<tr>
<th>Replaced By</th>
<td>
<a href={`/products/${d.replaced_by.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")}/`}>
{d.replaced_by}
</a>
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
<hr style="border-color: var(--sl-color-gray-5); margin-block: 2rem;" />
<p style="font-size: 0.875rem; color: var(--sl-color-gray-3);">
Query this data programmatically via the <a href="/reference/product-catalog/">Product Catalog MCP tools</a>.
</p>
</StarlightPage>

View File

@ -0,0 +1,246 @@
---
import { getCollection } from "astro:content";
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
const allProducts = await getCollection("products");
// Extract unique series for filter chips
const allSeries = [...new Set(allProducts.map((p) => p.data.series).filter(Boolean))].sort();
const allStatuses = [...new Set(allProducts.map((p) => p.data.status).filter(Boolean))].sort();
// Serialize products for Alpine.js
const productsJson = JSON.stringify(
allProducts.map((p) => ({
id: p.id,
...p.data,
})),
);
---
<StarlightPage
frontmatter={{
title: "Product Catalog",
description: "Browse the complete Espressif ESP32 and ESP8266 product catalog — SoCs, modules, and dev kits with full specifications.",
template: "splash",
hero: {
title: "Espressif Product Catalog",
tagline: "Browse all ESP32 and ESP8266 chips and modules. Data sourced live from Espressif at build time.",
},
}}
>
<div x-data={`catalog(${productsJson})`}>
<div class="product-filters">
<div class="filter-row">
<input
type="text"
class="filter-search"
placeholder="Search by name, MPN, or series..."
x-model="search"
/>
</div>
<div class="filter-row">
<span class="filter-label">Type</span>
<div class="filter-group">
<button
class="filter-chip"
:class="{ active: typeFilter === '' }"
@click="typeFilter = ''"
>All</button>
<button
class="filter-chip"
:class="{ active: typeFilter === 'SoC' }"
@click="typeFilter = typeFilter === 'SoC' ? '' : 'SoC'"
>SoC</button>
<button
class="filter-chip"
:class="{ active: typeFilter === 'Module' }"
@click="typeFilter = typeFilter === 'Module' ? '' : 'Module'"
>Module</button>
</div>
<span class="filter-sep"></span>
<span class="filter-label">Connectivity</span>
<div class="filter-group">
<button
class="filter-chip"
:class="{ active: wifiFilter }"
@click="wifiFilter = !wifiFilter"
>Wi-Fi</button>
<button
class="filter-chip"
:class="{ active: btFilter }"
@click="btFilter = !btFilter"
>Bluetooth</button>
<button
class="filter-chip"
:class="{ active: threadFilter }"
@click="threadFilter = !threadFilter"
>Thread/Zigbee</button>
</div>
</div>
<div class="filter-row">
<span class="filter-label">Series</span>
<div class="filter-group">
<button
class="filter-chip"
:class="{ active: seriesFilter === '' }"
@click="seriesFilter = ''"
>All</button>
{allSeries.map((s) => (
<button
class="filter-chip"
x-bind:class={`{ active: seriesFilter === '${s}' }`}
x-on:click={`seriesFilter = seriesFilter === '${s}' ? '' : '${s}'`}
>{s}</button>
))}
</div>
</div>
<div class="filter-row">
<span class="filter-label">Status</span>
<div class="filter-group">
<button
class="filter-chip"
:class="{ active: statusFilter === '' }"
@click="statusFilter = ''"
>All</button>
{allStatuses.map((s) => (
<button
class="filter-chip"
x-bind:class={`{ active: statusFilter === '${s}' }`}
x-on:click={`statusFilter = statusFilter === '${s}' ? '' : '${s}'`}
>{s}</button>
))}
</div>
</div>
</div>
<div class="product-count">
Showing <strong x-text="filtered.length"></strong> of <strong>{allProducts.length}</strong> products
</div>
<div class="product-grid">
<template x-for="p in filtered" :key="p.id">
<a :href="`/products/${p.id}/`" class="product-card">
<div class="product-card-header">
<span class="product-card-name" x-text="p.name"></span>
</div>
<div class="product-card-badges">
<span class="badge badge-series" x-text="p.series" x-show="p.series"></span>
<span
class="badge"
:class="p.type === 'SoC' ? 'badge-soc' : 'badge-module'"
x-text="p.type"
x-show="p.type"
></span>
<span
class="badge"
:class="statusClass(p.status)"
x-text="p.status"
x-show="p.status"
></span>
</div>
<dl class="product-card-specs">
<template x-if="p.flash_mb > 0">
<div>
<dt>Flash</dt>
<dd x-text="p.flash_mb + ' MB' + (p.flash_type ? ' ' + p.flash_type : '')"></dd>
</div>
</template>
<template x-if="p.psram_mb > 0">
<div>
<dt>PSRAM</dt>
<dd x-text="p.psram_mb + ' MB' + (p.psram_type ? ' ' + p.psram_type : '')"></dd>
</div>
</template>
<template x-if="p.sram_kb > 0">
<div>
<dt>SRAM</dt>
<dd x-text="p.sram_kb + ' KB'"></dd>
</div>
</template>
<template x-if="p.gpio > 0">
<div>
<dt>GPIO</dt>
<dd x-text="p.gpio"></dd>
</div>
</template>
</dl>
<div class="product-card-connectivity">
<span class="conn-badge conn-badge-wifi" x-show="p.has_wifi" x-text="'Wi-Fi'"></span>
<span class="conn-badge conn-badge-bt" x-show="p.has_bluetooth" x-text="'BT'"></span>
<span class="conn-badge conn-badge-thread" x-show="p.has_thread_zigbee" x-text="'Thread/Zigbee'"></span>
<span class="conn-badge" x-show="p.antenna" x-text="p.antenna"></span>
</div>
</a>
</template>
</div>
<div class="product-empty" x-show="filtered.length === 0">
<p>No products match your current filters. Try broadening your search.</p>
</div>
</div>
<script>
document.addEventListener("alpine:init", () => {
// @ts-ignore
window.Alpine.data("catalog", (products: any[]) => ({
products,
search: "",
typeFilter: "",
seriesFilter: "",
statusFilter: "",
wifiFilter: false,
btFilter: false,
threadFilter: false,
get filtered() {
let result = this.products;
if (this.search) {
const q = this.search.toLowerCase();
result = result.filter(
(p: any) =>
p.name.toLowerCase().includes(q) ||
p.mpn.toLowerCase().includes(q) ||
p.series.toLowerCase().includes(q),
);
}
if (this.typeFilter) {
result = result.filter((p: any) => p.type === this.typeFilter);
}
if (this.seriesFilter) {
result = result.filter((p: any) => p.series === this.seriesFilter);
}
if (this.statusFilter) {
result = result.filter((p: any) => p.status === this.statusFilter);
}
if (this.wifiFilter) {
result = result.filter((p: any) => p.has_wifi);
}
if (this.btFilter) {
result = result.filter((p: any) => p.has_bluetooth);
}
if (this.threadFilter) {
result = result.filter((p: any) => p.has_thread_zigbee);
}
return result;
},
statusClass(status: string) {
const map: Record<string, string> = {
"Mass Production": "badge-mass-production",
Sample: "badge-sample",
NRND: "badge-nrnd",
EOL: "badge-eol",
Replaced: "badge-replaced",
};
return map[status] || "";
},
}));
});
</script>
</StarlightPage>

View File

@ -0,0 +1,438 @@
/* Product Catalog — filter bar, card grid, detail pages */
/* ── Filter bar ─────────────────────────────────────────── */
.product-filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-block-end: 1.5rem;
padding: 1rem;
background: var(--sl-color-gray-6);
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.75rem;
}
.product-filters .filter-group {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.product-filters .filter-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--sl-color-gray-3);
margin-inline-end: 0.25rem;
white-space: nowrap;
}
.product-filters .filter-sep {
width: 1px;
height: 1.5rem;
background: var(--sl-color-gray-5);
margin-inline: 0.375rem;
}
.filter-chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
font-size: 0.8125rem;
line-height: 1.4;
border: 1px solid var(--sl-color-gray-5);
border-radius: 9999px;
background: transparent;
color: var(--sl-color-gray-2);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
user-select: none;
}
.filter-chip:hover {
border-color: var(--sl-color-accent);
color: var(--sl-color-accent-high);
}
.filter-chip.active {
background: var(--sl-color-accent);
border-color: var(--sl-color-accent);
color: var(--sl-color-black);
font-weight: 600;
}
.filter-search {
flex: 1 1 12rem;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.5rem;
background: var(--sl-color-black);
color: var(--sl-color-white);
outline: none;
transition: border-color 0.15s ease;
}
.filter-search::placeholder {
color: var(--sl-color-gray-4);
}
.filter-search:focus {
border-color: var(--sl-color-accent);
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
width: 100%;
}
.filter-row + .filter-row {
margin-block-start: 0.5rem;
}
/* ── Result count ───────────────────────────────────────── */
.product-count {
font-size: 0.8125rem;
color: var(--sl-color-gray-3);
margin-block-end: 1rem;
}
.product-count strong {
color: var(--sl-color-accent-high);
}
/* ── Card grid ──────────────────────────────────────────── */
.product-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 640px) {
.product-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.product-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.product-card {
display: flex;
flex-direction: column;
padding: 1rem;
background: var(--sl-color-gray-6);
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.75rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
text-decoration: none;
color: inherit;
}
.product-card:hover {
border-color: var(--sl-color-accent);
box-shadow: 0 0 0 1px var(--sl-color-accent);
}
.product-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-block-end: 0.75rem;
}
.product-card-name {
font-size: 0.9375rem;
font-weight: 600;
color: var(--sl-color-white);
line-height: 1.3;
word-break: break-word;
}
.product-card-badges {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-block-end: 0.625rem;
}
.product-card-specs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.25rem 1rem;
font-size: 0.8125rem;
color: var(--sl-color-gray-2);
margin-block-end: 0.625rem;
}
.product-card-specs dt {
color: var(--sl-color-gray-3);
font-size: 0.75rem;
}
.product-card-specs dd {
margin: 0;
font-weight: 500;
}
.product-card-connectivity {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
margin-block-start: auto;
padding-block-start: 0.5rem;
border-block-start: 1px solid var(--sl-color-gray-5);
}
/* ── Badges ─────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.03em;
border-radius: 9999px;
white-space: nowrap;
line-height: 1.5;
}
/* Series badges */
.badge-series {
background: color-mix(in srgb, var(--sl-color-accent) 15%, transparent);
color: var(--sl-color-accent-high);
border: 1px solid color-mix(in srgb, var(--sl-color-accent) 30%, transparent);
}
/* Type badges */
.badge-soc {
background: color-mix(in srgb, #3b82f6 15%, transparent);
color: #93c5fd;
border: 1px solid color-mix(in srgb, #3b82f6 30%, transparent);
}
.badge-module {
background: color-mix(in srgb, #8b5cf6 15%, transparent);
color: #c4b5fd;
border: 1px solid color-mix(in srgb, #8b5cf6 30%, transparent);
}
/* Status badges */
.badge-mass-production {
background: color-mix(in srgb, var(--sl-color-accent) 15%, transparent);
color: var(--sl-color-accent-high);
border: 1px solid color-mix(in srgb, var(--sl-color-accent) 30%, transparent);
}
.badge-sample {
background: color-mix(in srgb, #3b82f6 15%, transparent);
color: #93c5fd;
border: 1px solid color-mix(in srgb, #3b82f6 30%, transparent);
}
.badge-nrnd {
background: color-mix(in srgb, #f59e0b 15%, transparent);
color: #fcd34d;
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
}
.badge-eol {
background: color-mix(in srgb, #ef4444 15%, transparent);
color: #fca5a5;
border: 1px solid color-mix(in srgb, #ef4444 30%, transparent);
}
.badge-replaced {
background: color-mix(in srgb, #ef4444 15%, transparent);
color: #fca5a5;
border: 1px solid color-mix(in srgb, #ef4444 30%, transparent);
}
/* Connectivity badges */
.conn-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
font-weight: 500;
border-radius: 9999px;
background: color-mix(in srgb, var(--sl-color-gray-4) 12%, transparent);
color: var(--sl-color-gray-2);
border: 1px solid var(--sl-color-gray-5);
}
.conn-badge-wifi {
background: color-mix(in srgb, #06b6d4 12%, transparent);
color: #67e8f9;
border-color: color-mix(in srgb, #06b6d4 25%, transparent);
}
.conn-badge-bt {
background: color-mix(in srgb, #3b82f6 12%, transparent);
color: #93c5fd;
border-color: color-mix(in srgb, #3b82f6 25%, transparent);
}
.conn-badge-thread {
background: color-mix(in srgb, #22c55e 12%, transparent);
color: #86efac;
border-color: color-mix(in srgb, #22c55e 25%, transparent);
}
/* ── Detail page ────────────────────────────────────────── */
.product-detail-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-block-end: 1.5rem;
}
.product-detail-badges {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.product-spec-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
margin-block-end: 2rem;
}
@media (min-width: 768px) {
.product-spec-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.spec-section {
padding: 1rem;
background: var(--sl-color-gray-6);
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.75rem;
}
.spec-section h3 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--sl-color-accent-high);
}
.spec-table {
width: 100%;
border-collapse: collapse;
}
.spec-table tr + tr {
border-block-start: 1px solid var(--sl-color-gray-5);
}
.spec-table th {
text-align: start;
padding: 0.375rem 0.75rem 0.375rem 0;
font-size: 0.8125rem;
font-weight: 500;
color: var(--sl-color-gray-3);
white-space: nowrap;
width: 40%;
}
.spec-table td {
padding: 0.375rem 0;
font-size: 0.8125rem;
color: var(--sl-color-white);
}
/* ── Back link ──────────────────────────────────────────── */
.product-back-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--sl-color-accent-high);
text-decoration: none;
margin-block-end: 1.5rem;
}
.product-back-link:hover {
text-decoration: underline;
}
/* ── Empty state ────────────────────────────────────────── */
.product-empty {
text-align: center;
padding: 3rem 1rem;
color: var(--sl-color-gray-3);
}
.product-empty p {
margin: 0;
font-size: 0.9375rem;
}
/* ── Light theme overrides ──────────────────────────────── */
:root[data-theme="light"] .badge-soc {
background: color-mix(in srgb, #3b82f6 10%, transparent);
color: #1d4ed8;
border-color: color-mix(in srgb, #3b82f6 25%, transparent);
}
:root[data-theme="light"] .badge-module {
background: color-mix(in srgb, #8b5cf6 10%, transparent);
color: #6d28d9;
border-color: color-mix(in srgb, #8b5cf6 25%, transparent);
}
:root[data-theme="light"] .badge-nrnd {
background: color-mix(in srgb, #f59e0b 10%, transparent);
color: #b45309;
border-color: color-mix(in srgb, #f59e0b 25%, transparent);
}
:root[data-theme="light"] .badge-eol,
:root[data-theme="light"] .badge-replaced {
background: color-mix(in srgb, #ef4444 10%, transparent);
color: #b91c1c;
border-color: color-mix(in srgb, #ef4444 25%, transparent);
}
:root[data-theme="light"] .badge-sample {
background: color-mix(in srgb, #3b82f6 10%, transparent);
color: #1d4ed8;
border-color: color-mix(in srgb, #3b82f6 25%, transparent);
}
:root[data-theme="light"] .conn-badge-wifi {
background: color-mix(in srgb, #06b6d4 10%, transparent);
color: #0e7490;
border-color: color-mix(in srgb, #06b6d4 20%, transparent);
}
:root[data-theme="light"] .conn-badge-bt {
background: color-mix(in srgb, #3b82f6 10%, transparent);
color: #1d4ed8;
border-color: color-mix(in srgb, #3b82f6 20%, transparent);
}
:root[data-theme="light"] .conn-badge-thread {
background: color-mix(in srgb, #22c55e 10%, transparent);
color: #15803d;
border-color: color-mix(in srgb, #22c55e 20%, transparent);
}