From 604896df2d81ef56e72f79a80fb881c59834bca6 Mon Sep 17 00:00:00 2001 From: Michael Selander Date: Mon, 13 Jul 2026 15:33:45 -0600 Subject: [PATCH] Add templates list and detail pages --- package-lock.json | 1 + package.json | 1 + src/_data/templates.js | 303 ++++++++++++++++++++++++++ src/_layouts/main.webc | 4 +- src/lib/template-pages.js | 397 ++++++++++++++++++++++++++++++++++ src/templates/family.11ty.js | 39 ++++ src/templates/index.11ty.js | 23 ++ src/templates/variant.11ty.js | 39 ++++ 8 files changed, 805 insertions(+), 2 deletions(-) create mode 100644 src/_data/templates.js create mode 100644 src/lib/template-pages.js create mode 100644 src/templates/family.11ty.js create mode 100644 src/templates/index.11ty.js create mode 100644 src/templates/variant.11ty.js diff --git a/package-lock.json b/package-lock.json index fe2d7a0..e8c7080 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@types/react": "^19.2.14", "gray-matter": "^4.0.3", "html-minifier-terser": "^7.2.0", + "js-yaml": "^4.1.1", "markdown-it": "^14.1.1", "tailwindcss": "^4.0.14" }, diff --git a/package.json b/package.json index 7a5ba19..ea6101e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@types/react": "^19.2.14", "gray-matter": "^4.0.3", "html-minifier-terser": "^7.2.0", + "js-yaml": "^4.1.1", "markdown-it": "^14.1.1", "tailwindcss": "^4.0.14" }, diff --git a/src/_data/templates.js b/src/_data/templates.js new file mode 100644 index 0000000..3a24f5f --- /dev/null +++ b/src/_data/templates.js @@ -0,0 +1,303 @@ +import fs from "fs"; +import yaml from "js-yaml"; +import providers from "./providers.js"; + +const REGISTRY_OWNER = "stripe"; +const REGISTRY_REPO = "projects-template-registry"; +const REGISTRY_REF = "main"; +const REGISTRY_API_BASE = `https://api.github.com/repos/${REGISTRY_OWNER}/${REGISTRY_REPO}`; +const REGISTRY_RAW_BASE = `https://raw.githubusercontent.com/${REGISTRY_OWNER}/${REGISTRY_REPO}/${REGISTRY_REF}`; +const GITHUB_HEADERS = { + Accept: "application/vnd.github+json", + "User-Agent": "projects-site", +}; + +const PROVIDER_NAME_OVERRIDES = { + e2b: "E2B", + openrouter: "OpenRouter", + auth0: "Auth0", + flyio: "Fly.io", +}; + +const ASSET_SOURCES = [ + { + dir: new URL("../assets/images/svg/provider-icons/", import.meta.url), + urlPrefix: "/assets/images/svg/provider-icons/", + }, + { + dir: new URL("../assets/images/svg/provider-logos/", import.meta.url), + urlPrefix: "/assets/images/svg/provider-logos/", + }, +]; + +const providerNameMap = new Map( + providers.map((provider) => [provider.slug.split("/")[0], provider.name]), +); + +function titleCase(value) { + return String(value) + .split(/[_-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function getProviderName(providerSlug) { + return ( + PROVIDER_NAME_OVERRIDES[providerSlug] || + providerNameMap.get(providerSlug) || + titleCase(providerSlug) + ); +} + +function getProviderInitials(name) { + const parts = String(name).split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return parts + .slice(0, 2) + .map((part) => part[0].toUpperCase()) + .join(""); +} + +function getProviderIconUrl(providerSlug) { + for (const source of ASSET_SOURCES) { + const assetUrl = new URL(`${providerSlug}.svg`, source.dir); + if (fs.existsSync(assetUrl)) { + return `${source.urlPrefix}${providerSlug}.svg`; + } + } + + return null; +} + +function getRepoRootUrl(repoUrl) { + if (!repoUrl) return ""; + const match = repoUrl.match(/^(https:\/\/github\.com\/[^/]+\/[^/]+)/i); + return match ? match[1] : repoUrl; +} + +function normalizeTags(tags = []) { + const seen = new Set(); + return tags.filter(Boolean).filter((tag) => { + const key = String(tag).toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function createServiceItem(serviceSlug) { + const [providerSlug, ...serviceParts] = String(serviceSlug).split("/"); + const providerName = getProviderName(providerSlug); + + return { + slug: serviceSlug, + providerSlug, + providerName, + serviceName: serviceParts.join("/"), + iconUrl: getProviderIconUrl(providerSlug), + fallbackInitials: getProviderInitials(providerName), + }; +} + +function sortVariants(left, right) { + if (left.isDefault !== right.isDefault) { + return left.isDefault ? -1 : 1; + } + + return left.variantId.localeCompare(right.variantId); +} + +function sortFamilies(left, right) { + return left.name.localeCompare(right.name); +} + +async function fetchJson(url) { + const response = await fetch(url, { headers: GITHUB_HEADERS }); + if (!response.ok) { + throw new Error(`GitHub request failed (${response.status}) for ${url}`); + } + return response.json(); +} + +async function fetchText(url) { + const response = await fetch(url, { headers: GITHUB_HEADERS }); + if (!response.ok) { + throw new Error(`GitHub request failed (${response.status}) for ${url}`); + } + return response.text(); +} + +export default async function () { + const tree = await fetchJson(`${REGISTRY_API_BASE}/git/trees/${REGISTRY_REF}?recursive=1`); + const guidedRaw = await fetchText(`${REGISTRY_RAW_BASE}/guided.yaml`); + const guided = yaml.load(guidedRaw) || {}; + const guidedCategories = new Map( + (guided.categories || []).map((category) => [category.key, category]), + ); + + const manifestPaths = (tree.tree || []) + .map((entry) => entry.path) + .filter((path) => path && /\.(yaml|yml)$/i.test(path)) + .filter((path) => path !== "guided.yaml" && path !== "example/example.yaml") + .sort((left, right) => left.localeCompare(right)); + + const manifestTexts = await Promise.all( + manifestPaths.map(async (path) => ({ + path, + source: await fetchText(`${REGISTRY_RAW_BASE}/${path}`), + })), + ); + + const variants = manifestTexts.map(({ path, source }) => { + const manifest = yaml.load(source); + + if (!manifest?.template || !manifest?.variant || !manifest?.metadata?.name) { + throw new Error(`Registry manifest is missing required fields: ${path}`); + } + + const [owner = "", templateSlug = ""] = String(manifest.template).split("/"); + const ownerSlug = owner.toLowerCase(); + const templatePathSlug = templateSlug.toLowerCase(); + const routeUrl = `/templates/${ownerSlug}/${templatePathSlug}/`; + const variantSlug = String(manifest.variant).toLowerCase(); + const services = (manifest.services || []).map(createServiceItem); + const categoryKey = manifest.guided?.category || "other"; + const frameworkKey = manifest.guided?.framework || "other"; + const categoryLabel = guidedCategories.get(categoryKey)?.label || titleCase(categoryKey); + + return { + templateId: manifest.template, + variantId: manifest.variant, + variantSlug, + fullTemplateId: `${manifest.template}/${manifest.variant}`, + familyPath: path.split("/")[0] || "", + path, + isDefault: Boolean(manifest.default), + routeUrl, + url: `/templates/${ownerSlug}/${templatePathSlug}/${variantSlug}/`, + ownerSlug, + templateSlug: templatePathSlug, + categoryKey, + categoryLabel, + frameworkKey, + frameworkLabel: titleCase(frameworkKey), + variantDescription: manifest.variant_description || "", + installCommand: manifest.install_command || "npm install", + repoUrl: manifest.repo, + repoRootUrl: getRepoRootUrl(manifest.repo), + ref: manifest.ref || "", + metadata: { + name: manifest.metadata.name, + description: manifest.metadata.description || "", + owner: manifest.metadata.owner || ownerSlug, + tags: normalizeTags(manifest.metadata.tags || []), + }, + services, + nextSteps: manifest.next_steps || [], + buildCommand: `stripe projects build my-app --template ${manifest.template}/${manifest.variant}`, + tierPlans: manifest.tier_plans || {}, + serviceOverrides: manifest.service_overrides || {}, + }; + }); + + const familiesById = new Map(); + + for (const variant of variants) { + const family = familiesById.get(variant.templateId) || { + templateId: variant.templateId, + ownerSlug: variant.ownerSlug, + templateSlug: variant.templateSlug, + url: variant.routeUrl, + variants: [], + }; + + family.variants.push(variant); + familiesById.set(variant.templateId, family); + } + + const families = Array.from(familiesById.values()) + .map((family) => { + const variants = family.variants.sort(sortVariants); + const canonicalVariant = variants[0]; + const servicesBySlug = new Map(); + const providersBySlug = new Map(); + const tags = []; + const seenTags = new Set(); + + for (const variant of variants) { + for (const service of variant.services) { + if (!servicesBySlug.has(service.slug)) { + servicesBySlug.set(service.slug, service); + } + if (!providersBySlug.has(service.providerSlug)) { + providersBySlug.set(service.providerSlug, { + providerSlug: service.providerSlug, + providerName: service.providerName, + iconUrl: service.iconUrl, + fallbackInitials: service.fallbackInitials, + }); + } + } + + for (const tag of variant.metadata.tags) { + const key = String(tag).toLowerCase(); + if (!seenTags.has(key)) { + seenTags.add(key); + tags.push(tag); + } + } + } + + return { + ...family, + name: canonicalVariant.metadata.name, + description: canonicalVariant.metadata.description, + owner: canonicalVariant.metadata.owner, + categoryKey: canonicalVariant.categoryKey, + categoryLabel: canonicalVariant.categoryLabel, + frameworkKey: canonicalVariant.frameworkKey, + frameworkLabel: canonicalVariant.frameworkLabel, + variantDescription: canonicalVariant.variantDescription, + buildCommand: `stripe projects build my-app --template ${family.templateId}`, + defaultVariant: canonicalVariant, + variantCount: variants.length, + services: Array.from(servicesBySlug.values()).sort((left, right) => + left.slug.localeCompare(right.slug), + ), + providers: Array.from(providersBySlug.values()).sort((left, right) => + left.providerName.localeCompare(right.providerName), + ), + tags, + variants, + }; + }) + .sort(sortFamilies); + + const enrichedVariants = families + .flatMap((family) => + family.variants.map((variant) => ({ + ...variant, + familyName: family.name, + familyDescription: family.description, + familyUrl: family.url, + familyBuildCommand: family.buildCommand, + familyVariantCount: family.variantCount, + siblingVariants: family.variants.map((sibling) => ({ + variantId: sibling.variantId, + variantDescription: sibling.variantDescription, + isDefault: sibling.isDefault, + url: sibling.url, + })), + })), + ) + .sort(sortVariants); + + return { + registryUrl: `https://github.com/${REGISTRY_OWNER}/${REGISTRY_REPO}`, + families, + variants: enrichedVariants, + categoryOptions: Array.from(guidedCategories.values()), + }; +} diff --git a/src/_layouts/main.webc b/src/_layouts/main.webc index 4373460..9da8c02 100644 --- a/src/_layouts/main.webc +++ b/src/_layouts/main.webc @@ -4,7 +4,7 @@ - + @@ -15,7 +15,7 @@ - + diff --git a/src/lib/template-pages.js b/src/lib/template-pages.js new file mode 100644 index 0000000..4b6696b --- /dev/null +++ b/src/lib/template-pages.js @@ -0,0 +1,397 @@ +function escapeHtml(value = "") { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function renderLogo(item, sizeClass = "w-16 h-16") { + if (item.iconUrl) { + return ``; + } + + return `${escapeHtml(item.fallbackInitials)}`; +} + +function renderMetaPill(label) { + return `${escapeHtml(label)}`; +} + +function renderServiceBadge(service) { + return `${renderLogo(service, "w-14 h-14")}${escapeHtml(service.slug)}`; +} + +function renderLinkButton(href, label) { + return ` + ${escapeHtml(label)} + + `; +} + +function renderCopyButton(command, label = "Copy command") { + return ``; +} + +function renderHeader({ backHref, backLabel, rightHref, rightLabel }) { + const left = backHref + ? ` + + ${escapeHtml(backLabel)} + ` + : "
"; + const right = rightHref + ? `` + : "
"; + + return `
+
+ ${left} + +
+ + + + Stripe Projects +
+
+ ${right} +
+
`; +} + +function renderFooter() { + return ``; +} + +function renderCopySupport() { + return ` + `; +} + +export function renderTemplatesIndex(templates) { + const cards = templates.families + .map((family) => { + const services = family.services.slice(0, 6); + const extraServices = family.services.length - services.length; + + return `
+
+
+
+ ${escapeHtml(family.owner)} + ${renderMetaPill(family.categoryLabel)} + ${renderMetaPill(family.frameworkLabel)} +
+

${escapeHtml(family.name)}

+

${escapeHtml(family.description)}

+
+ ${renderMetaPill(`${family.variantCount} variant${family.variantCount === 1 ? "" : "s"}`)} +
+ +
+ ${services.map(renderServiceBadge).join("")} + ${extraServices > 0 ? renderMetaPill(`+${extraServices} more`) : ""} +
+ +
+

Default command

+ ${escapeHtml(family.buildCommand)} +
+ +
+

${escapeHtml(family.variantDescription || "Browse variants and providers")}

+
+ ${renderCopyButton(family.buildCommand)} + ${renderLinkButton(family.url, "View template")} +
+
+
`; + }) + .join(""); + + return `
+ ${renderHeader({ backHref: "/", backLabel: "Home", rightHref: templates.registryUrl, rightLabel: "Registry repo" })} + +
+
+
+
+

Templates

+

Browse every starter currently published in the public Stripe Projects template registry. Each template page shows its variants, included services, and the exact stripe projects build command to run.

+
+ ${renderMetaPill(`${templates.families.length} families`)} + ${renderMetaPill(`${templates.variants.length} variants`)} + View public registry +
+
+
+
+ +
+
+ ${cards} +
+
+
+ + ${renderFooter()} + ${renderCopySupport()} +
`; +} + +export function renderTemplateFamilyPage(family) { + const variants = family.variants + .map((variant) => { + const services = variant.services.map(renderServiceBadge).join(""); + const nextSteps = variant.nextSteps.length + ? `
+

Next steps

+ ${variant.nextSteps + .map( + (step) => `
+

${escapeHtml(step.label)}

+ ${escapeHtml(step.command)} +
`, + ) + .join("")} +
` + : ""; + + return `
+
+
+
+ ${variant.isDefault ? renderMetaPill("Default") : ""} + ${renderMetaPill(variant.variantId)} +
+

${escapeHtml(variant.variantDescription || variant.variantId)}

+

Template selector: ${escapeHtml(variant.fullTemplateId)}

+
+ ${renderCopyButton(variant.buildCommand)} +
+ +
+

Build command

+ ${escapeHtml(variant.buildCommand)} +
+ +
+ ${services} +
+ +
+
+

Source

+

${escapeHtml(variant.repoRootUrl)}

+

Pinned ref: ${escapeHtml(variant.ref || "Unpinned")}

+
+
+

Setup

+

Install command

+ ${escapeHtml(variant.installCommand)} +
+
+ + ${nextSteps} + +
+ ${renderLinkButton(variant.url, "Open variant page")} +
+
`; + }) + .join(""); + + const tags = family.tags.length ? `
${family.tags.map(renderMetaPill).join("")}
` : ""; + + return `
+ ${renderHeader({ backHref: "/templates/", backLabel: "All templates", rightHref: family.defaultVariant.repoUrl, rightLabel: "Source repo" })} + +
+
+
+
+ ${renderMetaPill(family.owner)} + ${renderMetaPill(family.categoryLabel)} + ${renderMetaPill(family.frameworkLabel)} + ${renderMetaPill(`${family.variantCount} variant${family.variantCount === 1 ? "" : "s"}`)} +
+ +

${escapeHtml(family.name)}

+

${escapeHtml(family.description)}

+ +
+ ${family.services.map(renderServiceBadge).join("")} +
+ + ${tags} + +
+
+
+

Default build command

+ ${escapeHtml(family.buildCommand)} +
+ ${renderCopyButton(family.buildCommand)} +
+

This uses the registry’s default variant for ${escapeHtml(family.templateId)}. Use the variant-specific commands below to target a particular stack.

+
+
+
+ +
+
+ ${variants} +
+
+
+ + ${renderFooter()} + ${renderCopySupport()} +
`; +} + +export function renderTemplateVariantPage(variant) { + const nextSteps = variant.nextSteps.length + ? `
+

Next steps

+
+ ${variant.nextSteps + .map( + (step) => `
+

${escapeHtml(step.label)}

+ ${escapeHtml(step.command)} +
`, + ) + .join("")} +
+
` + : ""; + + const siblingVariants = variant.siblingVariants.length + ? `
+
+
+

Other variants

+

Browse the other stacks published under ${escapeHtml(variant.templateId)}.

+
+ ${renderLinkButton(variant.familyUrl, "View all variants")} +
+
+ ${variant.siblingVariants + .map((sibling) => + sibling.url === variant.url + ? `${escapeHtml(sibling.variantDescription || sibling.variantId)}${sibling.isDefault ? " • Default" : ""}` + : `${escapeHtml(sibling.variantDescription || sibling.variantId)}${sibling.isDefault ? " • Default" : ""}`, + ) + .join("")} +
+
` + : ""; + + return `
+ ${renderHeader({ backHref: variant.familyUrl, backLabel: "All variants", rightHref: variant.repoUrl, rightLabel: "Source repo" })} + +
+
+
+
+ ${renderMetaPill(variant.metadata.owner)} + ${renderMetaPill(variant.categoryLabel)} + ${renderMetaPill(variant.frameworkLabel)} + ${variant.isDefault ? renderMetaPill("Default variant") : ""} +
+ +

${escapeHtml(variant.metadata.name)}

+

${escapeHtml(variant.metadata.description)}

+

Variant: ${escapeHtml(variant.fullTemplateId)}

+ +
+ ${variant.services.map(renderServiceBadge).join("")} +
+ +
+
+
+
+

Build command

+ ${escapeHtml(variant.buildCommand)} +
+ ${renderCopyButton(variant.buildCommand)} +
+

This targets the specific ${escapeHtml(variant.variantId)} variant directly.

+
+ +
+
+

Source

+

${escapeHtml(variant.repoRootUrl)}

+

Pinned ref: ${escapeHtml(variant.ref || "Unpinned")}

+
+
+

Setup

+

Install command

+ ${escapeHtml(variant.installCommand)} +
+
+ + ${nextSteps} + ${siblingVariants} +
+
+
+
+ + ${renderFooter()} + ${renderCopySupport()} +
`; +} diff --git a/src/templates/family.11ty.js b/src/templates/family.11ty.js new file mode 100644 index 0000000..d689c6d --- /dev/null +++ b/src/templates/family.11ty.js @@ -0,0 +1,39 @@ +import { renderTemplateFamilyPage } from "../lib/template-pages.js"; + +export default class TemplateFamilyPage { + data() { + return { + pagination: { + data: "templates.families", + size: 1, + alias: "templateFamily", + }, + permalink: (data) => data.templateFamily?.url, + layout: "main.webc", + ogImage: "/assets/images/og/default.jpg", + ogImageAlt: "Stripe Projects template details", + eleventyComputed: { + canonicalUrl: (data) => + `${data.base?.siteOrigin || ""}${data.templateFamily?.url || data.page?.url || "/"}`, + ogUrl: (data) => + `${data.base?.siteOrigin || ""}${data.templateFamily?.url || data.page?.url || "/"}`, + seoTitle: (data) => + `${data.templateFamily?.name || "Template"} | Stripe Projects Templates`, + ogTitle: (data) => + `${data.templateFamily?.name || "Template"} | Stripe Projects Templates`, + seoDesc: (data) => + data.templateFamily?.description || + "Inspect template variants, services, and build commands.", + ogDesc: (data) => + data.templateFamily?.description || + "Inspect template variants, services, and build commands.", + }, + changefreq: "daily", + wave: false, + }; + } + + render(data) { + return renderTemplateFamilyPage(data.templateFamily); + } +} diff --git a/src/templates/index.11ty.js b/src/templates/index.11ty.js new file mode 100644 index 0000000..aeb69a6 --- /dev/null +++ b/src/templates/index.11ty.js @@ -0,0 +1,23 @@ +import { renderTemplatesIndex } from "../lib/template-pages.js"; + +export default class TemplatesIndexPage { + data() { + return { + permalink: "/templates/", + layout: "main.webc", + title: "Templates", + seoTitle: "Templates | Stripe Projects", + ogTitle: "Templates | Stripe Projects", + seoDesc: "Browse Stripe Projects templates and inspect every variant, provider, and build command.", + ogDesc: "Browse Stripe Projects templates and inspect every variant, provider, and build command.", + ogImage: "/assets/images/og/default.jpg", + ogImageAlt: "Stripe Projects templates", + changefreq: "daily", + wave: false, + }; + } + + render(data) { + return renderTemplatesIndex(data.templates); + } +} diff --git a/src/templates/variant.11ty.js b/src/templates/variant.11ty.js new file mode 100644 index 0000000..3885396 --- /dev/null +++ b/src/templates/variant.11ty.js @@ -0,0 +1,39 @@ +import { renderTemplateVariantPage } from "../lib/template-pages.js"; + +export default class TemplateVariantPage { + data() { + return { + pagination: { + data: "templates.variants", + size: 1, + alias: "templateVariant", + }, + permalink: (data) => data.templateVariant?.url, + layout: "main.webc", + ogImage: "/assets/images/og/default.jpg", + ogImageAlt: "Stripe Projects template variant details", + eleventyComputed: { + canonicalUrl: (data) => + `${data.base?.siteOrigin || ""}${data.templateVariant?.url || data.page?.url || "/"}`, + ogUrl: (data) => + `${data.base?.siteOrigin || ""}${data.templateVariant?.url || data.page?.url || "/"}`, + seoTitle: (data) => + `${data.templateVariant?.metadata?.name || "Template"}: ${data.templateVariant?.variantDescription || data.templateVariant?.variantId || "Variant"} | Stripe Projects`, + ogTitle: (data) => + `${data.templateVariant?.metadata?.name || "Template"}: ${data.templateVariant?.variantDescription || data.templateVariant?.variantId || "Variant"} | Stripe Projects`, + seoDesc: (data) => + data.templateVariant?.metadata?.description || + "Inspect a Stripe Projects template variant, included services, and its exact build command.", + ogDesc: (data) => + data.templateVariant?.metadata?.description || + "Inspect a Stripe Projects template variant, included services, and its exact build command.", + }, + changefreq: "daily", + wave: false, + }; + } + + render(data) { + return renderTemplateVariantPage(data.templateVariant); + } +}