chore: import upstream snapshot with attribution
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.next/
|
||||
@@ -0,0 +1,47 @@
|
||||
FROM node:20-slim AS builder
|
||||
ARG COMMIT_SHA=unknown
|
||||
ARG BRANCH=unknown
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only what the shell + scripts need
|
||||
COPY showcase/scripts/package.json ./scripts/package.json
|
||||
COPY showcase/shell/package.json ./shell/package.json
|
||||
|
||||
# Install deps for both (standalone, no workspace)
|
||||
RUN cd scripts && npm install --silent && cd ../shell && npm install --silent
|
||||
|
||||
# Copy source
|
||||
COPY showcase/shared/ ./shared/
|
||||
COPY showcase/integrations/ ./integrations/
|
||||
COPY showcase/scripts/ ./scripts/
|
||||
COPY showcase/shell/ ./shell/
|
||||
COPY showcase/shell-docs/src/content/ ./shell-docs/src/content/
|
||||
|
||||
# Bake commit info into Next.js build
|
||||
ENV NEXT_PUBLIC_COMMIT_SHA=${COMMIT_SHA}
|
||||
ENV NEXT_PUBLIC_BRANCH=${BRANCH}
|
||||
|
||||
# Generate registry + content, then build Next.js
|
||||
RUN cd scripts && node node_modules/tsx/dist/cli.mjs generate-registry.ts \
|
||||
&& node node_modules/tsx/dist/cli.mjs bundle-demo-content.ts \
|
||||
&& node node_modules/tsx/dist/cli.mjs bundle-starter-content.ts \
|
||||
&& node node_modules/tsx/dist/cli.mjs generate-search-index.ts \
|
||||
&& cd ../shell && npx next build
|
||||
|
||||
FROM node:20-slim AS runner
|
||||
WORKDIR /app
|
||||
ARG COMMIT_SHA=unknown
|
||||
ARG BRANCH=unknown
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=10000
|
||||
ENV NEXT_PUBLIC_COMMIT_SHA=${COMMIT_SHA}
|
||||
ENV NEXT_PUBLIC_BRANCH=${BRANCH}
|
||||
|
||||
COPY --from=builder /app/shell/.next ./.next
|
||||
COPY --from=builder /app/shell/node_modules ./node_modules
|
||||
COPY --from=builder /app/shell/package.json ./
|
||||
COPY --from=builder /app/shell/public ./public
|
||||
|
||||
EXPOSE 10000
|
||||
CMD ["npx", "next", "start", "-p", "10000"]
|
||||
# Cache bust: 1775013460
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
// Logic lives in src/lib so vitest can cover it (missing-file warning,
|
||||
// TCP-port validation) — see src/lib/local-backends-env.test.ts.
|
||||
import { localBackendsEnv } from "./src/lib/local-backends-env";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const LOCAL_PORTS_PATH = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"shared",
|
||||
"local-ports.json",
|
||||
);
|
||||
|
||||
// NOTE: the docs-host 308s (/docs, /ag-ui, /reference and the
|
||||
// /<framework-slug> routes) intentionally do NOT live here. A
|
||||
// next.config `redirects()` table is baked into the build artifact, so
|
||||
// the destination host would freeze to whatever was current at Docker
|
||||
// build (staging shells 301'd to the PROD docs host). They are issued
|
||||
// by src/middleware.ts instead, resolving the docs host from runtime
|
||||
// config (DOCS_HOST env var) on every request — see
|
||||
// src/lib/docs-redirects.ts.
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
env: {
|
||||
// NEXT_PUBLIC_BASE_URL intentionally NOT listed here: it must be
|
||||
// read at request time via runtime-config (otherwise `next build`
|
||||
// re-bakes the build-time value into every chunk). NEXT_PUBLIC_LOCAL_BACKENDS
|
||||
// is fine to bake because it is computed from `shared/local-ports.json`
|
||||
// (a JSON file on disk, not an env var) and only used in local-dev.
|
||||
NEXT_PUBLIC_LOCAL_BACKENDS: localBackendsEnv(LOCAL_PORTS_PATH),
|
||||
},
|
||||
serverExternalPackages: ["@copilotkit/runtime"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@copilotkit/showcase-shell",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx ../scripts/generate-registry.ts && tsx ../scripts/bundle-demo-content.ts && tsx ../scripts/bundle-starter-content.ts && tsx ../scripts/generate-search-index.ts && npx -y concurrently -k -n bundle,next \"tsx ../scripts/bundle-demo-content.ts --watch\" \"next dev\"",
|
||||
"build": "tsx ../scripts/generate-registry.ts && tsx ../scripts/bundle-demo-content.ts && tsx ../scripts/bundle-starter-content.ts && tsx ../scripts/generate-search-index.ts && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@copilotkit/react-core": "1.61.2",
|
||||
"@copilotkit/runtime": "1.61.2",
|
||||
"client-only": "0.0.1",
|
||||
"gray-matter": "^4.0.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hono": "^4.6.0",
|
||||
"next": "^15.5.15",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^9.0.0",
|
||||
"react-syntax-highlighter": "^15.6.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"yaml": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,363 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Terminal Light — Enterprise Front Door</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Berkeley+Mono:wght@400;500;600&family=Instrument+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fafaf9;
|
||||
--surface: #ffffff;
|
||||
--elevated: #f5f5f3;
|
||||
--border: #e5e5e0;
|
||||
--border-strong: #d4d4cf;
|
||||
--text: #1a1a18;
|
||||
--text-secondary: #57574f;
|
||||
--text-muted: #a3a39b;
|
||||
--accent: #0d6e3f;
|
||||
--accent-light: #e8f5ee;
|
||||
--accent-dim: rgba(13, 110, 63, 0.06);
|
||||
--blue: #1a5fb4;
|
||||
--blue-light: #e8f0fa;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: "Instrument Sans", "Helvetica Neue", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32px;
|
||||
height: 52px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.topbar .brand {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.topbar .nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.topbar .nav a {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.topbar .nav a:hover {
|
||||
color: var(--text);
|
||||
background: var(--elevated);
|
||||
}
|
||||
.topbar .nav a.active {
|
||||
color: var(--text);
|
||||
background: var(--elevated);
|
||||
}
|
||||
|
||||
.hero {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 72px 24px 48px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
margin-bottom: 20px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.badge .dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.8px;
|
||||
line-height: 1.2;
|
||||
color: var(--text);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 36px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
margin-bottom: 40px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
.search-box:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
.search-box .prompt {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
.search-box input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.search-box .shortcut {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--elevated);
|
||||
}
|
||||
|
||||
.paths {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.path {
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.path:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.path .flag {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.path h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.path p {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.frameworks-section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.frameworks-label {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.fw-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.fw-pill {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.fw-pill:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
.fw-pill.more {
|
||||
color: var(--text-muted);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stats-bar .stat {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
.stats-bar .stat .num {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
text-align: center;
|
||||
padding: 32px 24px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 40px;
|
||||
}
|
||||
.footer-note a {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">CopilotKit</div>
|
||||
<nav class="nav">
|
||||
<a href="#" class="active">Showcase</a>
|
||||
<a href="#">Docs</a>
|
||||
<a href="#">Integrations</a>
|
||||
<a href="#">Feature Matrix</a>
|
||||
<a href="#">Reference</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="hero">
|
||||
<div class="badge">
|
||||
<span class="dot"></span> 26 integrations · 46 features
|
||||
</div>
|
||||
<h1>Find the right integration for your agent stack</h1>
|
||||
<p class="subtitle">
|
||||
Explore live demos, browse source code, and get started with any major
|
||||
agent framework — from LangGraph to AWS Strands.
|
||||
</p>
|
||||
|
||||
<div class="search-box">
|
||||
<span class="prompt">❯</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search frameworks, features, demos, docs..."
|
||||
/>
|
||||
<span class="shortcut">⌘K</span>
|
||||
</div>
|
||||
|
||||
<div class="paths">
|
||||
<div class="path">
|
||||
<div class="flag">BY FRAMEWORK</div>
|
||||
<h3>I have an agent framework</h3>
|
||||
<p>
|
||||
See what CopilotKit adds to LangGraph, Mastra, CrewAI, and 23 more
|
||||
</p>
|
||||
</div>
|
||||
<div class="path">
|
||||
<div class="flag">BY CAPABILITY</div>
|
||||
<h3>I need a specific feature</h3>
|
||||
<p>Generative UI, human-in-the-loop, shared state, voice, and more</p>
|
||||
</div>
|
||||
<div class="path">
|
||||
<div class="flag">GUIDED</div>
|
||||
<h3>Help me choose</h3>
|
||||
<p>Answer a few questions and get a tailored recommendation</p>
|
||||
</div>
|
||||
<div class="path">
|
||||
<div class="flag">COMPARE</div>
|
||||
<h3>Feature matrix</h3>
|
||||
<p>Full comparison across 46 features × 26 integrations</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="frameworks-section">
|
||||
<div class="frameworks-label">Frameworks</div>
|
||||
<div class="fw-pills">
|
||||
<span class="fw-pill">LangGraph</span>
|
||||
<span class="fw-pill">Mastra</span>
|
||||
<span class="fw-pill">CrewAI</span>
|
||||
<span class="fw-pill">PydanticAI</span>
|
||||
<span class="fw-pill">Google ADK</span>
|
||||
<span class="fw-pill">AWS Strands</span>
|
||||
<span class="fw-pill">Agno</span>
|
||||
<span class="fw-pill">AG2</span>
|
||||
<span class="fw-pill">Claude SDK</span>
|
||||
<span class="fw-pill">Spring AI</span>
|
||||
<span class="fw-pill more">+16 more →</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-bar">
|
||||
<div class="stat"><span class="num">46</span> features</div>
|
||||
<div class="stat"><span class="num">26</span> integrations</div>
|
||||
<div class="stat"><span class="num">12</span> categories</div>
|
||||
<div class="stat"><span class="num">9</span> live demos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-note">
|
||||
<a href="#">Docs</a> · <a href="#">GitHub</a> · <a href="#">Discord</a> ·
|
||||
<a href="#">CopilotKit.ai</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,141 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Showcase Design Explorations</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #09090b;
|
||||
color: #fafafa;
|
||||
min-height: 100vh;
|
||||
padding: 48px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sub {
|
||||
font-size: 14px;
|
||||
color: #71717a;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
a.card {
|
||||
display: block;
|
||||
padding: 24px;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
background: #111114;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
a.card:hover {
|
||||
border-color: #3b82f6;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
a.card h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
a.card p {
|
||||
font-size: 12px;
|
||||
color: #71717a;
|
||||
line-height: 1.5;
|
||||
}
|
||||
a.card .tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.tag-frontdoor {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #60a5fa;
|
||||
}
|
||||
.tag-docs {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #a78bfa;
|
||||
}
|
||||
.tag-stack {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #4ade80;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Showcase Design Explorations</h1>
|
||||
<p class="sub">Work in progress — share and discuss</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="front-door-v1.html">
|
||||
<h3>Front Door — Round 1</h3>
|
||||
<p>
|
||||
Three initial concepts: Guided Wizard, Feature-First Explorer,
|
||||
Interactive Configurator
|
||||
</p>
|
||||
<span class="tag tag-frontdoor">Front Door</span>
|
||||
</a>
|
||||
<a class="card" href="front-door-v2.html">
|
||||
<h3>Front Door — Round 2</h3>
|
||||
<p>
|
||||
Five variations: Terminal, Editorial, Constellation, Dashboard,
|
||||
Conversational
|
||||
</p>
|
||||
<span class="tag tag-frontdoor">Front Door</span>
|
||||
</a>
|
||||
<a class="card" href="front-door-terminal-light.html">
|
||||
<h3>Terminal Light</h3>
|
||||
<p>Enterprise-friendly light theme version of the Terminal concept</p>
|
||||
<span class="tag tag-frontdoor">Front Door</span>
|
||||
</a>
|
||||
<a class="card" href="model-c.html">
|
||||
<h3>Model C — Unified Docs + Showcase</h3>
|
||||
<p>
|
||||
Interactive mockup: Front Door, Guide Page, Demo Viewer, Matrix. Tabs
|
||||
at bottom to switch views.
|
||||
</p>
|
||||
<span class="tag tag-docs">Docs Integration</span>
|
||||
</a>
|
||||
<a class="card" href="model-c-variations.html">
|
||||
<h3>Model C — Layout Variations</h3>
|
||||
<p>Three approaches: Split Pane, Contextual Drawer, Inline Demos</p>
|
||||
<span class="tag tag-docs">Docs Integration</span>
|
||||
</a>
|
||||
<a class="card" href="model-c-v2.html" style="border-color: #0d6e3f44">
|
||||
<h3>Model C v2 — Revised ⭐</h3>
|
||||
<p>
|
||||
Incorporates all feedback: Conversational home + Stack-as-nav +
|
||||
Contextual drawer + Light theme. The current leading direction.
|
||||
</p>
|
||||
<span class="tag tag-docs">Docs Integration</span>
|
||||
</a>
|
||||
<a class="card" href="stack-v2.html" style="border-color: #0d6e3f44">
|
||||
<h3>Stack Diagram v2 ⭐</h3>
|
||||
<p>
|
||||
Revised styling (glassmorphism, light theme). A2A/MCP lateral. Gen UI
|
||||
no-wrap spectrum. Based on team feedback.
|
||||
</p>
|
||||
<span class="tag tag-stack">Architecture</span>
|
||||
</a>
|
||||
<a class="card" href="stack.html">
|
||||
<h3>Stack Diagram v1</h3>
|
||||
<p>Original dark theme version. Reference only.</p>
|
||||
<span class="tag tag-stack">Architecture</span>
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,968 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Model C — 3 Variations</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&family=Crimson+Pro:ital,wght@0,400;0,600;1,400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--surface: #111114;
|
||||
--elevated: #18181b;
|
||||
--hover: #1f1f23;
|
||||
--border: #27272a;
|
||||
--border-dim: #1e1e22;
|
||||
--text: #fafafa;
|
||||
--text2: #a1a1aa;
|
||||
--text3: #52525b;
|
||||
--text4: #3f3f46;
|
||||
--blue: #3b82f6;
|
||||
--green: #22c55e;
|
||||
--green-dim: rgba(34, 197, 94, 0.1);
|
||||
--violet: #8b5cf6;
|
||||
--amber: #f59e0b;
|
||||
--rose: #f472b6;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: "Outfit", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Tab nav */
|
||||
.vtabs {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
.vtabs .lbl {
|
||||
color: var(--text4);
|
||||
margin-right: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
.vtab {
|
||||
padding: 5px 12px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid transparent;
|
||||
background: none;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.vtab:hover {
|
||||
color: var(--text2);
|
||||
}
|
||||
.vtab.active {
|
||||
color: var(--text);
|
||||
background: var(--elevated);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.variation {
|
||||
display: none;
|
||||
padding-top: 44px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.variation.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Shared topbar for all variations */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
.topbar .brand {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.topbar .nav {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.topbar .nav a {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text3);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.topbar .nav a:hover {
|
||||
color: var(--text2);
|
||||
background: var(--elevated);
|
||||
}
|
||||
.topbar .nav a.active {
|
||||
color: var(--text);
|
||||
background: var(--elevated);
|
||||
}
|
||||
.topbar .search {
|
||||
padding: 6px 12px 6px 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text2);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
width: 240px;
|
||||
outline: none;
|
||||
}
|
||||
.topbar .search::placeholder {
|
||||
color: var(--text4);
|
||||
}
|
||||
|
||||
/* ==============================
|
||||
V1: SPLIT PANE — docs left, demo right
|
||||
============================== */
|
||||
.v1-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 1fr;
|
||||
height: calc(100vh - 44px - 48px);
|
||||
}
|
||||
.v1-sidebar {
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
.v1-sidebar .sec {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text4);
|
||||
padding: 14px 16px 6px;
|
||||
}
|
||||
.v1-sidebar .item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 16px 7px 24px;
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.v1-sidebar .item:hover {
|
||||
background: var(--hover);
|
||||
color: var(--text);
|
||||
}
|
||||
.v1-sidebar .item.active {
|
||||
color: var(--blue);
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
border-left-color: var(--blue);
|
||||
}
|
||||
.v1-sidebar .badge {
|
||||
font-size: 8px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--green-dim);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.v1-docs {
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
padding: 32px 36px;
|
||||
}
|
||||
.v1-docs .crumb {
|
||||
font-size: 11px;
|
||||
color: var(--text4);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.v1-docs h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.v1-docs .desc {
|
||||
font-size: 14px;
|
||||
color: var(--text2);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.v1-docs h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0 8px;
|
||||
}
|
||||
.v1-docs p {
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.v1-docs code {
|
||||
background: var(--elevated);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
color: var(--violet);
|
||||
}
|
||||
.v1-docs .codeblock {
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 12px 0 16px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text2);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.v1-demo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
.v1-demo-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--surface);
|
||||
}
|
||||
.v1-demo-header .title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.v1-demo-header .live {
|
||||
font-size: 9px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--green-dim);
|
||||
color: var(--green);
|
||||
}
|
||||
.v1-demo-header .tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.v1-demo-header .tab {
|
||||
padding: 4px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.v1-demo-header .tab.active {
|
||||
color: var(--text);
|
||||
background: var(--elevated);
|
||||
}
|
||||
.v1-demo-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text4);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ==============================
|
||||
V2: CONTEXTUAL DRAWER — docs primary, demo slides in
|
||||
============================== */
|
||||
.v2-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
height: calc(100vh - 44px - 48px);
|
||||
position: relative;
|
||||
}
|
||||
.v2-sidebar {
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
.v2-sidebar .sec {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text4);
|
||||
padding: 14px 16px 6px;
|
||||
}
|
||||
.v2-sidebar .item {
|
||||
padding: 7px 16px 7px 24px;
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.v2-sidebar .item:hover {
|
||||
background: var(--hover);
|
||||
color: var(--text);
|
||||
}
|
||||
.v2-sidebar .item.active {
|
||||
color: var(--blue);
|
||||
}
|
||||
.v2-sidebar .badge {
|
||||
font-size: 8px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--green-dim);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.v2-main {
|
||||
overflow-y: auto;
|
||||
padding: 36px 48px;
|
||||
max-width: 760px;
|
||||
}
|
||||
.v2-main .crumb {
|
||||
font-size: 11px;
|
||||
color: var(--text4);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.v2-main h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.v2-main .desc {
|
||||
font-size: 14px;
|
||||
color: var(--text2);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.v2-main h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 28px 0 10px;
|
||||
}
|
||||
.v2-main p {
|
||||
font-size: 14px;
|
||||
color: var(--text2);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.v2-main code {
|
||||
background: var(--elevated);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
color: var(--violet);
|
||||
}
|
||||
.v2-main .codeblock {
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 12px 0 16px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.v2-demo-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--green-dim);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
color: var(--green);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-bottom: 24px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.v2-demo-cta:hover {
|
||||
background: rgba(34, 197, 94, 0.16);
|
||||
}
|
||||
|
||||
.v2-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.v2-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.v2-drawer-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.v2-drawer-header .title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.v2-drawer-header .close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.v2-drawer-header .close:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
.v2-drawer-header .tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.v2-drawer-header .tab {
|
||||
padding: 4px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.v2-drawer-header .tab.active {
|
||||
color: var(--text);
|
||||
background: var(--elevated);
|
||||
}
|
||||
.v2-drawer-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text4);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Overlay */
|
||||
.v2-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 40;
|
||||
display: none;
|
||||
}
|
||||
.v2-overlay.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ==============================
|
||||
V3: INLINE DEMOS — long scroll with demos embedded in docs
|
||||
============================== */
|
||||
.v3-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
height: calc(100vh - 44px - 48px);
|
||||
}
|
||||
.v3-sidebar {
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
.v3-sidebar .sec {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text4);
|
||||
padding: 14px 16px 6px;
|
||||
}
|
||||
.v3-sidebar .item {
|
||||
padding: 7px 16px 7px 24px;
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.v3-sidebar .item:hover {
|
||||
background: var(--hover);
|
||||
color: var(--text);
|
||||
}
|
||||
.v3-sidebar .item.active {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.v3-main {
|
||||
overflow-y: auto;
|
||||
padding: 36px 48px;
|
||||
max-width: 800px;
|
||||
}
|
||||
.v3-main .crumb {
|
||||
font-size: 11px;
|
||||
color: var(--text4);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.v3-main h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.v3-main .desc {
|
||||
font-size: 14px;
|
||||
color: var(--text2);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.v3-main h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 32px 0 10px;
|
||||
}
|
||||
.v3-main p {
|
||||
font-size: 14px;
|
||||
color: var(--text2);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.v3-main code {
|
||||
background: var(--elevated);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
color: var(--violet);
|
||||
}
|
||||
.v3-main .codeblock {
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 12px 0 16px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.v3-inline-demo {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin: 20px 0 28px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.v3-inline-demo-header {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--elevated);
|
||||
}
|
||||
.v3-inline-demo-header .left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.v3-inline-demo-header .title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.v3-inline-demo-header .live {
|
||||
font-size: 9px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--green-dim);
|
||||
color: var(--green);
|
||||
}
|
||||
.v3-inline-demo-header .expand {
|
||||
font-size: 10px;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
font-family: inherit;
|
||||
}
|
||||
.v3-inline-demo-header .expand:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text3);
|
||||
}
|
||||
.v3-inline-demo-body {
|
||||
height: 320px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text4);
|
||||
font-size: 13px;
|
||||
background: var(--bg);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="vtabs">
|
||||
<span class="lbl">Variation</span>
|
||||
<button class="vtab active" onclick="showV('v1')">1 · Split Pane</button>
|
||||
<button class="vtab" onclick="showV('v2')">2 · Contextual Drawer</button>
|
||||
<button class="vtab" onclick="showV('v3')">3 · Inline Demos</button>
|
||||
</nav>
|
||||
|
||||
<!-- ===== V1: SPLIT PANE ===== -->
|
||||
<section id="v1" class="variation active">
|
||||
<header class="topbar">
|
||||
<div class="brand">CopilotKit</div>
|
||||
<nav class="nav">
|
||||
<a href="#">Home</a><a href="#" class="active">Integrations</a
|
||||
><a href="#">Matrix</a><a href="#">Reference</a>
|
||||
</nav>
|
||||
<input class="search" placeholder="⌕ Search docs, demos, APIs..." />
|
||||
</header>
|
||||
<div class="v1-layout">
|
||||
<aside class="v1-sidebar">
|
||||
<div class="sec">LangGraph · Python</div>
|
||||
<div class="item">Overview</div>
|
||||
<div class="item">Quickstart</div>
|
||||
<div class="sec">Features</div>
|
||||
<div class="item active">
|
||||
Generative UI <span class="badge">demo</span>
|
||||
</div>
|
||||
<div class="item">Shared State <span class="badge">demo</span></div>
|
||||
<div class="item">
|
||||
Human in the Loop <span class="badge">demo</span>
|
||||
</div>
|
||||
<div class="item">Frontend Tools</div>
|
||||
<div class="item">Multi-Agent <span class="badge">demo</span></div>
|
||||
<div class="sec">Reference</div>
|
||||
<div class="item">Hooks & Components →</div>
|
||||
</aside>
|
||||
<div class="v1-docs">
|
||||
<div class="crumb">LangGraph / Features / Generative UI</div>
|
||||
<h1>Generative UI</h1>
|
||||
<p class="desc">
|
||||
Let your agent generate interactive React components on the fly.
|
||||
</p>
|
||||
<h2>Tool-Based Approach</h2>
|
||||
<p>
|
||||
Register a frontend tool with <code>useFrontendTool</code> and the
|
||||
agent calls it to render components.
|
||||
</p>
|
||||
<div class="codeblock">
|
||||
useFrontendTool({ name: "render_chart", parameters: z.object({...}),
|
||||
handler: async ({ data }) => {...} });
|
||||
</div>
|
||||
<h2>Agentic Approach</h2>
|
||||
<p>
|
||||
For long-running tasks, the agent orchestrates multi-step workflows
|
||||
and renders progress UI.
|
||||
</p>
|
||||
<h2>Declarative (BYOC)</h2>
|
||||
<p>
|
||||
Use A2UI, Hashbrown, JSON Render, or other declarative renderers for
|
||||
schema-driven UI generation.
|
||||
</p>
|
||||
</div>
|
||||
<div class="v1-demo">
|
||||
<div class="v1-demo-header">
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<span class="title">Tool-Based Generative UI</span>
|
||||
<span class="live">● Live</span>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button class="tab active">Preview</button>
|
||||
<button class="tab">Code</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="v1-demo-body">
|
||||
<div style="text-align: center">
|
||||
<div style="font-size: 36px; opacity: 0.2; margin-bottom: 8px">
|
||||
💬
|
||||
</div>
|
||||
Live demo iframe<br />
|
||||
<span style="font-size: 11px; color: var(--text4)"
|
||||
>Always visible alongside the docs</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== V2: CONTEXTUAL DRAWER ===== -->
|
||||
<section id="v2" class="variation">
|
||||
<header class="topbar">
|
||||
<div class="brand">CopilotKit</div>
|
||||
<nav class="nav">
|
||||
<a href="#">Home</a><a href="#" class="active">Integrations</a
|
||||
><a href="#">Matrix</a><a href="#">Reference</a>
|
||||
</nav>
|
||||
<input class="search" placeholder="⌕ Search docs, demos, APIs..." />
|
||||
</header>
|
||||
<div class="v2-layout">
|
||||
<aside class="v2-sidebar">
|
||||
<div class="sec">LangGraph · Python</div>
|
||||
<div class="item">Overview</div>
|
||||
<div class="item">Quickstart</div>
|
||||
<div class="sec">Features</div>
|
||||
<div class="item active">
|
||||
Generative UI <span class="badge">demo</span>
|
||||
</div>
|
||||
<div class="item">Shared State <span class="badge">demo</span></div>
|
||||
<div class="item">
|
||||
Human in the Loop <span class="badge">demo</span>
|
||||
</div>
|
||||
<div class="item">Frontend Tools</div>
|
||||
<div class="sec">Reference</div>
|
||||
<div class="item">Hooks & Components →</div>
|
||||
</aside>
|
||||
<div class="v2-main">
|
||||
<div class="crumb">LangGraph / Features / Generative UI</div>
|
||||
<h1>Generative UI</h1>
|
||||
<p class="desc">
|
||||
Let your agent generate interactive React components on the fly.
|
||||
</p>
|
||||
<div class="v2-demo-cta" onclick="toggleDrawer()">
|
||||
▶ Try Live Demo
|
||||
</div>
|
||||
<h2>Tool-Based Approach</h2>
|
||||
<p>
|
||||
Register a frontend tool with <code>useFrontendTool</code> and the
|
||||
agent calls it to render components. The agent determines when to
|
||||
invoke the tool based on the user's request.
|
||||
</p>
|
||||
<div class="codeblock">
|
||||
useFrontendTool({ name: "render_chart", parameters: z.object({ data:
|
||||
z.array(...), type: z.enum(["bar", "pie"]) }), handler: async ({
|
||||
data, type }) => { return { rendered: true }; }, });
|
||||
</div>
|
||||
<p>
|
||||
When the agent calls this tool, CopilotKit renders the component you
|
||||
defined. The user sees an interactive chart appear in the chat.
|
||||
</p>
|
||||
<h2>Agentic Approach</h2>
|
||||
<p>
|
||||
For long-running tasks, the agent orchestrates multi-step workflows
|
||||
and renders progress UI along the way.
|
||||
</p>
|
||||
<div
|
||||
class="v2-demo-cta"
|
||||
onclick="toggleDrawer()"
|
||||
style="
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
border-color: rgba(139, 92, 246, 0.2);
|
||||
color: var(--violet);
|
||||
"
|
||||
>
|
||||
▶ Try Agentic GenUI Demo
|
||||
</div>
|
||||
<h2>Declarative (BYOC)</h2>
|
||||
<p>
|
||||
Use A2UI, Hashbrown, JSON Render, or other declarative renderers for
|
||||
schema-driven UI generation. The agent sends a schema, your renderer
|
||||
turns it into components.
|
||||
</p>
|
||||
</div>
|
||||
<div class="v2-overlay" id="v2-overlay" onclick="toggleDrawer()"></div>
|
||||
<div class="v2-drawer" id="v2-drawer">
|
||||
<div class="v2-drawer-header">
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<span class="title">Tool-Based Generative UI</span>
|
||||
<div class="tabs">
|
||||
<button class="tab active">Preview</button>
|
||||
<button class="tab">Code</button>
|
||||
<button class="tab">Guide</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="close" onclick="toggleDrawer()">✕</button>
|
||||
</div>
|
||||
<div class="v2-drawer-body">
|
||||
<div style="text-align: center">
|
||||
<div style="font-size: 36px; opacity: 0.2; margin-bottom: 8px">
|
||||
💬
|
||||
</div>
|
||||
Live demo iframe<br />
|
||||
<span style="font-size: 11px; color: var(--text4)"
|
||||
>Slides in when you click "Try Demo"</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== V3: INLINE DEMOS ===== -->
|
||||
<section id="v3" class="variation">
|
||||
<header class="topbar">
|
||||
<div class="brand">CopilotKit</div>
|
||||
<nav class="nav">
|
||||
<a href="#">Home</a><a href="#" class="active">Integrations</a
|
||||
><a href="#">Matrix</a><a href="#">Reference</a>
|
||||
</nav>
|
||||
<input class="search" placeholder="⌕ Search docs, demos, APIs..." />
|
||||
</header>
|
||||
<div class="v3-layout">
|
||||
<aside class="v3-sidebar">
|
||||
<div class="sec">LangGraph · Python</div>
|
||||
<div class="item">Overview</div>
|
||||
<div class="item">Quickstart</div>
|
||||
<div class="sec">Features</div>
|
||||
<div class="item active">Generative UI</div>
|
||||
<div class="item">Shared State</div>
|
||||
<div class="item">Human in the Loop</div>
|
||||
<div class="item">Frontend Tools</div>
|
||||
<div class="sec">Reference</div>
|
||||
<div class="item">Hooks & Components →</div>
|
||||
</aside>
|
||||
<div class="v3-main">
|
||||
<div class="crumb">LangGraph / Features / Generative UI</div>
|
||||
<h1>Generative UI</h1>
|
||||
<p class="desc">
|
||||
Let your agent generate interactive React components on the fly.
|
||||
</p>
|
||||
|
||||
<h2>Tool-Based Approach</h2>
|
||||
<p>
|
||||
Register a frontend tool with <code>useFrontendTool</code> and the
|
||||
agent calls it to render components.
|
||||
</p>
|
||||
<div class="codeblock">
|
||||
useFrontendTool({ name: "render_chart", parameters: z.object({ data:
|
||||
z.array(...) }), handler: async ({ data }) => { ... } });
|
||||
</div>
|
||||
|
||||
<!-- Inline demo -->
|
||||
<div class="v3-inline-demo">
|
||||
<div class="v3-inline-demo-header">
|
||||
<div class="left">
|
||||
<span class="title">Tool-Based Generative UI</span>
|
||||
<span class="live">● Live</span>
|
||||
</div>
|
||||
<button class="expand">↗ Full Screen</button>
|
||||
</div>
|
||||
<div class="v3-inline-demo-body">
|
||||
<div style="text-align: center">
|
||||
<div style="font-size: 36px; opacity: 0.2; margin-bottom: 8px">
|
||||
💬
|
||||
</div>
|
||||
Live demo embedded in the doc flow<br />
|
||||
<span style="font-size: 11px"
|
||||
>Try it right here, then keep reading</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
When the agent calls this tool, CopilotKit renders the component you
|
||||
defined. The user sees an interactive chart appear in the chat.
|
||||
</p>
|
||||
|
||||
<h2>Agentic Approach</h2>
|
||||
<p>
|
||||
For long-running tasks, the agent orchestrates multi-step workflows
|
||||
and renders progress UI along the way.
|
||||
</p>
|
||||
|
||||
<!-- Another inline demo -->
|
||||
<div class="v3-inline-demo">
|
||||
<div class="v3-inline-demo-header">
|
||||
<div class="left">
|
||||
<span class="title">Agentic Generative UI</span>
|
||||
<span class="live">● Live</span>
|
||||
</div>
|
||||
<button class="expand">↗ Full Screen</button>
|
||||
</div>
|
||||
<div class="v3-inline-demo-body">
|
||||
<div style="text-align: center">
|
||||
<div style="font-size: 36px; opacity: 0.2; margin-bottom: 8px">
|
||||
⚡
|
||||
</div>
|
||||
Another live demo inline<br />
|
||||
<span style="font-size: 11px"
|
||||
>Each feature section has its own demo</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Declarative (BYOC)</h2>
|
||||
<p>
|
||||
Use A2UI, Hashbrown, JSON Render, or other declarative renderers.
|
||||
The agent sends a schema, your renderer turns it into components.
|
||||
</p>
|
||||
<p>
|
||||
This approach gives you the most flexibility while keeping a
|
||||
structured contract between agent and UI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function showV(id) {
|
||||
document
|
||||
.querySelectorAll(".variation")
|
||||
.forEach((v) => v.classList.remove("active"));
|
||||
document
|
||||
.querySelectorAll(".vtab")
|
||||
.forEach((t) => t.classList.remove("active"));
|
||||
document.getElementById(id).classList.add("active");
|
||||
event.currentTarget.classList.add("active");
|
||||
}
|
||||
function toggleDrawer() {
|
||||
document.getElementById("v2-drawer").classList.toggle("open");
|
||||
document.getElementById("v2-overlay").classList.toggle("open");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,860 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CopilotKit Stack — Big Picture</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #08080c;
|
||||
--surface: #0e0e14;
|
||||
--elevated: #14141c;
|
||||
--border: #1e1e2a;
|
||||
--text: #e8e8f0;
|
||||
--text-dim: #8888a0;
|
||||
--text-faint: #444460;
|
||||
--blue: #4d7cff;
|
||||
--cyan: #22d3ee;
|
||||
--violet: #8b5cf6;
|
||||
--green: #22c55e;
|
||||
--amber: #f59e0b;
|
||||
--rose: #f472b6;
|
||||
--orange: #f97316;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: "Outfit", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 48px 24px 32px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.header h1 strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.stack {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* === LAYER === */
|
||||
.layer {
|
||||
position: relative;
|
||||
}
|
||||
.layer-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.layer-label .tag {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.layer-label .line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
}
|
||||
.layer-label .desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layer-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 0 24px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* === CHIPS === */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid;
|
||||
cursor: default;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.chip:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.chip .icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
.chip.sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chip-frontend {
|
||||
background: rgba(77, 124, 255, 0.06);
|
||||
border-color: rgba(77, 124, 255, 0.2);
|
||||
color: #8ab4ff;
|
||||
}
|
||||
.chip-frontend:hover {
|
||||
border-color: rgba(77, 124, 255, 0.5);
|
||||
background: rgba(77, 124, 255, 0.1);
|
||||
}
|
||||
.chip-genui {
|
||||
background: rgba(139, 92, 246, 0.06);
|
||||
border-color: rgba(139, 92, 246, 0.2);
|
||||
color: #b8a0f8;
|
||||
}
|
||||
.chip-genui:hover {
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
.chip-interaction {
|
||||
background: rgba(34, 211, 238, 0.06);
|
||||
border-color: rgba(34, 211, 238, 0.2);
|
||||
color: #66e0f0;
|
||||
}
|
||||
.chip-interaction:hover {
|
||||
border-color: rgba(34, 211, 238, 0.5);
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
}
|
||||
.chip-state {
|
||||
background: rgba(249, 115, 22, 0.06);
|
||||
border-color: rgba(249, 115, 22, 0.2);
|
||||
color: #fbb070;
|
||||
}
|
||||
.chip-state:hover {
|
||||
border-color: rgba(249, 115, 22, 0.5);
|
||||
background: rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
.chip-framework {
|
||||
background: rgba(251, 191, 36, 0.06);
|
||||
border-color: rgba(251, 191, 36, 0.15);
|
||||
color: #f0d070;
|
||||
}
|
||||
.chip-framework:hover {
|
||||
border-color: rgba(251, 191, 36, 0.4);
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
}
|
||||
.chip-platform {
|
||||
background: rgba(136, 136, 160, 0.06);
|
||||
border-color: rgba(136, 136, 160, 0.15);
|
||||
color: #a0a0b8;
|
||||
}
|
||||
.chip-platform:hover {
|
||||
border-color: rgba(136, 136, 160, 0.35);
|
||||
background: rgba(136, 136, 160, 0.1);
|
||||
}
|
||||
.chip-provider {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
color: #888898;
|
||||
}
|
||||
.chip-provider:hover {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.chip-protocol {
|
||||
background: rgba(244, 114, 182, 0.06);
|
||||
border-color: rgba(244, 114, 182, 0.2);
|
||||
color: #f8a0cc;
|
||||
}
|
||||
.chip-protocol:hover {
|
||||
border-color: rgba(244, 114, 182, 0.5);
|
||||
background: rgba(244, 114, 182, 0.1);
|
||||
}
|
||||
|
||||
/* Subcategory grouping */
|
||||
.subgroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
}
|
||||
.subgroup-label {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.subgroup .chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Central nodes */
|
||||
.central {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.central-node {
|
||||
padding: 16px 48px;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
border: 2px solid;
|
||||
position: relative;
|
||||
}
|
||||
.central-node h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.central-node p {
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.copilotkit-node {
|
||||
background: rgba(34, 197, 94, 0.06);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
color: #5ee0a0;
|
||||
box-shadow: 0 0 60px rgba(34, 197, 94, 0.08);
|
||||
}
|
||||
.copilotkit-node p {
|
||||
color: rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
.agui-node {
|
||||
background: rgba(244, 114, 182, 0.06);
|
||||
border-color: rgba(244, 114, 182, 0.25);
|
||||
color: #f8a0cc;
|
||||
box-shadow: 0 0 40px rgba(244, 114, 182, 0.06);
|
||||
}
|
||||
.agui-node p {
|
||||
color: rgba(244, 114, 182, 0.5);
|
||||
}
|
||||
|
||||
/* Vertical connectors */
|
||||
.v-connector {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.v-line {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
}
|
||||
.v-line::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -3px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
}
|
||||
.v-line.green {
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.v-line.green::after {
|
||||
border-top: 5px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.v-line.pink {
|
||||
background: rgba(244, 114, 182, 0.3);
|
||||
}
|
||||
.v-line.pink::after {
|
||||
border-top: 5px solid rgba(244, 114, 182, 0.3);
|
||||
}
|
||||
.v-line.blue {
|
||||
background: rgba(77, 124, 255, 0.2);
|
||||
}
|
||||
.v-line.blue::after {
|
||||
border-top: 5px solid rgba(77, 124, 255, 0.2);
|
||||
}
|
||||
.v-line.violet {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
.v-line.violet::after {
|
||||
border-top: 5px solid rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
.v-line.cyan {
|
||||
background: rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
.v-line.cyan::after {
|
||||
border-top: 5px solid rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
.v-line.orange {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
}
|
||||
.v-line.orange::after {
|
||||
border-top: 5px solid rgba(249, 115, 22, 0.2);
|
||||
}
|
||||
.v-line.amber {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
.v-line.amber::after {
|
||||
border-top: 5px solid rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
.v-line.gray {
|
||||
background: rgba(136, 136, 160, 0.2);
|
||||
}
|
||||
.v-line.gray::after {
|
||||
border-top: 5px solid rgba(136, 136, 160, 0.2);
|
||||
}
|
||||
|
||||
.annotation {
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
font-size: 10px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* === FRAMEWORK ROW WITH LATERAL PROTOCOLS === */
|
||||
.framework-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
.protocol-wing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
min-width: 140px;
|
||||
}
|
||||
.protocol-wing .wing-label {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.protocol-wing .wing-desc {
|
||||
font-size: 9px;
|
||||
color: var(--text-faint);
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.h-connector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.h-line {
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
position: relative;
|
||||
}
|
||||
.h-line.pink {
|
||||
background: rgba(244, 114, 182, 0.3);
|
||||
}
|
||||
.h-line.amber {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
.h-arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
}
|
||||
.h-arrow.left.pink {
|
||||
border-right: 5px solid rgba(244, 114, 182, 0.3);
|
||||
}
|
||||
.h-arrow.right.pink {
|
||||
border-left: 5px solid rgba(244, 114, 182, 0.3);
|
||||
}
|
||||
.h-arrow.left.amber {
|
||||
border-right: 5px solid rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
.h-arrow.right.amber {
|
||||
border-left: 5px solid rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
|
||||
.frameworks-center {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.frameworks-center .chip {
|
||||
white-space: nowrap;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>The <strong>CopilotKit</strong> Stack</h1>
|
||||
<p>
|
||||
From frontend platform to model provider — every layer, every choice
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<!-- LAYER: Frontend Platforms -->
|
||||
<div class="layer">
|
||||
<div class="layer-label">
|
||||
<span
|
||||
class="tag"
|
||||
style="background: rgba(77, 124, 255, 0.1); color: #8ab4ff"
|
||||
>Frontend Platform</span
|
||||
>
|
||||
<div class="line" style="background: rgba(77, 124, 255, 0.15)"></div>
|
||||
<span class="desc">Where your UI runs</span>
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
padding: 0 0 24px;
|
||||
"
|
||||
>
|
||||
<div class="subgroup" style="border-color: rgba(77, 124, 255, 0.15)">
|
||||
<div class="subgroup-label">Web</div>
|
||||
<div class="chips">
|
||||
<div class="chip chip-frontend sm">React</div>
|
||||
<div class="chip chip-frontend sm">Angular</div>
|
||||
<div class="chip chip-frontend sm">Svelte</div>
|
||||
<div class="chip chip-frontend sm">Vue</div>
|
||||
<div class="chip chip-frontend sm">TanStack</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subgroup" style="border-color: rgba(77, 124, 255, 0.15)">
|
||||
<div class="subgroup-label">Native</div>
|
||||
<div class="chips">
|
||||
<div class="chip chip-frontend sm">React Native</div>
|
||||
<div class="chip chip-frontend sm">SwiftUI</div>
|
||||
<div class="chip chip-frontend sm">Android</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subgroup" style="border-color: rgba(77, 124, 255, 0.15)">
|
||||
<div class="subgroup-label">Messaging</div>
|
||||
<div class="chips">
|
||||
<div class="chip chip-frontend sm">Slack</div>
|
||||
<div class="chip chip-frontend sm">Teams</div>
|
||||
<div class="chip chip-frontend sm">WhatsApp</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-connector"><div class="v-line blue"></div></div>
|
||||
|
||||
<!-- LAYER: Chat UI — spectrum from pre-built to fully headless -->
|
||||
<div class="layer">
|
||||
<div class="layer-label">
|
||||
<span
|
||||
class="tag"
|
||||
style="background: rgba(139, 92, 246, 0.1); color: #b8a0f8"
|
||||
>Chat UI</span
|
||||
>
|
||||
<div class="line" style="background: rgba(139, 92, 246, 0.15)"></div>
|
||||
<span class="desc">Pre-built → customized → headless</span>
|
||||
</div>
|
||||
<div class="layer-content" style="position: relative">
|
||||
<!-- Spectrum arrow underneath -->
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 8px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 1px;
|
||||
"
|
||||
>
|
||||
OPINIONATED
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(139, 92, 246, 0.3),
|
||||
rgba(139, 92, 246, 0.08)
|
||||
);
|
||||
margin: 0 8px;
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
style="
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 8px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 1px;
|
||||
"
|
||||
>
|
||||
FULL CONTROL
|
||||
</div>
|
||||
</div>
|
||||
<div class="chip chip-genui">CopilotChat</div>
|
||||
<div class="chip chip-genui">CopilotSidebar</div>
|
||||
<div class="chip chip-genui">CopilotPopup</div>
|
||||
<div
|
||||
style="
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border);
|
||||
margin: 0 4px;
|
||||
"
|
||||
></div>
|
||||
<div class="chip chip-genui" style="border-style: dashed">
|
||||
CSS Variables
|
||||
</div>
|
||||
<div class="chip chip-genui" style="border-style: dashed">Slots</div>
|
||||
<div class="chip chip-genui" style="border-style: dashed">
|
||||
Custom Sub-Components
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border);
|
||||
margin: 0 4px;
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
class="chip chip-genui"
|
||||
style="background: rgba(139, 92, 246, 0.12); font-weight: 600"
|
||||
>
|
||||
Headless (useAgent)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-connector"><div class="v-line violet"></div></div>
|
||||
|
||||
<!-- LAYER: Generative UI — spectrum from controlled to open -->
|
||||
<div class="layer">
|
||||
<div class="layer-label">
|
||||
<span
|
||||
class="tag"
|
||||
style="background: rgba(139, 92, 246, 0.1); color: #b8a0f8"
|
||||
>Generative UI</span
|
||||
>
|
||||
<div class="line" style="background: rgba(139, 92, 246, 0.15)"></div>
|
||||
<span class="desc">Controlled → declarative → open</span>
|
||||
</div>
|
||||
<div class="layer-content" style="position: relative">
|
||||
<!-- Spectrum arrow underneath -->
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 8px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 1px;
|
||||
"
|
||||
>
|
||||
MORE CONTROL
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(139, 92, 246, 0.3),
|
||||
rgba(139, 92, 246, 0.08)
|
||||
);
|
||||
margin: 0 8px;
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
style="
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 8px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 1px;
|
||||
"
|
||||
>
|
||||
MORE FREEDOM
|
||||
</div>
|
||||
</div>
|
||||
<div class="subgroup" style="border-color: rgba(139, 92, 246, 0.15)">
|
||||
<div class="subgroup-label">Controlled</div>
|
||||
<div class="chips">
|
||||
<div class="chip chip-genui sm">Tool-Based</div>
|
||||
<div class="chip chip-genui sm">Agentic</div>
|
||||
<div class="chip chip-genui sm">Interrupt-Based</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="subgroup"
|
||||
style="border-style: dashed; border-color: rgba(139, 92, 246, 0.15)"
|
||||
>
|
||||
<div class="subgroup-label">Declarative (BYOC)</div>
|
||||
<div class="chips">
|
||||
<div class="chip chip-genui sm">A2UI</div>
|
||||
<div class="chip chip-genui sm">Hashbrown</div>
|
||||
<div class="chip chip-genui sm">JSON Render</div>
|
||||
<div class="chip chip-genui sm">OpenGenUI</div>
|
||||
<div class="chip chip-genui sm">Tambo</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="subgroup"
|
||||
style="
|
||||
border-color: rgba(139, 92, 246, 0.25);
|
||||
background: rgba(139, 92, 246, 0.04);
|
||||
"
|
||||
>
|
||||
<div class="subgroup-label">Open</div>
|
||||
<div class="chips">
|
||||
<div class="chip chip-genui sm" style="font-weight: 600">
|
||||
Fully Agent-Generated
|
||||
</div>
|
||||
<div class="chip chip-genui sm" style="font-weight: 600">
|
||||
MCP Apps
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-connector"><div class="v-line cyan"></div></div>
|
||||
|
||||
<!-- LAYER: Interaction Patterns -->
|
||||
<div class="layer">
|
||||
<div class="layer-label">
|
||||
<span
|
||||
class="tag"
|
||||
style="background: rgba(34, 211, 238, 0.1); color: #66e0f0"
|
||||
>Interaction Patterns</span
|
||||
>
|
||||
<div class="line" style="background: rgba(34, 211, 238, 0.15)"></div>
|
||||
<span class="desc">How users and agents collaborate</span>
|
||||
</div>
|
||||
<div class="layer-content">
|
||||
<div class="chip chip-interaction">Human in the Loop</div>
|
||||
<div class="chip chip-interaction">Frontend Tools</div>
|
||||
<div class="chip chip-interaction">Tool Rendering</div>
|
||||
<div class="chip chip-interaction">Readables</div>
|
||||
<div class="chip chip-interaction">Agent Context</div>
|
||||
<div class="chip chip-interaction">Suggestions</div>
|
||||
<div class="chip chip-interaction">Voice</div>
|
||||
<div class="chip chip-interaction">Multi-modal</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-connector"><div class="v-line orange"></div></div>
|
||||
|
||||
<!-- LAYER: State Sync -->
|
||||
<div class="layer">
|
||||
<div class="layer-label">
|
||||
<span
|
||||
class="tag"
|
||||
style="background: rgba(249, 115, 22, 0.1); color: #fbb070"
|
||||
>State Sync</span
|
||||
>
|
||||
<div class="line" style="background: rgba(249, 115, 22, 0.15)"></div>
|
||||
<span class="desc">Shared state between UI and agent</span>
|
||||
</div>
|
||||
<div class="layer-content">
|
||||
<div class="chip chip-state">State Reading</div>
|
||||
<div class="chip chip-state">State Writing</div>
|
||||
<div class="chip chip-state">State Streaming</div>
|
||||
<div class="chip chip-state">I/O Schemas</div>
|
||||
<div class="chip chip-state">State Rendering</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-connector"><div class="v-line green"></div></div>
|
||||
|
||||
<!-- CORE: CopilotKit -->
|
||||
<div class="central">
|
||||
<div class="central-node copilotkit-node">
|
||||
<h3>CopilotKit</h3>
|
||||
<p>Runtime · SDK · React Bindings</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-connector"><div class="v-line pink"></div></div>
|
||||
|
||||
<!-- CORE: AG-UI Protocol -->
|
||||
<div class="central">
|
||||
<div class="central-node agui-node">
|
||||
<h3>AG-UI Protocol</h3>
|
||||
<p>Agent ↔ UI interop standard</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-connector"><div class="v-line amber"></div></div>
|
||||
|
||||
<!-- LAYER: Agent Frameworks + lateral protocols -->
|
||||
<div class="layer">
|
||||
<div class="layer-label">
|
||||
<span
|
||||
class="tag"
|
||||
style="background: rgba(251, 191, 36, 0.1); color: #f0d070"
|
||||
>Agent Frameworks</span
|
||||
>
|
||||
<div class="line" style="background: rgba(251, 191, 36, 0.15)"></div>
|
||||
<span class="desc">Libraries you write agent code with</span>
|
||||
</div>
|
||||
<div class="framework-row">
|
||||
<!-- Left wing: Agent-to-Agent -->
|
||||
<div class="protocol-wing">
|
||||
<div class="wing-label">Agent ↔ Agent</div>
|
||||
<div class="chip chip-protocol sm">A2A</div>
|
||||
<div class="chip chip-protocol sm">Open Agent Spec</div>
|
||||
<div class="wing-desc">Agents communicate<br />with each other</div>
|
||||
</div>
|
||||
|
||||
<div class="h-connector">
|
||||
<div class="h-arrow left pink"></div>
|
||||
<div class="h-line pink"></div>
|
||||
<div class="h-arrow right pink"></div>
|
||||
</div>
|
||||
|
||||
<!-- Center: frameworks -->
|
||||
<div class="frameworks-center">
|
||||
<div class="chip chip-framework">LangGraph</div>
|
||||
<div class="chip chip-framework">Mastra</div>
|
||||
<div class="chip chip-framework">CrewAI</div>
|
||||
<div class="chip chip-framework">PydanticAI</div>
|
||||
<div class="chip chip-framework">AG2</div>
|
||||
<div class="chip chip-framework">Agno</div>
|
||||
<div class="chip chip-framework">LlamaIndex</div>
|
||||
<div class="chip chip-framework">Langroid</div>
|
||||
<div class="chip chip-framework">AWS Strands</div>
|
||||
<div class="chip chip-framework">Spring AI</div>
|
||||
<div class="chip chip-framework">MAF</div>
|
||||
</div>
|
||||
|
||||
<div class="h-connector">
|
||||
<div class="h-arrow left amber"></div>
|
||||
<div class="h-line amber"></div>
|
||||
<div class="h-arrow right amber"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right wing: Agent-to-Tools -->
|
||||
<div class="protocol-wing">
|
||||
<div class="wing-label">Agent ↔ Tools</div>
|
||||
<div class="chip chip-protocol sm">MCP</div>
|
||||
<div class="wing-desc">
|
||||
Agents discover &<br />call external tools
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-connector"><div class="v-line gray"></div></div>
|
||||
|
||||
<!-- LAYER: Agent Platforms -->
|
||||
<div class="layer">
|
||||
<div class="layer-label">
|
||||
<span
|
||||
class="tag"
|
||||
style="background: rgba(136, 136, 160, 0.1); color: #a0a0b8"
|
||||
>Agent Platforms</span
|
||||
>
|
||||
<div class="line" style="background: rgba(136, 136, 160, 0.15)"></div>
|
||||
<span class="desc">Where you deploy and run your agents</span>
|
||||
</div>
|
||||
<div class="layer-content">
|
||||
<div class="chip chip-platform">LangSmith / LangGraph Cloud</div>
|
||||
<div class="chip chip-platform">AWS Agent Core</div>
|
||||
<div class="chip chip-platform">Google Vertex AI</div>
|
||||
<div class="chip chip-platform">Azure AI Foundry</div>
|
||||
<div class="chip chip-platform">Cloudflare Workers</div>
|
||||
<div class="chip chip-platform">Vercel</div>
|
||||
<div class="chip chip-platform">Render / Railway / Fly</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-connector"><div class="v-line gray"></div></div>
|
||||
|
||||
<!-- LAYER: LLM Providers -->
|
||||
<div class="layer">
|
||||
<div class="layer-label">
|
||||
<span
|
||||
class="tag"
|
||||
style="background: rgba(255, 255, 255, 0.04); color: #888898"
|
||||
>LLM Providers</span
|
||||
>
|
||||
<div class="line" style="background: rgba(255, 255, 255, 0.06)"></div>
|
||||
<span class="desc">The models powering your agents</span>
|
||||
</div>
|
||||
<div class="layer-content">
|
||||
<div class="chip chip-provider">OpenAI</div>
|
||||
<div class="chip chip-provider">Anthropic</div>
|
||||
<div class="chip chip-provider">Google</div>
|
||||
<div class="chip chip-provider">AWS Bedrock</div>
|
||||
<div class="chip chip-provider">Azure OpenAI</div>
|
||||
<div class="chip chip-provider">Groq</div>
|
||||
<div class="chip chip-provider">Ollama</div>
|
||||
<div class="chip chip-provider">Any OpenAI-compatible</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:baa7d3e76f2339bb00b721d472a8353a4f9f4e1ed0959bea30c12ccc045d8c3c
|
||||
size 599513
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c1fc61bc22dfb3b42ee9d903aacadc7450291d7201f80861740c22130466b714
|
||||
size 634934
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:23a22b0d95a23a003114846f0913bad84efbc01dec9ba94d22716b49fffba010
|
||||
size 2355700
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c1fc61bc22dfb3b42ee9d903aacadc7450291d7201f80861740c22130466b714
|
||||
size 634934
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d2eed66218e943b9e143d7d94ff2af63abf40541006e2a405dd16a6aebd6c1fb
|
||||
size 420095
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:da2670624435bbe7393c59095998c9da48bf0400d4d260e89d67d99cd0cffa4d
|
||||
size 135082
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7373e0bbfffe8bb9140ea1953a1c2f1ebd6fe86978592b9380bfac41cb92fa31
|
||||
size 205679
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eb771cdce63d48398eaa8e732b02cf92795aa4f79f44498ef170fcf4b60693d1
|
||||
size 192665
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7ef0700c1b1b5243a7e47430f54b944440c04b3ac8ddc90ef85b7eaf3fdd3c0a
|
||||
size 42859
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fe22569ad66d2d4dc0c159c7cb3adf0a36bb66f2d17753a4a8b42e117e829c0a
|
||||
size 496127
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dc08002f0fd2792bb1d24523800d26c654146e4142b8654b745a35bf1e1c0bfa
|
||||
size 408807
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0a20718f42a21321878725119d29e682ab451659be06981a5f3aca2b0d4cfa88
|
||||
size 225872
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b717393909ca56e5a3a995bd256cabb02ead33e3438f8a456a6cb085693d3ae0
|
||||
size 213295
|
||||
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 218 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">AG</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">Ag</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="111 0 25 26" width="24" height="25" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="cpk_g0" x1="129.301" y1="2.339" x2="125.623" y2="12.452" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6430AB"/>
|
||||
<stop offset="1" stop-color="#AA89D8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cpk_g1" x1="126.451" y1="8.039" x2="121.717" y2="17.187" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#005DBB"/>
|
||||
<stop offset="1" stop-color="#3D92E8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cpk_g2" x1="128.565" y1="2.339" x2="127.139" y2="6.798" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1B70C4"/>
|
||||
<stop offset="1" stop-color="#54A4F2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cpk_g3" x1="117.94" y1="22.784" x2="132.981" y2="22.784" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4497EA"/>
|
||||
<stop offset="0.2548" stop-color="#1463B2"/>
|
||||
<stop offset="0.4987" stop-color="#0A437D"/>
|
||||
<stop offset="0.6667" stop-color="#2476C8"/>
|
||||
<stop offset="0.9725" stop-color="#0C549A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M119.539 8.67724C121.647 5.91912 123.397 3.19174 124.071 0.989235C124.089 0.929306 124.159 0.903848 124.211 0.938445C126.553 2.4891 130.818 3.50978 134.591 3.53373C134.655 3.53415 134.7 3.59815 134.677 3.65868C133.422 6.84085 131.89 12.5427 131.831 19.054C131.831 19.1507 131.695 19.1854 131.647 19.1014C129.5 15.3443 122.623 10.0649 119.574 8.81884C119.517 8.79565 119.501 8.72596 119.539 8.67724Z" fill="url(#cpk_g0)"/>
|
||||
<path d="M126.653 6.99011C123.357 8.03363 120.345 8.61377 119.626 8.74558C119.581 8.75399 119.571 8.81729 119.615 8.83516C122.687 10.1126 129.53 15.3766 131.657 19.1184C131.661 19.1266 131.672 19.1296 131.68 19.1259C131.689 19.1218 131.693 19.1112 131.69 19.1021L126.653 6.99011Z" fill="url(#cpk_g1)"/>
|
||||
<path d="M124.221 0.931583C127.042 2.47061 130.303 3.16182 134.629 3.52604C134.656 3.52836 134.665 3.56478 134.641 3.57743C134.087 3.86176 130.918 5.47449 128.565 6.33825C127.934 6.56966 127.3 6.78434 126.675 6.98241C126.662 6.98674 126.647 6.97992 126.641 6.96671L124.156 0.989873C124.139 0.949626 124.183 0.91071 124.221 0.931583Z" fill="url(#cpk_g2)"/>
|
||||
<path d="M125.209 3.30419L122.405 12.6362M122.405 12.6362H129.07M122.405 12.6362L111.874 25.0387" stroke="#ABABAB" stroke-width="0.321797" stroke-linecap="round"/>
|
||||
<path d="M119.181 22.4856L117.94 22.6601C118.584 24.3624 119.904 25.1059 121.479 25.1059C125.341 25.1059 124.163 20.7388 126.4 20.7388C128.023 20.7388 127.364 24.2784 130.857 24.2784C132.989 24.2784 133.201 22.1307 132.837 21.2067C132.835 21.201 132.833 21.1959 132.83 21.1908L132.259 20.316C132.222 20.2578 132.131 20.2797 132.125 20.3489L132.018 21.4092C132.011 21.483 132.013 21.5565 132.021 21.6301C132.109 22.3627 132.165 24.1405 130.857 24.1405C129.477 24.1405 129.145 20.6468 126.4 20.6468C123.181 20.6468 123.594 24.968 121.618 24.968C120.313 24.968 119.319 23.497 119.181 22.4856Z" fill="url(#cpk_g3)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">Cl</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">Cl</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">Cr</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">Go</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">LG</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">LG</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">LG</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">La</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">Ll</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">Ma</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">MS</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">MS</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">Py</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">Sp</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">St</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#F0F0F0"/>
|
||||
<text x="16" y="20" text-anchor="middle" font-size="10" fill="#666" font-family="system-ui">St</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:973887a784fcd8b5f2cf69f3b4e7dd869534eb07f27d1eb66ea17d1b5c4bf53f
|
||||
size 27097
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotEndpoint,
|
||||
InMemoryAgentRunner,
|
||||
BuiltInAgent,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { handle } from "hono/vercel";
|
||||
|
||||
const agent = new BuiltInAgent({
|
||||
model: "openai/gpt-4o",
|
||||
prompt: `You are the CopilotKit Showcase assistant. You help developers explore CopilotKit integrations, find the right agent framework, and try live demos.
|
||||
|
||||
You should:
|
||||
- Help users understand what CopilotKit does and how different frameworks integrate
|
||||
- Suggest demos they can try based on their interests
|
||||
- Explain features like generative UI, human-in-the-loop, tool rendering, etc.
|
||||
- Be concise and helpful — 1-3 sentences unless they ask for detail
|
||||
|
||||
When suggesting demos, provide links like /integrations/{slug}/{demoId}.
|
||||
Available integrations: LangGraph (Python) at /integrations/langgraph-python, Mastra at /integrations/mastra.
|
||||
Available features: agentic-chat, human-in-the-loop, tool-rendering, gen-ui-tool-based.`,
|
||||
maxSteps: 3,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore — BuiltInAgent type mismatch with AbstractAgent, pending upstream fix
|
||||
agents: { default: agent },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
});
|
||||
|
||||
const app = createCopilotEndpoint({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit",
|
||||
});
|
||||
|
||||
export const GET = handle(app);
|
||||
export const POST = handle(app);
|
||||
@@ -0,0 +1,189 @@
|
||||
@import "tailwindcss";
|
||||
@import "@copilotkit/react-core/v2/styles.css";
|
||||
@import "highlight.js/styles/github.css";
|
||||
|
||||
:root {
|
||||
/* Backgrounds — match reference (oklch(0.9038 0.0149 286.1) ≈ #e6e6ec).
|
||||
* Shell keeps a slightly lighter cast so code blocks on white surface
|
||||
* still read well; key is that it's the same cool-gray family. */
|
||||
--bg: #eaeaee;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-elevated: #f1f1f6;
|
||||
--bg-hover: #e5e5eb;
|
||||
--border: #d9d9e0;
|
||||
--border-dim: #e9e9ef;
|
||||
--sidebar: #efeff3;
|
||||
--text: #010507;
|
||||
--text-secondary: #2b3238;
|
||||
--text-muted: #6a7079;
|
||||
--text-faint: #a3a8af;
|
||||
/* Reference primary is oklch(0.55 0.25 285) — a blue-violet. Hex approx. */
|
||||
--accent: #5a3cd1;
|
||||
--accent-light: #ebe7fa;
|
||||
--accent-dim: rgba(90, 60, 209, 0.06);
|
||||
--blue: #1a5fb4;
|
||||
--violet: #5a3cd1;
|
||||
--violet-light: #ebe7fa;
|
||||
|
||||
/* Font stacks — fed by next/font CSS variables in layout.tsx with
|
||||
* safe fallbacks for environments where the font fails to load.
|
||||
* We expose them under `--font-prose`/`--font-code` and use those
|
||||
* names in component CSS so Tailwind's own `--font-sans`/`--font-mono`
|
||||
* theme tokens don't collide. */
|
||||
--font-docs-prose:
|
||||
var(--font-prose, "Plus Jakarta Sans"), -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
--font-docs-code:
|
||||
var(--font-mono, "Spline Sans Mono"), ui-monospace, SFMono-Regular,
|
||||
"SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-docs-prose);
|
||||
font-feature-settings: "cv11", "ss01";
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
/* Code font inherits Spline Sans Mono via CSS variable */
|
||||
code,
|
||||
pre,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--font-docs-code);
|
||||
}
|
||||
|
||||
/* Reference content (MDX) typography — calibrated against docs.copilotkit.ai.
|
||||
* The reference uses fumadocs' default prose scale: body ~15px, h2 ~1.5rem
|
||||
* with tighter tracking, and tonally-darker headings than body. */
|
||||
.reference-content {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
line-height: 1.65;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.reference-content h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.reference-content h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.reference-content h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.reference-content p {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.reference-content ul,
|
||||
.reference-content ol {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-secondary);
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.reference-content li {
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.reference-content ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.reference-content ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.reference-content code {
|
||||
font-size: 0.8125rem;
|
||||
background: var(--bg-elevated);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: var(--font-docs-code);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Bare <pre> fallback (when an MDX page uses a triple-fence rather than
|
||||
* <Snippet>). Mirrors the chrome on <Snippet>'s figure so pages look
|
||||
* consistent whether the code is live-from-cell or inline. */
|
||||
.reference-content pre {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.reference-content pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.reference-content a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.reference-content a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.reference-content table {
|
||||
width: 100%;
|
||||
font-size: 0.8125rem;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.reference-content th,
|
||||
.reference-content td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.reference-content th {
|
||||
background: var(--bg-elevated);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reference-content strong {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.reference-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="6" fill="#0a0a0f"/>
|
||||
<path d="M5 4 L27 4 L27 17 Q27 27 16 30 Q5 27 5 17 Z" fill="none" stroke="#ff44bb" stroke-width="1.5"/>
|
||||
<g transform="translate(8.19, 8.51) scale(0.055)">
|
||||
<path d="M76.4556 75.7547C97.0552 48.81 114.152 22.1656 120.731 0.648813C120.908 0.0633572 121.594 -0.185355 122.103 0.152636C144.979 15.3013 186.646 25.2726 223.5 25.5066C224.134 25.5107 224.571 26.1359 224.342 26.7272C212.088 57.8146 197.122 113.518 196.54 177.128C196.54 178.073 195.21 178.412 194.742 177.59C173.768 140.886 106.586 89.3107 76.7986 77.138C76.2477 76.9114 76.0919 76.2307 76.4556 75.7547Z" fill="#ff44bb"/>
|
||||
<path d="M145.956 59.273C113.757 69.4674 84.3336 75.1349 77.3077 76.4226C76.8608 76.5047 76.7673 77.1231 77.1934 77.2977C107.209 89.777 174.059 141.202 194.835 177.757C194.877 177.837 194.981 177.867 195.064 177.83C195.147 177.791 195.189 177.687 195.158 177.597L145.956 59.273Z" fill="#ff44bb"/>
|
||||
<path d="M122.197 0.0860308C149.76 15.1211 181.615 21.8738 223.875 25.4319C224.135 25.4546 224.228 25.8103 223.989 25.9339C218.585 28.7116 187.623 44.4667 164.633 52.905C158.47 55.1657 152.275 57.263 146.174 59.1979C146.039 59.2402 145.894 59.1736 145.842 59.0446L121.563 0.655481C121.397 0.262302 121.823 -0.117886 122.197 0.0860308Z" fill="#ff44bb"/>
|
||||
<path d="M72.9636 210.65L60.8346 212.356C67.1226 228.985 80.0206 236.249 95.4131 236.249C133.141 236.249 121.625 193.585 143.482 193.585C159.342 193.585 152.899 228.165 187.02 228.165C207.848 228.165 209.927 207.183 206.372 198.156C206.351 198.101 206.331 198.051 206.299 198.002L200.718 189.455C200.355 188.887 199.471 189.101 199.409 189.776L198.369 200.135C198.297 200.856 198.317 201.574 198.401 202.293C199.253 209.45 199.804 226.818 187.02 226.818C173.54 226.818 170.297 192.686 143.482 192.686C112.032 192.686 116.075 234.902 96.7643 234.902C84.0221 234.902 74.3043 220.531 72.9636 210.65Z" fill="#ff44bb"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import type { Integration } from "@/lib/registry";
|
||||
|
||||
interface DemoFile {
|
||||
filename: string;
|
||||
language: string;
|
||||
content: string;
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
interface DemoContent {
|
||||
readme: string | null;
|
||||
files: DemoFile[];
|
||||
backend_files?: DemoFile[];
|
||||
}
|
||||
|
||||
type TreeNode =
|
||||
| { kind: "file"; file: DemoFile; name: string }
|
||||
| { kind: "dir"; name: string; children: TreeNode[] };
|
||||
|
||||
function buildTree(files: DemoFile[]): TreeNode[] {
|
||||
const root: TreeNode[] = [];
|
||||
for (const file of files) {
|
||||
const parts = file.filename.split("/");
|
||||
let level = root;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const dirName = parts[i];
|
||||
let existing = level.find(
|
||||
(n): n is Extract<TreeNode, { kind: "dir" }> =>
|
||||
n.kind === "dir" && n.name === dirName,
|
||||
);
|
||||
if (!existing) {
|
||||
existing = { kind: "dir", name: dirName, children: [] };
|
||||
level.push(existing);
|
||||
}
|
||||
level = existing.children;
|
||||
}
|
||||
level.push({ kind: "file", file, name: parts[parts.length - 1] });
|
||||
}
|
||||
// Sort each level: directories first, then files; alpha within each group;
|
||||
// but highlighted files float to top within their group.
|
||||
const sort = (nodes: TreeNode[]) => {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1;
|
||||
if (a.kind === "file" && b.kind === "file") {
|
||||
if (!!a.file.highlighted !== !!b.file.highlighted) {
|
||||
return a.file.highlighted ? -1 : 1;
|
||||
}
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (const n of nodes) if (n.kind === "dir") sort(n.children);
|
||||
};
|
||||
sort(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function parseLineRange(spec: string | null): Set<number> {
|
||||
const highlighted = new Set<number>();
|
||||
if (!spec) return highlighted;
|
||||
for (const part of spec.split(",")) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) continue;
|
||||
const [a, b] = trimmed.split("-").map((n) => parseInt(n, 10));
|
||||
if (Number.isNaN(a)) continue;
|
||||
const end = Number.isNaN(b) ? a : b;
|
||||
for (let i = a; i <= end; i++) highlighted.add(i);
|
||||
}
|
||||
return highlighted;
|
||||
}
|
||||
|
||||
function FileTree({
|
||||
nodes,
|
||||
depth,
|
||||
activeFilename,
|
||||
onSelect,
|
||||
}: {
|
||||
nodes: TreeNode[];
|
||||
depth: number;
|
||||
activeFilename?: string;
|
||||
onSelect: (f: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node) => {
|
||||
const indent = { paddingLeft: `${12 + depth * 14}px` };
|
||||
if (node.kind === "dir") {
|
||||
return (
|
||||
<div key={`d:${node.name}:${depth}`}>
|
||||
<div
|
||||
className="py-1 text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)]"
|
||||
style={indent}
|
||||
>
|
||||
{node.name}/
|
||||
</div>
|
||||
<FileTree
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
activeFilename={activeFilename}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const active = activeFilename === node.file.filename;
|
||||
const hl = node.file.highlighted;
|
||||
return (
|
||||
<button
|
||||
key={`f:${node.file.filename}`}
|
||||
onClick={() => onSelect(node.file.filename)}
|
||||
style={indent}
|
||||
className={`block w-full py-1.5 pr-3 text-left text-xs font-mono transition-colors truncate ${
|
||||
active
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text)]"
|
||||
: hl
|
||||
? "text-[var(--text)] hover:bg-[var(--bg-elevated)]/50"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]/50"
|
||||
} ${hl ? "font-semibold" : ""}`}
|
||||
title={hl ? "core file" : undefined}
|
||||
>
|
||||
{hl && <span className="text-[var(--accent)] mr-1">★</span>}
|
||||
{node.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StandaloneCodePage() {
|
||||
const params = useParams<{ slug: string; demo: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [integration, setIntegration] = useState<Integration | null>(null);
|
||||
const [content, setContent] = useState<DemoContent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
import("@/data/registry.json").then((mod) => {
|
||||
const registry = mod.default as { integrations: Integration[] };
|
||||
const integ = registry.integrations.find((i) => i.slug === params.slug);
|
||||
if (integ) setIntegration(integ);
|
||||
});
|
||||
|
||||
import("@/data/demo-content.json").then((mod) => {
|
||||
const data = mod.default as { demos: Record<string, DemoContent> };
|
||||
const key = `${params.slug}::${params.demo}`;
|
||||
setContent(data.demos[key] ?? null);
|
||||
});
|
||||
}, [params.slug, params.demo]);
|
||||
|
||||
const allFiles: DemoFile[] = useMemo(() => {
|
||||
if (!content) return [];
|
||||
// Prefer flat `files`. Fall back to combining files + backend_files for
|
||||
// older bundles that still emit that shape.
|
||||
return [...content.files, ...(content.backend_files ?? [])];
|
||||
}, [content]);
|
||||
|
||||
const hasHighlights = useMemo(
|
||||
() => allFiles.some((f) => f.highlighted),
|
||||
[allFiles],
|
||||
);
|
||||
|
||||
// `view` toggles the sidebar between "core" (highlights only) and "all"
|
||||
// (full tree). Default is "core" when any file is highlighted; otherwise
|
||||
// "all" (with nothing marked core, core view would be empty).
|
||||
const view: "core" | "all" =
|
||||
searchParams.get("view") === "all" ? "all" : hasHighlights ? "core" : "all";
|
||||
|
||||
const visibleFiles: DemoFile[] = useMemo(
|
||||
() => (view === "core" ? allFiles.filter((f) => f.highlighted) : allFiles),
|
||||
[allFiles, view],
|
||||
);
|
||||
|
||||
// Default-open a highlighted file if any; otherwise the first file.
|
||||
const defaultFile =
|
||||
allFiles.find((f) => f.highlighted)?.filename ?? allFiles[0]?.filename;
|
||||
const activeFilename = searchParams.get("file") ?? defaultFile;
|
||||
const activeFile =
|
||||
allFiles.find((f) => f.filename === activeFilename) ?? allFiles[0];
|
||||
const tree = useMemo(() => buildTree(visibleFiles), [visibleFiles]);
|
||||
|
||||
const highlightedLines = useMemo(
|
||||
() => parseLineRange(searchParams.get("lines")),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
if (!integration) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-52px)] items-center justify-center text-[var(--text-muted)]">
|
||||
Loading code…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!content || allFiles.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-52px)] items-center justify-center text-[var(--text-muted)]">
|
||||
No source files bundled for this demo.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectFile = (filename: string) => {
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
next.set("file", filename);
|
||||
next.delete("lines");
|
||||
router.replace(`?${next.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const setView = (v: "core" | "all") => {
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
if (v === "all") next.set("view", "all");
|
||||
else next.delete("view"); // "core" is default — omit the param
|
||||
router.replace(`?${next.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-52px)]">
|
||||
<div className="flex h-full w-64 flex-col border-r border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<div className="flex items-center justify-between px-3 pt-3 pb-2">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)]">
|
||||
Files
|
||||
</span>
|
||||
{hasHighlights && (
|
||||
<label
|
||||
className="flex items-center gap-2 cursor-pointer select-none"
|
||||
title="Include scaffolding (Dockerfile, configs, etc.)"
|
||||
>
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
show all files
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={view === "all"}
|
||||
onClick={() => setView(view === "all" ? "core" : "all")}
|
||||
className={`relative inline-block h-4 w-7 rounded-full border transition-colors ${
|
||||
view === "all"
|
||||
? "bg-[var(--accent)] border-[var(--accent)]"
|
||||
: "bg-[var(--bg-muted)] border-[var(--border-strong)]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-[1px] left-[1px] h-3 w-3 rounded-full shadow-sm transition-transform ${
|
||||
view === "all"
|
||||
? "bg-white translate-x-3"
|
||||
: "bg-[var(--text-muted)] translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<FileTree
|
||||
nodes={tree}
|
||||
depth={0}
|
||||
activeFilename={activeFile?.filename}
|
||||
onSelect={selectFile}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto border-t border-[var(--border)] p-3">
|
||||
<a
|
||||
href={integration.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-[var(--accent)] hover:underline"
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeFile && (
|
||||
<SyntaxHighlighter
|
||||
language={activeFile.language}
|
||||
style={oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
background: "var(--bg)",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
// oneLight sets a near-white background on both <pre> and the
|
||||
// inner <code>. `customStyle` overrides the outer <pre>, but
|
||||
// the <code> child keeps its own background — and since <code>
|
||||
// renders inline, that bg hugs each line of text and shows as
|
||||
// a pale per-line rectangle against the page chrome. Unset it
|
||||
// so the chosen <pre> background reads as a flat block.
|
||||
codeTagProps={{ style: { background: "transparent" } }}
|
||||
showLineNumbers
|
||||
wrapLines={highlightedLines.size > 0}
|
||||
lineNumberStyle={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: "11px",
|
||||
paddingRight: "1em",
|
||||
minWidth: "3em",
|
||||
}}
|
||||
lineProps={(lineNumber) =>
|
||||
highlightedLines.has(lineNumber)
|
||||
? {
|
||||
style: {
|
||||
display: "block",
|
||||
background: "rgba(250, 204, 21, 0.18)",
|
||||
},
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{activeFile.content}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import type { Demo, Integration } from "@/lib/registry";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config.client";
|
||||
import { resolveBackendUrl } from "@/lib/backend-url";
|
||||
|
||||
type Tab = "preview" | "code" | "docs";
|
||||
|
||||
interface DemoFile {
|
||||
filename: string;
|
||||
language: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface DemoContent {
|
||||
readme: string | null;
|
||||
files: DemoFile[];
|
||||
backend_files?: DemoFile[];
|
||||
}
|
||||
|
||||
export default function DemoViewerPage() {
|
||||
const params = useParams<{ slug: string; demo: string }>();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("preview");
|
||||
const [integration, setIntegration] = useState<Integration | null>(null);
|
||||
const [demo, setDemo] = useState<Demo | null>(null);
|
||||
const [demoContent, setDemoContent] = useState<DemoContent | null>(null);
|
||||
const [activeFile, setActiveFile] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
import("@/data/registry.json").then((mod) => {
|
||||
const registry = mod.default as { integrations: Integration[] };
|
||||
const integ = registry.integrations.find((i) => i.slug === params.slug);
|
||||
if (integ) {
|
||||
setIntegration(integ);
|
||||
setDemo(integ.demos.find((d) => d.id === params.demo) ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
import("@/data/demo-content.json").then((mod) => {
|
||||
const content = mod.default as {
|
||||
demos: Record<string, DemoContent | undefined>;
|
||||
};
|
||||
const key = `${params.slug}::${params.demo}`;
|
||||
const entry = content.demos[key];
|
||||
if (entry) {
|
||||
setDemoContent(entry);
|
||||
}
|
||||
});
|
||||
}, [params.slug, params.demo]);
|
||||
|
||||
if (!integration || !demo) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] items-center justify-center text-[var(--text-muted)]">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Command-only demos (e.g. `langgraph-python::cli-start`) have no
|
||||
// `route`, so iframeSrc stays null and the preview tab shows the CLI
|
||||
// instructions instead of an iframe. For demos with a route, the
|
||||
// backend host is derived at request time from the injected runtime
|
||||
// config's host pattern — never the build-baked registry backend_url
|
||||
// (see preview/page.tsx for rationale). This line only runs
|
||||
// post-hydration, so the injected runtime config is present.
|
||||
const iframeSrc = demo.route
|
||||
? `${resolveBackendUrl(integration.slug, getRuntimeConfig().backendHostPattern)}${demo.route}`
|
||||
: null;
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: "preview", label: "Preview" },
|
||||
{ id: "code", label: "Code" },
|
||||
{ id: "docs", label: "Docs" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-52px)] flex-col p-4 gap-3">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between border border-[var(--border)] bg-[var(--bg-surface)] px-6 py-3 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/integrations/${integration.slug}`}
|
||||
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
← {integration.name}
|
||||
</Link>
|
||||
<span className="text-[var(--text-muted)]">/</span>
|
||||
<span className="text-sm font-medium text-[var(--text)]">
|
||||
{demo.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1 rounded-lg bg-[var(--bg)] p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden rounded-xl border border-[var(--border)]">
|
||||
{activeTab === "preview" &&
|
||||
(iframeSrc ? (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
className="h-full w-full border-0 rounded-xl"
|
||||
title={`${demo.name} demo`}
|
||||
allow="clipboard-read; clipboard-write; microphone"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-[var(--text-muted)]">
|
||||
<p className="text-sm font-semibold text-[var(--text)]">
|
||||
No live preview for this demo
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{demo.name} is a CLI-only demo. See the Docs tab or
|
||||
{demo.command ? (
|
||||
<>
|
||||
{" "}
|
||||
run{" "}
|
||||
<code className="rounded bg-[var(--bg-elevated)] px-1.5 py-0.5 font-mono text-[var(--accent)]">
|
||||
{demo.command}
|
||||
</code>{" "}
|
||||
to get started.
|
||||
</>
|
||||
) : (
|
||||
" the integration page for instructions."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{activeTab === "code" && (
|
||||
<div className="flex h-full">
|
||||
{/* File tabs */}
|
||||
<div className="flex h-full flex-col border-r border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<div className="p-3 text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)]">
|
||||
Files
|
||||
</div>
|
||||
{demoContent?.files.map((file, idx) => (
|
||||
<button
|
||||
key={file.filename}
|
||||
onClick={() => setActiveFile(idx)}
|
||||
className={`px-4 py-2 text-left text-xs font-mono transition-colors ${
|
||||
activeFile === idx
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]/50"
|
||||
}`}
|
||||
>
|
||||
{file.filename}
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-auto border-t border-[var(--border)] p-3">
|
||||
<a
|
||||
href={integration.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-[var(--accent)] hover:underline"
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/* Code viewer */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{demoContent?.files[activeFile] ? (
|
||||
<SyntaxHighlighter
|
||||
language={demoContent.files[activeFile].language}
|
||||
style={oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
background: "var(--bg)",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
showLineNumbers
|
||||
lineNumberStyle={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: "11px",
|
||||
paddingRight: "1em",
|
||||
minWidth: "3em",
|
||||
}}
|
||||
>
|
||||
{demoContent.files[activeFile].content}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[var(--text-muted)]">
|
||||
No source files bundled for this demo.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "docs" && (
|
||||
<div className="h-full overflow-auto p-8">
|
||||
{demoContent?.readme ? (
|
||||
<div className="mx-auto max-w-3xl [&_h1]:text-2xl [&_h1]:font-light [&_h1]:text-[var(--text)] [&_h1]:mb-4 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-[var(--text)] [&_h2]:mt-8 [&_h2]:mb-3 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-[var(--text)] [&_h3]:mt-6 [&_h3]:mb-2 [&_p]:text-sm [&_p]:text-[var(--text-secondary)] [&_p]:leading-relaxed [&_p]:mb-4 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-4 [&_li]:text-sm [&_li]:text-[var(--text-secondary)] [&_li]:mb-1 [&_strong]:text-[var(--text)] [&_code]:text-[var(--accent)] [&_code]:bg-[var(--bg-elevated)] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono [&_pre]:bg-[var(--bg-surface)] [&_pre]:rounded-lg [&_pre]:p-4 [&_pre]:mb-4 [&_pre]:overflow-x-auto [&_hr]:border-[var(--border)] [&_hr]:my-6">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{demoContent.readme}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[var(--text-muted)]">
|
||||
No documentation available for this demo.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Integration, Demo } from "@/lib/registry";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config.client";
|
||||
import { resolveBackendUrl } from "@/lib/backend-url";
|
||||
|
||||
export default function StandalonePreviewPage() {
|
||||
const params = useParams<{ slug: string; demo: string }>();
|
||||
const [integration, setIntegration] = useState<Integration | null>(null);
|
||||
const [demo, setDemo] = useState<Demo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
import("@/data/registry.json").then((mod) => {
|
||||
const registry = mod.default as { integrations: Integration[] };
|
||||
const integ = registry.integrations.find((i) => i.slug === params.slug);
|
||||
if (integ) {
|
||||
setIntegration(integ);
|
||||
setDemo(integ.demos.find((d) => d.id === params.demo) ?? null);
|
||||
}
|
||||
});
|
||||
}, [params.slug, params.demo]);
|
||||
|
||||
if (!integration || !demo) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-52px)] items-center justify-center text-[var(--text-muted)]">
|
||||
Loading preview…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Command-only demos (no `route`) have no iframe URL to build —
|
||||
// concatenating them would produce `${base}undefined`. Render the
|
||||
// command instead, matching the Get Started section on the profile page.
|
||||
if (!demo.route) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-52px)] w-full items-center justify-center px-6">
|
||||
<div className="max-w-md text-center">
|
||||
<p className="text-sm font-semibold text-[var(--text)]">
|
||||
{demo.name} has no live preview
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-[var(--text-muted)]">
|
||||
This demo is CLI-only.
|
||||
{demo.command ? (
|
||||
<>
|
||||
{" "}
|
||||
Run{" "}
|
||||
<code className="rounded bg-[var(--bg-elevated)] px-1.5 py-0.5 font-mono text-[var(--accent)]">
|
||||
{demo.command}
|
||||
</code>{" "}
|
||||
to get started.
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Derive the backend host at runtime from the injected config —
|
||||
// never from registry.json's backend_url, which is baked at Docker
|
||||
// build time (a staging shell would iframe PROD backends).
|
||||
// resolveBackendUrl keeps the NEXT_PUBLIC_LOCAL_BACKENDS local-dev
|
||||
// override. Safe to read here: this point is only reachable
|
||||
// post-hydration (registry loads in useEffect), so the real
|
||||
// window.__SHOWCASE_CONFIG__ is present.
|
||||
const base = resolveBackendUrl(
|
||||
integration.slug,
|
||||
getRuntimeConfig().backendHostPattern,
|
||||
);
|
||||
const src = `${base}${demo.route}`;
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-52px)] w-full">
|
||||
<iframe
|
||||
src={src}
|
||||
className="h-full w-full border-0"
|
||||
title={`${integration.name} — ${demo.name}`}
|
||||
allow="clipboard-read; clipboard-write; microphone"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getIntegration,
|
||||
getIntegrations,
|
||||
getFeature,
|
||||
getCategoryLabel,
|
||||
getLanguageLabel,
|
||||
} from "@/lib/registry";
|
||||
import { ProfileClient } from "./profile-client";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getIntegrations().map((i) => ({ slug: i.slug }));
|
||||
}
|
||||
|
||||
export default async function IntegrationProfilePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const integration = getIntegration(slug);
|
||||
if (!integration) notFound();
|
||||
|
||||
const featureInfos = integration.features.map((featureId) => {
|
||||
const feature = getFeature(featureId);
|
||||
return {
|
||||
id: featureId,
|
||||
name: feature?.name || featureId,
|
||||
hasDemo: integration.demos.some((d) => d.id === featureId),
|
||||
};
|
||||
});
|
||||
|
||||
// Other integrations that share demo IDs — for framework switcher
|
||||
const allIntegrations = getIntegrations().filter(
|
||||
(i) => i.deployed && i.slug !== slug,
|
||||
);
|
||||
// No backendUrl here: this page is statically prerendered
|
||||
// (generateStaticParams), so any URL computed server-side would be
|
||||
// frozen at build time — the exact baked-prod-URL defect this fixes.
|
||||
// ProfileClient derives backend URLs client-side from the runtime
|
||||
// config pattern.
|
||||
const demoAlternatives: Record<
|
||||
string,
|
||||
Array<{ slug: string; name: string }>
|
||||
> = {};
|
||||
for (const demo of integration.demos) {
|
||||
const alts = allIntegrations
|
||||
.filter((i) => i.demos.some((d) => d.id === demo.id))
|
||||
.map((i) => ({
|
||||
slug: i.slug,
|
||||
name: i.name,
|
||||
}));
|
||||
if (alts.length > 0) {
|
||||
demoAlternatives[demo.id] = alts;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ProfileClient
|
||||
integration={integration}
|
||||
featureInfos={featureInfos}
|
||||
categoryLabel={getCategoryLabel(integration.category)}
|
||||
languageLabel={getLanguageLabel(integration.language)}
|
||||
demoAlternatives={demoAlternatives}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import { DemoDrawer } from "@/components/demo-drawer";
|
||||
import type { Demo, Integration } from "@/lib/registry";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config.client";
|
||||
import { resolveBackendUrl } from "@/lib/backend-url";
|
||||
|
||||
interface StarterFile {
|
||||
filename: string;
|
||||
language: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface FeatureInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
hasDemo: boolean;
|
||||
}
|
||||
|
||||
export function ProfileClient({
|
||||
integration,
|
||||
featureInfos,
|
||||
categoryLabel,
|
||||
languageLabel,
|
||||
demoAlternatives = {},
|
||||
}: {
|
||||
integration: Integration;
|
||||
featureInfos: FeatureInfo[];
|
||||
categoryLabel: string;
|
||||
languageLabel: string;
|
||||
demoAlternatives?: Record<string, Array<{ slug: string; name: string }>>;
|
||||
}) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [activeDemo, setActiveDemo] = useState<Demo | null>(null);
|
||||
const [starterFiles, setStarterFiles] = useState<StarterFile[] | null>(null);
|
||||
const [starterReadme, setStarterReadme] = useState<string | null>(null);
|
||||
const [starterTab, setStarterTab] = useState<"demo" | "code" | "docs">(
|
||||
"demo",
|
||||
);
|
||||
const [showAllFiles, setShowAllFiles] = useState(false);
|
||||
const [cloneCopied, setCloneCopied] = useState(false);
|
||||
const [copiedCommandId, setCopiedCommandId] = useState<string | null>(null);
|
||||
|
||||
const liveDemos = integration.demos.filter((d) => d.route && !d.command);
|
||||
const commandDemos = integration.demos.filter((d) => d.command);
|
||||
|
||||
// Load starter content dynamically when the integration has a starter
|
||||
useEffect(() => {
|
||||
if (!integration.starter) return;
|
||||
let cancelled = false;
|
||||
import("@/data/starter-content.json")
|
||||
.then((mod) => {
|
||||
if (cancelled) return;
|
||||
const content = mod.default as any;
|
||||
const starterData = content.starters[integration.slug];
|
||||
if (starterData) {
|
||||
setStarterFiles(starterData.files || []);
|
||||
setStarterReadme(starterData.readme || null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
console.error("[profile] Failed to load starter content:", err);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [integration.slug, integration.starter]);
|
||||
|
||||
function openDemo(demo: Demo) {
|
||||
setActiveDemo(demo);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
function copyDemoCommand(demoId: string, command: string) {
|
||||
navigator.clipboard
|
||||
.writeText(command)
|
||||
.then(() => {
|
||||
setCopiedCommandId(demoId);
|
||||
setTimeout(() => setCopiedCommandId(null), 2000);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// navigator.clipboard requires HTTPS — fall back to window.prompt so
|
||||
// the user can still copy manually. Log the original failure so
|
||||
// missing-clipboard-and-blocked-prompt double failures are
|
||||
// diagnosable via devtools.
|
||||
console.warn(
|
||||
"[profile] clipboard write failed, falling back to prompt:",
|
||||
err,
|
||||
);
|
||||
window.prompt("Copy this command:", command);
|
||||
});
|
||||
}
|
||||
|
||||
function copyCloneCommand() {
|
||||
if (!integration.starter) return;
|
||||
const cmd =
|
||||
integration.starter.clone_command ||
|
||||
`npx degit CopilotKit/CopilotKit/${integration.starter.path} my-copilotkit-app`;
|
||||
navigator.clipboard
|
||||
.writeText(cmd)
|
||||
.then(() => {
|
||||
setCloneCopied(true);
|
||||
setTimeout(() => setCloneCopied(false), 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback: show the command in a prompt if clipboard fails
|
||||
window.prompt("Copy this command:", cmd);
|
||||
});
|
||||
}
|
||||
|
||||
// Find the "key" agent file — prefer a Python file with "agent" or "main" in the name, else first backend file
|
||||
const keyFile = starterFiles
|
||||
? starterFiles.find(
|
||||
(f) => /agent|main/.test(f.filename) && f.language === "python",
|
||||
) ||
|
||||
starterFiles.find((f) => f.language === "python") ||
|
||||
starterFiles[0]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-5xl px-6 py-12">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
{integration.logo && (
|
||||
<img
|
||||
src={integration.logo}
|
||||
alt={`${integration.name} logo`}
|
||||
className="w-10 h-10 rounded-lg"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-3xl font-light text-[var(--text)]">
|
||||
{integration.name}
|
||||
</h1>
|
||||
<span className="rounded-md bg-[var(--bg-elevated)] px-2 py-0.5 text-[10px] font-mono text-[var(--text-muted)] border border-[var(--border)]">
|
||||
{languageLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-mono uppercase tracking-widest text-[var(--accent)]">
|
||||
{categoryLabel}
|
||||
</p>
|
||||
<p className="mt-4 text-[var(--text-secondary)] max-w-2xl leading-relaxed">
|
||||
{integration.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{integration.partner_docs && (
|
||||
<a
|
||||
href={integration.partner_docs}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-lg border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-secondary)] hover:text-[var(--text)] hover:border-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
Partner Docs
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={integration.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-lg border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-secondary)] hover:text-[var(--text)] hover:border-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
Source Code
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/CopilotKit/CopilotKit/blob/main/showcase/STYLING-GUIDE.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-lg border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-secondary)] hover:text-[var(--text)] hover:border-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
Developer Guide
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full Starter — wider breakout container */}
|
||||
{integration.starter && keyFile && (
|
||||
<div className="mx-auto max-w-[90rem] px-6">
|
||||
<section className="mt-0">
|
||||
<div className="rounded-xl border-2 border-[var(--accent)] bg-[var(--bg-elevated)] p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold text-[var(--text)]">
|
||||
<span className="text-xl">🚀</span>
|
||||
Full Starter: {integration.starter.name}
|
||||
</h2>
|
||||
{integration.starter.description && (
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)] leading-relaxed">
|
||||
{integration.starter.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="mt-4 flex gap-1">
|
||||
{[
|
||||
{ id: "demo" as const, label: "Live Demo" },
|
||||
{ id: "code" as const, label: "Code" },
|
||||
{ id: "docs" as const, label: "Docs" },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setStarterTab(tab.id)}
|
||||
className={`px-3 py-1 rounded-md text-[11px] font-medium transition-colors ${
|
||||
starterTab === tab.id
|
||||
? "bg-[var(--bg-surface)] text-[var(--text)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="mt-4">
|
||||
{/* Live Demo tab */}
|
||||
{starterTab === "demo" && (
|
||||
<div className="rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
{integration.starter.demo_url ? (
|
||||
<iframe
|
||||
src={integration.starter.demo_url}
|
||||
className="w-full rounded-lg border border-[var(--border)]"
|
||||
style={{ height: "min(80vh, 900px)" }}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-20 text-sm text-[var(--text-muted)]">
|
||||
Coming soon — deploy in progress
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code tab */}
|
||||
{starterTab === "code" && (
|
||||
<>
|
||||
<div className="rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<span className="text-[11px] font-mono text-[var(--text-secondary)]">
|
||||
{keyFile.filename}
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={keyFile.language}
|
||||
style={oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
background: "var(--bg-surface)",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
padding: "16px 20px",
|
||||
maxHeight: showAllFiles ? "none" : "600px",
|
||||
}}
|
||||
showLineNumbers
|
||||
lineNumberStyle={{
|
||||
color: "var(--text-faint)",
|
||||
fontSize: "11px",
|
||||
paddingRight: "1em",
|
||||
minWidth: "3em",
|
||||
}}
|
||||
>
|
||||
{showAllFiles
|
||||
? keyFile.content
|
||||
: keyFile.content.split("\n").slice(0, 30).join("\n")}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
|
||||
{/* Show all files toggle */}
|
||||
{starterFiles && starterFiles.length > 1 && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setShowAllFiles(!showAllFiles)}
|
||||
className="text-xs font-medium text-[var(--accent)] hover:underline transition-colors"
|
||||
>
|
||||
{showAllFiles
|
||||
? "Show less ▲"
|
||||
: `Show all ${starterFiles.length} files ▼`}
|
||||
</button>
|
||||
|
||||
{showAllFiles && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{starterFiles
|
||||
.filter((f) => f.filename !== keyFile.filename)
|
||||
.map((file, idx) => (
|
||||
<div
|
||||
key={`${file.filename}-${idx}`}
|
||||
className="rounded-lg border border-[var(--border)] overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-2 border-b border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<span className="text-[11px] font-mono text-[var(--text-secondary)]">
|
||||
{file.filename}
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={file.language}
|
||||
style={oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
background: "var(--bg-surface)",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
padding: "16px 20px",
|
||||
}}
|
||||
showLineNumbers
|
||||
lineNumberStyle={{
|
||||
color: "var(--text-faint)",
|
||||
fontSize: "11px",
|
||||
paddingRight: "1em",
|
||||
minWidth: "3em",
|
||||
}}
|
||||
>
|
||||
{file.content}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Docs tab */}
|
||||
{starterTab === "docs" && (
|
||||
<div
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] p-6 overflow-auto"
|
||||
style={{ maxHeight: "600px" }}
|
||||
>
|
||||
{starterReadme ? (
|
||||
<div className="max-w-none [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:text-[var(--text)] [&_h1]:mb-3 [&_h1]:mt-6 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-[var(--text)] [&_h2]:mt-6 [&_h2]:mb-2 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-[var(--text)] [&_h3]:mt-4 [&_h3]:mb-1 [&_h4]:text-sm [&_h4]:font-semibold [&_h4]:text-[var(--text)] [&_h4]:mt-3 [&_h4]:mb-1 [&_p]:text-sm [&_p]:text-[var(--text-secondary)] [&_p]:leading-relaxed [&_p]:mb-3 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:mb-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:mb-3 [&_li]:text-sm [&_li]:text-[var(--text-secondary)] [&_li]:mb-1 [&_strong]:text-[var(--text)] [&_code]:text-[var(--accent)] [&_code]:bg-[var(--bg-elevated)] [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono [&_pre]:bg-[var(--bg-elevated)] [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:mb-3 [&_pre]:overflow-x-auto [&_pre]:text-xs [&_a]:text-[var(--accent)] [&_a]:underline [&_details]:mb-3 [&_details]:text-sm [&_summary]:cursor-pointer [&_summary]:font-medium [&_summary]:text-[var(--text)] [&_hr]:border-[var(--border)] [&_hr]:my-4">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
>
|
||||
{starterReadme}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
No README available for this starter.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons (below tabs) */}
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{integration.starter.github_url && (
|
||||
<a
|
||||
href={integration.starter.github_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-[var(--accent)] px-4 py-2 text-xs font-medium text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
View on GitHub
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={copyCloneCommand}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[var(--border)] px-4 py-2 text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text)] hover:border-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
{cloneCopied ? "Copied!" : "Clone"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-5xl px-6 pb-12">
|
||||
{/* Get Started — CLI / command-only entries */}
|
||||
{commandDemos.length > 0 && (
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-4 text-xs font-mono uppercase tracking-widest text-[var(--text-muted)]">
|
||||
Get Started
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{commandDemos.map((demo) => (
|
||||
<div
|
||||
key={demo.id}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] p-5"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-[var(--text)]">
|
||||
{demo.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||
{demo.description}
|
||||
</p>
|
||||
<div className="mt-3 flex items-start gap-2">
|
||||
<code className="flex-1 min-w-0 whitespace-pre-wrap break-all rounded-md border border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-2 text-xs font-mono text-[var(--text)]">
|
||||
{demo.command}
|
||||
</code>
|
||||
<button
|
||||
onClick={() =>
|
||||
copyDemoCommand(demo.id, demo.command ?? "")
|
||||
}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text)] hover:border-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<rect
|
||||
x="9"
|
||||
y="9"
|
||||
width="13"
|
||||
height="13"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
{copiedCommandId === demo.id ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Live Demos */}
|
||||
{liveDemos.length > 0 && (
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-4 text-xs font-mono uppercase tracking-widest text-[var(--text-muted)]">
|
||||
Live Demos
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{liveDemos.map((demo) => (
|
||||
<button
|
||||
key={demo.id}
|
||||
onClick={() => openDemo(demo)}
|
||||
className="group text-left rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] p-5 hover:border-[var(--accent)] hover:-translate-y-0.5 transition-all"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-[var(--text)]">
|
||||
{demo.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||
{demo.description}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{demo.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded bg-[var(--bg-elevated)] px-2 py-0.5 text-[10px] font-mono text-[var(--text-muted)]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Full page viewer link */}
|
||||
{activeDemo && (
|
||||
<p className="mt-4 text-xs text-[var(--text-muted)]">
|
||||
<Link
|
||||
href={`/integrations/${integration.slug}/${activeDemo.id}`}
|
||||
className="text-[var(--accent)] hover:underline"
|
||||
>
|
||||
Open in full page →
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drawer */}
|
||||
{/* Backend URLs are derived at runtime from the injected config
|
||||
pattern (never the build-baked registry backend_url). Safe:
|
||||
activeDemo is only set by a click, i.e. post-hydration, so
|
||||
window.__SHOWCASE_CONFIG__ is populated by the time these
|
||||
evaluate. */}
|
||||
{activeDemo && (
|
||||
<DemoDrawer
|
||||
isOpen={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
integrationSlug={integration.slug}
|
||||
integrationName={integration.name}
|
||||
demoId={activeDemo.id}
|
||||
demoName={activeDemo.name}
|
||||
backendUrl={resolveBackendUrl(
|
||||
integration.slug,
|
||||
getRuntimeConfig().backendHostPattern,
|
||||
)}
|
||||
demoRoute={activeDemo.route ?? ""}
|
||||
wide={
|
||||
activeDemo.id.includes("gen-ui") ||
|
||||
activeDemo.id.includes("shared-state") ||
|
||||
activeDemo.id.includes("subagent")
|
||||
}
|
||||
alternatives={demoAlternatives[activeDemo.id]?.map((alt) => ({
|
||||
...alt,
|
||||
backendUrl: resolveBackendUrl(
|
||||
alt.slug,
|
||||
getRuntimeConfig().backendHostPattern,
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
getIntegrations,
|
||||
getFeatures,
|
||||
getFeatureCategories,
|
||||
} from "@/lib/registry";
|
||||
import { IntegrationsTabs } from "@/components/integrations-tabs";
|
||||
import { FeatureCatalog } from "@/components/feature-catalog";
|
||||
|
||||
export default function ByFeaturePage() {
|
||||
const integrations = getIntegrations();
|
||||
const features = getFeatures();
|
||||
const categories = getFeatureCategories();
|
||||
|
||||
return (
|
||||
<div className="px-6 py-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<IntegrationsTabs />
|
||||
</div>
|
||||
<FeatureCatalog
|
||||
features={features}
|
||||
categories={categories}
|
||||
integrations={integrations}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getIntegrations } from "@/lib/registry";
|
||||
import { IntegrationExplorer } from "@/components/integration-explorer";
|
||||
import { IntegrationsTabs } from "@/components/integrations-tabs";
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
searchParams: Promise<{ feature?: string }>;
|
||||
}
|
||||
|
||||
export default async function IntegrationsPage({
|
||||
searchParams,
|
||||
}: IntegrationsPageProps) {
|
||||
const integrations = getIntegrations();
|
||||
const params = await searchParams;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<IntegrationsTabs />
|
||||
</div>
|
||||
<IntegrationExplorer
|
||||
integrations={integrations}
|
||||
initialFeatureFilter={params.feature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Plus_Jakarta_Sans, Spline_Sans_Mono } from "next/font/google";
|
||||
import { BrandNav } from "@/components/brand-nav";
|
||||
import { FrameworkProvider } from "@/components/framework-provider";
|
||||
import { getIntegrations } from "@/lib/registry";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config";
|
||||
import "./globals.css";
|
||||
|
||||
/**
|
||||
* Serialize the runtime config for inline injection. We must
|
||||
* JSON.stringify-then-escape because the value lands inside a
|
||||
* `<script>...</script>` tag, where three substrings would otherwise
|
||||
* break out of (or corrupt) the parser:
|
||||
*
|
||||
* - `<` — guards against the `</script>` breakout (XSS).
|
||||
* `JSON.stringify` does NOT escape `<` by default, so a URL
|
||||
* containing `</script>` (e.g. a hostile env value) would
|
||||
* terminate the inline script and inject HTML. Escape every
|
||||
* `<` to `<` so the substring `</script>` can never appear.
|
||||
* - `
|
||||
` (LINE SEPARATOR) and `
|
||||
` (PARAGRAPH SEPARATOR) —
|
||||
* legal inside JSON strings, but a syntax error inside a JS
|
||||
* string literal in older engines / when the page is parsed as
|
||||
* `text/javascript`. Escape both.
|
||||
*
|
||||
* IMPORTANT: the regex sources below use explicit `
|
||||
` / `
|
||||
`
|
||||
* ECMAScript-Unicode escapes — the regex engine resolves the escape at
|
||||
* compile time, so `/
|
||||
/` matches the actual U+2028 codepoint.
|
||||
* Using a literal U+2028 / U+2029 character in the regex source would
|
||||
* break the parser (those codepoints terminate a regex literal in
|
||||
* pre-ES2019 engines, and are visually invisible — easy to ship
|
||||
* accidentally). Reviewers MUST confirm these regexes are written with
|
||||
* `
|
||||
` / `
|
||||
` escapes literally.
|
||||
*
|
||||
* Canonical OWASP-recommended escape for inline JSON in HTML.
|
||||
*/
|
||||
function serializeRuntimeConfig(cfg: unknown): string {
|
||||
return JSON.stringify(cfg)
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
}
|
||||
|
||||
const plusJakartaSans = Plus_Jakarta_Sans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-prose",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const splineSansMono = Spline_Sans_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CopilotKit Showcase",
|
||||
description:
|
||||
"Live integration gallery for CopilotKit — 17 AI frameworks, real-time health probes, and interactive demos",
|
||||
icons: { icon: "/icon.svg" },
|
||||
openGraph: {
|
||||
title: "CopilotKit Showcase",
|
||||
description:
|
||||
"Live integration gallery for CopilotKit — 17 AI frameworks, real-time health probes, and interactive demos",
|
||||
images: [{ url: "/og-image.png", width: 1200, height: 630 }],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// FrameworkProvider needs the set of known framework slugs so it can
|
||||
// detect URL-scoped framework views. The framework *selector* now
|
||||
// lives inside the docs sidebar, not in the top bar, so its own
|
||||
// options are wired up in the docs page-level server components.
|
||||
const knownFrameworks = getIntegrations().map((i) => i.slug);
|
||||
|
||||
// Server-side: read live env at request time. `unstable_noStore()`
|
||||
// inside getRuntimeConfig opts this segment out of the static cache
|
||||
// so the inline <script> below always reflects the current Railway
|
||||
// env vars rather than baked-in build-time values.
|
||||
const runtimeConfig = getRuntimeConfig();
|
||||
const injection = `window.__SHOWCASE_CONFIG__=${serializeRuntimeConfig(runtimeConfig)};`;
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${plusJakartaSans.variable} ${splineSansMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
{/* Must be the first (and currently only) child of <head>:
|
||||
* every client component reads window.__SHOWCASE_CONFIG__
|
||||
* during hydration; it must be populated by the time the
|
||||
* parser reaches <body>. */}
|
||||
<script
|
||||
id="__showcase_config__"
|
||||
dangerouslySetInnerHTML={{ __html: injection }}
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen">
|
||||
<FrameworkProvider knownFrameworks={knownFrameworks}>
|
||||
<BrandNav />
|
||||
<main>{children}</main>
|
||||
</FrameworkProvider>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "8px",
|
||||
right: "12px",
|
||||
fontSize: "10px",
|
||||
fontFamily: "monospace",
|
||||
color: "rgba(0,0,0,0.15)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 9999,
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{(process.env.NEXT_PUBLIC_COMMIT_SHA || "dev").slice(0, 9)}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
getIntegrations,
|
||||
getFeatures,
|
||||
getFeatureCategories,
|
||||
getCategoryLabel,
|
||||
} from "@/lib/registry";
|
||||
import { IntegrationsTabs } from "@/components/integrations-tabs";
|
||||
|
||||
export default function FeatureMatrixPage() {
|
||||
const integrations = getIntegrations();
|
||||
const features = getFeatures();
|
||||
const featureCategories = getFeatureCategories();
|
||||
|
||||
if (integrations.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
<IntegrationsTabs />
|
||||
<h1 className="text-3xl font-light text-[var(--text)] text-center">
|
||||
Feature Matrix
|
||||
</h1>
|
||||
<p className="mt-6 text-[var(--text-muted)]">
|
||||
No integrations registered yet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeFeatures = features.filter((feature) =>
|
||||
integrations.some((i) => i.features.includes(feature.id)),
|
||||
);
|
||||
|
||||
const featuresByCategory = featureCategories
|
||||
.map((cat) => ({
|
||||
...cat,
|
||||
features: activeFeatures.filter((f) => f.category === cat.id),
|
||||
}))
|
||||
.filter((cat) => cat.features.length > 0);
|
||||
|
||||
return (
|
||||
<div className="px-6 py-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<IntegrationsTabs />
|
||||
<h1 className="text-3xl font-light text-[var(--text)]">
|
||||
Feature Matrix
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
{integrations.length} integration
|
||||
{integrations.length !== 1 ? "s" : ""} across {activeFeatures.length}{" "}
|
||||
features
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 overflow-x-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
{/* Category header row */}
|
||||
<tr className="border-b border-[var(--border)]">
|
||||
<th className="sticky left-0 z-20 bg-[var(--bg-elevated)] min-w-[200px]" />
|
||||
{featuresByCategory.map((cat) => (
|
||||
<th
|
||||
key={cat.id}
|
||||
colSpan={cat.features.length}
|
||||
className="border-l border-[var(--border)] bg-[var(--bg-elevated)] px-4 py-3 text-center"
|
||||
>
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--accent)]">
|
||||
{cat.name}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
{/* Feature name row */}
|
||||
<tr className="border-b-2 border-[var(--border)]">
|
||||
<th className="sticky left-0 z-20 bg-[var(--bg-elevated)] p-4 text-left min-w-[200px]">
|
||||
<span className="text-xs font-mono uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Integration
|
||||
</span>
|
||||
</th>
|
||||
{featuresByCategory.map((cat) =>
|
||||
cat.features.map((feature, idx) => (
|
||||
<th
|
||||
key={feature.id}
|
||||
className={`bg-[var(--bg-elevated)] px-3 py-3 text-center min-w-[120px] ${
|
||||
idx === 0 ? "border-l border-[var(--border)]" : ""
|
||||
}`}
|
||||
title={feature.description}
|
||||
>
|
||||
<div className="text-[11px] font-medium text-[var(--text-secondary)] leading-tight">
|
||||
{feature.name}
|
||||
</div>
|
||||
</th>
|
||||
)),
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{integrations.map((integration, rowIdx) => (
|
||||
<tr
|
||||
key={integration.slug}
|
||||
className={`border-t border-[var(--border)] hover:bg-[var(--bg-elevated)]/50 transition-colors ${
|
||||
rowIdx % 2 === 1 ? "bg-[var(--bg)]/30" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-[var(--bg-surface)] p-4 border-r border-[var(--border)]">
|
||||
<Link
|
||||
href={`/integrations/${integration.slug}`}
|
||||
className="font-medium text-sm text-[var(--text)] hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
{integration.name}
|
||||
</Link>
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono mt-0.5">
|
||||
{getCategoryLabel(integration.category)}
|
||||
</div>
|
||||
</td>
|
||||
{featuresByCategory.map((cat) =>
|
||||
cat.features.map((feature, idx) => {
|
||||
const supported = integration.features.includes(feature.id);
|
||||
const hasDemo = integration.demos.some(
|
||||
(d) => d.id === feature.id,
|
||||
);
|
||||
return (
|
||||
<td
|
||||
key={feature.id}
|
||||
className={`px-3 py-3 text-center ${
|
||||
idx === 0 ? "border-l border-[var(--border)]" : ""
|
||||
}`}
|
||||
>
|
||||
{supported && hasDemo ? (
|
||||
<Link
|
||||
href={`/integrations/${integration.slug}/${feature.id}`}
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-[var(--accent-dim)] text-[var(--accent)] hover:bg-[var(--accent-light)] transition-colors text-sm"
|
||||
title={`Try ${feature.name} demo`}
|
||||
>
|
||||
✓
|
||||
</Link>
|
||||
) : supported ? (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-[var(--bg-elevated)] text-[var(--text-muted)] text-sm"
|
||||
title="Supported, no demo yet"
|
||||
>
|
||||
○
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 text-[var(--border)] text-sm">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mx-auto max-w-7xl mt-4 flex gap-6 text-xs text-[var(--text-muted)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-[var(--accent-dim)] text-[var(--accent)] text-[10px]">
|
||||
✓
|
||||
</span>
|
||||
Supported with live demo
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-[var(--bg-elevated)] text-[var(--text-muted)] text-[10px]">
|
||||
○
|
||||
</span>
|
||||
Supported, no demo
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 text-[var(--border)] text-[10px]">
|
||||
—
|
||||
</span>
|
||||
Not supported
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
getIntegrations,
|
||||
getFeatures,
|
||||
getFeatureCategories,
|
||||
} from "@/lib/registry";
|
||||
import { GuidedFlow } from "@/components/guided-flow";
|
||||
|
||||
// Derive framework list from registry sort_order — no hardcoded lists
|
||||
function getFrameworkNames(integrations: ReturnType<typeof getIntegrations>) {
|
||||
const seen = new Set<string>();
|
||||
return integrations
|
||||
.filter((i) => i.deployed)
|
||||
.map((i) => {
|
||||
// Group LangGraph variants under "LangGraph"
|
||||
const name = i.name.startsWith("LangGraph") ? "LangGraph" : i.name;
|
||||
if (seen.has(name)) return null;
|
||||
seen.add(name);
|
||||
return name;
|
||||
})
|
||||
.filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const integrations = getIntegrations();
|
||||
const features = getFeatures();
|
||||
const categories = getFeatureCategories();
|
||||
|
||||
const deployedIntegrations = integrations.filter((i) => i.deployed);
|
||||
const totalDemos = integrations.reduce((sum, i) => sum + i.demos.length, 0);
|
||||
|
||||
const frameworkNames = getFrameworkNames(integrations);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center min-h-[calc(100vh-52px)] bg-[var(--bg)] px-6 py-16">
|
||||
{/* Hero */}
|
||||
<div className="max-w-2xl text-center mb-12">
|
||||
<h1 className="text-3xl font-semibold text-[var(--text)] tracking-tight mb-3">
|
||||
Build AI-powered apps with any agent framework
|
||||
</h1>
|
||||
<p className="text-base text-[var(--text-secondary)] leading-relaxed">
|
||||
Explore live integrations, compare features across frameworks, and
|
||||
find the right starting point for your project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="w-full max-w-lg mb-14">
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm cursor-pointer hover:border-[var(--text-muted)] transition-colors">
|
||||
<svg
|
||||
className="w-4 h-4 text-[var(--text-muted)] shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-[var(--text-muted)] flex-1">
|
||||
Search integrations, features, demos...
|
||||
</span>
|
||||
<kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-[var(--border)] bg-[var(--bg-elevated)] text-[10px] font-mono text-[var(--text-faint)]">
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path cards */}
|
||||
<div className="w-full max-w-3xl grid grid-cols-1 sm:grid-cols-3 gap-4 mb-14">
|
||||
<PathCard
|
||||
href="/docs/quickstart"
|
||||
title="Get Started"
|
||||
description="Follow the quickstart guide and start building in minutes."
|
||||
icon={
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PathCard
|
||||
href="/integrations"
|
||||
title="Explore Integrations"
|
||||
description="Browse all integrations by framework, language, and feature."
|
||||
icon={
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 10h18M3 14h18M10 3v18M14 3v18"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PathCard
|
||||
href="/docs"
|
||||
title="Read the Docs"
|
||||
description="Full guides, integration docs, and troubleshooting."
|
||||
icon={
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Guided flow CTA */}
|
||||
<div className="mb-14">
|
||||
<GuidedFlow integrations={integrations} />
|
||||
</div>
|
||||
|
||||
{/* Framework pills */}
|
||||
<div className="w-full max-w-2xl mb-14">
|
||||
<h2 className="text-[11px] font-mono uppercase tracking-[1.5px] text-[var(--text-faint)] mb-4 text-center">
|
||||
Agent Frameworks
|
||||
</h2>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{frameworkNames.map((fw) => {
|
||||
const isLive = true; // already filtered to deployed
|
||||
const match = isLive
|
||||
? integrations.find(
|
||||
(i) => i.deployed && (i.name === fw || i.name.startsWith(fw)),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (isLive && match) {
|
||||
return (
|
||||
<Link
|
||||
key={fw}
|
||||
href={`/integrations/${match.slug}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-[var(--bg-surface)] border border-[var(--border)] text-[var(--text)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)]" />
|
||||
{fw}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={fw}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-[var(--bg-elevated)] border border-transparent text-[var(--text-faint)] cursor-default"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--text-faint)] opacity-40" />
|
||||
{fw}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="flex flex-wrap justify-center items-center gap-3 text-xs text-[var(--text-muted)]">
|
||||
<span className="whitespace-nowrap">
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{deployedIntegrations.length}
|
||||
</span>{" "}
|
||||
live integrations
|
||||
</span>
|
||||
<span className="text-[var(--border)]">·</span>
|
||||
<span className="whitespace-nowrap">
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{totalDemos}
|
||||
</span>{" "}
|
||||
demos
|
||||
</span>
|
||||
<span className="text-[var(--border)]">·</span>
|
||||
<span className="whitespace-nowrap">
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{features.length}
|
||||
</span>{" "}
|
||||
features
|
||||
</span>
|
||||
<span className="text-[var(--border)]">·</span>
|
||||
<span className="whitespace-nowrap">
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{categories.length}
|
||||
</span>{" "}
|
||||
categories
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PathCard({
|
||||
href,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex flex-col gap-3 p-5 rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] hover:border-[var(--accent)] hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-[var(--accent-light)] flex items-center justify-center">
|
||||
<svg
|
||||
className="w-4 h-4 text-[var(--accent)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{icon}
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--text)] group-hover:text-[var(--accent)] transition-colors mb-1">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
/* trigger */
|
||||
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { SearchTrigger } from "./search-trigger";
|
||||
|
||||
function CopilotKitIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="111 0 25 26"
|
||||
width="18"
|
||||
height="20"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="cpk_g0"
|
||||
x1="129.301"
|
||||
y1="2.339"
|
||||
x2="125.623"
|
||||
y2="12.452"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#6430AB" />
|
||||
<stop offset="1" stopColor="#AA89D8" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="cpk_g1"
|
||||
x1="126.451"
|
||||
y1="8.039"
|
||||
x2="121.717"
|
||||
y2="17.187"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#005DBB" />
|
||||
<stop offset="1" stopColor="#3D92E8" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="cpk_g2"
|
||||
x1="128.565"
|
||||
y1="2.339"
|
||||
x2="127.139"
|
||||
y2="6.798"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#1B70C4" />
|
||||
<stop offset="1" stopColor="#54A4F2" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="cpk_g3"
|
||||
x1="117.94"
|
||||
y1="22.784"
|
||||
x2="132.981"
|
||||
y2="22.784"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#4497EA" />
|
||||
<stop offset="0.2548" stopColor="#1463B2" />
|
||||
<stop offset="0.4987" stopColor="#0A437D" />
|
||||
<stop offset="0.6667" stopColor="#2476C8" />
|
||||
<stop offset="0.9725" stopColor="#0C549A" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M119.539 8.67724C121.647 5.91912 123.397 3.19174 124.071 0.989235C124.089 0.929306 124.159 0.903848 124.211 0.938445C126.553 2.4891 130.818 3.50978 134.591 3.53373C134.655 3.53415 134.7 3.59815 134.677 3.65868C133.422 6.84085 131.89 12.5427 131.831 19.054C131.831 19.1507 131.695 19.1854 131.647 19.1014C129.5 15.3443 122.623 10.0649 119.574 8.81884C119.517 8.79565 119.501 8.72596 119.539 8.67724Z"
|
||||
fill="url(#cpk_g0)"
|
||||
/>
|
||||
<path
|
||||
d="M126.653 6.99011C123.357 8.03363 120.345 8.61377 119.626 8.74558C119.581 8.75399 119.571 8.81729 119.615 8.83516C122.687 10.1126 129.53 15.3766 131.657 19.1184C131.661 19.1266 131.672 19.1296 131.68 19.1259C131.689 19.1218 131.693 19.1112 131.69 19.1021L126.653 6.99011Z"
|
||||
fill="url(#cpk_g1)"
|
||||
/>
|
||||
<path
|
||||
d="M124.221 0.931583C127.042 2.47061 130.303 3.16182 134.629 3.52604C134.656 3.52836 134.665 3.56478 134.641 3.57743C134.087 3.86176 130.918 5.47449 128.565 6.33825C127.934 6.56966 127.3 6.78434 126.675 6.98241C126.662 6.98674 126.647 6.97992 126.641 6.96671L124.156 0.989873C124.139 0.949626 124.183 0.91071 124.221 0.931583Z"
|
||||
fill="url(#cpk_g2)"
|
||||
/>
|
||||
<path
|
||||
d="M125.209 3.30419L122.405 12.6362M122.405 12.6362H129.07M122.405 12.6362L111.874 25.0387"
|
||||
stroke="#ABABAB"
|
||||
strokeWidth="0.321797"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M119.181 22.4856L117.94 22.6601C118.584 24.3624 119.904 25.1059 121.479 25.1059C125.341 25.1059 124.163 20.7388 126.4 20.7388C128.023 20.7388 127.364 24.2784 130.857 24.2784C132.989 24.2784 133.201 22.1307 132.837 21.2067C132.835 21.201 132.833 21.1959 132.83 21.1908L132.259 20.316C132.222 20.2578 132.131 20.2797 132.125 20.3489L132.018 21.4092C132.011 21.483 132.013 21.5565 132.021 21.6301C132.109 22.3627 132.165 24.1405 130.857 24.1405C129.477 24.1405 129.145 20.6468 126.4 20.6468C123.181 20.6468 123.594 24.968 121.618 24.968C120.313 24.968 119.319 23.497 119.181 22.4856Z"
|
||||
fill="url(#cpk_g3)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AgUiIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 57 66"
|
||||
width="14"
|
||||
height="16"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g
|
||||
transform="translate(2, -2)"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3.3125"
|
||||
fill="none"
|
||||
>
|
||||
<g transform="translate(0, 4)">
|
||||
<path
|
||||
d="M0,25.9335975 L16.5448881,6.52325783e-15 C40.848296,5.37332138 53,8.05998207 53,8.05998207 L43.1229639,62 L0,25.9335975 Z"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line x1="16.5828221" y1="-1.07552856e-15" x2="43.2453988" y2="62" />
|
||||
<line x1="0" y1="25.9335975" x2="53" y2="8.48421053" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type Brand = "copilotkit" | "ag-ui";
|
||||
|
||||
const AG_UI_PREFIXES = ["/ag-ui"];
|
||||
|
||||
const COPILOTKIT_LINKS = [
|
||||
{ href: "/docs", label: "Docs" },
|
||||
{ href: "/integrations", label: "Integrations" },
|
||||
{ href: "/reference", label: "Reference" },
|
||||
];
|
||||
|
||||
const AG_UI_LINKS = [
|
||||
{ href: "/ag-ui", label: "Overview" },
|
||||
{ href: "/ag-ui/concepts/architecture", label: "Concepts" },
|
||||
{ href: "/ag-ui/quickstart/introduction", label: "Quick Start" },
|
||||
{ href: "/ag-ui/sdk/js/core/overview", label: "JS SDK" },
|
||||
{ href: "/ag-ui/sdk/python/core/overview", label: "Python SDK" },
|
||||
];
|
||||
|
||||
function activeBrandFromPath(pathname: string): Brand {
|
||||
return AG_UI_PREFIXES.some((p) => pathname.startsWith(p))
|
||||
? "ag-ui"
|
||||
: "copilotkit";
|
||||
}
|
||||
|
||||
export interface BrandNavProps {
|
||||
// Note: the framework selector previously lived in the top bar. It's
|
||||
// now rendered at the top of the docs sidebar instead — mirroring the
|
||||
// docs.copilotkit.ai reference. Props preserved for API compatibility
|
||||
// with the current call site but intentionally unused here.
|
||||
frameworkOptions?: unknown;
|
||||
frameworkCategoryOrder?: unknown;
|
||||
}
|
||||
|
||||
export function BrandNav(_props: BrandNavProps = {}) {
|
||||
const pathname = usePathname();
|
||||
const active = activeBrandFromPath(pathname);
|
||||
const links = active === "copilotkit" ? COPILOTKIT_LINKS : AG_UI_LINKS;
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 border-b border-[var(--border)] bg-[var(--bg-surface)]/90 backdrop-blur-lg">
|
||||
<div className="mx-auto flex h-[52px] items-center justify-between px-6">
|
||||
{/* Brand tabs */}
|
||||
<div className="flex items-center gap-0">
|
||||
<Link
|
||||
href="/"
|
||||
className="relative flex items-center gap-1.5 px-1 pb-1 text-sm font-bold tracking-tight transition-colors"
|
||||
style={{
|
||||
color:
|
||||
active === "copilotkit" ? "var(--accent)" : "var(--text-faint)",
|
||||
}}
|
||||
>
|
||||
<CopilotKitIcon />
|
||||
CopilotKit
|
||||
{active === "copilotkit" && (
|
||||
<span
|
||||
className="absolute bottom-0 left-0 right-0 h-[2px] rounded-full"
|
||||
style={{ background: "var(--accent)" }}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--border)] select-none">|</span>
|
||||
<Link
|
||||
href="/ag-ui"
|
||||
className="relative flex items-center gap-1.5 px-1 pb-1 text-sm font-bold tracking-tight transition-colors"
|
||||
style={{
|
||||
color: active === "ag-ui" ? "var(--accent)" : "var(--text-faint)",
|
||||
}}
|
||||
>
|
||||
<AgUiIcon />
|
||||
AG-UI
|
||||
{active === "ag-ui" && (
|
||||
<span
|
||||
className="absolute bottom-0 left-0 right-0 h-[2px] rounded-full"
|
||||
style={{ background: "var(--accent)" }}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Context-dependent nav links (desktop) */}
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
{links.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="rounded-md px-3 py-1.5 text-[13px] font-medium text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-all"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop search */}
|
||||
<div className="hidden sm:block">
|
||||
<SearchTrigger />
|
||||
</div>
|
||||
|
||||
{/* Mobile: search icon + hamburger */}
|
||||
<div className="flex sm:hidden items-center gap-1">
|
||||
<SearchTrigger iconOnly />
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-md text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-colors cursor-pointer"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile slide-out menu */}
|
||||
{mobileMenuOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
{/* Panel */}
|
||||
<div
|
||||
className="fixed top-0 right-0 bottom-0 z-50 w-[280px] bg-[var(--bg-surface)] border-l border-[var(--border)] flex flex-col"
|
||||
style={{
|
||||
boxShadow: "-8px 0 30px rgba(0,0,0,0.1)",
|
||||
animation: "mobileMenuSlideIn 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{/* Close button */}
|
||||
<div className="flex items-center justify-end px-4 py-3 border-b border-[var(--border)]">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-md text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-colors cursor-pointer"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* Nav links */}
|
||||
<div className="flex flex-col px-4 py-4 gap-1">
|
||||
{links.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="rounded-md px-3 py-2.5 text-[14px] font-medium text-[var(--text-secondary)] hover:text-[var(--text)] hover:bg-[var(--bg-elevated)] transition-all"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{/* AG-UI link at bottom */}
|
||||
<div className="mt-auto px-4 py-4 border-t border-[var(--border)]">
|
||||
<Link
|
||||
href="/ag-ui"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2.5 text-[13px] font-medium text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-all"
|
||||
>
|
||||
<AgUiIcon />
|
||||
AG-UI
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<style jsx global>{`
|
||||
@keyframes mobileMenuSlideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// <CopyButton> — minimal clipboard button used by <Snippet>.
|
||||
// Lives on the client so it can access navigator.clipboard.
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
export function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// noop — clipboard blocked by browser
|
||||
}
|
||||
}}
|
||||
aria-label={copied ? "Copied" : "Copy code"}
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
fontSize: "10px",
|
||||
lineHeight: 1.2,
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "4px",
|
||||
background: copied ? "var(--accent-light)" : "var(--bg-surface)",
|
||||
color: copied ? "var(--accent)" : "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontFamily: "inherit",
|
||||
transition: "background 120ms, color 120ms",
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
|
||||
interface Alternative {
|
||||
slug: string;
|
||||
name: string;
|
||||
backendUrl: string;
|
||||
}
|
||||
|
||||
interface DemoDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
integrationSlug: string;
|
||||
integrationName: string;
|
||||
demoId: string;
|
||||
demoName: string;
|
||||
backendUrl: string;
|
||||
demoRoute: string;
|
||||
wide?: boolean;
|
||||
alternatives?: Alternative[];
|
||||
}
|
||||
|
||||
export function DemoDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
integrationSlug,
|
||||
integrationName,
|
||||
demoId,
|
||||
demoName,
|
||||
backendUrl,
|
||||
demoRoute,
|
||||
wide = false,
|
||||
alternatives,
|
||||
}: DemoDrawerProps) {
|
||||
const [activeBackendUrl, setActiveBackendUrl] = useState(backendUrl);
|
||||
const [activeIntegrationName, setActiveIntegrationName] =
|
||||
useState(integrationName);
|
||||
const [activeIntegrationSlug, setActiveIntegrationSlug] =
|
||||
useState(integrationSlug);
|
||||
|
||||
// Reset when a new demo is opened
|
||||
useEffect(() => {
|
||||
setActiveBackendUrl(backendUrl);
|
||||
setActiveIntegrationName(integrationName);
|
||||
setActiveIntegrationSlug(integrationSlug);
|
||||
setCodeSubTab("frontend");
|
||||
}, [demoId, backendUrl, integrationName, integrationSlug]);
|
||||
const [activeTab, setActiveTab] = useState<"preview" | "code" | "docs">(
|
||||
"preview",
|
||||
);
|
||||
const [codeSubTab, setCodeSubTab] = useState<"frontend" | "backend">(
|
||||
"frontend",
|
||||
);
|
||||
const [demoContent, setDemoContent] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (isOpen && demoId) {
|
||||
import("@/data/demo-content.json")
|
||||
.then((mod) => {
|
||||
if (cancelled) return;
|
||||
const content = mod.default as any;
|
||||
const key = `${activeIntegrationSlug}::${demoId}`;
|
||||
if (content.demos[key]) {
|
||||
setDemoContent(content.demos[key]);
|
||||
} else {
|
||||
// Fallback to original integration
|
||||
const fallbackKey = `${integrationSlug}::${demoId}`;
|
||||
setDemoContent(content.demos[fallbackKey] || null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
console.error("[demo-drawer] Failed to load demo content:", err);
|
||||
setDemoContent(null);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, activeIntegrationSlug, integrationSlug, demoId]);
|
||||
|
||||
// Close on ESC
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const iframeSrc = `${activeBackendUrl}${demoRoute}`;
|
||||
|
||||
const tabs: { id: "preview" | "code" | "docs"; label: string }[] = [
|
||||
{ id: "preview", label: "Preview" },
|
||||
{ id: "code", label: "Code" },
|
||||
{ id: "docs", label: "Docs" },
|
||||
];
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="fixed inset-0 z-[60] bg-black/15 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
style={{ top: "52px" }}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className="fixed top-[52px] right-0 bottom-0 z-[70] flex flex-col bg-[var(--bg-surface)] border-l border-[var(--border)]"
|
||||
style={{
|
||||
width: wide ? "80%" : "55%",
|
||||
boxShadow: "-8px 0 30px rgba(0,0,0,0.06)",
|
||||
animation: "slideIn 0.25s ease",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[13px] font-semibold text-[var(--text)]">
|
||||
{demoName}
|
||||
</span>
|
||||
{/* Framework switcher */}
|
||||
{alternatives && alternatives.length > 0 ? (
|
||||
<select
|
||||
value={activeIntegrationSlug}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === integrationSlug) {
|
||||
setActiveBackendUrl(backendUrl);
|
||||
setActiveIntegrationName(integrationName);
|
||||
setActiveIntegrationSlug(integrationSlug);
|
||||
} else {
|
||||
const alt = alternatives.find((a) => a.slug === val);
|
||||
if (alt) {
|
||||
setActiveBackendUrl(alt.backendUrl);
|
||||
setActiveIntegrationName(alt.name);
|
||||
setActiveIntegrationSlug(alt.slug);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="text-[10px] font-mono rounded-md border border-[var(--border)] bg-[var(--bg-elevated)] px-2 py-1 text-[var(--text-secondary)] cursor-pointer"
|
||||
>
|
||||
<option value={integrationSlug}>{integrationName}</option>
|
||||
{alternatives.map((alt) => (
|
||||
<option key={alt.slug} value={alt.slug}>
|
||||
{alt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-[10px] font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] px-2 py-0.5 rounded">
|
||||
{activeIntegrationName}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-[9px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--accent-light)",
|
||||
color: "var(--accent)",
|
||||
}}
|
||||
>
|
||||
● Live
|
||||
</span>
|
||||
<div className="flex gap-1 ml-3">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-3 py-1 rounded-md text-[11px] font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-colors text-sm"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab === "preview" && (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
className="h-full w-full border-0"
|
||||
title={`${demoName} demo`}
|
||||
allow="clipboard-read; clipboard-write; microphone"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "code" && (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* Sub-tab bar: only show when backend files exist */}
|
||||
{demoContent?.backend_files?.length > 0 && (
|
||||
<div className="flex gap-1 px-4 py-2 border-b border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
{(["frontend", "backend"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setCodeSubTab(tab)}
|
||||
className={`px-3 py-1 rounded-md text-[11px] font-medium transition-colors ${
|
||||
codeSubTab === tab
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{tab === "frontend" ? "Frontend" : "Backend"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{(() => {
|
||||
const files =
|
||||
codeSubTab === "backend"
|
||||
? demoContent?.backend_files
|
||||
: demoContent?.files;
|
||||
if (!files || files.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-[var(--text-muted)] text-sm">
|
||||
No source files available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return files.map(
|
||||
(
|
||||
file: {
|
||||
filename: string;
|
||||
language: string;
|
||||
content: string;
|
||||
},
|
||||
idx: number,
|
||||
) => (
|
||||
<div key={`${file.filename}-${idx}`}>
|
||||
<div className="sticky top-0 z-10 px-4 py-1.5 border-b border-[var(--border)] bg-[var(--bg-surface)]">
|
||||
<span className="text-[11px] font-mono text-[var(--text-secondary)]">
|
||||
{file.filename}
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={file.language}
|
||||
style={oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
background: "var(--bg-elevated)",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
padding: "20px",
|
||||
}}
|
||||
showLineNumbers
|
||||
lineNumberStyle={{
|
||||
color: "var(--text-faint)",
|
||||
fontSize: "11px",
|
||||
paddingRight: "1em",
|
||||
minWidth: "3em",
|
||||
}}
|
||||
>
|
||||
{file.content}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "docs" && (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
{demoContent?.readme ? (
|
||||
<div className="max-w-2xl mx-auto [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:text-[var(--text)] [&_h1]:mb-3 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:text-[var(--text)] [&_h2]:mt-6 [&_h2]:mb-2 [&_h3]:text-sm [&_h3]:font-semibold [&_h3]:text-[var(--text)] [&_h3]:mt-4 [&_h3]:mb-1 [&_p]:text-sm [&_p]:text-[var(--text-secondary)] [&_p]:leading-relaxed [&_p]:mb-3 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:mb-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:mb-3 [&_li]:text-sm [&_li]:text-[var(--text-secondary)] [&_li]:mb-1 [&_strong]:text-[var(--text)] [&_code]:text-[var(--accent)] [&_code]:bg-[var(--bg-elevated)] [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono [&_pre]:bg-[var(--bg-elevated)] [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:mb-3 [&_pre]:overflow-x-auto [&_pre]:text-xs">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
>
|
||||
{demoContent.readme}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[var(--text-muted)] text-sm">
|
||||
No documentation available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
type Feature,
|
||||
type FeatureCategory,
|
||||
type Integration,
|
||||
} from "@/lib/registry";
|
||||
|
||||
interface FeatureCatalogProps {
|
||||
features: Feature[];
|
||||
categories: FeatureCategory[];
|
||||
integrations: Integration[];
|
||||
}
|
||||
|
||||
interface FeatureWithIntegrations {
|
||||
feature: Feature;
|
||||
integrations: Integration[];
|
||||
}
|
||||
|
||||
export function FeatureCatalog({
|
||||
features,
|
||||
categories,
|
||||
integrations,
|
||||
}: FeatureCatalogProps) {
|
||||
const deployed = useMemo(
|
||||
() => integrations.filter((i) => i.deployed),
|
||||
[integrations],
|
||||
);
|
||||
|
||||
// Group features by category, attaching which deployed integrations support each
|
||||
const groupedFeatures = useMemo(() => {
|
||||
const groups: {
|
||||
category: FeatureCategory;
|
||||
items: FeatureWithIntegrations[];
|
||||
}[] = [];
|
||||
|
||||
for (const category of categories) {
|
||||
const categoryFeatures = features.filter(
|
||||
(f) => f.category === category.id,
|
||||
);
|
||||
const items: FeatureWithIntegrations[] = [];
|
||||
|
||||
for (const feature of categoryFeatures) {
|
||||
const supporting = deployed.filter((i) =>
|
||||
i.features.includes(feature.id),
|
||||
);
|
||||
items.push({ feature, integrations: supporting });
|
||||
}
|
||||
|
||||
// Only show categories that have at least one feature with at least one integration
|
||||
if (items.some((item) => item.integrations.length > 0)) {
|
||||
groups.push({ category, items });
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [features, categories, deployed]);
|
||||
|
||||
if (groupedFeatures.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16 text-[var(--text-muted)] text-sm">
|
||||
No features with live integrations yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10 max-w-5xl mx-auto">
|
||||
{groupedFeatures.map(({ category, items }) => (
|
||||
<section key={category.id}>
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-4">
|
||||
{category.name}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{items.map(({ feature, integrations: supporting }) => (
|
||||
<FeatureCard
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
integrations={supporting}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
feature,
|
||||
integrations,
|
||||
}: {
|
||||
feature: Feature;
|
||||
integrations: Integration[];
|
||||
}) {
|
||||
const hasIntegrations = integrations.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border p-4 transition-all ${
|
||||
hasIntegrations
|
||||
? "border-[var(--border)] bg-[var(--bg-surface)]"
|
||||
: "border-transparent bg-[var(--bg-elevated)] opacity-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h4 className="text-sm font-semibold text-[var(--text)]">
|
||||
{feature.name}
|
||||
</h4>
|
||||
{hasIntegrations && (
|
||||
<Link
|
||||
href={`/integrations?feature=${feature.id}`}
|
||||
className="shrink-0 text-[10px] font-medium text-[var(--accent)] hover:underline"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--text-secondary)] leading-relaxed mb-3">
|
||||
{feature.description}
|
||||
</p>
|
||||
{hasIntegrations ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{integrations.map((i) => (
|
||||
<Link
|
||||
key={i.slug}
|
||||
href={`/integrations/${i.slug}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-[var(--accent-light)] text-[var(--accent)] hover:bg-[var(--accent)] hover:text-white transition-colors"
|
||||
>
|
||||
{i.logo && (
|
||||
<img
|
||||
src={i.logo}
|
||||
alt=""
|
||||
className="w-3 h-3 rounded-sm"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{i.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-[var(--text-faint)] italic">
|
||||
Coming soon
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
// FrameworkProvider — tracks the currently "active" agentic backend.
|
||||
//
|
||||
// IMPORTANT: `framework` is STRICTLY URL-derived. It's non-null only when
|
||||
// the user is actually on a framework-scoped route (`/<framework>/...`).
|
||||
// On `/docs/...`, `/`, and other non-scoped routes, `framework` is null
|
||||
// and the page renders the "no agentic backend selected" state.
|
||||
//
|
||||
// `storedFramework` is a separate, advisory signal: the user's last
|
||||
// remembered choice from localStorage. Consumers use it to mark "this
|
||||
// was your last pick" (e.g. highlight that card in the framework picker)
|
||||
// WITHOUT treating it as the active framework. Visiting `/docs/` after
|
||||
// previously picking LangChain must still show the unselected state —
|
||||
// only explicit navigation to `/langgraph-python/...` (or clicking the
|
||||
// card) makes LangChain active.
|
||||
//
|
||||
// Whenever the URL asserts a framework, we persist it as the new
|
||||
// storedFramework so the preference carries.
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export interface FrameworkContextValue {
|
||||
/**
|
||||
* Currently ACTIVE framework — strictly URL-derived. Non-null only on
|
||||
* `/<framework>/...` routes. Consumers that render "is this a
|
||||
* framework-scoped view?" chrome (selectors, banners, snippets) should
|
||||
* branch on this field.
|
||||
*/
|
||||
framework: string | null;
|
||||
/**
|
||||
* Last REMEMBERED framework from localStorage — advisory, does NOT
|
||||
* auto-activate. Use to mark the user's last pick in a picker UI
|
||||
* without implying the current view is scoped to it.
|
||||
*/
|
||||
storedFramework: string | null;
|
||||
/** All known framework slugs derived from the registry. */
|
||||
knownFrameworks: string[];
|
||||
/** Persist a new framework preference (does not navigate). */
|
||||
setStoredFramework: (slug: string | null) => void;
|
||||
}
|
||||
|
||||
const FrameworkContext = createContext<FrameworkContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = "selectedFramework";
|
||||
|
||||
function readStoredFramework(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredFramework(slug: string | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (slug === null) {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
} else {
|
||||
window.localStorage.setItem(STORAGE_KEY, slug);
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable (SSR, private mode, etc.) — silent no-op
|
||||
}
|
||||
}
|
||||
|
||||
export function FrameworkProvider({
|
||||
children,
|
||||
knownFrameworks,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
knownFrameworks: string[];
|
||||
}) {
|
||||
const pathname = usePathname() ?? "";
|
||||
const urlFramework = useMemo(() => {
|
||||
const first = pathname.split("/").filter(Boolean)[0];
|
||||
if (first && knownFrameworks.includes(first)) return first;
|
||||
return null;
|
||||
}, [pathname, knownFrameworks]);
|
||||
|
||||
const [stored, setStored] = useState<string | null>(null);
|
||||
|
||||
// Hydrate stored framework on client mount
|
||||
useEffect(() => {
|
||||
setStored(readStoredFramework());
|
||||
}, []);
|
||||
|
||||
// Whenever the URL asserts a framework, persist it so the preference
|
||||
// follows the user when they navigate back to /docs/*
|
||||
useEffect(() => {
|
||||
if (urlFramework && urlFramework !== stored) {
|
||||
writeStoredFramework(urlFramework);
|
||||
setStored(urlFramework);
|
||||
}
|
||||
}, [urlFramework, stored]);
|
||||
|
||||
// ACTIVE framework is strictly URL-derived. localStorage NEVER promotes
|
||||
// itself into `framework` — it lives in `storedFramework` where it can
|
||||
// be shown as "your last pick" without implying the current view is
|
||||
// scoped to it.
|
||||
const framework = urlFramework;
|
||||
|
||||
const setStoredFramework = (slug: string | null) => {
|
||||
writeStoredFramework(slug);
|
||||
setStored(slug);
|
||||
};
|
||||
|
||||
const value: FrameworkContextValue = {
|
||||
framework,
|
||||
storedFramework: stored,
|
||||
knownFrameworks,
|
||||
setStoredFramework,
|
||||
};
|
||||
|
||||
return (
|
||||
<FrameworkContext.Provider value={value}>
|
||||
{children}
|
||||
</FrameworkContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFramework(): FrameworkContextValue {
|
||||
const ctx = useContext(FrameworkContext);
|
||||
if (!ctx) {
|
||||
// Graceful fallback for trees that forgot to wrap in the provider —
|
||||
// return a neutral, read-only value rather than throwing.
|
||||
return {
|
||||
framework: null,
|
||||
storedFramework: null,
|
||||
knownFrameworks: [],
|
||||
setStoredFramework: () => {},
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { type Integration } from "@/lib/registry";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GuidedAnswers {
|
||||
features: string[];
|
||||
language: string | null;
|
||||
constraints: string[];
|
||||
}
|
||||
|
||||
// Stored payload shape permits legacy `undefined` for `language` from
|
||||
// versions that pre-dated the field. Callers normalize to `GuidedAnswers`
|
||||
// immediately after validation.
|
||||
interface StoredGuidedAnswers {
|
||||
features: string[];
|
||||
language: string | null | undefined;
|
||||
constraints: string[];
|
||||
}
|
||||
|
||||
// Runtime guard mirroring StoredGuidedAnswers. Used before trusting values
|
||||
// parsed from localStorage (where the schema could drift across
|
||||
// component versions or be hand-edited).
|
||||
function isGuidedAnswers(v: unknown): v is StoredGuidedAnswers {
|
||||
if (v === null || typeof v !== "object") return false;
|
||||
const r = v as Record<string, unknown>;
|
||||
if (
|
||||
!Array.isArray(r.features) ||
|
||||
!r.features.every((x) => typeof x === "string")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Accept undefined for backwards compatibility with stored state from
|
||||
// component versions that pre-dated the `language` field.
|
||||
if (
|
||||
r.language !== null &&
|
||||
r.language !== undefined &&
|
||||
typeof r.language !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!Array.isArray(r.constraints) ||
|
||||
!r.constraints.every((x) => typeof x === "string")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface ScoredIntegration {
|
||||
integration: Integration;
|
||||
matches: number;
|
||||
total: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FEATURE_OPTIONS = [
|
||||
{ id: "chat", label: "Chat interface" },
|
||||
{ id: "tools", label: "AI-powered tools" },
|
||||
{ id: "genui", label: "Generative UI" },
|
||||
{ id: "orchestration", label: "Agent orchestration" },
|
||||
{ id: "hitl", label: "Human-in-the-loop workflows" },
|
||||
{ id: "state", label: "Shared state management" },
|
||||
] as const;
|
||||
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ id: "python", label: "Python (LangGraph, PydanticAI, CrewAI, etc.)" },
|
||||
{ id: "typescript", label: "TypeScript/Node.js (Mastra, LangGraph JS)" },
|
||||
{ id: "dotnet", label: ".NET (Microsoft Agent Framework)" },
|
||||
{ id: "java", label: "Java (Spring AI)" },
|
||||
{ id: "unsure", label: "Not sure yet" },
|
||||
] as const;
|
||||
|
||||
const CONSTRAINT_OPTIONS = [
|
||||
{ id: "aws", label: "Must use AWS" },
|
||||
{ id: "google", label: "Must use Google Cloud" },
|
||||
{ id: "azure", label: "Must use Azure" },
|
||||
{ id: "multi-agent", label: "Need multi-agent support" },
|
||||
{ id: "simple", label: "Need the simplest setup" },
|
||||
] as const;
|
||||
|
||||
const STORAGE_KEY = "showcase-guided-flow";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FEATURE_MAP: Record<string, string> = {
|
||||
chat: "agentic-chat",
|
||||
tools: "frontend-tools",
|
||||
genui: "gen-ui-tool-based",
|
||||
orchestration: "subagents",
|
||||
hitl: "hitl-in-chat",
|
||||
state: "shared-state-read",
|
||||
};
|
||||
|
||||
const LANG_MAP: Record<string, string> = {
|
||||
python: "python",
|
||||
typescript: "typescript",
|
||||
dotnet: "dotnet",
|
||||
java: "java",
|
||||
};
|
||||
|
||||
// Module-load invariants: every FEATURE_OPTION id must have a FEATURE_MAP
|
||||
// entry, and every LANGUAGE_OPTION id (except "unsure" which is handled
|
||||
// separately) must have a LANG_MAP entry. Drift between the UI option
|
||||
// arrays and these maps silently produces zero-score scoring for the
|
||||
// drifted option. We warn instead of throwing because this module runs in
|
||||
// a "use client" component — a thrown error at module load would crash
|
||||
// SSR and client hydration globally on any drift. A future Vitest test
|
||||
// should enforce this invariant at build time; for now, console.error
|
||||
// flags the drift in dev while keeping the app up.
|
||||
for (const opt of FEATURE_OPTIONS) {
|
||||
if (!(opt.id in FEATURE_MAP)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`guided-flow: FEATURE_OPTIONS contains id '${opt.id}' with no FEATURE_MAP entry. ` +
|
||||
"Add the mapping in showcase/shell/src/components/guided-flow.tsx.",
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const opt of LANGUAGE_OPTIONS) {
|
||||
if (opt.id === "unsure") continue;
|
||||
if (!(opt.id in LANG_MAP)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`guided-flow: LANGUAGE_OPTIONS contains id '${opt.id}' with no LANG_MAP entry. ` +
|
||||
"Add the mapping in showcase/shell/src/components/guided-flow.tsx.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function scoreIntegration(
|
||||
integration: Integration,
|
||||
answers: GuidedAnswers,
|
||||
): ScoredIntegration {
|
||||
let matches = 0;
|
||||
let total = 0;
|
||||
|
||||
// Step 1: features
|
||||
for (const feature of answers.features) {
|
||||
total++;
|
||||
const mapped = FEATURE_MAP[feature];
|
||||
if (mapped && integration.features?.includes(mapped)) matches++;
|
||||
}
|
||||
|
||||
// Step 2: language
|
||||
if (answers.language && answers.language !== "unsure") {
|
||||
total++;
|
||||
const mapped = LANG_MAP[answers.language];
|
||||
if (mapped && integration.language === mapped) matches++;
|
||||
}
|
||||
|
||||
// Step 3: constraints
|
||||
for (const constraint of answers.constraints) {
|
||||
total++;
|
||||
if (constraint === "aws" && ["strands", "agno"].includes(integration.slug))
|
||||
matches++;
|
||||
if (constraint === "google" && integration.slug === "google-adk") matches++;
|
||||
if (
|
||||
constraint === "azure" &&
|
||||
["ms-agent-python", "ms-agent-dotnet"].includes(integration.slug)
|
||||
)
|
||||
matches++;
|
||||
if (
|
||||
constraint === "multi-agent" &&
|
||||
["crewai-crews", "ag2"].includes(integration.slug)
|
||||
)
|
||||
matches++;
|
||||
if (
|
||||
constraint === "simple" &&
|
||||
["mastra", "langgraph-typescript"].includes(integration.slug)
|
||||
)
|
||||
matches++;
|
||||
}
|
||||
|
||||
return {
|
||||
integration,
|
||||
matches,
|
||||
total,
|
||||
score: total > 0 ? matches / total : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function GuidedFlow({ integrations }: { integrations: Integration[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState(0);
|
||||
const [answers, setAnswers] = useState<GuidedAnswers>({
|
||||
features: [],
|
||||
language: null,
|
||||
constraints: [],
|
||||
});
|
||||
|
||||
// Warn-once refs for localStorage failure paths. Declared above both
|
||||
// effects so the restore useEffect (which reads cleanupWarnedRef in its
|
||||
// catch branch) does not forward-reference a later declaration. The
|
||||
// restore effect's callback body runs after the full render — so the
|
||||
// previous ordering worked at runtime — but a refactor to useLayoutEffect
|
||||
// or a synchronous call in render would break silently. Keep these
|
||||
// adjacent to the effects that consume them.
|
||||
//
|
||||
// persistWarnedRef guards the persist (setItem) effect below.
|
||||
const persistWarnedRef = useRef(false);
|
||||
// cleanupWarnedRef guards the removeItem calls in both the restore
|
||||
// useEffect (malformed payload cleanup) and `reset`. Kept separate from
|
||||
// persistWarnedRef so a persist failure doesn't silence cleanup
|
||||
// diagnostics and vice versa.
|
||||
const cleanupWarnedRef = useRef(false);
|
||||
// restoredRef gates the persist effect. Without it, the persist effect
|
||||
// runs on the initial render with the still-empty `answers` state and
|
||||
// clobbers the saved localStorage payload before the restore effect's
|
||||
// setAnswers call triggers a re-render. The persist effect skips its
|
||||
// write until the restore effect has finished (marked at the end of
|
||||
// its body regardless of which branch ran).
|
||||
const restoredRef = useRef(false);
|
||||
// resettingRef skips the NEXT scheduled persist-effect run after a
|
||||
// removeItem call — because the same state change that triggers
|
||||
// removeItem (empty-answers reset, or the malformed-payload cleanup path
|
||||
// on mount) would otherwise cause the persist effect to fire and rewrite
|
||||
// an empty payload to localStorage, undoing removeItem.
|
||||
//
|
||||
// Two call sites set this to true:
|
||||
// 1. `reset()` — before setAnswers + removeItem.
|
||||
// 2. The restore effect's malformed-payload branch — before
|
||||
// restoredRef flips to true (the flip is what lets the persist
|
||||
// effect run for the first time, so this flag must be set before
|
||||
// then to be observed on that first run).
|
||||
//
|
||||
// The persist effect, when it sees this flag, skips the write AND
|
||||
// clears the flag back to false so subsequent mutations persist normally.
|
||||
const resettingRef = useRef(false);
|
||||
|
||||
// Restore from localStorage. Validate the shape before trusting the
|
||||
// parsed value: an older version of this component, a browser
|
||||
// extension, or manual editing could have left a malformed record
|
||||
// behind, and setAnswers(anyObject) would silently propagate the bad
|
||||
// shape into JSX array methods below.
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) return;
|
||||
const parsed: unknown = JSON.parse(saved);
|
||||
if (!isGuidedAnswers(parsed)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[guided-flow] Discarding malformed ${STORAGE_KEY} payload from localStorage.`,
|
||||
);
|
||||
// Mark a reset so the persist effect skips its first run. The
|
||||
// `finally` block below flips restoredRef to true, which
|
||||
// otherwise re-enables the persist effect — and because we did
|
||||
// not call setAnswers here, the persist effect would see the
|
||||
// initial empty answers state and rewrite an empty payload,
|
||||
// undoing the removeItem below.
|
||||
resettingRef.current = true;
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (err) {
|
||||
if (!cleanupWarnedRef.current) {
|
||||
cleanupWarnedRef.current = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[guided-flow] Failed to remove ${STORAGE_KEY} from localStorage (further warnings suppressed):`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Coerce legacy undefined language to null AND filter to known IDs
|
||||
// so legacy payloads with retired feature/constraint/language IDs
|
||||
// do not silently depress scoring by counting against `total`
|
||||
// without matching any integration.
|
||||
const normalized: GuidedAnswers = {
|
||||
features: parsed.features.filter((f) =>
|
||||
FEATURE_OPTIONS.some((o) => o.id === f),
|
||||
),
|
||||
language:
|
||||
parsed.language &&
|
||||
(parsed.language === "unsure" ||
|
||||
LANGUAGE_OPTIONS.some((o) => o.id === parsed.language))
|
||||
? parsed.language
|
||||
: null,
|
||||
constraints: parsed.constraints.filter((c) =>
|
||||
CONSTRAINT_OPTIONS.some((o) => o.id === c),
|
||||
),
|
||||
};
|
||||
setAnswers(normalized);
|
||||
// Only jump to results when features are present — features are
|
||||
// the only field required for meaningful scoring. A stored payload
|
||||
// with only language/constraints set should land on step 0 so the
|
||||
// user can complete the flow without being stranded on an empty
|
||||
// results screen.
|
||||
if (normalized.features.length > 0) {
|
||||
setStep(3);
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[guided-flow] Failed to restore ${STORAGE_KEY} from localStorage:`,
|
||||
err,
|
||||
);
|
||||
} finally {
|
||||
// Mark restore complete in all exit paths (no-saved-value,
|
||||
// malformed-and-cleaned, successfully-restored, or threw). The
|
||||
// persist effect is gated on this flag to avoid clobbering the
|
||||
// saved payload on the initial render before setAnswers has had
|
||||
// a chance to swap in the restored state.
|
||||
restoredRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Persist to localStorage. Warn at most once per mount: the write effect
|
||||
// runs on every answer change and users in restrictive storage modes
|
||||
// (Safari private, quota exceeded, disabled cookies) would otherwise see
|
||||
// dozens of duplicate warnings per session. The persistWarnedRef /
|
||||
// cleanupWarnedRef refs are declared above the restore useEffect so
|
||||
// neither effect forward-references them.
|
||||
useEffect(() => {
|
||||
// Skip until the restore effect has finished. Without this gate, the
|
||||
// initial render writes `{features: [], language: null, constraints: []}`
|
||||
// to localStorage — overwriting the saved payload the restore effect
|
||||
// has not yet applied to state. The restore effect sets
|
||||
// restoredRef.current = true in a finally block, so every exit path
|
||||
// (including early returns and thrown errors) releases this gate.
|
||||
if (!restoredRef.current) return;
|
||||
// Skip (and consume) a reset tombstone: reset() and the
|
||||
// malformed-payload cleanup branch both set resettingRef before
|
||||
// mutating state / issuing removeItem. Without this, the persist
|
||||
// effect would rewrite an empty-answers payload to localStorage in
|
||||
// the same tick, undoing removeItem.
|
||||
if (resettingRef.current) {
|
||||
resettingRef.current = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(answers));
|
||||
} catch (err) {
|
||||
if (!persistWarnedRef.current) {
|
||||
persistWarnedRef.current = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[guided-flow] Failed to persist ${STORAGE_KEY} to localStorage (further warnings suppressed):`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [answers]);
|
||||
|
||||
const deployed = useMemo(
|
||||
() => integrations.filter((i) => i.deployed),
|
||||
[integrations],
|
||||
);
|
||||
|
||||
const results = useMemo(() => {
|
||||
const scored = deployed.map((i) => scoreIntegration(i, answers));
|
||||
return scored
|
||||
.filter((s) => s.score > 0)
|
||||
.sort((a, b) => b.score - a.score || b.matches - a.matches);
|
||||
}, [deployed, answers]);
|
||||
|
||||
const toggleFeature = useCallback((id: string) => {
|
||||
setAnswers((prev) => ({
|
||||
...prev,
|
||||
features: prev.features.includes(id)
|
||||
? prev.features.filter((f) => f !== id)
|
||||
: [...prev.features, id],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const toggleConstraint = useCallback((id: string) => {
|
||||
setAnswers((prev) => ({
|
||||
...prev,
|
||||
constraints: prev.constraints.includes(id)
|
||||
? prev.constraints.filter((c) => c !== id)
|
||||
: [...prev.constraints, id],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setLanguage = useCallback((id: string) => {
|
||||
// Language is a required single-select; clicking an already-selected
|
||||
// pill is a no-op (idempotent set) rather than a toggle-off that
|
||||
// would disable the Next button and trap the user on step 1.
|
||||
setAnswers((prev) => ({ ...prev, language: id }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
// Mark a reset BEFORE mutating state. The setAnswers below schedules
|
||||
// a re-render; the persist effect that would otherwise fire on that
|
||||
// re-render checks resettingRef and skips its write, leaving the
|
||||
// removeItem below sticky. The persist effect clears resettingRef
|
||||
// back to false on the skip, so the next real mutation persists.
|
||||
resettingRef.current = true;
|
||||
setAnswers({ features: [], language: null, constraints: [] });
|
||||
setStep(0);
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (err) {
|
||||
if (!cleanupWarnedRef.current) {
|
||||
cleanupWarnedRef.current = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[guided-flow] Failed to remove ${STORAGE_KEY} from localStorage (further warnings suppressed):`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const canAdvance =
|
||||
step === 0
|
||||
? answers.features.length > 0
|
||||
: step === 1
|
||||
? answers.language !== null
|
||||
: true; // step 2 (constraints) is optional
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="group inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-[var(--accent)] text-[var(--accent)] text-sm font-medium hover:bg-[var(--accent)] hover:text-white transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
Help me choose
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 z-40"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="fixed inset-y-0 right-0 w-full max-w-lg bg-[var(--bg)] border-l border-[var(--border)] shadow-2xl z-50 flex flex-col guided-animate-slide-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--text)]">
|
||||
{step < 3 ? "Help me choose" : "Your matches"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--text)] transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{step < 3 && (
|
||||
<div className="px-6 pt-4">
|
||||
<div className="flex gap-1.5">
|
||||
{[0, 1, 2].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`h-1 flex-1 rounded-full transition-colors ${
|
||||
s <= step ? "bg-[var(--accent)]" : "bg-[var(--border)]"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-2">
|
||||
Step {step + 1} of 3
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
{step === 0 && (
|
||||
<StepContent title="What are you building?">
|
||||
<PillGrid
|
||||
options={FEATURE_OPTIONS}
|
||||
selected={answers.features}
|
||||
onToggle={toggleFeature}
|
||||
multi
|
||||
/>
|
||||
</StepContent>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<StepContent title="What's your backend?">
|
||||
<PillGrid
|
||||
options={LANGUAGE_OPTIONS}
|
||||
selected={answers.language ? [answers.language] : []}
|
||||
onToggle={setLanguage}
|
||||
/>
|
||||
</StepContent>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<StepContent
|
||||
title="Any constraints?"
|
||||
subtitle="Optional -- skip if none apply."
|
||||
>
|
||||
<PillGrid
|
||||
options={CONSTRAINT_OPTIONS}
|
||||
selected={answers.constraints}
|
||||
onToggle={toggleConstraint}
|
||||
multi
|
||||
/>
|
||||
</StepContent>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div>
|
||||
{results.length === 0 ? (
|
||||
<p className="text-sm text-[var(--text-muted)] text-center py-8">
|
||||
No integrations match your criteria. Try adjusting your
|
||||
answers.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{results.map(({ integration, matches, total, score }) => (
|
||||
<Link
|
||||
key={integration.slug}
|
||||
href={`/integrations/${integration.slug}`}
|
||||
className="block rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] p-4 hover:border-[var(--accent)] hover:shadow-sm transition-all"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{integration.logo && (
|
||||
<Image
|
||||
src={integration.logo}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="w-5 h-5 rounded"
|
||||
onError={(e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[guided-flow] image failed to load:",
|
||||
e.currentTarget.src,
|
||||
);
|
||||
(e.target as HTMLImageElement).style.display =
|
||||
"none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-semibold text-[var(--text)]">
|
||||
{integration.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] line-clamp-2">
|
||||
{integration.description}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
score >= 0.8
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
: score >= 0.5
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
: "bg-[var(--bg-elevated)] text-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
{matches}/{total} match
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer nav */}
|
||||
<div className="px-6 py-4 border-t border-[var(--border)] flex items-center justify-between">
|
||||
{step === 3 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(0)}
|
||||
className="text-sm text-[var(--text-muted)] hover:text-[var(--text)] transition-colors"
|
||||
>
|
||||
Edit answers
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="text-sm text-[var(--text-muted)] hover:text-[var(--text)] transition-colors"
|
||||
>
|
||||
Start over
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--accent)] text-white text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step > 0 && setStep(step - 1)}
|
||||
className={`text-sm transition-colors ${
|
||||
step > 0
|
||||
? "text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
: "text-transparent pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{step === 2 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(3)}
|
||||
className="text-sm text-[var(--text-muted)] hover:text-[var(--text)] transition-colors"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canAdvance && setStep(step + 1)}
|
||||
disabled={!canAdvance}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
canAdvance
|
||||
? "bg-[var(--accent)] text-white hover:opacity-90"
|
||||
: "bg-[var(--bg-elevated)] text-[var(--text-faint)] cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{step === 2 ? "See results" : "Next"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* eslint-disable-next-line react/no-unknown-property */}
|
||||
<style>{`
|
||||
@keyframes guided-slide-in {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
.guided-animate-slide-in {
|
||||
animation: guided-slide-in 0.25s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StepContent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StepContent({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-[var(--text)] mb-1">
|
||||
{title}
|
||||
</h3>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-[var(--text-muted)] mb-4">{subtitle}</p>
|
||||
)}
|
||||
{!subtitle && <div className="mb-4" />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PillGrid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PillGrid({
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
multi,
|
||||
}: {
|
||||
options: readonly { id: string; label: string }[];
|
||||
selected: string[];
|
||||
onToggle: (id: string) => void;
|
||||
multi?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(({ id, label }) => {
|
||||
const active = selected.includes(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onToggle(id)}
|
||||
className={`px-4 py-2 rounded-full text-sm border transition-all ${
|
||||
active
|
||||
? "border-[var(--accent)] bg-[var(--accent-light)] text-[var(--accent)] font-medium"
|
||||
: "border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-secondary)] hover:border-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
{active && multi && (
|
||||
<svg
|
||||
className="inline w-3.5 h-3.5 mr-1.5 -mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CopilotKitProvider,
|
||||
CopilotChat,
|
||||
useConfigureSuggestions,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
|
||||
export function HomeChat() {
|
||||
return (
|
||||
<CopilotKitProvider runtimeUrl="/api/copilotkit">
|
||||
<HomeChatInner />
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeChatInner() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Get started",
|
||||
message:
|
||||
"I want to get started with CopilotKit. Which agent framework should I use?",
|
||||
},
|
||||
{
|
||||
title: "Generative UI",
|
||||
message: "Show me what Generative UI looks like. What demos can I try?",
|
||||
},
|
||||
{
|
||||
title: "Live demo",
|
||||
message: "I want to try a live demo right now. What's available?",
|
||||
},
|
||||
{
|
||||
title: "Compare frameworks",
|
||||
message:
|
||||
"Compare the features supported by each agent framework integration.",
|
||||
},
|
||||
{
|
||||
title: "Help me choose",
|
||||
message: "Help me choose the right agent framework for my project.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col border-r border-[var(--border)]">
|
||||
<div className="px-8 pt-6 pb-4 border-b border-[var(--border)]">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-[var(--text)]">
|
||||
CopilotKit Docs
|
||||
</h1>
|
||||
<p className="text-[13px] text-[var(--text-secondary)] mt-1">
|
||||
Ask anything, explore the stack, or jump to what you need.
|
||||
</p>
|
||||
</div>
|
||||
<CopilotChat className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { type Integration, getCategoryLabel } from "@/lib/registry";
|
||||
import constraintsData from "@/data/constraints.json";
|
||||
|
||||
// Module-scope: stable reference, no useMemo needed
|
||||
const constraints = constraintsData as {
|
||||
generative_ui: Record<string, { allowed?: string[]; excluded?: string[] }>;
|
||||
interaction_modalities: Record<
|
||||
string,
|
||||
{ allowed?: string[]; excluded?: string[] }
|
||||
>;
|
||||
};
|
||||
|
||||
const GEN_UI_LABELS: Record<string, string> = {
|
||||
"constrained-declarative": "Declarative",
|
||||
"constrained-explicit": "Explicitly Specified",
|
||||
open: "Open",
|
||||
};
|
||||
|
||||
const MODALITY_LABELS: Record<string, string> = {
|
||||
sidebar: "Sidebar",
|
||||
embedded: "Embedded",
|
||||
popup: "Popup",
|
||||
chat: "Chat",
|
||||
headless: "Headless",
|
||||
};
|
||||
|
||||
const GEN_UI_VALUES = Object.keys(GEN_UI_LABELS);
|
||||
const MODALITY_VALUES = Object.keys(MODALITY_LABELS);
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"popular",
|
||||
"agent-framework",
|
||||
"enterprise-platform",
|
||||
"provider-sdk",
|
||||
"protocol",
|
||||
"emerging",
|
||||
"starter",
|
||||
];
|
||||
|
||||
interface IntegrationExplorerProps {
|
||||
integrations: Integration[];
|
||||
initialFeatureFilter?: string;
|
||||
}
|
||||
|
||||
interface FilteredDemo {
|
||||
integration: Integration;
|
||||
demo: Integration["demos"][number];
|
||||
}
|
||||
|
||||
export function IntegrationExplorer({
|
||||
integrations,
|
||||
initialFeatureFilter,
|
||||
}: IntegrationExplorerProps) {
|
||||
const [framework, setFramework] = useState("all");
|
||||
const [genUi, setGenUi] = useState("all");
|
||||
const [modality, setModality] = useState("all");
|
||||
const [featureFilter, setFeatureFilter] = useState(
|
||||
initialFeatureFilter ?? "",
|
||||
);
|
||||
|
||||
// Only deployed integrations participate in filtering
|
||||
const deployed = useMemo(
|
||||
() => integrations.filter((i) => i.deployed),
|
||||
[integrations],
|
||||
);
|
||||
|
||||
// Unique framework names from deployed integrations
|
||||
const frameworkOptions = useMemo(() => {
|
||||
// Preserve registry sort_order (not alphabetical)
|
||||
const seen = new Set<string>();
|
||||
return deployed
|
||||
.filter((i) => {
|
||||
if (seen.has(i.name)) return false;
|
||||
seen.add(i.name);
|
||||
return true;
|
||||
})
|
||||
.map((i) => i.name);
|
||||
}, [deployed]);
|
||||
|
||||
// Coming-soon frameworks: present in integrations but not deployed
|
||||
const comingSoonFrameworks = useMemo(() => {
|
||||
const deployedNames = new Set(deployed.map((i) => i.name));
|
||||
const allNames = new Set(integrations.map((i) => i.name));
|
||||
return new Set([...allNames].filter((n) => !deployedNames.has(n)));
|
||||
}, [integrations, deployed]);
|
||||
|
||||
// Determine "coming soon" enum values: those with no deployed packages
|
||||
const comingSoonGenUi = useMemo(() => {
|
||||
const available = new Set(deployed.flatMap((i) => i.generative_ui ?? []));
|
||||
return new Set(GEN_UI_VALUES.filter((v) => !available.has(v)));
|
||||
}, [deployed]);
|
||||
|
||||
const comingSoonModality = useMemo(() => {
|
||||
const available = new Set(
|
||||
deployed.flatMap((i) => i.interaction_modalities ?? []),
|
||||
);
|
||||
return new Set(MODALITY_VALUES.filter((v) => !available.has(v)));
|
||||
}, [deployed]);
|
||||
|
||||
// Count demos per framework (deployed only)
|
||||
const frameworkCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const i of deployed) {
|
||||
counts[i.name] = (counts[i.name] ?? 0) + i.demos.length;
|
||||
}
|
||||
return counts;
|
||||
}, [deployed]);
|
||||
|
||||
const totalDemoCount = useMemo(
|
||||
() => deployed.reduce((sum, i) => sum + i.demos.length, 0),
|
||||
[deployed],
|
||||
);
|
||||
|
||||
const filteredDemos = useMemo(() => {
|
||||
let packages = deployed;
|
||||
|
||||
// Feature filter: keep only packages that support the selected feature
|
||||
if (featureFilter) {
|
||||
packages = packages.filter((i) => i.features.includes(featureFilter));
|
||||
}
|
||||
|
||||
// Framework filter: keep only packages matching selected framework
|
||||
if (framework !== "all") {
|
||||
packages = packages.filter((i) => i.name === framework);
|
||||
}
|
||||
|
||||
// Generative UI filter: package-level then demo-level
|
||||
if (genUi !== "all") {
|
||||
packages = packages.filter(
|
||||
(i) => i.generative_ui && i.generative_ui.includes(genUi),
|
||||
);
|
||||
}
|
||||
|
||||
// Interaction modality filter: package-level
|
||||
if (modality !== "all") {
|
||||
packages = packages.filter(
|
||||
(i) =>
|
||||
i.interaction_modalities &&
|
||||
i.interaction_modalities.includes(modality),
|
||||
);
|
||||
}
|
||||
|
||||
// Collect demos with demo-level filtering
|
||||
const results: FilteredDemo[] = [];
|
||||
|
||||
for (const integration of packages) {
|
||||
for (const demo of integration.demos) {
|
||||
// Generative UI demo-level: keep only demos in allowed list
|
||||
if (genUi !== "all") {
|
||||
const genUiConstraint = constraints.generative_ui[genUi];
|
||||
if (
|
||||
genUiConstraint?.allowed &&
|
||||
!genUiConstraint.allowed.includes(demo.id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Interaction modality demo-level: exclude demos in excluded list
|
||||
if (modality !== "all") {
|
||||
const modalityConstraint =
|
||||
constraints.interaction_modalities[modality];
|
||||
if (
|
||||
modalityConstraint?.excluded &&
|
||||
modalityConstraint.excluded.includes(demo.id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ integration, demo });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [deployed, framework, genUi, modality, featureFilter]);
|
||||
|
||||
const groupedDemos = useMemo(() => {
|
||||
const groups: Record<string, FilteredDemo[]> = {};
|
||||
for (const entry of filteredDemos) {
|
||||
const cat = entry.integration.category;
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(entry);
|
||||
}
|
||||
// Return ordered array of [category, demos] pairs
|
||||
const result = CATEGORY_ORDER.filter(
|
||||
(cat) => groups[cat] && groups[cat].length > 0,
|
||||
).map((cat) => [cat, groups[cat]] as [string, FilteredDemo[]]);
|
||||
|
||||
// Append any categories not in CATEGORY_ORDER so they aren't silently dropped
|
||||
const orderedCats = new Set(CATEGORY_ORDER);
|
||||
for (const cat of Object.keys(groups)) {
|
||||
if (!orderedCats.has(cat) && groups[cat].length > 0) {
|
||||
console.warn(
|
||||
`[integration-explorer] Unknown category "${cat}" — appending to end`,
|
||||
);
|
||||
result.push([cat, groups[cat]]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [filteredDemos]);
|
||||
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
|
||||
const hasActiveFilters =
|
||||
framework !== "all" ||
|
||||
genUi !== "all" ||
|
||||
modality !== "all" ||
|
||||
!!featureFilter;
|
||||
|
||||
const clearFilter = (
|
||||
which: "framework" | "genUi" | "modality" | "feature",
|
||||
) => {
|
||||
if (which === "framework") setFramework("all");
|
||||
else if (which === "genUi") setGenUi("all");
|
||||
else if (which === "feature") setFeatureFilter("");
|
||||
else setModality("all");
|
||||
};
|
||||
|
||||
const activeFilterLabel =
|
||||
framework !== "all"
|
||||
? framework
|
||||
: genUi !== "all"
|
||||
? GEN_UI_LABELS[genUi]
|
||||
: modality !== "all"
|
||||
? MODALITY_LABELS[modality]
|
||||
: "All Frameworks";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
|
||||
{/* ---- Mobile filter dropdown ---- */}
|
||||
<div className="block sm:hidden relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileFilterOpen(!mobileFilterOpen)}
|
||||
className="w-full flex items-center justify-between rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] px-4 py-2.5 text-[13px] font-medium text-[var(--text-secondary)] hover:border-[var(--text-faint)] transition-colors"
|
||||
>
|
||||
<span>Filter: {activeFilterLabel}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-[var(--text-muted)] transition-transform ${mobileFilterOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{mobileFilterOpen && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-40 rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg p-4 max-h-[60vh] overflow-y-auto">
|
||||
<FilterGroup title="Framework">
|
||||
<RadioOption
|
||||
label="All"
|
||||
count={totalDemoCount}
|
||||
selected={framework === "all"}
|
||||
onClick={() => {
|
||||
setFramework("all");
|
||||
setMobileFilterOpen(false);
|
||||
}}
|
||||
/>
|
||||
{frameworkOptions.map((name) => (
|
||||
<RadioOption
|
||||
key={name}
|
||||
label={name}
|
||||
count={frameworkCounts[name] ?? 0}
|
||||
selected={framework === name}
|
||||
onClick={() => {
|
||||
setFramework(name);
|
||||
setMobileFilterOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{[...comingSoonFrameworks].map((name) => (
|
||||
<RadioOption key={name} label={name} comingSoon />
|
||||
))}
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup title="Generative UI">
|
||||
<RadioOption
|
||||
label="All"
|
||||
selected={genUi === "all"}
|
||||
onClick={() => {
|
||||
setGenUi("all");
|
||||
setMobileFilterOpen(false);
|
||||
}}
|
||||
/>
|
||||
{GEN_UI_VALUES.map((v) => (
|
||||
<RadioOption
|
||||
key={v}
|
||||
label={GEN_UI_LABELS[v]}
|
||||
selected={genUi === v}
|
||||
comingSoon={comingSoonGenUi.has(v)}
|
||||
onClick={
|
||||
comingSoonGenUi.has(v)
|
||||
? undefined
|
||||
: () => {
|
||||
setGenUi(v);
|
||||
setMobileFilterOpen(false);
|
||||
}
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup title="Interaction">
|
||||
<RadioOption
|
||||
label="All"
|
||||
selected={modality === "all"}
|
||||
onClick={() => {
|
||||
setModality("all");
|
||||
setMobileFilterOpen(false);
|
||||
}}
|
||||
/>
|
||||
{MODALITY_VALUES.map((v) => (
|
||||
<RadioOption
|
||||
key={v}
|
||||
label={MODALITY_LABELS[v]}
|
||||
selected={modality === v}
|
||||
comingSoon={comingSoonModality.has(v)}
|
||||
onClick={
|
||||
comingSoonModality.has(v)
|
||||
? undefined
|
||||
: () => {
|
||||
setModality(v);
|
||||
setMobileFilterOpen(false);
|
||||
}
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- Desktop sidebar ---- */}
|
||||
<aside
|
||||
className="hidden sm:block shrink-0 sticky top-4 self-start"
|
||||
style={{ width: 220 }}
|
||||
>
|
||||
{/* Framework group */}
|
||||
<FilterGroup title="Framework">
|
||||
<RadioOption
|
||||
label="All"
|
||||
count={totalDemoCount}
|
||||
selected={framework === "all"}
|
||||
onClick={() => setFramework("all")}
|
||||
/>
|
||||
{frameworkOptions.map((name) => (
|
||||
<RadioOption
|
||||
key={name}
|
||||
label={name}
|
||||
count={frameworkCounts[name] ?? 0}
|
||||
selected={framework === name}
|
||||
onClick={() => setFramework(name)}
|
||||
/>
|
||||
))}
|
||||
{[...comingSoonFrameworks].map((name) => (
|
||||
<RadioOption key={name} label={name} comingSoon />
|
||||
))}
|
||||
</FilterGroup>
|
||||
|
||||
{/* Generative UI group */}
|
||||
<FilterGroup title="Generative UI">
|
||||
<RadioOption
|
||||
label="All"
|
||||
selected={genUi === "all"}
|
||||
onClick={() => setGenUi("all")}
|
||||
/>
|
||||
{GEN_UI_VALUES.map((v) => (
|
||||
<RadioOption
|
||||
key={v}
|
||||
label={GEN_UI_LABELS[v]}
|
||||
selected={genUi === v}
|
||||
comingSoon={comingSoonGenUi.has(v)}
|
||||
onClick={comingSoonGenUi.has(v) ? undefined : () => setGenUi(v)}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
|
||||
{/* Interaction group */}
|
||||
<FilterGroup title="Interaction">
|
||||
<RadioOption
|
||||
label="All"
|
||||
selected={modality === "all"}
|
||||
onClick={() => setModality("all")}
|
||||
/>
|
||||
{MODALITY_VALUES.map((v) => (
|
||||
<RadioOption
|
||||
key={v}
|
||||
label={MODALITY_LABELS[v]}
|
||||
selected={modality === v}
|
||||
comingSoon={comingSoonModality.has(v)}
|
||||
onClick={
|
||||
comingSoonModality.has(v) ? undefined : () => setModality(v)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
</aside>
|
||||
|
||||
{/* ---- Main area ---- */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-baseline gap-3 mb-4">
|
||||
<h2 className="text-[18px] font-semibold text-[var(--text)]">
|
||||
Demos
|
||||
</h2>
|
||||
<span className="text-[13px] text-[var(--text-muted)]">
|
||||
{filteredDemos.length}{" "}
|
||||
{filteredDemos.length === 1 ? "result" : "results"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Active filter chips */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{featureFilter && (
|
||||
<FilterChip
|
||||
label={`Feature: ${featureFilter}`}
|
||||
onDismiss={() => clearFilter("feature")}
|
||||
/>
|
||||
)}
|
||||
{framework !== "all" && (
|
||||
<FilterChip
|
||||
label={framework}
|
||||
onDismiss={() => clearFilter("framework")}
|
||||
/>
|
||||
)}
|
||||
{genUi !== "all" && (
|
||||
<FilterChip
|
||||
label={GEN_UI_LABELS[genUi]}
|
||||
onDismiss={() => clearFilter("genUi")}
|
||||
/>
|
||||
)}
|
||||
{modality !== "all" && (
|
||||
<FilterChip
|
||||
label={MODALITY_LABELS[modality]}
|
||||
onDismiss={() => clearFilter("modality")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo cards grouped by category */}
|
||||
{filteredDemos.length === 0 ? (
|
||||
<div className="text-center py-16 text-[var(--text-muted)] text-sm">
|
||||
No demos match the current filters.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-8">
|
||||
{groupedDemos.map(([category, demos]) => (
|
||||
<section key={category}>
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-3">
|
||||
{getCategoryLabel(category)}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{demos.map(({ integration, demo }) => (
|
||||
<DemoCard
|
||||
key={`${integration.slug}::${demo.id}`}
|
||||
integration={integration}
|
||||
demo={demo}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FilterGroup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FilterGroup({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-2 px-1">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-0.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RadioOption
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RadioOption({
|
||||
label,
|
||||
count,
|
||||
selected,
|
||||
comingSoon,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count?: number;
|
||||
selected?: boolean;
|
||||
comingSoon?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
if (comingSoon) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-md px-2 py-1.5 cursor-default">
|
||||
<span className="text-[13px] text-[var(--text-faint)]">{label}</span>
|
||||
<span className="text-[10px] text-[var(--text-faint)] italic">
|
||||
soon
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-center justify-between rounded-md px-2 py-1.5 text-left transition-colors ${
|
||||
selected
|
||||
? "bg-[var(--accent-light)] text-[var(--accent)]"
|
||||
: "text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
<span className={`text-[13px] ${selected ? "font-medium" : ""}`}>
|
||||
{label}
|
||||
</span>
|
||||
{count !== undefined && (
|
||||
<span
|
||||
className={`text-[11px] ${selected ? "text-[var(--accent)]" : "text-[var(--text-muted)]"}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FilterChip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FilterChip({
|
||||
label,
|
||||
onDismiss,
|
||||
}: {
|
||||
label: string;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--border)] bg-[var(--bg-elevated)] px-2.5 py-1 text-[12px] text-[var(--text-secondary)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
{label}
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M2.5 2.5L7.5 7.5M7.5 2.5L2.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DemoCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DemoCard({
|
||||
integration,
|
||||
demo,
|
||||
}: {
|
||||
integration: Integration;
|
||||
demo: Integration["demos"][number];
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const previewUrl =
|
||||
demo.animated_preview_url || integration.animated_preview_url;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/integrations/${integration.slug}?demo=${demo.id}`}
|
||||
className="group relative block rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] p-4 transition-all hover:border-[var(--accent)] hover:shadow-md"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Animated preview — floating overlay on hover, no layout shift */}
|
||||
{hovered && previewUrl && (
|
||||
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 pointer-events-none">
|
||||
<video
|
||||
src={previewUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="w-full h-auto rounded-lg shadow-xl border border-[var(--border)]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{integration.logo && (
|
||||
<img
|
||||
src={integration.logo}
|
||||
alt=""
|
||||
className="w-5 h-5 rounded"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="inline-flex items-center rounded-full bg-[var(--accent-light)] px-2.5 py-0.5 text-[11px] font-medium text-[var(--accent)]">
|
||||
{integration.name}
|
||||
</span>
|
||||
{integration.managed_platform && (
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-medium text-[var(--blue)] cursor-pointer"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--blue) 10%, transparent)",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.open(integration.managed_platform!.url, "_blank");
|
||||
}}
|
||||
>
|
||||
{integration.managed_platform.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name + description */}
|
||||
<h3 className="text-[14px] font-semibold text-[var(--text)] mb-1 group-hover:text-[var(--accent)] transition-colors">
|
||||
{demo.name}
|
||||
</h3>
|
||||
<p className="text-[12px] text-[var(--text-secondary)] leading-relaxed line-clamp-2">
|
||||
{demo.description}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const TABS = [
|
||||
{ href: "/integrations", label: "Explorer" },
|
||||
{ href: "/integrations/by-feature", label: "By Feature" },
|
||||
{ href: "/matrix", label: "Compare" },
|
||||
];
|
||||
|
||||
export function IntegrationsTabs() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 mb-6">
|
||||
{TABS.map(({ href, label }) => {
|
||||
const active = pathname === href;
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`rounded-lg px-4 py-2 text-[13px] font-medium transition-colors ${
|
||||
active
|
||||
? "bg-[var(--accent-light)] text-[var(--accent)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import searchIndex from "@/data/search-index.json";
|
||||
|
||||
interface SearchResult {
|
||||
type: "integration" | "feature" | "demo" | "page" | "reference" | "ag-ui";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
section?: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function SearchModal({ onClose }: { onClose: () => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [registryData, setRegistryData] = useState<any>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
import("@/data/registry.json").then((mod) => setRegistryData(mod.default));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!query.trim() || !registryData) return [];
|
||||
|
||||
const q = query.toLowerCase();
|
||||
const items: SearchResult[] = [];
|
||||
|
||||
// Auto-generated by: npx tsx showcase/scripts/generate-search-index.ts
|
||||
const pages = searchIndex as SearchResult[];
|
||||
|
||||
for (const p of pages) {
|
||||
if (
|
||||
p.title.toLowerCase().includes(q) ||
|
||||
p.subtitle.toLowerCase().includes(q) ||
|
||||
(p.section && p.section.toLowerCase().includes(q))
|
||||
) {
|
||||
items.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
for (const i of registryData.integrations || []) {
|
||||
if (
|
||||
i.name.toLowerCase().includes(q) ||
|
||||
i.description?.toLowerCase().includes(q)
|
||||
) {
|
||||
items.push({
|
||||
type: "integration",
|
||||
title: i.name,
|
||||
subtitle: (i.description || "").slice(0, 80),
|
||||
href: `/integrations/${i.slug}`,
|
||||
});
|
||||
}
|
||||
for (const d of i.demos || []) {
|
||||
if (
|
||||
d.name.toLowerCase().includes(q) ||
|
||||
d.description?.toLowerCase().includes(q) ||
|
||||
d.tags?.some((t: string) => t.toLowerCase().includes(q))
|
||||
) {
|
||||
items.push({
|
||||
type: "demo",
|
||||
title: d.name,
|
||||
subtitle: `${i.name} · ${d.description}`,
|
||||
href: `/integrations/${i.slug}/${d.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const f of registryData.feature_registry?.features || []) {
|
||||
if (
|
||||
f.name.toLowerCase().includes(q) ||
|
||||
f.description?.toLowerCase().includes(q)
|
||||
) {
|
||||
items.push({
|
||||
type: "feature",
|
||||
title: f.name,
|
||||
subtitle: f.description,
|
||||
href: "/matrix",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items.slice(0, 12);
|
||||
}, [query, registryData]);
|
||||
|
||||
function onInputKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" && results[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
router.push(results[selectedIndex].href);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[200] bg-black/20 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="fixed top-[20%] left-1/2 -translate-x-1/2 z-[201] w-full max-w-lg px-4">
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border)] rounded-2xl shadow-2xl overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-[var(--border)]">
|
||||
<span className="text-[var(--text-muted)]">⌕</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onKeyDown={onInputKeyDown}
|
||||
placeholder="Search docs, demos, integrations..."
|
||||
className="flex-1 bg-transparent text-[15px] text-[var(--text)] outline-none placeholder:text-[var(--text-faint)]"
|
||||
/>
|
||||
<kbd className="text-[10px] font-mono text-[var(--text-faint)] border border-[var(--border)] px-1.5 py-0.5 rounded bg-[var(--bg-elevated)]">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="max-h-[320px] overflow-y-auto py-2">
|
||||
{results.map((r, idx) => (
|
||||
<button
|
||||
key={`${r.href}-${idx}`}
|
||||
className={`w-full text-left px-5 py-3 flex items-center gap-3 transition-colors ${
|
||||
idx === selectedIndex
|
||||
? "bg-[var(--bg-elevated)]"
|
||||
: "hover:bg-[var(--bg-hover)]"
|
||||
}`}
|
||||
onClick={() => {
|
||||
router.push(r.href);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<span className="text-[10px] font-mono text-[var(--text-faint)] uppercase w-14 shrink-0">
|
||||
{r.type}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-medium text-[var(--text)] truncate">
|
||||
{r.title}
|
||||
{r.section && (
|
||||
<span className="ml-2 text-[11px] font-normal text-[var(--text-faint)]">
|
||||
{r.section}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] truncate">
|
||||
{r.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.trim() && results.length === 0 && (
|
||||
<div className="px-5 py-8 text-center text-[13px] text-[var(--text-muted)]">
|
||||
No results for “{query}”
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!query.trim() && (
|
||||
<div className="px-5 py-6 text-center text-[12px] text-[var(--text-faint)]">
|
||||
Type to search integrations, features, and demos
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-5 py-2.5 border-t border-[var(--border)] text-[10px] text-[var(--text-faint)]">
|
||||
<span>↑↓ navigate · ↵ select · esc close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { SearchModal } from "./search-modal";
|
||||
|
||||
export function SearchTrigger({
|
||||
iconOnly = false,
|
||||
}: { iconOnly?: boolean } = {}) {
|
||||
const [isMac, setIsMac] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mac =
|
||||
typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent);
|
||||
setIsMac(mac);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
if (iconOnly) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-md text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-colors cursor-pointer"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && <SearchModalWrapper onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-1.5 text-xs text-[var(--text-muted)] cursor-pointer hover:border-[var(--text-faint)] transition-colors min-w-[200px]"
|
||||
>
|
||||
<span>⌕</span>
|
||||
<span>Search docs, demos...</span>
|
||||
<span className="ml-auto font-mono text-[10px] border border-[var(--border)] px-1 py-0.5 rounded bg-[var(--bg-surface)]">
|
||||
{isMac ? "⌘K" : "Ctrl+K"}
|
||||
</span>
|
||||
</button>
|
||||
{open && <SearchModalWrapper onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchModalWrapper({ onClose }: { onClose: () => void }) {
|
||||
return <SearchModal onClose={onClose} />;
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from "vitest";
|
||||
import {
|
||||
backendUrlFromPattern,
|
||||
normalizeBackendHostPattern,
|
||||
parseLocalBackends,
|
||||
resolveBackendUrl,
|
||||
} from "./backend-url";
|
||||
|
||||
describe("backendUrlFromPattern", () => {
|
||||
it("substitutes {slug} into the host pattern and prepends https://", () => {
|
||||
expect(
|
||||
backendUrlFromPattern(
|
||||
"showcase-{slug}-production.up.railway.app",
|
||||
"mastra",
|
||||
),
|
||||
).toBe("https://showcase-mastra-production.up.railway.app");
|
||||
});
|
||||
|
||||
it("matches the registry-baked prod URL for the default pattern (byte parity)", () => {
|
||||
// generate-registry.ts synthesizes backend_url with the exact same
|
||||
// pattern + https:// prefix. The runtime derivation MUST reproduce
|
||||
// it byte-for-byte so prod behavior is unchanged with env unset.
|
||||
expect(
|
||||
backendUrlFromPattern(
|
||||
"showcase-{slug}-production.up.railway.app",
|
||||
"langgraph-python",
|
||||
),
|
||||
).toBe("https://showcase-langgraph-python-production.up.railway.app");
|
||||
});
|
||||
|
||||
it("supports a staging-style pattern override", () => {
|
||||
expect(
|
||||
backendUrlFromPattern("showcase-{slug}-staging.up.railway.app", "agno"),
|
||||
).toBe("https://showcase-agno-staging.up.railway.app");
|
||||
});
|
||||
|
||||
it("substitutes EVERY {slug} occurrence, not just the first", () => {
|
||||
expect(
|
||||
backendUrlFromPattern("{slug}.demos.example.com/{slug}", "mastra"),
|
||||
).toBe("https://mastra.demos.example.com/mastra");
|
||||
});
|
||||
|
||||
it("throws on a slug outside [a-z0-9-] (host-injection choke point)", () => {
|
||||
// Every backend URL flows through here and the slug lands in the
|
||||
// HOST of an iframe src — "." or "/" in a slug is host/path
|
||||
// injection. All registry slugs are [a-z0-9-]; anything else is a
|
||||
// contract violation, not data to be passed through.
|
||||
for (const bad of [
|
||||
"evil.example.com",
|
||||
"slug/../path",
|
||||
"$&",
|
||||
"UPPER",
|
||||
"under_score",
|
||||
"",
|
||||
"white space",
|
||||
]) {
|
||||
expect(
|
||||
() => backendUrlFromPattern("showcase-{slug}.example.com", bad),
|
||||
`slug ${JSON.stringify(bad)} should throw`,
|
||||
).toThrow(/invalid integration slug/);
|
||||
}
|
||||
// The registry's real slug shapes all pass.
|
||||
for (const ok of ["mastra", "langgraph-python", "ag2", "ms-agent-dotnet"]) {
|
||||
expect(() =>
|
||||
backendUrlFromPattern("showcase-{slug}.example.com", ok),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeBackendHostPattern", () => {
|
||||
let warns: string[];
|
||||
let warnSpy: MockInstance<typeof console.warn>;
|
||||
let normalizeFresh: typeof normalizeBackendHostPattern;
|
||||
|
||||
beforeEach(async () => {
|
||||
// The warn-once guard is module state, which makes warning
|
||||
// assertions non-idempotent under vitest --retry — use a fresh
|
||||
// module instance per test.
|
||||
vi.resetModules();
|
||||
normalizeFresh = (await import("./backend-url"))
|
||||
.normalizeBackendHostPattern;
|
||||
warns = [];
|
||||
warnSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation((m: string) => void warns.push(m));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("passes a well-formed pattern through untouched, no warning", () => {
|
||||
expect(normalizeFresh("showcase-{slug}-production.up.railway.app")).toBe(
|
||||
"showcase-{slug}-production.up.railway.app",
|
||||
);
|
||||
expect(warns).toEqual([]);
|
||||
});
|
||||
|
||||
it("strips a leading scheme (consumer prepends https://) and warns", () => {
|
||||
// A scheme-bearing env value would otherwise yield `https://https://…`.
|
||||
expect(
|
||||
normalizeFresh("https://showcase-{slug}-staging.up.railway.app"),
|
||||
).toBe("showcase-{slug}-staging.up.railway.app");
|
||||
expect(warns.some((m) => m.includes("scheme"))).toBe(true);
|
||||
});
|
||||
|
||||
it("trims trailing slashes (route concat would yield //route) and warns", () => {
|
||||
expect(normalizeFresh("showcase-{slug}-staging.up.railway.app/")).toBe(
|
||||
"showcase-{slug}-staging.up.railway.app",
|
||||
);
|
||||
expect(warns.some((m) => m.includes("trailing"))).toBe(true);
|
||||
});
|
||||
|
||||
it("warns when the pattern lacks {slug} (all integrations → one host)", () => {
|
||||
expect(normalizeFresh("showcase-static.example.com")).toBe(
|
||||
"showcase-static.example.com",
|
||||
);
|
||||
expect(warns.some((m) => m.includes("{slug}"))).toBe(true);
|
||||
});
|
||||
|
||||
it("trims leading/trailing whitespace (paste artifact) and warns", () => {
|
||||
// Previously the ONE misconfig class with zero warning — a pasted
|
||||
// ` host` survives into `https:// host` iframe srcs.
|
||||
expect(normalizeFresh(" showcase-{slug}-staging.up.railway.app\t")).toBe(
|
||||
"showcase-{slug}-staging.up.railway.app",
|
||||
);
|
||||
expect(warns.some((m) => m.includes("whitespace"))).toBe(true);
|
||||
});
|
||||
|
||||
it("strips internal tab/CR/LF instead of shipping raw control characters", () => {
|
||||
// The WHATWG URL parser deletes \t\r\n BEFORE parsing, so a
|
||||
// tab-bearing pattern passed the usability gate (the probe parsed
|
||||
// fine) while the RAW control character shipped in every iframe
|
||||
// src — and the old warn text claimed it would "fall back". Strip
|
||||
// them exactly the way the parser would, and warn.
|
||||
expect(normalizeFresh("showcase-{slug}\t-staging.up.railway.app")).toBe(
|
||||
"showcase-{slug}-staging.up.railway.app",
|
||||
);
|
||||
expect(normalizeFresh("show\rcase-{slug}.example\n.com")).toBe(
|
||||
"showcase-{slug}.example.com",
|
||||
);
|
||||
expect(warns.some((m) => m.includes("whitespace"))).toBe(true);
|
||||
});
|
||||
|
||||
it("whitespace warn text matches the actual outcomes (host position falls back, path position ships)", () => {
|
||||
// The old text claimed internal whitespace "yields a broken backend
|
||||
// host" unconditionally — but HOST-position whitespace fails the
|
||||
// usability gate and gets the DEFAULT fallback (it never ships),
|
||||
// while PATH-position whitespace parses and genuinely ships broken.
|
||||
normalizeFresh(" showcase-{slug}.example.com ");
|
||||
const w = warns.find((m) => m.includes("whitespace"));
|
||||
expect(w).toBeDefined();
|
||||
expect(w).toContain("falls back");
|
||||
expect(w).toContain("path");
|
||||
expect(w).not.toContain("yields a broken backend host");
|
||||
});
|
||||
|
||||
it("canonicalizes the authority like the override path (lowercase host, :443 elided)", () => {
|
||||
// The override path returns the parsed-normalized form (lowercase
|
||||
// host, default port elided) — the pattern path shipped the RAW
|
||||
// string into iframe srcs, leaking uppercase hosts and an explicit
|
||||
// :443. Hosts are case-insensitive and the consumer always
|
||||
// prepends https://, so this matches what the URL parser does to
|
||||
// the composed URL anyway. The {slug} placeholder is lowercase and
|
||||
// survives verbatim.
|
||||
expect(normalizeFresh("SHOWCASE-{slug}-Staging.UP.Railway.App:443")).toBe(
|
||||
"showcase-{slug}-staging.up.railway.app",
|
||||
);
|
||||
// :80 is NOT the https default — a real port must survive.
|
||||
expect(normalizeFresh("showcase-{slug}.example.com:80")).toBe(
|
||||
"showcase-{slug}.example.com:80",
|
||||
);
|
||||
// Path segments are case-SENSITIVE and stay untouched.
|
||||
expect(normalizeFresh("Backends.example.com/Base/{slug}")).toBe(
|
||||
"backends.example.com/Base/{slug}",
|
||||
);
|
||||
});
|
||||
|
||||
it("names EVERY stripped scheme on a stacked-scheme pattern", () => {
|
||||
// The strip loop is once-guarded per pattern — the second
|
||||
// iteration's warn was swallowed, so only the first scheme was ever
|
||||
// named and the log understated what was removed.
|
||||
normalizeFresh("https://http://showcase-{slug}.example.com");
|
||||
const w = warns.find((m) => m.includes("scheme"));
|
||||
expect(w).toBeDefined();
|
||||
expect(w).toContain('"https://"');
|
||||
expect(w).toContain('"http://"');
|
||||
});
|
||||
|
||||
it("warns once per distinct pattern value, not per call", () => {
|
||||
const pattern = "https://warn-once-{slug}.example.com/";
|
||||
normalizeFresh(pattern);
|
||||
const after = warns.length;
|
||||
expect(after).toBeGreaterThan(0);
|
||||
normalizeFresh(pattern);
|
||||
expect(warns.length).toBe(after);
|
||||
});
|
||||
|
||||
it("warns when the pattern carries an internal path segment (bare-host contract)", () => {
|
||||
// `host.app/base/{slug}` silently violates the documented bare-host
|
||||
// contract — the consumer prepends https:// and concatenates routes,
|
||||
// so a base path lands in every backend URL unannounced.
|
||||
expect(normalizeFresh("backends.example.com/base/{slug}")).toBe(
|
||||
"backends.example.com/base/{slug}",
|
||||
);
|
||||
expect(warns.some((m) => m.includes("path"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fire the path warning for a path-free pattern", () => {
|
||||
normalizeFresh("showcase-{slug}-production.up.railway.app");
|
||||
expect(warns.some((m) => m.includes("path segment"))).toBe(false);
|
||||
});
|
||||
|
||||
it("strips a stacked scheme to convergence (https://https://host)", () => {
|
||||
// The strip previously ran ONCE — `https://https://host` left a
|
||||
// scheme behind, and the consumer prepends https:// on top: a
|
||||
// double-prepended garbage host.
|
||||
expect(normalizeFresh("https://https://showcase-{slug}.example.com")).toBe(
|
||||
"showcase-{slug}.example.com",
|
||||
);
|
||||
expect(warns.some((m) => m.includes("scheme"))).toBe(true);
|
||||
});
|
||||
|
||||
describe("degenerate patterns fall back to the default with one FATAL", () => {
|
||||
let errs: string[];
|
||||
let errSpy: MockInstance<typeof console.error>;
|
||||
|
||||
beforeEach(() => {
|
||||
// The FATAL-CONFIG error (with Railway guidance) is the PROD
|
||||
// posture — in dev the same fallback logs a warn instead (see the
|
||||
// dev-mode test below). NODE_ENV is read at call time.
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
errs = [];
|
||||
errSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation((m: string) => void errs.push(m));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errSpy.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["bare scheme", "https://"],
|
||||
["lone slash", "/"],
|
||||
["whitespace only", " "],
|
||||
["internal whitespace (unparseable host)", "ho st-{slug}.example.com"],
|
||||
])(
|
||||
"%s (%j) yields the default pattern, never an empty string",
|
||||
(_label, bad) => {
|
||||
const result = normalizeFresh(bad);
|
||||
expect(result).toBe("showcase-{slug}-production.up.railway.app");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
errs.some(
|
||||
(m) =>
|
||||
m.includes("FATAL-CONFIG") &&
|
||||
m.includes("SHOWCASE_BACKEND_HOST_PATTERN"),
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a credentialed pattern (userinfo) with the default fallback + one FATAL", () => {
|
||||
// A credentialed pattern yields iframe srcs that Chromium
|
||||
// silently blocks — the integration pane just never loads, with
|
||||
// zero signal. Same userinfo rejection validateBaseUrl has
|
||||
// (runtime-config.ts).
|
||||
for (const bad of [
|
||||
"user:pass@showcase-{slug}.example.com",
|
||||
// Scheme-bearing form: the strip leaves the credentials behind.
|
||||
"https://user:pass@showcase-{slug}.example.com",
|
||||
]) {
|
||||
const result = normalizeFresh(bad);
|
||||
expect(result, `pattern ${JSON.stringify(bad)}`).toBe(
|
||||
"showcase-{slug}-production.up.railway.app",
|
||||
);
|
||||
expect(
|
||||
errs.some(
|
||||
(m) =>
|
||||
m.includes("FATAL-CONFIG") &&
|
||||
m.includes("SHOWCASE_BACKEND_HOST_PATTERN") &&
|
||||
m.includes("userinfo"),
|
||||
),
|
||||
`pattern ${JSON.stringify(bad)} should log a userinfo FATAL`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a pattern carrying a query or fragment with the default fallback + one FATAL", () => {
|
||||
// Route concatenation appends demo routes to the composed URL —
|
||||
// `https://host?x=1` + `/route` yields `https://host?x=1/route`,
|
||||
// corrupting EVERY backend URL. Same gate class as userinfo.
|
||||
for (const [bad, component] of [
|
||||
["showcase-{slug}.example.com?x=1", "query"],
|
||||
["showcase-{slug}.example.com#frag", "fragment"],
|
||||
] as const) {
|
||||
const result = normalizeFresh(bad);
|
||||
expect(result, `pattern ${JSON.stringify(bad)}`).toBe(
|
||||
"showcase-{slug}-production.up.railway.app",
|
||||
);
|
||||
expect(
|
||||
errs.some(
|
||||
(m) =>
|
||||
m.includes("FATAL-CONFIG") &&
|
||||
m.includes("SHOWCASE_BACKEND_HOST_PATTERN") &&
|
||||
m.includes(component),
|
||||
),
|
||||
`pattern ${JSON.stringify(bad)} should log a ${component} FATAL`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an empty-userinfo '@' in the authority (empty username/password evade the getters)", () => {
|
||||
// `https://@host` and `https://:@host` parse with username ===
|
||||
// "" AND password === "" — the present-but-EMPTY userinfo slips
|
||||
// the getter check, so the RAW `@`-bearing string previously
|
||||
// shipped into every iframe src.
|
||||
for (const bad of [
|
||||
"@showcase-{slug}.example.com",
|
||||
":@showcase-{slug}.example.com",
|
||||
]) {
|
||||
const result = normalizeFresh(bad);
|
||||
expect(result, `pattern ${JSON.stringify(bad)}`).toBe(
|
||||
"showcase-{slug}-production.up.railway.app",
|
||||
);
|
||||
expect(
|
||||
errs.some(
|
||||
(m) =>
|
||||
m.includes("FATAL-CONFIG") &&
|
||||
m.includes("SHOWCASE_BACKEND_HOST_PATTERN") &&
|
||||
m.includes("userinfo"),
|
||||
),
|
||||
`pattern ${JSON.stringify(bad)} should log a userinfo FATAL`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a bare trailing '?' / '#' (empty component evades the WHATWG getters)", () => {
|
||||
// `https://host?` parses with search === "" and `https://host#`
|
||||
// with hash === "" — a present-but-EMPTY component slips the
|
||||
// probe getters, so the RAW string (literal `?`/`#` included)
|
||||
// previously shipped, and route concatenation swallowed every
|
||||
// demo route into the query/fragment.
|
||||
for (const [bad, component] of [
|
||||
["showcase-{slug}.example.com?", "query"],
|
||||
["showcase-{slug}.example.com#", "fragment"],
|
||||
] as const) {
|
||||
const result = normalizeFresh(bad);
|
||||
expect(result, `pattern ${JSON.stringify(bad)}`).toBe(
|
||||
"showcase-{slug}-production.up.railway.app",
|
||||
);
|
||||
expect(
|
||||
errs.some(
|
||||
(m) =>
|
||||
m.includes("FATAL-CONFIG") &&
|
||||
m.includes("SHOWCASE_BACKEND_HOST_PATTERN") &&
|
||||
m.includes(component),
|
||||
),
|
||||
`pattern ${JSON.stringify(bad)} should log a ${component} FATAL`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("calls out a stray scheme fragment (host literally http/https) instead of claiming unparseable", () => {
|
||||
// `https:/host` (missing one slash, so SCHEME_RE never strips
|
||||
// it) probes to `https://https:/host` — hostname "https". The
|
||||
// value DOES parse, so the old "cannot form a parseable backend
|
||||
// URL" text was a lie that hid the actual problem: a stray
|
||||
// scheme fragment in host position.
|
||||
for (const bad of ["https:/showcase-{slug}.example.com", "http"]) {
|
||||
const result = normalizeFresh(bad);
|
||||
expect(result, `pattern ${JSON.stringify(bad)}`).toBe(
|
||||
"showcase-{slug}-production.up.railway.app",
|
||||
);
|
||||
const e = errs.find(
|
||||
(m) => m.includes("FATAL-CONFIG") && m.includes(JSON.stringify(bad)),
|
||||
);
|
||||
expect(e, `pattern ${JSON.stringify(bad)} should FATAL`).toBeDefined();
|
||||
expect(e).toContain("stray scheme fragment");
|
||||
expect(e).not.toContain("cannot form a parseable");
|
||||
}
|
||||
});
|
||||
|
||||
it("FATAL fires once per distinct value, not per call", () => {
|
||||
normalizeFresh("https://");
|
||||
const after = errs.length;
|
||||
expect(after).toBeGreaterThan(0);
|
||||
normalizeFresh("https://");
|
||||
expect(errs.length).toBe(after);
|
||||
});
|
||||
|
||||
it("a well-formed pattern never trips the fallback or the FATAL", () => {
|
||||
expect(normalizeFresh("showcase-{slug}-staging.up.railway.app")).toBe(
|
||||
"showcase-{slug}-staging.up.railway.app",
|
||||
);
|
||||
expect(errs).toEqual([]);
|
||||
});
|
||||
|
||||
it("dev-mode degenerate pattern warns (no FATAL-CONFIG / Railway guidance) and still falls back", () => {
|
||||
// Same dev-vs-prod branch validateBaseUrl has (runtime-config.ts):
|
||||
// Railway guidance is useless on a laptop — dev logs a warn, prod
|
||||
// keeps the FATAL-CONFIG error. The fallback VALUE is identical.
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
expect(normalizeFresh("https://")).toBe(
|
||||
"showcase-{slug}-production.up.railway.app",
|
||||
);
|
||||
expect(errs).toEqual([]);
|
||||
expect(
|
||||
warns.some((m) => m.includes("SHOWCASE_BACKEND_HOST_PATTERN")),
|
||||
).toBe(true);
|
||||
expect(warns.some((m) => m.includes("Railway"))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLocalBackends", () => {
|
||||
let warns: string[];
|
||||
let warnSpy: MockInstance<typeof console.warn>;
|
||||
let parseFresh: typeof parseLocalBackends;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Warn-once + memo module state: a fresh module instance per test
|
||||
// keeps the warning assertions order-independent and retry-safe.
|
||||
vi.resetModules();
|
||||
parseFresh = (await import("./backend-url")).parseLocalBackends;
|
||||
warns = [];
|
||||
warnSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation((m: string) => void warns.push(m));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns {} for undefined / empty / invalid JSON", () => {
|
||||
expect(parseFresh(undefined)).toEqual({});
|
||||
expect(parseFresh("")).toEqual({});
|
||||
expect(parseFresh("not json")).toEqual({});
|
||||
});
|
||||
|
||||
it("names the env var in a warning when the JSON is unparseable", () => {
|
||||
expect(parseFresh("not json")).toEqual({});
|
||||
expect(warns.some((m) => m.includes("NEXT_PUBLIC_LOCAL_BACKENDS"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores non-object JSON top-levels (array / string / null) with a warning", () => {
|
||||
expect(parseFresh("[1,2]")).toEqual({});
|
||||
expect(parseFresh('"str"')).toEqual({});
|
||||
expect(parseFresh("null")).toEqual({});
|
||||
expect(warns.filter((m) => m.includes("not a JSON object")).length).toBe(3);
|
||||
});
|
||||
|
||||
it("parses a slug->url map", () => {
|
||||
expect(parseFresh('{"mastra":"http://localhost:4111"}')).toEqual({
|
||||
mastra: "http://localhost:4111",
|
||||
});
|
||||
expect(warns).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips (and warns about) non-string values instead of passing them through", () => {
|
||||
expect(
|
||||
parseFresh(
|
||||
'{"mastra":"http://localhost:4111","agno":4111,"crewai":null}',
|
||||
),
|
||||
).toEqual({ mastra: "http://localhost:4111" });
|
||||
expect(warns.some((m) => m.includes("agno"))).toBe(true);
|
||||
expect(warns.some((m) => m.includes("crewai"))).toBe(true);
|
||||
});
|
||||
|
||||
it("warns once per distinct raw value, not per call", () => {
|
||||
parseFresh("not json");
|
||||
parseFresh("not json");
|
||||
expect(warns.filter((m) => m.includes("not valid JSON")).length).toBe(1);
|
||||
});
|
||||
|
||||
it("memoizes the parse on the raw string (same object identity per raw)", () => {
|
||||
// Identity is the OBSERVABLE memo contract. (A global JSON.parse
|
||||
// call-count spy used to assert "called once" — fragile: ANY other
|
||||
// code touching JSON.parse during the test, including vitest
|
||||
// internals, breaks it for reasons unrelated to this module.)
|
||||
const raw = '{"mastra":"http://localhost:4111"}';
|
||||
const first = parseFresh(raw);
|
||||
const second = parseFresh(raw);
|
||||
expect(second).toEqual({ mastra: "http://localhost:4111" });
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("does not poison the memo when the uncached parse throws mid-compute", () => {
|
||||
// The memo key (raw) was committed BEFORE the value was computed —
|
||||
// if the compute throws (console.warn is a foreign call, and a
|
||||
// logging shim CAN throw), the next call with the same raw found
|
||||
// the key already cached and returned the PREVIOUS raw's value.
|
||||
const good = parseFresh('{"mastra":"http://localhost:4111"}');
|
||||
expect(good).toEqual({ mastra: "http://localhost:4111" });
|
||||
// Make the warn inside the compute throw once ("not json" warns).
|
||||
warnSpy.mockImplementationOnce(() => {
|
||||
throw new Error("logging shim exploded");
|
||||
});
|
||||
expect(() => parseFresh("not json")).toThrow("logging shim exploded");
|
||||
// Same raw again (warn restored): must NOT return the stale good
|
||||
// map committed under the previous raw.
|
||||
expect(parseFresh("not json")).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps a __proto__ key as map data instead of silently dropping it", () => {
|
||||
// The accumulator was a plain `{}` — assigning `backends["__proto__"]`
|
||||
// hits the Object.prototype setter and is a silent no-op, so the
|
||||
// entry vanished with NO warning (every other rejected entry warns).
|
||||
// A null-prototype accumulator makes it an ordinary own property.
|
||||
const parsed = parseFresh(
|
||||
'{"__proto__":"http://localhost:4111","mastra":"http://localhost:4112"}',
|
||||
);
|
||||
expect(Object.keys(parsed)).toContain("__proto__");
|
||||
expect(parsed["__proto__"]).toBe("http://localhost:4111");
|
||||
expect(parsed["mastra"]).toBe("http://localhost:4112");
|
||||
// And it must land as DATA — never as prototype pollution.
|
||||
expect(({} as Record<string, unknown>).mastra).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns frozen objects (the memo is shared across every caller)", () => {
|
||||
// The memoized object is handed to EVERY caller — a consumer
|
||||
// mutating its "own" map would silently change the local-backend
|
||||
// overrides for the whole process.
|
||||
const parsed = parseFresh('{"mastra":"http://localhost:4111"}');
|
||||
expect(Object.isFrozen(parsed)).toBe(true);
|
||||
expect(() => {
|
||||
(parsed as Record<string, string>).mastra = "http://evil.example.com";
|
||||
}).toThrow(TypeError);
|
||||
// The unset/empty/invalid paths return shared objects too.
|
||||
expect(Object.isFrozen(parseFresh(undefined))).toBe(true);
|
||||
expect(Object.isFrozen(parseFresh("not json"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBackendUrl", () => {
|
||||
const ORIGINAL = process.env.NEXT_PUBLIC_LOCAL_BACKENDS;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_LOCAL_BACKENDS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL === undefined) {
|
||||
delete process.env.NEXT_PUBLIC_LOCAL_BACKENDS;
|
||||
} else {
|
||||
process.env.NEXT_PUBLIC_LOCAL_BACKENDS = ORIGINAL;
|
||||
}
|
||||
});
|
||||
|
||||
it("derives from the pattern when no local backend override exists", () => {
|
||||
expect(
|
||||
resolveBackendUrl("mastra", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("https://showcase-mastra-production.up.railway.app");
|
||||
});
|
||||
|
||||
it("prefers NEXT_PUBLIC_LOCAL_BACKENDS in local dev (behavior preserved)", () => {
|
||||
process.env.NEXT_PUBLIC_LOCAL_BACKENDS = JSON.stringify({
|
||||
mastra: "http://localhost:4111",
|
||||
});
|
||||
expect(
|
||||
resolveBackendUrl("mastra", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("http://localhost:4111");
|
||||
// Slugs absent from the local map still derive from the pattern.
|
||||
expect(
|
||||
resolveBackendUrl("agno", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("https://showcase-agno-production.up.railway.app");
|
||||
});
|
||||
|
||||
it("trims trailing slashes from an accepted local override (host//route class)", () => {
|
||||
// The pattern path guarantees trailing-slash normalization
|
||||
// (normalizeBackendHostPattern) — an override skipping it yields
|
||||
// `host//route` when consumers concatenate demo routes.
|
||||
process.env.NEXT_PUBLIC_LOCAL_BACKENDS = JSON.stringify({
|
||||
mastra: "http://localhost:4111/",
|
||||
agno: "http://localhost:4112//",
|
||||
});
|
||||
expect(
|
||||
resolveBackendUrl("mastra", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("http://localhost:4111");
|
||||
expect(
|
||||
resolveBackendUrl("agno", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("http://localhost:4112");
|
||||
});
|
||||
|
||||
it("returns the parsed-normalized form of an accepted override (host case, default port)", () => {
|
||||
// The override is already parsed for validation — returning the raw
|
||||
// string leaked un-normalized values (uppercase hosts, explicit
|
||||
// default ports) into iframe srcs, while the pattern path always
|
||||
// yields canonical hosts.
|
||||
process.env.NEXT_PUBLIC_LOCAL_BACKENDS = JSON.stringify({
|
||||
mastra: "http://LOCALHOST:4111/Api/",
|
||||
agno: "https://Proxy.Example.COM:443",
|
||||
});
|
||||
expect(
|
||||
resolveBackendUrl("mastra", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("http://localhost:4111/Api");
|
||||
expect(
|
||||
resolveBackendUrl("agno", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("https://proxy.example.com");
|
||||
});
|
||||
|
||||
it("warns about and ignores an empty-string local override instead of yielding an empty URL", async () => {
|
||||
// A `??` fallback treated `{"mastra": ""}` as a valid override and
|
||||
// returned "" — which renders an iframe src of just the route. The
|
||||
// emptiness skip then happened BEFORE the warn block, so the dead
|
||||
// override was ignored with ZERO signal — the one bad-override
|
||||
// class that never warned. Fresh module instance: the warn-once
|
||||
// latch is module state.
|
||||
vi.resetModules();
|
||||
const { resolveBackendUrl: resolveFresh } = await import("./backend-url");
|
||||
const warns: string[] = [];
|
||||
const warnSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation((m: string) => void warns.push(m));
|
||||
try {
|
||||
process.env.NEXT_PUBLIC_LOCAL_BACKENDS = JSON.stringify({ mastra: "" });
|
||||
expect(
|
||||
resolveFresh("mastra", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("https://showcase-mastra-production.up.railway.app");
|
||||
expect(
|
||||
warns.some((m) => m.includes("mastra") && m.includes("empty")),
|
||||
"ignoring an empty override should warn, naming the slug",
|
||||
).toBe(true);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("warns and ignores a local override with a non-http(s) scheme (iframe-src injection guard)", async () => {
|
||||
// `javascript://...` and `ftp://...` are scheme-bearing AND
|
||||
// parseable, so they previously passed straight into iframe srcs.
|
||||
// Fresh module instance: the warn-once latch is module state.
|
||||
vi.resetModules();
|
||||
const { resolveBackendUrl: resolveFresh } = await import("./backend-url");
|
||||
const warns: string[] = [];
|
||||
const warnSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation((m: string) => void warns.push(m));
|
||||
try {
|
||||
process.env.NEXT_PUBLIC_LOCAL_BACKENDS = JSON.stringify({
|
||||
mastra: "javascript://alert(1)",
|
||||
agno: "ftp://files.example.com",
|
||||
crewai: "http://localhost:4112",
|
||||
});
|
||||
expect(
|
||||
resolveFresh("mastra", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("https://showcase-mastra-production.up.railway.app");
|
||||
expect(
|
||||
resolveFresh("agno", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("https://showcase-agno-production.up.railway.app");
|
||||
// http(s) overrides still win.
|
||||
expect(
|
||||
resolveFresh("crewai", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("http://localhost:4112");
|
||||
expect(warns.some((m) => m.includes("mastra"))).toBe(true);
|
||||
expect(warns.some((m) => m.includes("agno"))).toBe(true);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("warns and ignores a local override carrying userinfo, query, or fragment components", async () => {
|
||||
// An override lands verbatim in iframe srcs and route concatenation:
|
||||
// userinfo gets silently blocked by Chromium (same rejection the
|
||||
// pattern path has), and a query/fragment corrupts every composed
|
||||
// URL (`http://host?x=1` + `/route` → `http://host?x=1/route`).
|
||||
// Fresh module instance: the warn-once latch is module state.
|
||||
vi.resetModules();
|
||||
const { resolveBackendUrl: resolveFresh } = await import("./backend-url");
|
||||
const warns: string[] = [];
|
||||
const warnSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation((m: string) => void warns.push(m));
|
||||
try {
|
||||
process.env.NEXT_PUBLIC_LOCAL_BACKENDS = JSON.stringify({
|
||||
mastra: "http://user:pass@localhost:4111",
|
||||
agno: "http://localhost:4112?x=1",
|
||||
crewai: "http://localhost:4113#frag",
|
||||
adk: "http://localhost:4114",
|
||||
});
|
||||
for (const slug of ["mastra", "agno", "crewai"]) {
|
||||
expect(
|
||||
resolveFresh(slug, "showcase-{slug}-production.up.railway.app"),
|
||||
`override for "${slug}" should be rejected`,
|
||||
).toBe(`https://showcase-${slug}-production.up.railway.app`);
|
||||
expect(
|
||||
warns.some((m) => m.includes(slug)),
|
||||
`rejecting "${slug}" should warn`,
|
||||
).toBe(true);
|
||||
}
|
||||
// A clean http(s) override still wins.
|
||||
expect(
|
||||
resolveFresh("adk", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("http://localhost:4114");
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("trims a whitespace-padded local override before validating it (paste artifact)", async () => {
|
||||
// The pattern path trims paste artifacts (normalizeBackendHostPattern)
|
||||
// — the override path rejected " http://localhost:4111" outright,
|
||||
// because the SCHEME_RE anchor fails on the leading space even
|
||||
// though the URL parser accepts the value. Align the philosophy:
|
||||
// trim first, then validate. Whitespace-ONLY collapses to the
|
||||
// empty-override warn path.
|
||||
vi.resetModules();
|
||||
const { resolveBackendUrl: resolveFresh } = await import("./backend-url");
|
||||
const warns: string[] = [];
|
||||
const warnSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation((m: string) => void warns.push(m));
|
||||
try {
|
||||
process.env.NEXT_PUBLIC_LOCAL_BACKENDS = JSON.stringify({
|
||||
mastra: " http://localhost:4111\t",
|
||||
agno: " ",
|
||||
});
|
||||
expect(
|
||||
resolveFresh("mastra", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("http://localhost:4111");
|
||||
expect(warns.some((m) => m.includes("mastra"))).toBe(false);
|
||||
expect(
|
||||
resolveFresh("agno", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("https://showcase-agno-production.up.railway.app");
|
||||
expect(warns.some((m) => m.includes("agno") && m.includes("empty"))).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("explains WHY a slashless-but-parseable override (http:localhost:4111) is rejected", async () => {
|
||||
// `http:localhost:4111` IS parseable (special schemes tolerate
|
||||
// missing slashes — it parses to http://localhost:4111/), so the
|
||||
// old "is not a plain parseable http(s) base URL" warn sent the
|
||||
// developer chasing a parse problem that doesn't exist. The real
|
||||
// rejection is the explicit `scheme://` requirement.
|
||||
vi.resetModules();
|
||||
const { resolveBackendUrl: resolveFresh } = await import("./backend-url");
|
||||
const warns: string[] = [];
|
||||
const warnSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation((m: string) => void warns.push(m));
|
||||
try {
|
||||
process.env.NEXT_PUBLIC_LOCAL_BACKENDS = JSON.stringify({
|
||||
mastra: "http:localhost:4111",
|
||||
});
|
||||
expect(
|
||||
resolveFresh("mastra", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("https://showcase-mastra-production.up.railway.app");
|
||||
const w = warns.find((m) => m.includes("mastra"));
|
||||
expect(w).toBeDefined();
|
||||
expect(w).toContain("://");
|
||||
expect(w).not.toContain("not a plain parseable");
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("warns and ignores a local override that is not a scheme-bearing parseable URL", async () => {
|
||||
// `{"mastra": "localhost:4111"}` (no scheme) or outright garbage
|
||||
// would land verbatim in an iframe src — fall back to the pattern
|
||||
// instead, with a warn.
|
||||
//
|
||||
// Fresh module instance: the warn-once latch is module state, so
|
||||
// asserting on the STATIC import is not retry-safe (a --retry rerun
|
||||
// finds the latch already consumed) — same discipline as the
|
||||
// sibling describes.
|
||||
vi.resetModules();
|
||||
const { resolveBackendUrl: resolveFresh } = await import("./backend-url");
|
||||
const warns: string[] = [];
|
||||
const warnSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation((m: string) => void warns.push(m));
|
||||
try {
|
||||
process.env.NEXT_PUBLIC_LOCAL_BACKENDS = JSON.stringify({
|
||||
mastra: "localhost:4111",
|
||||
agno: "http://exa mple/",
|
||||
crewai: "http://localhost:4112",
|
||||
});
|
||||
expect(
|
||||
resolveFresh("mastra", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("https://showcase-mastra-production.up.railway.app");
|
||||
expect(
|
||||
resolveFresh("agno", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("https://showcase-agno-production.up.railway.app");
|
||||
// A well-formed override still wins.
|
||||
expect(
|
||||
resolveFresh("crewai", "showcase-{slug}-production.up.railway.app"),
|
||||
).toBe("http://localhost:4112");
|
||||
expect(warns.some((m) => m.includes("mastra"))).toBe(true);
|
||||
expect(warns.some((m) => m.includes("agno"))).toBe(true);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,484 @@
|
||||
// Runtime derivation of integration backend URLs.
|
||||
//
|
||||
// The registry's `backend_url` is synthesized at Docker BUILD time by
|
||||
// scripts/generate-registry.ts, which bakes the production hostnames
|
||||
// into every image — so a staging shell iframed prod integrations.
|
||||
// These helpers derive the backend host at REQUEST time from the
|
||||
// `backendHostPattern` carried in RuntimeConfig (env var
|
||||
// SHOWCASE_BACKEND_HOST_PATTERN, default = the prod pattern), keeping
|
||||
// registry.json as the source for non-URL metadata only.
|
||||
//
|
||||
// Pattern semantics are IDENTICAL to generate-registry.ts: the pattern
|
||||
// is a bare host with `{slug}` as the only placeholder, and `https://`
|
||||
// is prepended. Keep the two in sync — they consume the same env var.
|
||||
//
|
||||
// This module is import-safe from client components, server components,
|
||||
// and middleware (pure functions, no next/* imports).
|
||||
|
||||
// Matches an explicit URL scheme prefix (e.g. `https://`, `http://`).
|
||||
// Exported as the single source of truth — runtime-config.ts shares it
|
||||
// (this module is import-safe everywhere, so the dependency is free).
|
||||
export const SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
||||
|
||||
// Default backend host pattern — reproduces today's baked prod values
|
||||
// exactly, so a deploy with the env var unset (i.e. current prod)
|
||||
// behaves byte-identically. Lives HERE (not runtime-config.ts, which
|
||||
// re-exports it) because normalizeBackendHostPattern falls back to it
|
||||
// for degenerate values and runtime-config already imports this module
|
||||
// — defining it there would create an import cycle.
|
||||
export const DEFAULT_BACKEND_HOST_PATTERN =
|
||||
"showcase-{slug}-production.up.railway.app";
|
||||
|
||||
// Warn once per distinct (pattern, issue) — config is re-read every
|
||||
// request, and per-request warn spam would drown real signal.
|
||||
const patternWarnings = new Set<string>();
|
||||
|
||||
function warnPatternOnce(key: string, message: string): void {
|
||||
if (patternWarnings.has(key)) return;
|
||||
patternWarnings.add(key);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[backend-url] SHOWCASE_BACKEND_HOST_PATTERN ${message}`);
|
||||
}
|
||||
|
||||
// FATAL once per distinct degenerate pattern value — parity with the
|
||||
// FATAL-CONFIG once-guards in runtime-config.ts (readUrl/readDocsHost).
|
||||
const patternFatals = new Set<string>();
|
||||
|
||||
// Same dev-vs-prod branch as validateBaseUrl/readDocsHost
|
||||
// (runtime-config.ts): in production this is a FATAL-CONFIG error with
|
||||
// Railway guidance; in dev it degrades to a warn — Railway guidance is
|
||||
// useless on a laptop, and the dev contract is frictionless iteration.
|
||||
// NODE_ENV is read at CALL time (this module is import-safe everywhere;
|
||||
// Next statically inlines the literal read in client bundles).
|
||||
function fatalPatternOnce(key: string, message: string): void {
|
||||
if (patternFatals.has(key)) return;
|
||||
patternFatals.add(key);
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[backend-url] FATAL-CONFIG: SHOWCASE_BACKEND_HOST_PATTERN ${message} ` +
|
||||
`Fix the SHOWCASE_BACKEND_HOST_PATTERN env var on the Railway service.`,
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[backend-url] SHOWCASE_BACKEND_HOST_PATTERN ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Can the normalized pattern actually form a backend URL? Probe by
|
||||
* substituting a registry-shaped slug and parsing the consumer's exact
|
||||
* composition (`https://` + pattern). Catches the degenerate classes
|
||||
* the per-issue normalizations can't fix: empty results ("https://" or
|
||||
* "/" normalize to ""), internal whitespace, and anything else
|
||||
* `new URL` rejects or that parses without a real host.
|
||||
*
|
||||
* Returns `undefined` when usable, otherwise a reason discriminant so
|
||||
* the FATAL can be honest: a probe whose HOST is literally
|
||||
* "http"/"https" (e.g. `https:/host` — one slash short, so SCHEME_RE
|
||||
* never strips it) DOES parse — calling it "unparseable" would hide
|
||||
* the actual problem (a stray scheme fragment in host position).
|
||||
*/
|
||||
function patternUsabilityProblem(
|
||||
normalized: string,
|
||||
): "unparseable" | "stray-scheme-fragment" | undefined {
|
||||
if (normalized.length === 0) return "unparseable";
|
||||
try {
|
||||
const probe = new URL(
|
||||
`https://${normalized.replaceAll("{slug}", "probe")}`,
|
||||
);
|
||||
if (probe.hostname.length === 0) return "unparseable";
|
||||
if (/^https?$/i.test(probe.hostname)) return "stray-scheme-fragment";
|
||||
return undefined;
|
||||
} catch {
|
||||
return "unparseable";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the pattern carry a component no backend base URL may have?
|
||||
* Detected on the LITERAL delimiter characters (see inline comments —
|
||||
* the WHATWG getters return "" for present-but-empty components, so a
|
||||
* probe-based check leaks bare `?`/`#`/`@`).
|
||||
* Returns a human-readable component name for the FATAL log, or
|
||||
* undefined when the pattern is clean. Three rejected classes (same
|
||||
* gates validateBaseUrl has in runtime-config.ts):
|
||||
*
|
||||
* - userinfo credentials: a credentialed pattern yields iframe srcs
|
||||
* that Chromium silently blocks — the integration pane just never
|
||||
* loads, with zero signal;
|
||||
* - query / fragment: consumers concatenate demo routes onto the
|
||||
* composed URL, so `https://host?x=1` + `/route` yields
|
||||
* `https://host?x=1/route` — every backend URL ships corrupted.
|
||||
*/
|
||||
function patternForbiddenComponent(normalized: string): string | undefined {
|
||||
// Query/fragment are detected on the LITERAL characters, not the
|
||||
// probe getters: WHATWG getters return "" for a present-but-EMPTY
|
||||
// component (`https://host?` has search === ""), so a bare trailing
|
||||
// `?`/`#` would slip a getter check while the raw character still
|
||||
// ships in the pattern — and route concatenation then swallows every
|
||||
// demo route into the query/fragment. The literal check is strictly
|
||||
// stronger than the getter check (a non-empty search/hash implies the
|
||||
// literal character), so the getters aren't consulted at all. Which
|
||||
// component a literal introduces follows URL precedence: `#` starts
|
||||
// the fragment; `?` starts a query only when no `#` precedes it.
|
||||
const hashAt = normalized.indexOf("#");
|
||||
const queryAt = normalized.indexOf("?");
|
||||
if (queryAt !== -1 && (hashAt === -1 || queryAt < hashAt)) {
|
||||
return "a query component";
|
||||
}
|
||||
if (hashAt !== -1) return "a fragment component";
|
||||
// Userinfo is a literal check too: `https://@host` and
|
||||
// `https://:@host` parse with username === "" AND password === ""
|
||||
// (present-but-EMPTY userinfo), so the getter check let the raw
|
||||
// `@`-bearing string ship. Any `@` in the AUTHORITY (the part before
|
||||
// the first `/` — query/fragment are already rejected above) is a
|
||||
// userinfo delimiter; it is never valid inside a hostname.
|
||||
const slashAt = normalized.indexOf("/");
|
||||
const authority = slashAt === -1 ? normalized : normalized.slice(0, slashAt);
|
||||
if (authority.includes("@")) return "userinfo credentials";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a backend host pattern read from the environment. The
|
||||
* pattern contract (same as scripts/generate-registry.ts) is a bare
|
||||
* host with `{slug}` as the only placeholder — but env misconfigs are
|
||||
* easy and were previously silent:
|
||||
*
|
||||
* - a scheme-bearing value would yield `https://https://…` (consumer
|
||||
* prepends the scheme) → strip it and warn;
|
||||
* - a trailing slash would yield `host//route` on concat → trim and warn;
|
||||
* - a missing `{slug}` placeholder silently sends EVERY integration to
|
||||
* the same host → warn (can't fix it for the operator).
|
||||
*
|
||||
* Warnings fire once per distinct value, not per request.
|
||||
*/
|
||||
export function normalizeBackendHostPattern(pattern: string): string {
|
||||
let normalized = pattern;
|
||||
if (/\s/.test(normalized)) {
|
||||
// Whitespace was the ONE misconfig class with zero warning — a
|
||||
// pasted ` host` yields an iframe src like `https:// host`. Trim
|
||||
// the ends (fixable) and strip internal tab/CR/LF (the WHATWG URL
|
||||
// parser deletes \t\r\n pre-parse, so a tab-bearing pattern would
|
||||
// pass the usability gate while the RAW control character shipped
|
||||
// in every iframe src — stripping matches what the parser does
|
||||
// anyway). Internal SPACES split by position: a host-position
|
||||
// space makes the pattern unparseable, so the usability gate below
|
||||
// falls back to the DEFAULT (it never ships); a path-position
|
||||
// space parses and genuinely ships broken.
|
||||
warnPatternOnce(
|
||||
`whitespace:${pattern}`,
|
||||
`"${pattern}" contains whitespace — trimming the ends and stripping internal tab/CR/LF (the URL parser ignores them anyway). An internal space in the host cannot form a usable pattern (falls back to the default); a space in a path segment ships in every backend URL.`,
|
||||
);
|
||||
normalized = normalized.replace(/[\t\r\n]/g, "").trim();
|
||||
}
|
||||
// Loop the strip to convergence: a single pass left "https://https://
|
||||
// host" with a scheme that the consumer then double-prepends. Collect
|
||||
// every stripped scheme first so the once-guarded warn can name them
|
||||
// ALL — warning inside the loop swallowed every name after the first.
|
||||
const strippedSchemes: string[] = [];
|
||||
for (
|
||||
let scheme = SCHEME_RE.exec(normalized);
|
||||
scheme;
|
||||
scheme = SCHEME_RE.exec(normalized)
|
||||
) {
|
||||
strippedSchemes.push(scheme[0]);
|
||||
normalized = normalized.slice(scheme[0].length);
|
||||
}
|
||||
if (strippedSchemes.length > 0) {
|
||||
warnPatternOnce(
|
||||
`scheme:${pattern}`,
|
||||
`"${pattern}" includes a scheme — the consumer prepends https://; stripping ${strippedSchemes
|
||||
.map((s) => `"${s}"`)
|
||||
.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
if (/\/+$/.test(normalized)) {
|
||||
warnPatternOnce(
|
||||
`trailing-slash:${pattern}`,
|
||||
`"${pattern}" has a trailing slash — route concatenation would yield "//"; trimming.`,
|
||||
);
|
||||
normalized = normalized.replace(/\/+$/, "");
|
||||
}
|
||||
// Usability gate AFTER the fixable normalizations, BEFORE the
|
||||
// advisory warns ({slug}/path) — a degenerate value ("https://", "/",
|
||||
// whitespace) normalizes to "" or an unparseable host, and previously
|
||||
// flowed out with NO fallback: server-side iframe srcs became
|
||||
// "https://", and the injected "" failed the client reader's
|
||||
// REQUIRED_CONFIG_FIELDS check, crashing every client component with
|
||||
// a message blaming the layout injection instead of this env var.
|
||||
//
|
||||
// DESIGN DECISION — degenerate/forbidden patterns fail OPEN to
|
||||
// DEFAULT_BACKEND_HOST_PATTERN, which is the PROD host pattern. On a
|
||||
// staging deploy, a mistyped SHOWCASE_BACKEND_HOST_PATTERN therefore
|
||||
// silently (FATAL log aside) iframes PROD integrations — the exact
|
||||
// staging→prod leakage this module exists to prevent. The
|
||||
// alternative (fail CLOSED: ship "" and break every integration
|
||||
// pane) was rejected because a hard outage is worse than serving
|
||||
// prod content with a FATAL-CONFIG log; revisit if staging/prod
|
||||
// divergence ever becomes load-bearing for correctness.
|
||||
const forbidden = patternForbiddenComponent(normalized);
|
||||
if (forbidden !== undefined) {
|
||||
fatalPatternOnce(
|
||||
`forbidden:${forbidden}:${pattern}`,
|
||||
`${JSON.stringify(pattern)} carries ${forbidden} — userinfo makes ` +
|
||||
`Chromium silently block every iframe src formed from it, and a ` +
|
||||
`query/fragment corrupts every backend URL when consumers ` +
|
||||
`concatenate demo routes; falling back to the default pattern ` +
|
||||
`${DEFAULT_BACKEND_HOST_PATTERN}.`,
|
||||
);
|
||||
return DEFAULT_BACKEND_HOST_PATTERN;
|
||||
}
|
||||
// Fails OPEN to the prod pattern too — see the DESIGN DECISION note
|
||||
// above the forbidden-component gate.
|
||||
const usabilityProblem = patternUsabilityProblem(normalized);
|
||||
if (usabilityProblem !== undefined) {
|
||||
fatalPatternOnce(
|
||||
`degenerate:${pattern}`,
|
||||
usabilityProblem === "stray-scheme-fragment"
|
||||
? `${JSON.stringify(pattern)} normalizes to ` +
|
||||
`${JSON.stringify(normalized)}, whose host is literally ` +
|
||||
`"http"/"https" — it looks like a stray scheme fragment, not ` +
|
||||
`a backend host; falling back to the default pattern ` +
|
||||
`${DEFAULT_BACKEND_HOST_PATTERN}.`
|
||||
: `${JSON.stringify(pattern)} normalizes to ` +
|
||||
`${JSON.stringify(normalized)}, which cannot form a parseable ` +
|
||||
`backend URL; falling back to the default pattern ` +
|
||||
`${DEFAULT_BACKEND_HOST_PATTERN}.`,
|
||||
);
|
||||
return DEFAULT_BACKEND_HOST_PATTERN;
|
||||
}
|
||||
// Canonicalize the authority for parity with the override path,
|
||||
// which returns the parsed-normalized form (lowercase host, default
|
||||
// port elided) — the pattern path ships this string RAW into iframe
|
||||
// srcs, leaking uppercase hosts and an explicit :443. Hosts are
|
||||
// case-insensitive and the consumer always prepends https://, so
|
||||
// lowercasing the authority and stripping a trailing :443 (the https
|
||||
// default — :80 is a REAL port under https and must survive) matches
|
||||
// what the URL parser does to the composed URL anyway. The path part
|
||||
// (from the first "/") is case-SENSITIVE and stays untouched; the
|
||||
// lowercase {slug} placeholder survives lowercasing verbatim.
|
||||
const pathAt = normalized.indexOf("/");
|
||||
const authority = pathAt === -1 ? normalized : normalized.slice(0, pathAt);
|
||||
const pathPart = pathAt === -1 ? "" : normalized.slice(pathAt);
|
||||
normalized = authority.toLowerCase().replace(/:443$/, "") + pathPart;
|
||||
if (!normalized.includes("{slug}")) {
|
||||
warnPatternOnce(
|
||||
`no-slug:${pattern}`,
|
||||
`"${pattern}" lacks the {slug} placeholder — EVERY integration will resolve to the same backend host.`,
|
||||
);
|
||||
}
|
||||
if (normalized.includes("/")) {
|
||||
// The documented contract is a BARE host — an internal path segment
|
||||
// (`host.app/base/{slug}`) violates it silently: the consumer
|
||||
// prepends https:// and concatenates routes, so the base path lands
|
||||
// in every backend URL. Can't fix it for the operator (the intent
|
||||
// is ambiguous) — flag it.
|
||||
warnPatternOnce(
|
||||
`path:${pattern}`,
|
||||
`"${pattern}" contains a path segment — the contract is a bare host; the path will be embedded in every backend URL.`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Registry slug contract — see scripts/generate-registry.ts manifests.
|
||||
const SLUG_RE = /^[a-z0-9-]+$/;
|
||||
|
||||
/** Substitute `{slug}` into the host pattern and prepend `https://`. */
|
||||
export function backendUrlFromPattern(pattern: string, slug: string): string {
|
||||
// Charset assert at the choke point: every backend URL flows through
|
||||
// here and the slug lands in the HOST of an iframe src — a slug
|
||||
// containing "." or "/" is host/path injection. All registry slugs
|
||||
// match [a-z0-9-]+; anything else is a contract violation upstream
|
||||
// (call sites resolve slugs from the registry), not data.
|
||||
if (!SLUG_RE.test(slug)) {
|
||||
throw new Error(
|
||||
`[backend-url] invalid integration slug ${JSON.stringify(slug)} — ` +
|
||||
`slugs must match ${String(SLUG_RE)} (host-injection guard).`,
|
||||
);
|
||||
}
|
||||
// Function replacer: a plain string replacement is subject to `$`
|
||||
// substitution patterns ("$&", "$'", ...), which would corrupt the
|
||||
// host for any slug containing `$` (defense in depth behind SLUG_RE).
|
||||
return `https://${pattern.replaceAll("{slug}", () => slug)}`;
|
||||
}
|
||||
|
||||
// Warn once per distinct (raw value, issue) — resolveBackendUrl runs on
|
||||
// every render, so per-call warns would spam exactly like the pattern
|
||||
// warnings the patternWarnings set above exists to prevent.
|
||||
const localBackendsWarnings = new Set<string>();
|
||||
|
||||
function warnLocalBackendsOnce(key: string, message: string): void {
|
||||
if (localBackendsWarnings.has(key)) return;
|
||||
localBackendsWarnings.add(key);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[backend-url] NEXT_PUBLIC_LOCAL_BACKENDS ${message}`);
|
||||
}
|
||||
|
||||
// Shared frozen empty map for the unset path — a fresh mutable {} per
|
||||
// call would dodge the freeze guarantee below.
|
||||
const NO_LOCAL_BACKENDS: Record<string, string> = Object.freeze({});
|
||||
|
||||
// Memoized on the raw string: the env value is baked at build time and
|
||||
// effectively constant, so re-running JSON.parse on every render is
|
||||
// pure waste.
|
||||
let lastLocalBackendsRaw: string | undefined;
|
||||
let lastLocalBackends: Record<string, string> = NO_LOCAL_BACKENDS;
|
||||
|
||||
/**
|
||||
* Parse the NEXT_PUBLIC_LOCAL_BACKENDS map (baked at build from
|
||||
* shared/local-ports.json — local-dev only, empty in deployed images).
|
||||
* Tolerant of unset/empty/corrupt values: local dev convenience must
|
||||
* never break rendering. The parse is memoized on the raw string and
|
||||
* warnings fire once per distinct (value, issue), not per call.
|
||||
*
|
||||
* The returned object is FROZEN: the memo is shared across every
|
||||
* caller, so a consumer mutating its "own" map would change the
|
||||
* local-backend overrides process-wide.
|
||||
*/
|
||||
export function parseLocalBackends(
|
||||
raw: string | undefined,
|
||||
): Record<string, string> {
|
||||
if (!raw) return NO_LOCAL_BACKENDS;
|
||||
if (raw !== lastLocalBackendsRaw) {
|
||||
// Compute FIRST, then commit key and value together: committing
|
||||
// the key before the value meant a throw mid-compute (console.warn
|
||||
// is a foreign call — a logging shim CAN throw) left the memo
|
||||
// poisoned, and the next call with the same raw returned the
|
||||
// PREVIOUS raw's value.
|
||||
const backends = Object.freeze(parseLocalBackendsUncached(raw));
|
||||
lastLocalBackendsRaw = raw;
|
||||
lastLocalBackends = backends;
|
||||
}
|
||||
return lastLocalBackends;
|
||||
}
|
||||
|
||||
function parseLocalBackendsUncached(raw: string): Record<string, string> {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
// Validate values instead of an unchecked `as Record<string,
|
||||
// string>` flow-through — a non-string value would otherwise
|
||||
// surface much later as a garbage iframe src.
|
||||
//
|
||||
// Null-prototype accumulator: on a plain `{}`, assigning a
|
||||
// "__proto__" key hits the Object.prototype setter and is a
|
||||
// silent no-op — the entry would vanish with no warning (every
|
||||
// other rejected entry warns). With no prototype it lands as an
|
||||
// ordinary own data property.
|
||||
const backends: Record<string, string> = Object.create(null);
|
||||
for (const [slug, url] of Object.entries(parsed)) {
|
||||
if (typeof url === "string") {
|
||||
backends[slug] = url;
|
||||
} else {
|
||||
warnLocalBackendsOnce(
|
||||
`non-string:${slug}:${raw}`,
|
||||
`value for "${slug}" is not a string — skipping it.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return backends;
|
||||
}
|
||||
warnLocalBackendsOnce(
|
||||
`non-object:${raw}`,
|
||||
"is not a JSON object — ignoring it.",
|
||||
);
|
||||
} catch {
|
||||
// Treat unparseable as unset (local-dev convenience must never
|
||||
// break rendering) — but say so, instead of silently eating it.
|
||||
warnLocalBackendsOnce(
|
||||
`invalid-json:${raw}`,
|
||||
"is not valid JSON — ignoring it.",
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an integration's backend base URL: a local-dev override from
|
||||
* NEXT_PUBLIC_LOCAL_BACKENDS wins (preserving the pre-existing
|
||||
* `SHOWCASE_LOCAL=1` behavior), otherwise derive from the runtime host
|
||||
* pattern.
|
||||
*/
|
||||
export function resolveBackendUrl(slug: string, pattern: string): string {
|
||||
// ASSUMPTION: the server must never SET NEXT_PUBLIC_LOCAL_BACKENDS at
|
||||
// runtime post-build — the client bundle bakes the build-time value,
|
||||
// so a live server-side value would silently diverge from what client
|
||||
// components resolve.
|
||||
const local = parseLocalBackends(process.env.NEXT_PUBLIC_LOCAL_BACKENDS);
|
||||
const rawOverride = local[slug];
|
||||
if (rawOverride !== undefined) {
|
||||
// Trim before validating — the same paste-artifact tolerance the
|
||||
// pattern path applies (normalizeBackendHostPattern trims the
|
||||
// ends): a leading-space override otherwise fails the SCHEME_RE
|
||||
// anchor even though the URL parser accepts the value.
|
||||
const override = rawOverride.trim();
|
||||
// Length-aware: an empty/whitespace-only override (e.g.
|
||||
// `{"mastra": ""}`) is not a usable URL — `??` would accept it and
|
||||
// yield an empty base. The emptiness check lives INSIDE the
|
||||
// override block so it warns like every other bad override:
|
||||
// skipping it in the outer condition ignored the dead override
|
||||
// with zero signal.
|
||||
if (override.length === 0) {
|
||||
warnLocalBackendsOnce(
|
||||
`bad-override:${slug}:empty`,
|
||||
`override for "${slug}" is empty (or whitespace-only) — ignoring it.`,
|
||||
);
|
||||
return backendUrlFromPattern(pattern, slug);
|
||||
}
|
||||
// The override lands verbatim in an iframe src — require a
|
||||
// scheme-bearing, parseable URL (`localhost:4111` without a scheme
|
||||
// parses as scheme "localhost:"!) whose protocol is http(s):
|
||||
// `javascript://...` and `ftp://...` are scheme-bearing AND
|
||||
// parseable, but have no business in an iframe src. Userinfo,
|
||||
// query, and fragment components are rejected too (same gates the
|
||||
// pattern path has): Chromium silently blocks credentialed iframe
|
||||
// srcs, and a query/fragment corrupts every composed URL when
|
||||
// consumers concatenate demo routes. Warn and fall back otherwise.
|
||||
const parsed = parseUrl(override);
|
||||
if (
|
||||
parsed !== undefined &&
|
||||
SCHEME_RE.test(override) &&
|
||||
/^https?:$/i.test(parsed.protocol) &&
|
||||
parsed.username === "" &&
|
||||
parsed.password === "" &&
|
||||
parsed.search === "" &&
|
||||
parsed.hash === ""
|
||||
) {
|
||||
// Return the parsed-normalized form (origin + path), not the raw
|
||||
// string: the parse already happened, and the raw form leaks
|
||||
// un-normalized values (uppercase hosts, explicit default ports)
|
||||
// into iframe srcs. The trailing-slash trim matches the
|
||||
// normalization the pattern path guarantees
|
||||
// (normalizeBackendHostPattern) — an override skipping it yields
|
||||
// `host//route` when consumers concatenate demo routes. Query,
|
||||
// fragment, and userinfo are empty here (gated above), so
|
||||
// origin + pathname IS the whole URL.
|
||||
return (parsed.origin + parsed.pathname).replace(/\/+$/, "");
|
||||
}
|
||||
// Name the actual requirements instead of "not parseable":
|
||||
// `http:localhost:4111` IS parseable (special schemes tolerate
|
||||
// missing slashes), but it fails the explicit `scheme://`
|
||||
// requirement — a "not parseable" claim sends the developer
|
||||
// chasing a parse problem that doesn't exist.
|
||||
warnLocalBackendsOnce(
|
||||
`bad-override:${slug}:${override}`,
|
||||
`override for "${slug}" (${JSON.stringify(override)}) must be an ` +
|
||||
`http(s) URL with an explicit "scheme://" and no userinfo, query, ` +
|
||||
`or fragment — ignoring it.`,
|
||||
);
|
||||
}
|
||||
return backendUrlFromPattern(pattern, slug);
|
||||
}
|
||||
|
||||
function parseUrl(value: string): URL | undefined {
|
||||
// URL.canParse needs Node 18.17+/modern browsers — try/catch keeps
|
||||
// this safe on every runtime the shell targets.
|
||||
try {
|
||||
return new URL(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveDocsHostRedirect,
|
||||
resolveSeoDestination,
|
||||
} from "./docs-redirects";
|
||||
|
||||
const DOCS_HOST = "https://docs.showcase.copilotkit.ai";
|
||||
const SLUGS = new Set(["mastra", "agno", "langgraph-python"]);
|
||||
|
||||
describe("resolveDocsHostRedirect", () => {
|
||||
it("redirects /docs to the docs host root (prefix stripped)", () => {
|
||||
expect(resolveDocsHostRedirect("/docs", DOCS_HOST, SLUGS)).toBe(DOCS_HOST);
|
||||
expect(resolveDocsHostRedirect("/docs/quickstart", DOCS_HOST, SLUGS)).toBe(
|
||||
`${DOCS_HOST}/quickstart`,
|
||||
);
|
||||
expect(resolveDocsHostRedirect("/docs/guides/a/b", DOCS_HOST, SLUGS)).toBe(
|
||||
`${DOCS_HOST}/guides/a/b`,
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects /ag-ui and /reference keeping their prefix", () => {
|
||||
expect(resolveDocsHostRedirect("/ag-ui", DOCS_HOST, SLUGS)).toBe(
|
||||
`${DOCS_HOST}/ag-ui`,
|
||||
);
|
||||
expect(resolveDocsHostRedirect("/ag-ui/events", DOCS_HOST, SLUGS)).toBe(
|
||||
`${DOCS_HOST}/ag-ui/events`,
|
||||
);
|
||||
expect(resolveDocsHostRedirect("/reference", DOCS_HOST, SLUGS)).toBe(
|
||||
`${DOCS_HOST}/reference`,
|
||||
);
|
||||
expect(
|
||||
resolveDocsHostRedirect(
|
||||
"/reference/hooks/useCopilotKit",
|
||||
DOCS_HOST,
|
||||
SLUGS,
|
||||
),
|
||||
).toBe(`${DOCS_HOST}/reference/hooks/useCopilotKit`);
|
||||
});
|
||||
|
||||
it("redirects framework-slug paths to the docs host, path preserved", () => {
|
||||
expect(resolveDocsHostRedirect("/mastra", DOCS_HOST, SLUGS)).toBe(
|
||||
`${DOCS_HOST}/mastra`,
|
||||
);
|
||||
expect(
|
||||
resolveDocsHostRedirect(
|
||||
"/langgraph-python/agentic-chat",
|
||||
DOCS_HOST,
|
||||
SLUGS,
|
||||
),
|
||||
).toBe(`${DOCS_HOST}/langgraph-python/agentic-chat`);
|
||||
});
|
||||
|
||||
it("uses the provided (runtime) docs host — staging host wires through", () => {
|
||||
const staging = "https://docs-staging.example.com";
|
||||
expect(resolveDocsHostRedirect("/docs/quickstart", staging, SLUGS)).toBe(
|
||||
`${staging}/quickstart`,
|
||||
);
|
||||
expect(resolveDocsHostRedirect("/mastra", staging, SLUGS)).toBe(
|
||||
`${staging}/mastra`,
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses duplicate slashes in destinations (SU-13)", () => {
|
||||
expect(resolveDocsHostRedirect("/docs//mastra", DOCS_HOST, SLUGS)).toBe(
|
||||
`${DOCS_HOST}/mastra`,
|
||||
);
|
||||
expect(resolveDocsHostRedirect("//mastra", DOCS_HOST, SLUGS)).toBe(
|
||||
`${DOCS_HOST}/mastra`,
|
||||
);
|
||||
expect(resolveDocsHostRedirect("/ag-ui//events", DOCS_HOST, SLUGS)).toBe(
|
||||
`${DOCS_HOST}/ag-ui/events`,
|
||||
);
|
||||
// A trailing slash on the docs host must not double up either.
|
||||
expect(
|
||||
resolveDocsHostRedirect("/docs/quickstart", `${DOCS_HOST}/`, SLUGS),
|
||||
).toBe(`${DOCS_HOST}/quickstart`);
|
||||
});
|
||||
|
||||
it("prepends https:// to a scheme-less docs host (SU2-A8)", () => {
|
||||
// normalizeDocsHostOrigin's scheme-less branch: an operator can set
|
||||
// DOCS_HOST host-only (the sibling SHOWCASE_BACKEND_HOST_PATTERN
|
||||
// format) — destinations must still be absolute https URLs.
|
||||
expect(
|
||||
resolveDocsHostRedirect("/docs/quickstart", "docs.example.com", SLUGS),
|
||||
).toBe("https://docs.example.com/quickstart");
|
||||
expect(resolveDocsHostRedirect("/mastra", "docs.example.com", SLUGS)).toBe(
|
||||
"https://docs.example.com/mastra",
|
||||
);
|
||||
// An explicit scheme is left untouched.
|
||||
expect(
|
||||
resolveDocsHostRedirect("/docs", "http://localhost:3001", SLUGS),
|
||||
).toBe("http://localhost:3001");
|
||||
});
|
||||
|
||||
it("redirects /docs/ (trailing slash, rest === '/') to the origin (SU2-A8)", () => {
|
||||
expect(resolveDocsHostRedirect("/docs/", DOCS_HOST, SLUGS)).toBe(DOCS_HOST);
|
||||
});
|
||||
|
||||
it("falls through to null for framework paths when the slug set is empty (SU2-A8)", () => {
|
||||
const empty = new Set<string>();
|
||||
expect(resolveDocsHostRedirect("/mastra", DOCS_HOST, empty)).toBeNull();
|
||||
expect(
|
||||
resolveDocsHostRedirect("/mastra/quickstart", DOCS_HOST, empty),
|
||||
).toBeNull();
|
||||
// Non-framework docs routes are unaffected by the slug set.
|
||||
expect(resolveDocsHostRedirect("/docs", DOCS_HOST, empty)).toBe(DOCS_HOST);
|
||||
});
|
||||
|
||||
it("matches case-insensitively, preserving the remainder's case (SU3-A4)", () => {
|
||||
// Parity with the removed next.config rules (path-to-regexp
|
||||
// sensitive:false): prefixes and slugs match in any case; the
|
||||
// destination uses the canonical lowercase prefix while the matched
|
||||
// remainder keeps its original case (path-to-regexp param behavior).
|
||||
expect(resolveDocsHostRedirect("/DOCS", DOCS_HOST, SLUGS)).toBe(DOCS_HOST);
|
||||
expect(resolveDocsHostRedirect("/DOCS/Quickstart", DOCS_HOST, SLUGS)).toBe(
|
||||
`${DOCS_HOST}/Quickstart`,
|
||||
);
|
||||
expect(resolveDocsHostRedirect("/AG-UI/Events", DOCS_HOST, SLUGS)).toBe(
|
||||
`${DOCS_HOST}/ag-ui/Events`,
|
||||
);
|
||||
expect(resolveDocsHostRedirect("/Reference", DOCS_HOST, SLUGS)).toBe(
|
||||
`${DOCS_HOST}/reference`,
|
||||
);
|
||||
expect(
|
||||
resolveDocsHostRedirect("/Mastra/Quickstart", DOCS_HOST, SLUGS),
|
||||
).toBe(`${DOCS_HOST}/mastra/Quickstart`);
|
||||
// Case-insensitivity must not loosen the prefix-lookalike guards.
|
||||
expect(resolveDocsHostRedirect("/DOCSify", DOCS_HOST, SLUGS)).toBeNull();
|
||||
expect(
|
||||
resolveDocsHostRedirect("/AG-UI-extra", DOCS_HOST, SLUGS),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("throws on an empty (post-trim) docs host instead of parsing the path AS the host (SU5-A6)", () => {
|
||||
// "" normalizes to the origin "https://", and
|
||||
// new URL("https:///faq") triggers WHATWG slash-skipping: "faq" is
|
||||
// parsed as the AUTHORITY — the destination path silently becomes
|
||||
// the redirect HOST (Location: https://faq/). Fail loudly instead;
|
||||
// runtime-config never hands middleware an empty host, so the
|
||||
// throw is unreachable through validated callers.
|
||||
expect(() => resolveSeoDestination("/faq", "")).toThrow(/docs host/i);
|
||||
expect(() => resolveSeoDestination("/faq", "///")).toThrow(/docs host/i);
|
||||
expect(() => resolveDocsHostRedirect("/docs/x", "", SLUGS)).toThrow(
|
||||
/docs host/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT redirect shell-owned routes", () => {
|
||||
expect(resolveDocsHostRedirect("/", DOCS_HOST, SLUGS)).toBeNull();
|
||||
expect(
|
||||
resolveDocsHostRedirect("/integrations", DOCS_HOST, SLUGS),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveDocsHostRedirect("/integrations/mastra", DOCS_HOST, SLUGS),
|
||||
).toBeNull();
|
||||
expect(resolveDocsHostRedirect("/matrix", DOCS_HOST, SLUGS)).toBeNull();
|
||||
// Prefix lookalikes must not match (/docsify is not /docs/...).
|
||||
expect(resolveDocsHostRedirect("/docsify", DOCS_HOST, SLUGS)).toBeNull();
|
||||
expect(
|
||||
resolveDocsHostRedirect("/ag-ui-extra", DOCS_HOST, SLUGS),
|
||||
).toBeNull();
|
||||
expect(resolveDocsHostRedirect("/references", DOCS_HOST, SLUGS)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
// Docs-host redirect resolution — shared by middleware.
|
||||
//
|
||||
// These permanent (308) redirects used to live in next.config.ts
|
||||
// `redirects()` (`permanent: true` emits 308), which bakes
|
||||
// the destination host into the build artifact (`DOCS_HOST` was a
|
||||
// hardcoded const) — so a staging shell 308'd docs routes to the PROD
|
||||
// docs host (cross-origin RSC prefetch → CORS). The table now resolves
|
||||
// per-request in middleware against the runtime `docsHost` from
|
||||
// RuntimeConfig (env var DOCS_HOST, default = the prod host).
|
||||
//
|
||||
// Route semantics are copied 1:1 from the removed next.config rules:
|
||||
// /docs -> <docsHost> (prefix STRIPPED — the
|
||||
// /docs/:path* -> <docsHost>/:path* docs shell serves at root)
|
||||
// /ag-ui[/:path*] -> <docsHost>/ag-ui[/:path*] (prefix kept)
|
||||
// /reference[/:path*] -> <docsHost>/reference[/:path*] (prefix kept)
|
||||
// /<slug>[/:path*] -> <docsHost>/<slug>[/:path*] for every registry
|
||||
// framework slug — enumerated (not a wildcard) so shell-owned
|
||||
// routes like /integrations and /matrix are never hijacked.
|
||||
//
|
||||
// Pure functions, Edge-safe (no next/* imports — backend-url is also
|
||||
// next-free; middleware already runs it on the Edge).
|
||||
|
||||
import { SCHEME_RE } from "./backend-url";
|
||||
|
||||
/** Prefixes forwarded with the prefix KEPT on the destination path. */
|
||||
const KEPT_PREFIXES = ["/ag-ui", "/reference"] as const;
|
||||
|
||||
/**
|
||||
* Normalize a destination path: collapse runs of slashes and strip a
|
||||
* trailing slash (root "/" survives). Collapsing the LEADING run is
|
||||
* defense-in-depth plus cosmetics: at every call site the path is
|
||||
* appended AFTER a fixed `https://<docs-host>` origin, so a leading
|
||||
* `//` can never be parsed as a scheme-relative URL — it would only
|
||||
* produce an ugly double-slash path on a host we own.
|
||||
*/
|
||||
export function normalizeRedirectPath(path: string): string {
|
||||
let normalized = path.replace(/\/{2,}/g, "/");
|
||||
if (normalized.length > 1 && normalized.endsWith("/")) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive docs-host normalization: strip trailing slashes and ensure
|
||||
* a scheme. For VALIDATED hosts (everything runtime-config hands
|
||||
* middleware) the `new URL(...)` consumers never throw or emit
|
||||
* scheme-relative URLs even when the value is a bare host. An
|
||||
* empty-post-trim host THROWS instead (SU5-A6): "" would normalize to
|
||||
* the origin "https://", and `new URL("https:///faq")` triggers WHATWG
|
||||
* slash-skipping — the destination PATH gets parsed as the authority,
|
||||
* silently redirecting to https://faq/. Unreachable through
|
||||
* runtime-config-validated callers; fail loud for any future raw one.
|
||||
*/
|
||||
function normalizeDocsHostOrigin(docsHost: string): string {
|
||||
const trimmed = docsHost.replace(/\/+$/, "");
|
||||
if (trimmed === "") {
|
||||
throw new Error(
|
||||
`[docs-redirects] docs host is empty after trimming (${JSON.stringify(
|
||||
docsHost,
|
||||
)}) — refusing to compose a redirect origin: "https://" + a path ` +
|
||||
"would let WHATWG slash-skipping parse the destination path as " +
|
||||
"the redirect host.",
|
||||
);
|
||||
}
|
||||
// SCHEME_RE is the shared scheme detector from backend-url (SU4-A6) —
|
||||
// the same test middleware's normalizePosthogHost uses; an inline
|
||||
// copy here had already drifted into a maintenance trap.
|
||||
return SCHEME_RE.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an SEO-table destination path (which targets the DOCS routing
|
||||
* surface) to an absolute URL on the docs host.
|
||||
*/
|
||||
export function resolveSeoDestination(
|
||||
destinationPath: string,
|
||||
docsHost: string,
|
||||
): URL {
|
||||
const origin = normalizeDocsHostOrigin(docsHost);
|
||||
return new URL(`${origin}${normalizeRedirectPath(destinationPath)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute destination URL (without query string) when
|
||||
* `pathname` belongs to the docs shell, or `null` when the path is
|
||||
* shell-owned and must fall through to normal handling.
|
||||
*
|
||||
* Leading-slash runs are collapsed in ONE place — middleware()
|
||||
* (SU4-A3) — so `pathname` arrives with a single leading "/". The
|
||||
* framework-slug branch's /^\/+/ regex below still tolerates runs as
|
||||
* defense-in-depth for other callers, but the ===/startsWith prefix
|
||||
* branches deliberately do NOT re-normalize.
|
||||
*/
|
||||
export function resolveDocsHostRedirect(
|
||||
pathname: string,
|
||||
docsHost: string,
|
||||
frameworkSlugs: ReadonlySet<string>,
|
||||
): string | null {
|
||||
const origin = normalizeDocsHostOrigin(docsHost);
|
||||
|
||||
// Case-insensitive matching (SU3-A4): parity with the removed
|
||||
// next.config rules (path-to-regexp sensitive:false). Prefixes and
|
||||
// slugs match in any case; the destination uses the canonical
|
||||
// lowercase prefix/slug literal while the matched remainder keeps the
|
||||
// ORIGINAL case (exactly what a path-to-regexp :path* param did).
|
||||
// toLowerCase() is length-preserving on the ASCII pathnames Next
|
||||
// hands us, so positional slicing against `pathname` is safe.
|
||||
const lower = pathname.toLowerCase();
|
||||
|
||||
// /docs → docs-host root; /docs/x → docs-host /x (prefix stripped).
|
||||
if (lower === "/docs") return origin;
|
||||
if (lower.startsWith("/docs/")) {
|
||||
const rest = normalizeRedirectPath(pathname.slice("/docs".length));
|
||||
return rest === "/" ? origin : `${origin}${rest}`;
|
||||
}
|
||||
|
||||
// /ag-ui and /reference keep their prefix on the docs host.
|
||||
for (const prefix of KEPT_PREFIXES) {
|
||||
if (lower === prefix || lower.startsWith(`${prefix}/`)) {
|
||||
return `${origin}${normalizeRedirectPath(prefix + pathname.slice(prefix.length))}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Framework-scoped routes: /<slug> and /<slug>/... — first segment
|
||||
// must exactly match a registry slug (registry slugs are canonical
|
||||
// lowercase). Match on the lowercased segment; forward the lowercase
|
||||
// slug + original-case remainder.
|
||||
const segmentMatch = /^\/+([^/]+)/.exec(lower);
|
||||
const first = segmentMatch?.[1];
|
||||
if (first && frameworkSlugs.has(first)) {
|
||||
const rest = pathname.slice(segmentMatch[0].length);
|
||||
return `${origin}${normalizeRedirectPath(`/${first}${rest}`)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { localBackendsEnv } from "./local-backends-env";
|
||||
|
||||
describe("localBackendsEnv (next.config build-time helper)", () => {
|
||||
let dir: string;
|
||||
let portsPath: string;
|
||||
let warns: string[];
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "local-backends-env-"));
|
||||
portsPath = path.join(dir, "local-ports.json");
|
||||
vi.stubEnv("SHOWCASE_LOCAL", "1");
|
||||
warns = [];
|
||||
warnSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation((m: string) => void warns.push(m));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns '' silently (file untouched) when SHOWCASE_LOCAL is explicitly blank", () => {
|
||||
// Blank is a deliberate "off" state and stays SILENT — a non-blank
|
||||
// value other than "1" warns instead (see the dedicated test
|
||||
// below), so the old "is not 1" title misdescribed what this
|
||||
// covers. No missing-file warn = the ports file was never read.
|
||||
vi.stubEnv("SHOWCASE_LOCAL", "");
|
||||
expect(localBackendsEnv(portsPath)).toBe("");
|
||||
expect(warns).toEqual([]);
|
||||
});
|
||||
|
||||
it("stays silent for unset AND blank/whitespace-only SHOWCASE_LOCAL (both mean off)", () => {
|
||||
// Unset (never exported) and blank (`SHOWCASE_LOCAL= npm run build`
|
||||
// to explicitly disable) are both deliberate "off" states — neither
|
||||
// reads the ports file or logs anything.
|
||||
for (const off of [undefined, "", " \t"]) {
|
||||
warns.length = 0;
|
||||
vi.stubEnv("SHOWCASE_LOCAL", off);
|
||||
expect(
|
||||
localBackendsEnv(portsPath),
|
||||
`SHOWCASE_LOCAL=${JSON.stringify(off)} should be silent off`,
|
||||
).toBe("");
|
||||
expect(warns).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("warns (naming the value) when SHOWCASE_LOCAL is set to something other than '1'", () => {
|
||||
// A developer exporting SHOWCASE_LOCAL=true (or =yes, or =0)
|
||||
// believes they toggled local backends — the strict "1" contract
|
||||
// made that a silent no-op ("why are my local backends not
|
||||
// wired?" with zero signal). Set-but-not-"1" must warn; the value
|
||||
// is still treated as off.
|
||||
for (const bad of ["true", "yes", "0"]) {
|
||||
warns.length = 0;
|
||||
vi.stubEnv("SHOWCASE_LOCAL", bad);
|
||||
expect(localBackendsEnv(portsPath)).toBe("");
|
||||
expect(
|
||||
warns.some(
|
||||
(m) =>
|
||||
m.includes("SHOWCASE_LOCAL") && m.includes(JSON.stringify(bad)),
|
||||
),
|
||||
`SHOWCASE_LOCAL=${JSON.stringify(bad)} should warn naming the value`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a whitespace-padded '1' as enabled (paste-artifact tolerance)", () => {
|
||||
// Same whitespace tolerance runtime-config.ts applies to every env
|
||||
// value (readEnvPair trims) — `SHOWCASE_LOCAL=" 1"` previously
|
||||
// failed the strict !== "1" gate and silently disabled local
|
||||
// backends.
|
||||
vi.stubEnv("SHOWCASE_LOCAL", " 1\t");
|
||||
fs.writeFileSync(portsPath, JSON.stringify({ mastra: 3104 }));
|
||||
expect(JSON.parse(localBackendsEnv(portsPath))).toEqual({
|
||||
mastra: "http://localhost:3104",
|
||||
});
|
||||
expect(warns).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps slugs to localhost URLs for valid integer ports", () => {
|
||||
fs.writeFileSync(portsPath, JSON.stringify({ mastra: 3104, agno: 3109 }));
|
||||
expect(JSON.parse(localBackendsEnv(portsPath))).toEqual({
|
||||
mastra: "http://localhost:3104",
|
||||
agno: "http://localhost:3109",
|
||||
});
|
||||
expect(warns).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns loudly (naming the path) when SHOWCASE_LOCAL=1 but the file is missing", () => {
|
||||
// The developer explicitly opted in — a silent '' here means "why
|
||||
// are my local backends not wired?" with zero signal, while corrupt
|
||||
// JSON in the same file THROWS. Missing must be loud too.
|
||||
expect(localBackendsEnv(portsPath)).toBe("");
|
||||
expect(warns.some((m) => m.includes(portsPath))).toBe(true);
|
||||
});
|
||||
|
||||
it("THROWS (naming the slug) on non-integer and out-of-range ports (3.5 / 0 / -1 / 99999)", () => {
|
||||
// This runs at BUILD time — the file's stated fail-loud posture is
|
||||
// that throwing IS the loud path. A warn+skip silently shipped a
|
||||
// build with that integration's override missing.
|
||||
for (const [slug, port] of [
|
||||
["frac", 3.5],
|
||||
["zero", 0],
|
||||
["neg", -1],
|
||||
["huge", 99999],
|
||||
] as const) {
|
||||
fs.writeFileSync(portsPath, JSON.stringify({ [slug]: port, ok: 3104 }));
|
||||
expect(
|
||||
() => localBackendsEnv(portsPath),
|
||||
`port ${port} for "${slug}" should throw`,
|
||||
).toThrow(new RegExp(`"${slug}"`));
|
||||
}
|
||||
// Boundary values stay valid.
|
||||
fs.writeFileSync(portsPath, JSON.stringify({ ok: 65535, low: 1 }));
|
||||
expect(JSON.parse(localBackendsEnv(portsPath))).toEqual({
|
||||
ok: "http://localhost:65535",
|
||||
low: "http://localhost:1",
|
||||
});
|
||||
});
|
||||
|
||||
it("THROWS (naming the slug) on non-number port values", () => {
|
||||
fs.writeFileSync(portsPath, JSON.stringify({ str: "3104", ok: 3104 }));
|
||||
expect(() => localBackendsEnv(portsPath)).toThrow(/"str"/);
|
||||
});
|
||||
|
||||
it("throws (naming the path) on corrupt JSON", () => {
|
||||
fs.writeFileSync(portsPath, "{not json");
|
||||
expect(() => localBackendsEnv(portsPath)).toThrow(portsPath);
|
||||
});
|
||||
|
||||
// chmod-based denial tests are meaningless as root (root bypasses
|
||||
// permission bits, so the read SUCCEEDS and the assertions fail for
|
||||
// a reason unrelated to the code under test) — skip them there.
|
||||
const runningAsRoot = process.getuid?.() === 0;
|
||||
|
||||
it.skipIf(runningAsRoot)(
|
||||
"labels an unreadable file as a read failure, not as invalid JSON",
|
||||
() => {
|
||||
// fs.readFileSync used to live INSIDE the JSON.parse try — an
|
||||
// EACCES surfaced as "<path> is not valid JSON", sending the
|
||||
// developer to inspect a file's syntax when the problem is its
|
||||
// permissions.
|
||||
fs.writeFileSync(portsPath, "{}");
|
||||
fs.chmodSync(portsPath, 0o000);
|
||||
try {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
localBackendsEnv(portsPath);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
const message = (thrown as Error).message;
|
||||
expect(message).toContain(portsPath);
|
||||
expect(message).toContain("could not be read");
|
||||
expect(message).not.toContain("not valid JSON");
|
||||
} finally {
|
||||
fs.chmodSync(portsPath, 0o600);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(runningAsRoot)(
|
||||
"labels an unsearchable parent directory as a read failure, not as a missing file",
|
||||
() => {
|
||||
// fs.existsSync returns false for EACCES on the parent directory —
|
||||
// the old guard masked a permissions problem as "file does not
|
||||
// exist", defeating the labeled-throw design the read/parse split
|
||||
// exists for. The direct-read ENOENT branch keeps missing-file
|
||||
// semantics while every OTHER read error throws with its real cause.
|
||||
fs.writeFileSync(portsPath, "{}");
|
||||
fs.chmodSync(dir, 0o000);
|
||||
try {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
localBackendsEnv(portsPath);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
const message = (thrown as Error).message;
|
||||
expect(message).toContain(portsPath);
|
||||
expect(message).toContain("could not be read");
|
||||
// It must NOT have taken the missing-file warn path.
|
||||
expect(warns.some((m) => m.includes("does not exist"))).toBe(false);
|
||||
} finally {
|
||||
fs.chmodSync(dir, 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("warns (naming the key) on slugs that violate the [a-z0-9-]+ contract", () => {
|
||||
// Registry slugs are [a-z0-9-]+ (see backend-url.ts SLUG_RE) — a
|
||||
// key like "Mastra" can never match an integration slug, so its
|
||||
// override is a silent no-op at runtime. Warn at build time.
|
||||
fs.writeFileSync(
|
||||
portsPath,
|
||||
JSON.stringify({ Mastra: 3104, under_score: 3105, ok: 3106 }),
|
||||
);
|
||||
const out = JSON.parse(localBackendsEnv(portsPath)) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
// Pin the include-vs-skip behavior: warn-only entries are still
|
||||
// EMITTED (the warn says "can never apply", not "dropped") — a
|
||||
// silent skip here would contradict the warn text.
|
||||
expect(out).toEqual({
|
||||
Mastra: "http://localhost:3104",
|
||||
under_score: "http://localhost:3105",
|
||||
ok: "http://localhost:3106",
|
||||
});
|
||||
expect(warns.some((m) => m.includes('"Mastra"'))).toBe(true);
|
||||
expect(warns.some((m) => m.includes('"under_score"'))).toBe(true);
|
||||
// A contract-conforming map stays silent.
|
||||
warns.length = 0;
|
||||
fs.writeFileSync(portsPath, JSON.stringify({ "langgraph-python": 3104 }));
|
||||
localBackendsEnv(portsPath);
|
||||
expect(warns).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits a __proto__ key as map data instead of silently dropping it", () => {
|
||||
// The accumulator was a plain `{}` — `map["__proto__"] = ...` hits
|
||||
// the Object.prototype setter and is a silent no-op, so the entry
|
||||
// vanished from the emitted JSON even though the slug-contract warn
|
||||
// fired. A null-prototype accumulator makes it an ordinary own
|
||||
// property (and the [a-z0-9-]+ warn still flags it).
|
||||
fs.writeFileSync(portsPath, '{"__proto__": 3104, "ok": 3105}');
|
||||
const out = JSON.parse(localBackendsEnv(portsPath)) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
expect(Object.keys(out)).toContain("__proto__");
|
||||
expect(out["__proto__"]).toBe("http://localhost:3104");
|
||||
expect(out.ok).toBe("http://localhost:3105");
|
||||
expect(warns.some((m) => m.includes('"__proto__"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("warns loudly when SHOWCASE_LOCAL=1 in a production build (localhost targets in a prod image)", () => {
|
||||
// `next build` runs with NODE_ENV=production — refusing outright
|
||||
// would break the documented local production-build flow, but a
|
||||
// silent pass bakes localhost iframe targets into an image that
|
||||
// must never deploy. Loud warn.
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
fs.writeFileSync(portsPath, JSON.stringify({ mastra: 3104 }));
|
||||
expect(JSON.parse(localBackendsEnv(portsPath))).toEqual({
|
||||
mastra: "http://localhost:3104",
|
||||
});
|
||||
expect(
|
||||
warns.some(
|
||||
(m) => m.includes("SHOWCASE_LOCAL") && m.includes("production"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("throws (naming the path) on a non-object top level", () => {
|
||||
fs.writeFileSync(portsPath, "[1,2]");
|
||||
expect(() => localBackendsEnv(portsPath)).toThrow(portsPath);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// Build-time helper for next.config.ts: computes the
|
||||
// NEXT_PUBLIC_LOCAL_BACKENDS env value from shared/local-ports.json when
|
||||
// SHOWCASE_LOCAL=1 (local-dev only — deployed images bake "").
|
||||
//
|
||||
// Extracted from next.config.ts so the logic is unit-testable (vitest
|
||||
// only includes src/**); next.config.ts imports it and passes the real
|
||||
// ports path. Node-only (fs/path) — never import from app/middleware
|
||||
// code.
|
||||
|
||||
import fs from "fs";
|
||||
|
||||
/**
|
||||
* Returns the JSON string to bake into NEXT_PUBLIC_LOCAL_BACKENDS, or
|
||||
* "" when local backends are not in play.
|
||||
*
|
||||
* Failure posture (this runs at BUILD time, so throwing IS the loud
|
||||
* path): an unreadable file, corrupt JSON, a non-object top level, or
|
||||
* an invalid port all throw, naming the file (read failures and parse
|
||||
* failures are labeled distinctly — an EACCES is not a syntax error).
|
||||
* A MISSING file with SHOWCASE_LOCAL=1 set warns instead of silently
|
||||
* returning "" — the developer explicitly opted in, so "why are my
|
||||
* local backends not wired?" must have a signal. SHOWCASE_LOCAL=1
|
||||
* combined with NODE_ENV=production warns loudly: it bakes localhost
|
||||
* iframe targets into the image, which must never deploy (not a throw
|
||||
* — `next build` always sets NODE_ENV=production, so refusing would
|
||||
* break the documented local production-build flow).
|
||||
*/
|
||||
export function localBackendsEnv(portsPath: string): string {
|
||||
// Unset-vs-blank distinction: unset (never exported) and
|
||||
// blank/whitespace-only (`SHOWCASE_LOCAL= npm run build` to
|
||||
// explicitly disable) are BOTH deliberate "off" states and stay
|
||||
// silent. The value is trimmed before the comparison — the same
|
||||
// paste-artifact tolerance runtime-config.ts applies to every env
|
||||
// value — so `SHOWCASE_LOCAL=" 1"` still opts in instead of
|
||||
// silently disabling local backends.
|
||||
const rawLocal = process.env.SHOWCASE_LOCAL;
|
||||
const showcaseLocal = rawLocal === undefined ? "" : rawLocal.trim();
|
||||
if (showcaseLocal !== "1") {
|
||||
// Set to a non-blank value other than "1" ("true", "yes", "0", …):
|
||||
// the developer believes they toggled local backends, but only "1"
|
||||
// opts in — a silent no-op here is exactly the "why are my local
|
||||
// backends not wired?" zero-signal failure the missing-file warn
|
||||
// below exists to prevent. Warn, treat as off.
|
||||
if (showcaseLocal !== "") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[next.config] SHOWCASE_LOCAL is set to ${JSON.stringify(rawLocal)} ` +
|
||||
`but only "1" enables local backend overrides — treating it as ` +
|
||||
`off. Set SHOWCASE_LOCAL=1 (or unset it).`,
|
||||
);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[next.config] SHOWCASE_LOCAL=1 in a production build — localhost ` +
|
||||
`backend overrides will be baked into this image. NEVER deploy it; ` +
|
||||
`unset SHOWCASE_LOCAL for deployable builds.`,
|
||||
);
|
||||
}
|
||||
// Read directly and branch on ENOENT instead of a separate existsSync
|
||||
// guard: existsSync returns false for an EACCES on the parent
|
||||
// directory too, which masked a permissions problem as "missing file"
|
||||
// — defeating the labeled-throw design the read/parse split exists
|
||||
// for. The read also stays OUTSIDE the parse try: an EACCES inside it
|
||||
// was mislabeled "not valid JSON", sending the developer to inspect
|
||||
// the file's syntax when the problem is its permissions.
|
||||
let rawText: string;
|
||||
try {
|
||||
rawText = fs.readFileSync(portsPath, "utf-8");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[next.config] SHOWCASE_LOCAL=1 but ${portsPath} does not exist — ` +
|
||||
`no local backend overrides will be baked. Generate it (or unset ` +
|
||||
`SHOWCASE_LOCAL).`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
throw new Error(`${portsPath} could not be read: ${String(err)}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawText);
|
||||
} catch (err) {
|
||||
throw new Error(`${portsPath} is not valid JSON: ${String(err)}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(
|
||||
`${portsPath} must be a JSON object mapping slug -> port (got ` +
|
||||
`${Array.isArray(parsed) ? "an array" : typeof parsed}).`,
|
||||
);
|
||||
}
|
||||
// Null-prototype accumulator: on a plain `{}`, a "__proto__" key in
|
||||
// the ports file would hit the Object.prototype setter — a silent
|
||||
// no-op that drops the entry from the emitted JSON even though the
|
||||
// slug-contract warn below fires. With no prototype it lands as an
|
||||
// ordinary own data property (JSON.stringify serializes it fine).
|
||||
const map: Record<string, string> = Object.create(null);
|
||||
for (const [slug, port] of Object.entries(parsed)) {
|
||||
// Registry slugs are [a-z0-9-]+ (see lib/backend-url.ts SLUG_RE) —
|
||||
// a key outside that contract can never match an integration slug,
|
||||
// so its override is a silent no-op at runtime. Warn, don't throw:
|
||||
// the entry breaks nothing, it just never applies.
|
||||
if (!/^[a-z0-9-]+$/.test(slug)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[next.config] ${portsPath}: key ${JSON.stringify(slug)} does not ` +
|
||||
`match the integration slug contract ([a-z0-9-]+) — its override ` +
|
||||
`can never apply (silent no-op at runtime).`,
|
||||
);
|
||||
}
|
||||
// Full TCP-port validation, not just typeof: 3.5 / 0 / -1 / 99999
|
||||
// are all numbers, and every one of them yields a URL that can
|
||||
// never connect. THROW — this is build time (the file's stated
|
||||
// fail-loud posture); a warn+skip silently shipped a build with
|
||||
// that integration's override missing.
|
||||
if (
|
||||
typeof port !== "number" ||
|
||||
!Number.isInteger(port) ||
|
||||
port <= 0 ||
|
||||
port > 65535
|
||||
) {
|
||||
throw new Error(
|
||||
`${portsPath}: port for "${slug}" is not a valid TCP port ` +
|
||||
`(integer 1-65535); got ${JSON.stringify(port)}.`,
|
||||
);
|
||||
}
|
||||
map[slug] = `http://localhost:${port}`;
|
||||
}
|
||||
return JSON.stringify(map);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import registryData from "@/data/registry.json";
|
||||
|
||||
export interface Feature {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface FeatureCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Demo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
route?: string;
|
||||
command?: string;
|
||||
animated_preview_url?: string | null;
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
name: string;
|
||||
slug: string;
|
||||
category: string;
|
||||
language: string;
|
||||
logo?: string;
|
||||
description: string;
|
||||
partner_docs: string | null;
|
||||
repo: string;
|
||||
copilotkit_version: string;
|
||||
backend_url: string;
|
||||
deployed: boolean;
|
||||
generative_ui?: string[];
|
||||
interaction_modalities?: string[];
|
||||
sort_order?: number;
|
||||
managed_platform?: { name: string; url: string };
|
||||
animated_preview_url?: string | null;
|
||||
starter?: {
|
||||
path: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
github_url?: string;
|
||||
demo_url?: string;
|
||||
clone_command?: string;
|
||||
};
|
||||
features: string[];
|
||||
demos: Demo[];
|
||||
}
|
||||
|
||||
export interface Registry {
|
||||
feature_registry: {
|
||||
version: string;
|
||||
categories: FeatureCategory[];
|
||||
features: Feature[];
|
||||
};
|
||||
integrations: Integration[];
|
||||
}
|
||||
|
||||
const registry = registryData as Registry;
|
||||
|
||||
export function getRegistry(): Registry {
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function getIntegrations(): Integration[] {
|
||||
return registry.integrations;
|
||||
}
|
||||
|
||||
export function getIntegration(slug: string): Integration | undefined {
|
||||
return registry.integrations.find((i) => i.slug === slug);
|
||||
}
|
||||
|
||||
export function getFeatures(): Feature[] {
|
||||
return registry.feature_registry.features;
|
||||
}
|
||||
|
||||
export function getFeature(id: string): Feature | undefined {
|
||||
return registry.feature_registry.features.find((f) => f.id === id);
|
||||
}
|
||||
|
||||
export function getFeatureCategories(): FeatureCategory[] {
|
||||
return registry.feature_registry.categories;
|
||||
}
|
||||
|
||||
export function getIntegrationsByCategory(): Record<string, Integration[]> {
|
||||
const grouped: Record<string, Integration[]> = {};
|
||||
for (const integration of registry.integrations) {
|
||||
if (!grouped[integration.category]) {
|
||||
grouped[integration.category] = [];
|
||||
}
|
||||
grouped[integration.category].push(integration);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function getDemo(
|
||||
integrationSlug: string,
|
||||
demoId: string,
|
||||
): { integration: Integration; demo: Demo } | undefined {
|
||||
const integration = getIntegration(integrationSlug);
|
||||
if (!integration) return undefined;
|
||||
const demo = integration.demos.find((d) => d.id === demoId);
|
||||
if (!demo) return undefined;
|
||||
return { integration, demo };
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
popular: "Most Popular",
|
||||
"agent-framework": "Agent Frameworks",
|
||||
"enterprise-platform": "Enterprise",
|
||||
"provider-sdk": "Provider SDKs",
|
||||
protocol: "Protocols & Standards",
|
||||
emerging: "Emerging",
|
||||
starter: "Getting Started",
|
||||
};
|
||||
|
||||
export function getCategoryLabel(slug: string): string {
|
||||
return CATEGORY_LABELS[slug] || slug;
|
||||
}
|
||||
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
python: "Python",
|
||||
typescript: "TypeScript",
|
||||
dotnet: ".NET",
|
||||
};
|
||||
|
||||
export function getLanguageLabel(lang: string): string {
|
||||
return LANGUAGE_LABELS[lang] || lang;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getRuntimeConfig } from "./runtime-config.client";
|
||||
|
||||
describe("client getRuntimeConfig (shell)", () => {
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
beforeEach(() => {
|
||||
// jsdom provides `window` by default in this vitest config — but a
|
||||
// prior test that deleted `window` and failed before its restore
|
||||
// would leave it undefined, and an unguarded dereference here turns
|
||||
// that one failure into a cascade across the whole file. Restore
|
||||
// first, then guard the delete.
|
||||
(globalThis as { window?: Window }).window = originalWindow;
|
||||
if (globalThis.window) {
|
||||
delete (globalThis.window as Window & { __SHOWCASE_CONFIG__?: unknown })
|
||||
.__SHOWCASE_CONFIG__;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore BEFORE the delete: if the test under teardown removed
|
||||
// `window` (the SSR test), the unguarded dereference would throw
|
||||
// inside the hook and mask the real failure.
|
||||
(globalThis as { window?: Window }).window = originalWindow;
|
||||
// Clean up the injected config HERE, not only in the next test's
|
||||
// beforeEach — the last test in this file must not leak
|
||||
// __SHOWCASE_CONFIG__ into other test files under worker reuse.
|
||||
if (globalThis.window) {
|
||||
delete (globalThis.window as Window & { __SHOWCASE_CONFIG__?: unknown })
|
||||
.__SHOWCASE_CONFIG__;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the injected config", () => {
|
||||
(window as Window & { __SHOWCASE_CONFIG__?: unknown }).__SHOWCASE_CONFIG__ =
|
||||
{
|
||||
baseUrl: "https://showcase.example.com",
|
||||
posthogHost: "https://eu.i.posthog.com",
|
||||
backendHostPattern: "showcase-{slug}-production.up.railway.app",
|
||||
docsHost: "https://docs.showcase.copilotkit.ai",
|
||||
};
|
||||
expect(getRuntimeConfig()).toEqual({
|
||||
baseUrl: "https://showcase.example.com",
|
||||
posthogHost: "https://eu.i.posthog.com",
|
||||
backendHostPattern: "showcase-{slug}-production.up.railway.app",
|
||||
docsHost: "https://docs.showcase.copilotkit.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when __SHOWCASE_CONFIG__ is missing (wiring bug)", () => {
|
||||
expect(() => getRuntimeConfig()).toThrow(
|
||||
/window\.__SHOWCASE_CONFIG__ is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the injection ran with empty inputs (incomplete config)", () => {
|
||||
// `!cfg` alone would accept a truthy-but-useless object — the
|
||||
// fail-loud contract covers empty injected fields too. ALL FOUR
|
||||
// URL-bearing fields are checked symmetrically (docsHost feeds
|
||||
// docs links, posthogHost feeds capture — an empty value in either
|
||||
// is the same wiring bug as an empty baseUrl).
|
||||
for (const broken of [
|
||||
{ baseUrl: "" },
|
||||
{ backendHostPattern: "" },
|
||||
{ docsHost: "" },
|
||||
{ posthogHost: "" },
|
||||
]) {
|
||||
(
|
||||
window as Window & { __SHOWCASE_CONFIG__?: unknown }
|
||||
).__SHOWCASE_CONFIG__ = {
|
||||
baseUrl: "https://showcase.example.com",
|
||||
posthogHost: "https://eu.i.posthog.com",
|
||||
backendHostPattern: "showcase-{slug}.example.com",
|
||||
docsHost: "https://docs.showcase.copilotkit.ai",
|
||||
...broken,
|
||||
};
|
||||
expect(
|
||||
() => getRuntimeConfig(),
|
||||
`empty ${Object.keys(broken)[0]} should throw`,
|
||||
).toThrow(/__SHOWCASE_CONFIG__ is incomplete/);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws (naming the field) when an injected field is not a string", () => {
|
||||
// A layout bug injecting a number previously sailed through the
|
||||
// truthiness check and exploded far from the cause (e.g. inside a
|
||||
// consumer's replaceAll).
|
||||
for (const [field, value] of [
|
||||
["baseUrl", 42],
|
||||
["backendHostPattern", null],
|
||||
["docsHost", { url: "https://docs.example.com" }],
|
||||
["posthogHost", 1],
|
||||
] as const) {
|
||||
(
|
||||
window as Window & { __SHOWCASE_CONFIG__?: unknown }
|
||||
).__SHOWCASE_CONFIG__ = {
|
||||
baseUrl: "https://showcase.example.com",
|
||||
posthogHost: "https://eu.i.posthog.com",
|
||||
backendHostPattern: "showcase-{slug}.example.com",
|
||||
docsHost: "https://docs.showcase.copilotkit.ai",
|
||||
[field]: value,
|
||||
};
|
||||
expect(
|
||||
() => getRuntimeConfig(),
|
||||
`non-string ${field} should throw`,
|
||||
).toThrow(new RegExp(`"${field}"`));
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a config without posthogKey (optional field)", () => {
|
||||
// posthogKey is legitimately absent off-prod — it must NOT be part
|
||||
// of the fail-loud required set.
|
||||
(window as Window & { __SHOWCASE_CONFIG__?: unknown }).__SHOWCASE_CONFIG__ =
|
||||
{
|
||||
baseUrl: "https://showcase.example.com",
|
||||
posthogHost: "https://eu.i.posthog.com",
|
||||
backendHostPattern: "showcase-{slug}-production.up.railway.app",
|
||||
docsHost: "https://docs.showcase.copilotkit.ai",
|
||||
};
|
||||
expect(getRuntimeConfig().posthogKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws (naming posthogKey) when a PRESENT posthogKey is not a string", () => {
|
||||
// posthogKey's absence exemption (legitimately unset off-prod) must
|
||||
// not exempt wrong TYPES: a layout bug injecting a number would
|
||||
// sail through and explode far from the cause in a capture consumer.
|
||||
for (const bad of [42, null, { key: "phc_x" }]) {
|
||||
// Full-replacement cast (not an intersection): the global Window
|
||||
// augmentation types the field as RuntimeConfig, which would
|
||||
// reject the deliberately-wrong posthogKey at compile time.
|
||||
(
|
||||
window as unknown as { __SHOWCASE_CONFIG__?: unknown }
|
||||
).__SHOWCASE_CONFIG__ = {
|
||||
baseUrl: "https://showcase.example.com",
|
||||
posthogHost: "https://eu.i.posthog.com",
|
||||
backendHostPattern: "showcase-{slug}-production.up.railway.app",
|
||||
docsHost: "https://docs.showcase.copilotkit.ai",
|
||||
posthogKey: bad,
|
||||
};
|
||||
expect(
|
||||
() => getRuntimeConfig(),
|
||||
`posthogKey ${JSON.stringify(bad)} should throw`,
|
||||
).toThrow(/"posthogKey"/);
|
||||
}
|
||||
// A present STRING key still passes.
|
||||
(window as Window & { __SHOWCASE_CONFIG__?: unknown }).__SHOWCASE_CONFIG__ =
|
||||
{
|
||||
baseUrl: "https://showcase.example.com",
|
||||
posthogHost: "https://eu.i.posthog.com",
|
||||
backendHostPattern: "showcase-{slug}-production.up.railway.app",
|
||||
docsHost: "https://docs.showcase.copilotkit.ai",
|
||||
posthogKey: "phc_x",
|
||||
};
|
||||
expect(getRuntimeConfig().posthogKey).toBe("phc_x");
|
||||
});
|
||||
|
||||
it("throws (naming posthogKey) when a PRESENT posthogKey is an empty string", () => {
|
||||
// The server reader can never produce "" (readEnvPair maps empty to
|
||||
// undefined), so a present-but-empty key is the same wiring-bug
|
||||
// class as a wrong type — NOT the legitimate absence case.
|
||||
(
|
||||
window as unknown as { __SHOWCASE_CONFIG__?: unknown }
|
||||
).__SHOWCASE_CONFIG__ = {
|
||||
baseUrl: "https://showcase.example.com",
|
||||
posthogHost: "https://eu.i.posthog.com",
|
||||
backendHostPattern: "showcase-{slug}-production.up.railway.app",
|
||||
docsHost: "https://docs.showcase.copilotkit.ai",
|
||||
posthogKey: "",
|
||||
};
|
||||
expect(() => getRuntimeConfig()).toThrow(/"posthogKey"/);
|
||||
});
|
||||
|
||||
it("returns a frozen object so consumers cannot mutate the shared config", () => {
|
||||
(window as Window & { __SHOWCASE_CONFIG__?: unknown }).__SHOWCASE_CONFIG__ =
|
||||
{
|
||||
baseUrl: "https://showcase.example.com",
|
||||
posthogHost: "https://eu.i.posthog.com",
|
||||
backendHostPattern: "showcase-{slug}-production.up.railway.app",
|
||||
docsHost: "https://docs.showcase.copilotkit.ai",
|
||||
};
|
||||
const cfg = getRuntimeConfig();
|
||||
expect(Object.isFrozen(cfg)).toBe(true);
|
||||
// window.__SHOWCASE_CONFIG__ is a process-wide singleton; a strict-
|
||||
// mode write must throw instead of silently changing it for everyone.
|
||||
expect(() => {
|
||||
(cfg as { baseUrl: string }).baseUrl = "https://evil.example.com";
|
||||
}).toThrow(TypeError);
|
||||
expect(getRuntimeConfig().baseUrl).toBe("https://showcase.example.com");
|
||||
});
|
||||
|
||||
it("returns SSR sentinel placeholder when window is undefined", () => {
|
||||
// Simulate SSR by stubbing window to undefined. "use client"
|
||||
// component bodies execute on the server during SSR, so this reader
|
||||
// MUST be SSR-safe (returns parseable-URL placeholders so
|
||||
// `new URL()` in consumers doesn't throw) and NOT throw —
|
||||
// otherwise the whole server-rendered HTML 500s. stubGlobal (not
|
||||
// delete/reassign) per repo discipline: vitest restores the
|
||||
// original even if an assertion throws mid-test (same pattern as
|
||||
// runtime-url-wiring.test.ts).
|
||||
vi.stubGlobal("window", undefined);
|
||||
try {
|
||||
const cfg = getRuntimeConfig();
|
||||
// URL fields must be parseable.
|
||||
expect(() => new URL(cfg.baseUrl)).not.toThrow();
|
||||
expect(() => new URL(cfg.posthogHost)).not.toThrow();
|
||||
expect(() => new URL(cfg.docsHost)).not.toThrow();
|
||||
// Structural parity with the server reader: every real value is
|
||||
// slashless (the server strips trailing slashes at every exit
|
||||
// path), so the SSR placeholder must be slashless too — consumers
|
||||
// string-compose against these values and must see ONE form.
|
||||
expect(cfg.baseUrl).not.toMatch(/\/$/);
|
||||
expect(cfg.posthogHost).not.toMatch(/\/$/);
|
||||
expect(cfg.docsHost).not.toMatch(/\/$/);
|
||||
// The host pattern is not a URL but must keep the {slug}
|
||||
// placeholder so substitution still yields a syntactically
|
||||
// valid (non-resolvable) host during SSR.
|
||||
expect(cfg.backendHostPattern).toContain("{slug}");
|
||||
// The placeholder is shared module state — must be frozen too.
|
||||
expect(Object.isFrozen(cfg)).toBe(true);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
// Client-side runtime config reader. Reads from
|
||||
// window.__SHOWCASE_CONFIG__ which the root layout injects via an
|
||||
// inline <script> tag BEFORE React hydrates (see app/layout.tsx). This
|
||||
// is the ONLY public API for these URLs in client code — never read
|
||||
// process.env.NEXT_PUBLIC_* directly.
|
||||
|
||||
// Build-time guard: importing this module from a Server Component
|
||||
// previously failed SILENTLY — the SSR branch below returns
|
||||
// placeholders, so a server-side consumer would render permanent
|
||||
// placeholder URLs with no signal. `client-only` turns that mistake
|
||||
// into a Next.js build error.
|
||||
import "client-only";
|
||||
|
||||
import type { RuntimeConfig } from "./runtime-config";
|
||||
|
||||
export type { RuntimeConfig };
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SHOWCASE_CONFIG__?: RuntimeConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel returned during SSR when `window` is unavailable. "use client"
|
||||
* components in the Next.js App Router are server-side rendered on the
|
||||
* initial request (that's how the HTML is streamed before hydration),
|
||||
* which means their function bodies execute on the server too. We can't
|
||||
* throw here without breaking SSR — instead we return a placeholder.
|
||||
*
|
||||
* Hydration story: the inline <script> in the root layout runs BEFORE
|
||||
* React hydrates, so window.__SHOWCASE_CONFIG__ is already populated by
|
||||
* the time the hydration render executes — the FIRST client render sees
|
||||
* the real values (there is no "post-hydration next render"). The
|
||||
* hazard is the opposite direction: the server-rendered HTML was
|
||||
* produced with THIS placeholder, so a component that renders a config
|
||||
* value directly into markup (text/attribute) will hydrate with a
|
||||
* server/client MISMATCH (React warning, possible flash). Consumers
|
||||
* that inline config values at render time should read them in an
|
||||
* effect/state instead.
|
||||
*/
|
||||
// URL fields use a parseable `https://ssr-placeholder.invalid` sentinel
|
||||
// — NOT the empty string — because consumer components may call
|
||||
// `new URL(cfg.someUrl)` inline during render, and `new URL("")` throws
|
||||
// a TypeError that escapes the SSR response as a 500. The `.invalid`
|
||||
// TLD is reserved by RFC 2606 so the URL also can't accidentally
|
||||
// resolve. Declared WITHOUT a trailing slash: the server reader strips
|
||||
// trailing slashes at every exit path, so every REAL value is slashless
|
||||
// — the placeholder keeps the SSR and client forms structurally
|
||||
// identical for consumers that string-compose against them.
|
||||
const SSR_PLACEHOLDER_URL = "https://ssr-placeholder.invalid";
|
||||
const SSR_PLACEHOLDER: Readonly<RuntimeConfig> = Object.freeze({
|
||||
baseUrl: SSR_PLACEHOLDER_URL,
|
||||
posthogHost: SSR_PLACEHOLDER_URL,
|
||||
// Keep the {slug} placeholder so an SSR-phase substitution still
|
||||
// yields a parseable, RFC-2606-unresolvable host. No iframe ever
|
||||
// renders from this: backend-URL consumers gate on client state
|
||||
// that is only populated post-hydration.
|
||||
backendHostPattern: "showcase-{slug}.ssr-placeholder.invalid",
|
||||
docsHost: SSR_PLACEHOLDER_URL,
|
||||
// Optional field (legitimately absent off-prod) — no placeholder
|
||||
// needed; client capture consumers must gate on it anyway.
|
||||
posthogKey: undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the runtime config injected by the root server layout.
|
||||
*
|
||||
* During SSR (no window) returns a sentinel placeholder; the inline
|
||||
* <script> runs before hydration, so every client render — including
|
||||
* the hydration render — sees the real values (see the hydration note
|
||||
* on SSR_PLACEHOLDER above). If the inline <script> never ran (a route
|
||||
* bypassed the layout) or injected an empty/incomplete object, this
|
||||
* read throws — surfacing the wiring bug loudly rather than silently
|
||||
* rendering empty URLs.
|
||||
*
|
||||
* The returned object is frozen: window.__SHOWCASE_CONFIG__ is a
|
||||
* process-wide singleton, so a consumer mutating its copy would change
|
||||
* the config for EVERY component.
|
||||
*/
|
||||
export function getRuntimeConfig(): Readonly<RuntimeConfig> {
|
||||
if (typeof window === "undefined") {
|
||||
// SSR phase — "use client" component bodies execute here too.
|
||||
// Return placeholder; the hydration render reads the real values.
|
||||
return SSR_PLACEHOLDER;
|
||||
}
|
||||
const cfg = window.__SHOWCASE_CONFIG__;
|
||||
if (!cfg) {
|
||||
// The root layout always emits the <script> tag, so a missing
|
||||
// value here is a wiring bug (e.g. a route bypassed the layout).
|
||||
// Surface it loudly rather than silently returning empty strings.
|
||||
throw new Error(
|
||||
"[runtime-config.client] window.__SHOWCASE_CONFIG__ is missing. " +
|
||||
"The root layout must inject runtime config before client mount.",
|
||||
);
|
||||
}
|
||||
// Field validation: an injection that ran with empty inputs (layout
|
||||
// wired to a broken server read) yields an object that is truthy but
|
||||
// useless — fail loud instead of rendering empty URLs. ALL FOUR
|
||||
// URL-bearing fields are checked symmetrically (docsHost feeds docs
|
||||
// links, posthogHost feeds capture — an empty value in either is the
|
||||
// same wiring bug as an empty baseUrl). The typeof check catches a
|
||||
// layout bug injecting a non-string (e.g. a number), which previously
|
||||
// sailed through truthiness and exploded far from the cause inside a
|
||||
// consumer's replaceAll. posthogKey is deliberately NOT required —
|
||||
// it is legitimately absent off-prod.
|
||||
for (const field of REQUIRED_CONFIG_FIELDS) {
|
||||
const value = cfg[field];
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(
|
||||
`[runtime-config.client] window.__SHOWCASE_CONFIG__ is incomplete: ` +
|
||||
`field "${field}" is ${
|
||||
typeof value === "string" ? "empty" : `of type ${typeof value}`
|
||||
}. The root layout injection ran with broken inputs — check the ` +
|
||||
`server-side runtime config.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// posthogKey is exempt from the REQUIRED set because ABSENCE is a
|
||||
// valid state (legitimately unset off-prod) — but the absence
|
||||
// exemption must not exempt wrong TYPES or the empty string: a layout
|
||||
// bug injecting a number would sail through and explode far from the
|
||||
// cause inside a capture consumer, and the server reader can never
|
||||
// produce "" (readEnvPair maps empty to undefined), so a present-but-
|
||||
// empty key is the same wiring-bug class.
|
||||
if (
|
||||
cfg.posthogKey !== undefined &&
|
||||
(typeof cfg.posthogKey !== "string" || cfg.posthogKey.length === 0)
|
||||
) {
|
||||
throw new Error(
|
||||
`[runtime-config.client] window.__SHOWCASE_CONFIG__ is malformed: ` +
|
||||
`field "posthogKey" is ${
|
||||
typeof cfg.posthogKey === "string"
|
||||
? "empty"
|
||||
: `of type ${typeof cfg.posthogKey}`
|
||||
} (expected a non-empty string or absence). The root layout ` +
|
||||
`injection ran with broken inputs — check the server-side ` +
|
||||
`runtime config.`,
|
||||
);
|
||||
}
|
||||
return Object.freeze(cfg);
|
||||
}
|
||||
|
||||
const REQUIRED_CONFIG_FIELDS = [
|
||||
"baseUrl",
|
||||
"posthogHost",
|
||||
"backendHostPattern",
|
||||
"docsHost",
|
||||
] as const satisfies readonly (keyof RuntimeConfig)[];
|
||||
@@ -0,0 +1,708 @@
|
||||
// Server-side runtime config for the showcase shell.
|
||||
//
|
||||
// This module reads URL / analytics env values at REQUEST time. Note
|
||||
// the import boundary is NOT the protective mechanism here: `next/cache`
|
||||
// is imported at module top level, so any bundle that pulls in this
|
||||
// module (including the Edge middleware bundle, via
|
||||
// getRuntimeConfigForMiddleware below) already contains it — and the
|
||||
// build succeeds. The real hazard is CALLING `unstable_noStore()` in a
|
||||
// scope that has no Next.js request store (Edge middleware, or any
|
||||
// non-render scope): the call throws at runtime. The middleware wrapper
|
||||
// below therefore skips the CALL, not the import.
|
||||
//
|
||||
// Client components must use runtime-config.client.ts instead — not
|
||||
// because this module fails their build, but because the server env
|
||||
// vars it reads (BASE_URL, DOCS_HOST, ...) are not exposed to the
|
||||
// browser (a client render would see the dev/sentinel fallbacks) and
|
||||
// calling noStore() during a client render throws. The client reader
|
||||
// consumes window.__SHOWCASE_CONFIG__ which the root layout injects.
|
||||
|
||||
// Build-time guard (mirror of the `client-only` import in
|
||||
// runtime-config.client.ts): importing this module from a Client
|
||||
// Component bundle previously failed SILENTLY — the browser doesn't
|
||||
// have the server env vars, so a client consumer would render the
|
||||
// dev/sentinel fallbacks with no signal. `server-only` turns that
|
||||
// mistake into a Next.js build error. It only errors in CLIENT bundles:
|
||||
// the RSC layer and the middleware/Edge layer resolve the package's
|
||||
// empty `react-server` export (verified via `next build` — middleware
|
||||
// imports this module through getRuntimeConfigForMiddleware). Vitest
|
||||
// resolves it to the same empty marker via a resolve.alias in
|
||||
// vitest.config.ts (plain Node hits the throwing `default` export).
|
||||
import "server-only";
|
||||
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import {
|
||||
DEFAULT_BACKEND_HOST_PATTERN,
|
||||
SCHEME_RE,
|
||||
normalizeBackendHostPattern,
|
||||
} from "./backend-url";
|
||||
|
||||
export interface RuntimeConfig {
|
||||
/** Canonical shell base URL — used for canonical hrefs, OG metadata, etc. */
|
||||
baseUrl: string;
|
||||
/** PostHog host — middleware ships seo_redirect events here. */
|
||||
posthogHost: string;
|
||||
/**
|
||||
* Backend host pattern — `{slug}` is the only placeholder. Used to
|
||||
* derive each integration's backend URL at request time instead of
|
||||
* trusting the registry value baked at Docker build (which froze
|
||||
* prod hostnames into every image — staging iframed prod). Same
|
||||
* semantics as SHOWCASE_BACKEND_HOST_PATTERN in
|
||||
* scripts/generate-registry.ts: host only, `https://` is prepended
|
||||
* by the consumer (see lib/backend-url.ts).
|
||||
*/
|
||||
backendHostPattern: string;
|
||||
/** Docs shell host — middleware 308s /docs, /ag-ui, /reference and framework-slug routes here. */
|
||||
docsHost: string;
|
||||
/**
|
||||
* PostHog project API key — middleware authenticates capture calls
|
||||
* with it. Optional: legitimately absent on non-production deploys
|
||||
* (capture is disabled, with a warn in middleware). PostHog project
|
||||
* keys are public-by-design (they ship in client bundles), so this
|
||||
* field riding along in the root layout's window.__SHOWCASE_CONFIG__
|
||||
* injection is safe. Optional in the type for the same reason —
|
||||
* absence is a valid state, not a wiring bug.
|
||||
*/
|
||||
posthogKey?: string;
|
||||
}
|
||||
|
||||
// Sentinel for a missing prod BASE_URL. Must be a normal hierarchical
|
||||
// https URL (parity with the client reader's `.invalid` sentinel) — the
|
||||
// previous `about:blank#...` form was an opaque-path URL, and
|
||||
// `new URL(path, baseUrl)` THROWS on opaque bases, so the sentinel
|
||||
// itself would 500 any consumer composing URLs. `.invalid` is reserved
|
||||
// by RFC 2606, so the breakage stays visible without resolving anywhere.
|
||||
// Declared WITHOUT a trailing slash — consumers receive it exactly as
|
||||
// written (the previous slash-bearing form was stripped at every exit
|
||||
// path, so the declared value never appeared anywhere).
|
||||
const PROD_INVALID_BASE_URL = "https://shell-base-url-missing.invalid";
|
||||
|
||||
// Defaults reproduce today's baked prod values exactly, so a deploy
|
||||
// with neither env var set (i.e. current prod) behaves byte-identically.
|
||||
// DEFAULT_BACKEND_HOST_PATTERN moved to backend-url.ts (its normalizer
|
||||
// falls back to it, and defining it here would create an import cycle)
|
||||
// — re-exported to keep this module's public surface unchanged.
|
||||
export { DEFAULT_BACKEND_HOST_PATTERN };
|
||||
export const DEFAULT_DOCS_HOST = "https://docs.showcase.copilotkit.ai";
|
||||
|
||||
/**
|
||||
* Sentinel docs host meaning "docs redirects are DISABLED for this
|
||||
* deploy". Returned by readDocsHost when NO usable docs host exists:
|
||||
* the configured value was rejected for pointing at the shell's own
|
||||
* host AND the DEFAULT_DOCS_HOST fallback has the same defect (the
|
||||
* shell is deployed AT the docs host — e.g. DOCS_HOST unset on that
|
||||
* very service). Falling back to the default there would re-create the
|
||||
* exact redirect loop the self-host guard exists to prevent.
|
||||
*
|
||||
* CONSUMER CONTRACT (middleware / docs-redirects): when
|
||||
* `config.docsHost === DOCS_REDIRECTS_DISABLED_HOST`, skip the
|
||||
* docs-host redirect step entirely (resolveDocsHostRedirect's callers
|
||||
* must not issue 308s to this host). The value is a normal parseable
|
||||
* https URL so incidental `new URL(docsHost)` consumers don't throw,
|
||||
* and uses the RFC-2606-reserved `.invalid` TLD so it can never
|
||||
* resolve if a redirect slips through anyway.
|
||||
*/
|
||||
export const DOCS_REDIRECTS_DISABLED_HOST =
|
||||
"https://docs-redirects-disabled.invalid";
|
||||
// Historic default — matches the previous middleware behavior.
|
||||
export const DEFAULT_POSTHOG_HOST = "https://eu.i.posthog.com";
|
||||
|
||||
/**
|
||||
* Resolve the runtime config for shell. Called by the root layout and
|
||||
* by middleware (via the wrapper below) — both on every request, and
|
||||
* each CALL re-reads process.env (no value caching; the only module
|
||||
* state is the warn-once log guards).
|
||||
*
|
||||
* Fail-loud strategy mirrors shell-dashboard: in production, missing
|
||||
* URL env vars produce sentinel URLs (visible breakage) AND a
|
||||
* console.error; in dev, we fall back to localhost so iteration is
|
||||
* frictionless. Analytics keys (posthogHost) use the dev fallback
|
||||
* unconditionally — historic POSTHOG_HOST default is the EU cloud.
|
||||
*
|
||||
* `opts.noStore` (default `true`) controls whether to call
|
||||
* `unstable_noStore()`. The Node.js server runtime needs the opt-out so
|
||||
* Next.js does not statically prerender callers and freeze the URLs into
|
||||
* the build artifact. Middleware MUST pass `{ noStore: false }`: the
|
||||
* `next/cache` IMPORT is fine in the Edge bundle (the build proves it),
|
||||
* but CALLING `unstable_noStore()` outside a Node.js render scope
|
||||
* throws at runtime — and middleware always runs per-request by
|
||||
* definition, so there is no static cache to opt out of anyway. The
|
||||
* thin `getRuntimeConfigForMiddleware()` wrapper below makes this
|
||||
* explicit at the call site.
|
||||
*/
|
||||
export function getRuntimeConfig(
|
||||
opts: { noStore?: boolean } = {},
|
||||
): RuntimeConfig {
|
||||
if (opts.noStore !== false) noStore();
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
|
||||
const baseUrl = validateBaseUrl(
|
||||
readUrl(
|
||||
"BASE_URL",
|
||||
isProd ? PROD_INVALID_BASE_URL : "http://localhost:3000",
|
||||
isProd,
|
||||
),
|
||||
isProd,
|
||||
);
|
||||
// PostHog host: legitimately absent on non-production deploys; never
|
||||
// log a FATAL-CONFIG when UNSET. A SET-but-broken value is still
|
||||
// validated (scheme prepend, degenerate-host rejection) — see
|
||||
// readPosthogHost.
|
||||
const posthogHost = readPosthogHost(isProd);
|
||||
// PostHog project key: same readEnvPair semantics as every other env
|
||||
// value (trim + NEXT_PUBLIC_ fallback). Middleware previously read
|
||||
// process.env.POSTHOG_KEY raw, bypassing both.
|
||||
const posthogKey = readEnvPair("POSTHOG_KEY");
|
||||
|
||||
// Both URL-routing values have legitimate prod defaults — unset env
|
||||
// means "production behavior", so (like POSTHOG_HOST) they never log
|
||||
// FATAL-CONFIG. Staging/preview deploys override them per-request via
|
||||
// SHOWCASE_BACKEND_HOST_PATTERN / DOCS_HOST service variables.
|
||||
//
|
||||
// backendHostPattern is a host *pattern*, not a URL — don't run it
|
||||
// through readKey/readUrl (`{slug}` must survive untouched). It IS
|
||||
// normalized against common env misconfigs (leading scheme, trailing
|
||||
// slash, missing `{slug}`) with warn-once guards — see
|
||||
// normalizeBackendHostPattern in lib/backend-url.ts.
|
||||
const backendHostPattern = normalizeBackendHostPattern(
|
||||
readEnvPair("SHOWCASE_BACKEND_HOST_PATTERN") ??
|
||||
DEFAULT_BACKEND_HOST_PATTERN,
|
||||
);
|
||||
const docsHost = readDocsHost(baseUrl, isProd);
|
||||
|
||||
return { baseUrl, posthogHost, backendHostPattern, docsHost, posthogKey };
|
||||
}
|
||||
|
||||
// Loopback hostnames that can never serve TLS on a local dev port —
|
||||
// scheme-less values pointing at them get http:// prepended instead of
|
||||
// https:// (see ensureScheme). The WHATWG URL hostname for an IPv6
|
||||
// literal keeps its brackets, hence the `[::1]` form.
|
||||
const LOOPBACK_HOSTNAME_RE = /^(localhost|127\.0\.0\.1|\[::1\])$/i;
|
||||
|
||||
function isLoopbackHostValue(value: string): boolean {
|
||||
// Probe-parse with https:// to extract the hostname; an unparseable
|
||||
// value is not loopback (the caller's validation rejects it later).
|
||||
try {
|
||||
return LOOPBACK_HOSTNAME_RE.test(new URL(`https://${value}`).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend a scheme to scheme-less host values so downstream
|
||||
// `new URL(...)` / fetch consumers don't throw on a host-only env value.
|
||||
// https:// by default; http:// for loopback hosts — the documented
|
||||
// local-dev DOCS_HOST wiring (`localhost:3005`) would otherwise become
|
||||
// a TLS-failing https destination with zero warn. The loopback prepend
|
||||
// serves DEV only: validateBaseUrl/readDocsHost reject loopback hosts
|
||||
// outright in production (POSTHOG_HOST deliberately keeps them — a
|
||||
// loopback capture proxy degrades analytics, not the site).
|
||||
function ensureScheme(value: string): string {
|
||||
if (SCHEME_RE.test(value)) return value;
|
||||
return isLoopbackHostValue(value) ? `http://${value}` : `https://${value}`;
|
||||
}
|
||||
|
||||
// NOTE on once-guard scope: every warn/error once-guard Set in this
|
||||
// module is per-ISOLATE module state. The Node server and the Edge
|
||||
// middleware runtime each instantiate their own copy (and a restart
|
||||
// resets them), so a misconfig can log once per isolate rather than
|
||||
// once globally. Intended: bounded repetition beats lost signal.
|
||||
|
||||
// One loud log per distinct (mode, malformed BASE_URL value) — not per
|
||||
// request.
|
||||
const baseUrlInvalidLogged = new Set<string>();
|
||||
// One warn per distinct (mode, origin-normalized BASE_URL value) —
|
||||
// mode-prefixed for consistency with every other guard key in this
|
||||
// module, even though the warn's text is mode-independent.
|
||||
const baseUrlNormalizedLogged = new Set<string>();
|
||||
|
||||
/**
|
||||
* Validate the BASE_URL value AFTER the unset-fallback resolution.
|
||||
* readUrl only covers the UNSET case — a SET-but-malformed value
|
||||
* (scheme-less `shell.copilotkit.ai`, or a bare `https://` that the
|
||||
* trailing-slash strip reduces to `https:`) previously passed through
|
||||
* unvalidated, and every consumer composing `new URL(path, baseUrl)`
|
||||
* threw: opaque 500s with NO log, because the env var IS set so the
|
||||
* unset-fallback (and its FATAL-CONFIG log) never fires. Same hardening
|
||||
* as its siblings (readDocsHost / readPosthogHost) — the scheme and
|
||||
* degenerate-host rejections originated there; the userinfo rejection
|
||||
* (3) originated HERE and is mirrored into both siblings:
|
||||
*
|
||||
* 1. scheme-less host-only values get `https://` prepended (fixable
|
||||
* misconfig — no log);
|
||||
* 2. non-http(s) schemes are rejected: `ftp://x` parses fine, and the
|
||||
* SCHEME_RE dot-scheme edge (`example.com://oops`) parses with
|
||||
* protocol "example.com:" — neither can serve consumers composing
|
||||
* http(s) URLs;
|
||||
* 3. userinfo-bearing values are rejected: `mailto:ops@x` lacks `://`
|
||||
* so the prepend yields `https://mailto:ops@x` (userinfo
|
||||
* "mailto:ops") — a base URL carrying credentials is always a
|
||||
* misconfig;
|
||||
* 4. degenerate values that parse but carry no real host (`https://` →
|
||||
* `https:` → hostname "https") are rejected — and in PRODUCTION,
|
||||
* loopback hosts are rejected too: the dev-only http:// prepend
|
||||
* (ensureScheme) must not silently point a prod deploy's canonical
|
||||
* URLs at localhost;
|
||||
* 5. a path/query/fragment is normalized to the origin with one warn —
|
||||
* consumers compose paths against this value, so subpath deploys
|
||||
* are deliberately UNSUPPORTED (composition would drop the subpath
|
||||
* silently anyway);
|
||||
* 6. anything unusable falls back with a once-guarded log NAMING the
|
||||
* bad value — in production the `.invalid` sentinel plus a
|
||||
* FATAL-CONFIG error (Railway guidance); in dev the localhost
|
||||
* fallback plus a console.warn (the module's frictionless-dev
|
||||
* contract — the prod sentinel and Railway guidance are useless on
|
||||
* a laptop).
|
||||
*/
|
||||
function validateBaseUrl(value: string, isProd: boolean): string {
|
||||
const candidate = ensureScheme(value);
|
||||
let reason: string;
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
if (!/^https?:$/i.test(parsed.protocol)) {
|
||||
reason = `uses unsupported scheme "${parsed.protocol}" (consumers compose http(s) URLs against it)`;
|
||||
} else if (parsed.username !== "" || parsed.password !== "") {
|
||||
reason = `carries userinfo credentials after the https:// prepend (e.g. a mailto: value)`;
|
||||
} else if (!parsed.hostname || /^https?$/i.test(parsed.hostname)) {
|
||||
reason = `carries no usable host (a bare scheme like "https://")`;
|
||||
} else if (isProd && LOOPBACK_HOSTNAME_RE.test(parsed.hostname)) {
|
||||
// The loopback http:// prepend (ensureScheme) exists for
|
||||
// frictionless DEV — in production it would silently "fix"
|
||||
// BASE_URL=localhost:3000 and run canonical hrefs, OG metadata,
|
||||
// and the docs loop guard against localhost with zero log.
|
||||
reason =
|
||||
`points at loopback host "${parsed.hostname}" in a production ` +
|
||||
`deploy (canonical URLs, OG metadata, and the docs loop guard ` +
|
||||
`would all target localhost)`;
|
||||
} else if (
|
||||
parsed.pathname !== "/" ||
|
||||
parsed.search !== "" ||
|
||||
parsed.hash !== ""
|
||||
) {
|
||||
const normalizedLogKey = `${isProd ? "prod" : "dev"}:${value}`;
|
||||
if (!baseUrlNormalizedLogged.has(normalizedLogKey)) {
|
||||
baseUrlNormalizedLogged.add(normalizedLogKey);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[shell runtime-config] BASE_URL ${JSON.stringify(value)} carries a ` +
|
||||
`path/query/fragment — consumers compose paths against this value ` +
|
||||
`(subpath deploys are unsupported), so the extra parts would ` +
|
||||
`corrupt every composed URL; using origin ${parsed.origin}.`,
|
||||
);
|
||||
}
|
||||
return parsed.origin;
|
||||
} else {
|
||||
// Parsed-normalized form, not the raw candidate: the value is
|
||||
// guaranteed origin-only here (path/query/fragment branch above),
|
||||
// and the raw form leaks un-normalized spellings (uppercase
|
||||
// hosts, explicit default ports) to every consumer while internal
|
||||
// comparisons use parsed forms.
|
||||
return parsed.origin;
|
||||
}
|
||||
} catch {
|
||||
reason = "is not a parseable URL (even after prepending https://)";
|
||||
}
|
||||
const fallback = isProd ? PROD_INVALID_BASE_URL : "http://localhost:3000";
|
||||
const logKey = `${isProd ? "prod" : "dev"}:${value}`;
|
||||
if (!baseUrlInvalidLogged.has(logKey)) {
|
||||
baseUrlInvalidLogged.add(logKey);
|
||||
if (isProd) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[shell runtime-config] FATAL-CONFIG: BASE_URL ${JSON.stringify(value)} ${reason}; ` +
|
||||
`using sentinel ${fallback}. Fix the BASE_URL env var on the Railway service.`,
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[shell runtime-config] BASE_URL ${JSON.stringify(value)} ${reason}; ` +
|
||||
`using dev fallback ${fallback}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// One warn per distinct (mode, bad POSTHOG_HOST value) — not per
|
||||
// request. Mode-prefixed for consistency with every other guard key in
|
||||
// this module (the warn's text is mode-independent).
|
||||
const posthogHostInvalidLogged = new Set<string>();
|
||||
// One warn per distinct (mode, query/fragment-normalized POSTHOG_HOST
|
||||
// value).
|
||||
const posthogHostNormalizedLogged = new Set<string>();
|
||||
|
||||
/**
|
||||
* Read POSTHOG_HOST with the same degenerate-host rejection readDocsHost
|
||||
* has: `POSTHOG_HOST="https://"` strips to `https:`, ensureScheme yields
|
||||
* `https://https:`, and that PARSES (hostname "https") — every capture
|
||||
* fetch then dies on DNS. Middleware warn-onces per capture-failure
|
||||
* class (see warnCaptureFailureOnce in src/middleware.ts), so the
|
||||
* breakage would be LOGGED — but capture stays down until the value is
|
||||
* fixed, hence the validation here. Differences from readDocsHost,
|
||||
* both deliberate:
|
||||
*
|
||||
* - a non-root PATH is preserved: path-based PostHog reverse proxies
|
||||
* (e.g. `https://proxy.example.com/ingest`) are a documented pattern,
|
||||
* so a path here is legitimate config — but a query/fragment IS
|
||||
* stripped (with one warn), since it corrupts every composed capture
|
||||
* URL;
|
||||
* - the fallback logs console.warn, not FATAL-CONFIG console.error —
|
||||
* broken analytics degrade reporting, they don't break the site.
|
||||
*/
|
||||
function readPosthogHost(isProd: boolean): string {
|
||||
const raw = readKey("POSTHOG_HOST", DEFAULT_POSTHOG_HOST);
|
||||
const candidate = ensureScheme(raw);
|
||||
// isProd feeds only the guard keys (the module-wide mode-prefixed
|
||||
// convention) — the warn level and text are mode-independent here.
|
||||
const logKey = `${isProd ? "prod" : "dev"}:${raw}`;
|
||||
// Branched rejection reason (same labeling readDocsHost has): the
|
||||
// previous catch-all warn claimed every rejected value "is not a
|
||||
// usable http(s) URL (even after prepending https://)" — false twice
|
||||
// for `ftp://ph.x` (it parsed fine, and no prepend happened) and for
|
||||
// the degenerate bare-scheme value (it parses too).
|
||||
let reason: string;
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
if (!/^https?:$/i.test(parsed.protocol)) {
|
||||
reason = `uses unsupported scheme "${parsed.protocol}" (capture calls must target an http(s) host)`;
|
||||
} else if (parsed.username !== "" || parsed.password !== "") {
|
||||
// Same userinfo rejection validateBaseUrl/readDocsHost have: the
|
||||
// Fetch spec forbids credentialed request URLs, so a userinfo-
|
||||
// bearing host makes EVERY capture fetch throw a TypeError that
|
||||
// middleware misattributes as a net-class failure.
|
||||
reason =
|
||||
`carries userinfo credentials after the https:// prepend (e.g. a ` +
|
||||
`mailto: value) — the Fetch spec forbids credentialed URLs, so ` +
|
||||
`every capture call would throw`;
|
||||
} else if (!parsed.hostname || /^https?$/i.test(parsed.hostname)) {
|
||||
reason = `carries no usable host (a bare scheme like "https://")`;
|
||||
} else if (parsed.search !== "" || parsed.hash !== "") {
|
||||
// Strip query/fragment but KEEP the path (reverse-proxy ingest
|
||||
// paths are documented config) — capture URLs are composed
|
||||
// against this value, and a `?x=1`/`#frag` corrupts every
|
||||
// capture into a persistent root-POST with misattributed
|
||||
// http-class warns from middleware.
|
||||
if (!posthogHostNormalizedLogged.has(logKey)) {
|
||||
posthogHostNormalizedLogged.add(logKey);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[shell runtime-config] POSTHOG_HOST ${JSON.stringify(raw)} carries a ` +
|
||||
`query/fragment — capture URLs are composed against this value, ` +
|
||||
`so the extra parts would corrupt every capture call; using ` +
|
||||
`${(parsed.origin + parsed.pathname).replace(/\/+$/, "")} (path kept ` +
|
||||
`for reverse-proxy setups).`,
|
||||
);
|
||||
}
|
||||
return (parsed.origin + parsed.pathname).replace(/\/+$/, "");
|
||||
} else {
|
||||
// Parsed-normalized form (origin + path), not the raw candidate:
|
||||
// query/fragment are empty here, so this is the whole URL — and the
|
||||
// raw form leaks un-normalized spellings (uppercase hosts, explicit
|
||||
// default ports) into every composed capture URL. The trailing-slash
|
||||
// strip preserves the readKey contract for a bare "/" pathname.
|
||||
return (parsed.origin + parsed.pathname).replace(/\/+$/, "");
|
||||
}
|
||||
} catch {
|
||||
reason = "is not a parseable URL (even after prepending https://)";
|
||||
}
|
||||
if (!posthogHostInvalidLogged.has(logKey)) {
|
||||
posthogHostInvalidLogged.add(logKey);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[shell runtime-config] POSTHOG_HOST ${JSON.stringify(raw)} ${reason}; ` +
|
||||
`falling back to ${DEFAULT_POSTHOG_HOST}. Fix the POSTHOG_HOST env ` +
|
||||
`var on the Railway service.`,
|
||||
);
|
||||
}
|
||||
return DEFAULT_POSTHOG_HOST;
|
||||
}
|
||||
|
||||
// Strip the trailing dot of a fully-qualified (root-anchored) hostname
|
||||
// from a URL authority for comparison purposes: `shell.example.com.`
|
||||
// and `shell.example.com` are the SAME authority to DNS and browsers.
|
||||
// The lookahead keeps a port intact (`shell.example.com.:8080` →
|
||||
// `shell.example.com:8080`); only the hostname's terminal dot matches.
|
||||
function stripTrailingHostDot(host: string): string {
|
||||
return host.replace(/\.(?=$|:)/, "");
|
||||
}
|
||||
|
||||
// One loud log per distinct (mode, shell host, bad DOCS_HOST value) —
|
||||
// not per request. The shell host is part of the key because the
|
||||
// message AND the outcome (default fallback vs disabled sentinel)
|
||||
// depend on it, and it re-reads live BASE_URL: a raw-only key would
|
||||
// silently swallow an outcome flip to redirects-disabled.
|
||||
const docsHostFallbackLogged = new Set<string>();
|
||||
// One warn per distinct (mode, origin-normalized DOCS_HOST value).
|
||||
const docsHostNormalizedLogged = new Set<string>();
|
||||
|
||||
/**
|
||||
* Read DOCS_HOST defensively. Middleware composes redirect destinations
|
||||
* from this value and hands them to `new URL(...)` (docs-redirects also
|
||||
* re-normalizes the host on its side — this is defense-in-depth, not
|
||||
* the only guard), so an unparseable value would 500 ALL docs traffic.
|
||||
* Hardening steps:
|
||||
*
|
||||
* 1. A scheme-less, host-only value (e.g. `docs-staging.example.com`)
|
||||
* gets `https://` prepended. This is a likely misconfig: the sibling
|
||||
* SHOWCASE_BACKEND_HOST_PATTERN var is documented as scheme-less,
|
||||
* and an operator can easily carry that format over.
|
||||
* 2. Non-http(s) schemes (e.g. `ftp://docs.x`) parse fine but can never
|
||||
* serve as a redirect destination — rejected.
|
||||
* 3. Degenerate values that parse but carry no real host (e.g.
|
||||
* `DOCS_HOST="https://"`, which strips to `https:` and yields a
|
||||
* "host" of `https`) are rejected. In PRODUCTION, loopback hosts are
|
||||
* rejected too — the dev-only http:// prepend (ensureScheme) must
|
||||
* not silently 308 a prod deploy's docs traffic to localhost.
|
||||
* 4. A value pointing at the shell's OWN host is rejected: the redirect
|
||||
* table has self-referential path entries (/faq → /faq etc.) that
|
||||
* terminate only because the destination host differs — same-host
|
||||
* docs redirects loop (ERR_TOO_MANY_REDIRECTS) on ~15 paths. The
|
||||
* comparison uses the full authority (URL.host, i.e. hostname:port),
|
||||
* not the hostname: localhost:3000 → localhost:3005 is the
|
||||
* documented local-dev wiring and must keep working. Both sides are
|
||||
* normalized through stripTrailingHostDot — a trailing-dot FQDN
|
||||
* spelling (`shell.example.com.`) is the same authority and loops
|
||||
* the same.
|
||||
* 5. A value carrying a path, query, or fragment is normalized to its
|
||||
* origin with one warn: docs-redirects composes destination paths
|
||||
* against this value, so the extra parts would silently corrupt
|
||||
* EVERY redirect (a `#frag` swallows the path entirely — all
|
||||
* redirects land on the docs root).
|
||||
* 6. If the value isn't usable, log loudly once — with a reason that
|
||||
* matches the actual rejection, not a catch-all "not parseable"
|
||||
* mislabel — and fall back to the default docs host:
|
||||
* degraded-but-working docs redirects beat a sitewide docs 500.
|
||||
* Same dev-vs-prod branch as validateBaseUrl: in production the log
|
||||
* is a FATAL-CONFIG console.error with Railway guidance; in dev it
|
||||
* is a console.warn (the Railway guidance is useless on a laptop).
|
||||
* The fallback VALUE is identical in both modes.
|
||||
*/
|
||||
function readDocsHost(baseUrl: string, isProd: boolean): string {
|
||||
// Best-effort shell authority for the loop guard — baseUrl has been
|
||||
// through validateBaseUrl, but guard the parse anyway. Normalized via
|
||||
// stripTrailingHostDot (as is every authority this is compared to):
|
||||
// a trailing-dot FQDN spelling on either side is the SAME authority
|
||||
// to DNS and browsers, so it loops the same.
|
||||
let shellHost: string | undefined;
|
||||
try {
|
||||
shellHost = stripTrailingHostDot(new URL(baseUrl).host);
|
||||
} catch {
|
||||
shellHost = undefined;
|
||||
}
|
||||
// Read the env pair directly (same trim/fallback semantics as
|
||||
// readKey) so the fallback path below can branch its FATAL message on
|
||||
// set-vs-unset — with DOCS_HOST unset on a shell deployed AT the docs
|
||||
// host, "DOCS_HOST <default> points at the shell's own host" sends
|
||||
// the operator hunting for an env var that does not exist.
|
||||
const envValue = readEnvPair("DOCS_HOST");
|
||||
const raw = (envValue ?? DEFAULT_DOCS_HOST).replace(/\/+$/, "");
|
||||
const candidate = ensureScheme(raw);
|
||||
// Branched rejection reason: the previous catch-all labeled values
|
||||
// that DID parse (degenerate host) as "not a parseable URL", sending
|
||||
// the operator hunting for a syntax error that isn't there.
|
||||
let reason: string;
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
if (!/^https?:$/i.test(parsed.protocol)) {
|
||||
reason = `uses unsupported scheme "${parsed.protocol}" (docs redirect destinations must be http/https)`;
|
||||
} else if (parsed.username !== "" || parsed.password !== "") {
|
||||
// Same userinfo rejection validateBaseUrl has: a credentialed
|
||||
// DOCS_HOST lands userinfo in every 308 Location header, which
|
||||
// browsers strip or silently block — docs redirects break with
|
||||
// zero signal.
|
||||
reason = `carries userinfo credentials after the https:// prepend (e.g. a mailto: value) — browsers strip or block credentialed redirect destinations`;
|
||||
} else if (!parsed.hostname || /^https?$/i.test(parsed.hostname)) {
|
||||
// `DOCS_HOST="https://"` slips through parsing: this function's
|
||||
// own trailing-slash strip (the `raw` computation above) reduces
|
||||
// it to "https:", ensureScheme yields "https://https:", and the
|
||||
// URL parses with hostname "https". Reject empty/scheme-word
|
||||
// hosts so the loud fallback fires instead.
|
||||
reason = `carries no usable host (a bare scheme like "https://")`;
|
||||
} else if (isProd && LOOPBACK_HOSTNAME_RE.test(parsed.hostname)) {
|
||||
// The loopback http:// prepend (ensureScheme) exists for the
|
||||
// documented local-dev wiring (`localhost:3005`) — in production
|
||||
// it would silently accept a loopback docs host and 308 every
|
||||
// docs visitor to localhost.
|
||||
reason =
|
||||
`points at loopback host "${parsed.hostname}" in a production ` +
|
||||
`deploy (every docs redirect would 308 visitors to localhost)`;
|
||||
} else if (
|
||||
shellHost !== undefined &&
|
||||
stripTrailingHostDot(parsed.host) === shellHost
|
||||
) {
|
||||
// Authority-only compare, deliberately scheme-INSENSITIVE: an
|
||||
// http:// docs host on the shell's https authority hits the same
|
||||
// middleware again (one scheme hop, then the same-host loop), so
|
||||
// over-flagging the cross-scheme case is the safe direction.
|
||||
reason =
|
||||
`points at the shell's own host "${shellHost}" — the redirect table's ` +
|
||||
`self-referential paths would loop (ERR_TOO_MANY_REDIRECTS)`;
|
||||
} else if (
|
||||
parsed.pathname !== "/" ||
|
||||
parsed.search !== "" ||
|
||||
parsed.hash !== ""
|
||||
) {
|
||||
const normalizedLogKey = `${isProd ? "prod" : "dev"}:${raw}`;
|
||||
if (!docsHostNormalizedLogged.has(normalizedLogKey)) {
|
||||
docsHostNormalizedLogged.add(normalizedLogKey);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[shell runtime-config] DOCS_HOST ${JSON.stringify(raw)} carries a ` +
|
||||
`path/query/fragment — docs redirects compose destination paths ` +
|
||||
`against this value, so the extra parts would corrupt every ` +
|
||||
`redirect; using origin ${parsed.origin}.`,
|
||||
);
|
||||
}
|
||||
return parsed.origin;
|
||||
} else {
|
||||
// Parsed-normalized form, not the raw candidate: the value is
|
||||
// guaranteed origin-only here (path/query/fragment branch above),
|
||||
// and the raw form leaks un-normalized spellings (uppercase
|
||||
// hosts, explicit default ports) into every redirect destination.
|
||||
// parsed.origin never carries a trailing slash, so the strip
|
||||
// applied to `raw` above is preserved too.
|
||||
return parsed.origin;
|
||||
}
|
||||
} catch {
|
||||
reason = "is not a parseable URL (even after prepending https://)";
|
||||
}
|
||||
// Re-check the FALLBACK against the shell host before handing it out:
|
||||
// the unconditional DEFAULT_DOCS_HOST fallback can carry the same
|
||||
// defect the configured value was just rejected for (shell deployed
|
||||
// AT the docs host). Without this, the unset-on-the-docs-host case
|
||||
// logged a self-contradictory "falling back to <the same looping
|
||||
// value>" AND returned the looping value.
|
||||
let fallbackCollides = false;
|
||||
if (shellHost !== undefined) {
|
||||
try {
|
||||
fallbackCollides =
|
||||
stripTrailingHostDot(new URL(DEFAULT_DOCS_HOST).host) === shellHost;
|
||||
} catch {
|
||||
fallbackCollides = false;
|
||||
}
|
||||
}
|
||||
const logKey = `${isProd ? "prod" : "dev"}:${shellHost ?? "<unparseable>"}:${raw}`;
|
||||
if (!docsHostFallbackLogged.has(logKey)) {
|
||||
docsHostFallbackLogged.add(logKey);
|
||||
// Core message without log-level dressing — the dev-vs-prod branch
|
||||
// below picks the level and (prod only) appends Railway guidance.
|
||||
let core: string;
|
||||
let guidance: string;
|
||||
if (fallbackCollides && envValue === undefined) {
|
||||
core =
|
||||
`DOCS_HOST is unset and the default ` +
|
||||
`docs host ${DEFAULT_DOCS_HOST} points at the shell's own host ` +
|
||||
`"${shellHost}" — docs redirects are disabled for this deploy ` +
|
||||
`(sentinel ${DOCS_REDIRECTS_DISABLED_HOST}).`;
|
||||
guidance = `Set DOCS_HOST on the Railway service to a host other than the shell's.`;
|
||||
} else if (fallbackCollides) {
|
||||
core =
|
||||
`DOCS_HOST ${JSON.stringify(raw)} ${reason}, ` +
|
||||
`and the default docs host ${DEFAULT_DOCS_HOST} ALSO points at the ` +
|
||||
`shell's own host "${shellHost}" — docs redirects are disabled for ` +
|
||||
`this deploy (sentinel ${DOCS_REDIRECTS_DISABLED_HOST}).`;
|
||||
guidance = `Fix the DOCS_HOST env var on the Railway service.`;
|
||||
} else {
|
||||
core =
|
||||
`DOCS_HOST ${JSON.stringify(raw)} ${reason}; ` +
|
||||
`falling back to ${DEFAULT_DOCS_HOST}.`;
|
||||
guidance = `Fix the DOCS_HOST env var on the Railway service.`;
|
||||
}
|
||||
if (isProd) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[shell runtime-config] FATAL-CONFIG: ${core} ${guidance}`);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[shell runtime-config] ${core}`);
|
||||
}
|
||||
}
|
||||
return fallbackCollides ? DOCS_REDIRECTS_DISABLED_HOST : DEFAULT_DOCS_HOST;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware variant. Identical semantics to `getRuntimeConfig()`
|
||||
* except the `unstable_noStore()` CALL is skipped. To be precise about
|
||||
* the mechanism: importing this wrapper pulls `next/cache` into the
|
||||
* Edge bundle exactly as importing `getRuntimeConfig` would (the
|
||||
* top-level import above is unconditional) and the build succeeds
|
||||
* either way. What breaks is CALLING `unstable_noStore()` in a scope
|
||||
* with no Next.js request store — it throws at runtime. Middleware
|
||||
* always runs per-request by definition, so skipping the call loses
|
||||
* nothing. Thin wrapper to keep the body single-sourced.
|
||||
*
|
||||
* Middleware (`src/middleware.ts`) MUST call this rather than
|
||||
* `getRuntimeConfig()` so the noStore() call never executes in the
|
||||
* Edge scope.
|
||||
*/
|
||||
export function getRuntimeConfigForMiddleware(): RuntimeConfig {
|
||||
return getRuntimeConfig({ noStore: false });
|
||||
}
|
||||
|
||||
// Env-name tolerance: deploy configs in the wild use either the bare
|
||||
// name (e.g. `BASE_URL`) or the `NEXT_PUBLIC_*`-prefixed name. We accept
|
||||
// either — the primary (passed-in) name wins, with transparent fallback
|
||||
// to the alternate so a Railway service variable set under the "wrong"
|
||||
// name still works without redeploy.
|
||||
function altEnvName(envKey: string): string {
|
||||
return envKey.startsWith("NEXT_PUBLIC_")
|
||||
? envKey.slice("NEXT_PUBLIC_".length)
|
||||
: `NEXT_PUBLIC_${envKey}`;
|
||||
}
|
||||
|
||||
// Length-aware env coalesce: a deliberately-empty primary (e.g. an
|
||||
// operator clearing `BASE_URL=""` on a Railway service) must NOT mask a
|
||||
// populated alternate. Treat empty-string as "unset" and fall through to
|
||||
// the alternate. Values are .trim()ed — whitespace paste artifacts in
|
||||
// deploy config (e.g. `BASE_URL=" https://x "`) would otherwise survive
|
||||
// into URLs/hosts; a whitespace-only value counts as unset.
|
||||
//
|
||||
// The dynamic `process.env[key]` reads assume a self-hosted Node runtime
|
||||
// (next start / Docker): Edge platforms that statically inline only
|
||||
// LITERAL `process.env.X` reads at build would see undefined here and
|
||||
// silently fall back to defaults.
|
||||
function readEnvPair(envKey: string): string | undefined {
|
||||
const primary = process.env[envKey]?.trim();
|
||||
if (primary && primary.length > 0) return primary;
|
||||
const alt = process.env[altEnvName(envKey)]?.trim();
|
||||
if (alt && alt.length > 0) return alt;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// One loud log per distinct (mode, env key) — middleware and the root
|
||||
// layout both call getRuntimeConfig() on EVERY request, so an unset
|
||||
// BASE_URL in prod would otherwise console.error per request. Mirrors
|
||||
// the once-guards in readDocsHost and normalizeBackendHostPattern.
|
||||
const urlFallbackLogged = new Set<string>();
|
||||
|
||||
function readUrl(envKey: string, fallback: string, isProd: boolean): string {
|
||||
const value = readEnvPair(envKey);
|
||||
if (value !== undefined) return value.replace(/\/+$/, "");
|
||||
// Strip BEFORE logging so the message names the exact value callers
|
||||
// receive — the pre-strip form (trailing slash) never appears anywhere.
|
||||
const stripped = fallback.replace(/\/+$/, "");
|
||||
const logKey = `${isProd ? "prod" : "dev"}:${envKey}`;
|
||||
if (!urlFallbackLogged.has(logKey)) {
|
||||
urlFallbackLogged.add(logKey);
|
||||
if (isProd) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[shell runtime-config] FATAL-CONFIG: ${envKey} is unset in a production deploy; ` +
|
||||
`using sentinel ${stripped}. Set the env var on the Railway service.`,
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[shell runtime-config] ${envKey} unset; using dev fallback ${stripped}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
// Analytics keys (POSTHOG_HOST etc.) are legitimately absent on
|
||||
// non-production envs; do NOT log a FATAL-CONFIG warning when missing.
|
||||
function readKey(envKey: string, fallback: string): string {
|
||||
const value = readEnvPair(envKey);
|
||||
if (value !== undefined) return value.replace(/\/+$/, "");
|
||||
return fallback.replace(/\/+$/, "");
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Wiring tests for the runtime-URL pipeline against the REAL generated
|
||||
// registry — not fixtures. Two invariants that unit tests with synthetic
|
||||
// data cannot catch:
|
||||
//
|
||||
// 1. The framework-slug set middleware derives from registry.json must be
|
||||
// non-empty. An empty set (schema drift, generator regression) would
|
||||
// silently disable every framework-scoped docs 301 — requests would
|
||||
// fall through to the SEO redirect table instead.
|
||||
// 2. The SSR placeholder config (runtime-config.client.ts) composed with
|
||||
// backendUrlFromPattern must yield a parseable, RFC-2606-unresolvable
|
||||
// URL for a real registry slug — `new URL()` in consumers must not
|
||||
// throw during SSR.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { backendUrlFromPattern } from "./backend-url";
|
||||
import { getRuntimeConfig as getClientRuntimeConfig } from "./runtime-config.client";
|
||||
|
||||
// Imported dynamically in beforeAll AFTER spying console.warn:
|
||||
// middleware emits its module-load table warns (duplicate
|
||||
// exact/wildcard sources) at import time, and a static import would
|
||||
// land them raw in the test output whenever this file is the first in
|
||||
// its worker to load the module.
|
||||
let REGISTRY_FRAMEWORK_SLUGS: ReadonlySet<string>;
|
||||
|
||||
const SHELL_ROOT = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../..",
|
||||
);
|
||||
const REGISTRY_PATH = path.join(SHELL_ROOT, "src", "data", "registry.json");
|
||||
|
||||
interface RegistryShape {
|
||||
// `slug` typed as unknown, not string: this test reads the REAL
|
||||
// generated artifact, and the comparison below must mirror
|
||||
// middleware's drop semantics for malformed entries rather than
|
||||
// assume the happy shape.
|
||||
integrations?: { slug?: unknown }[];
|
||||
}
|
||||
|
||||
let registry: RegistryShape;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
({ REGISTRY_FRAMEWORK_SLUGS } = await import("../middleware"));
|
||||
// registry.json is a generated, gitignored artifact (see
|
||||
// showcase/.gitignore). The vitest globalSetup (vitest.global-setup.ts)
|
||||
// generates it before any worker starts — if this assert fires, the
|
||||
// setup didn't run (or wrote somewhere unexpected).
|
||||
expect(
|
||||
fs.existsSync(REGISTRY_PATH),
|
||||
`registry.json missing at ${REGISTRY_PATH} — vitest.global-setup.ts should have generated it`,
|
||||
).toBe(true);
|
||||
registry = JSON.parse(
|
||||
fs.readFileSync(REGISTRY_PATH, "utf8"),
|
||||
) as RegistryShape;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("registry framework slugs (real registry.json)", () => {
|
||||
it("derives a non-empty slug set (middleware docs-301 precondition)", () => {
|
||||
// Assert the PRODUCTION value — the actual Set middleware builds at
|
||||
// module load from registry.json — not a re-implementation of the
|
||||
// derivation that could drift out of sync.
|
||||
expect(REGISTRY_FRAMEWORK_SLUGS.size).toBeGreaterThan(0);
|
||||
for (const slug of REGISTRY_FRAMEWORK_SLUGS) {
|
||||
expect(typeof slug).toBe("string");
|
||||
expect(slug.length).toBeGreaterThan(0);
|
||||
}
|
||||
// And it must reflect the real artifact 1:1 — modulo middleware's
|
||||
// construction-time lowercasing (SU4-A4): compare against the
|
||||
// LOWERCASED file slugs (SU5-A6), or a future mixed-case registry
|
||||
// slug fails this wiring test even though middleware handles it.
|
||||
// The re-derivation mirrors extractFrameworkSlugs' DROP semantics
|
||||
// (SU6-B6): entries with a missing or non-string slug are skipped,
|
||||
// not dereferenced — the previous `i.slug.toLowerCase()` would
|
||||
// TypeError on a malformed entry that middleware merely drops,
|
||||
// turning a meaningful set-diff failure into an unrelated crash.
|
||||
const fromFile = new Set(
|
||||
(registry.integrations ?? [])
|
||||
.map((i) => i.slug)
|
||||
.filter((s): s is string => typeof s === "string")
|
||||
.map((s) => s.toLowerCase()),
|
||||
);
|
||||
expect(REGISTRY_FRAMEWORK_SLUGS).toEqual(fromFile);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSR placeholder composed with backendUrlFromPattern", () => {
|
||||
it("produces a parseable .invalid URL for a real registry slug", () => {
|
||||
const slug = registry.integrations?.[0]?.slug;
|
||||
// Hard narrow (not a bare truthiness expect): slug is `unknown` in
|
||||
// RegistryShape, and the substitution below needs a real string.
|
||||
if (typeof slug !== "string" || slug.length === 0) {
|
||||
throw new Error(
|
||||
"registry.json's first integration carries no string slug — " +
|
||||
"the generated artifact is malformed",
|
||||
);
|
||||
}
|
||||
|
||||
// Simulate the SSR phase: no `window`, so the client reader returns
|
||||
// the placeholder config rather than throwing. stubGlobal (not
|
||||
// delete/reassign) so vitest restores the original even if an
|
||||
// assertion throws mid-test.
|
||||
vi.stubGlobal("window", undefined);
|
||||
try {
|
||||
const cfg = getClientRuntimeConfig();
|
||||
const url = backendUrlFromPattern(cfg.backendHostPattern, slug);
|
||||
// Parseable (consumers may `new URL()` it inline during SSR) and
|
||||
// unresolvable (.invalid is RFC-2606 reserved — no real fetch).
|
||||
expect(() => new URL(url)).not.toThrow();
|
||||
expect(url).toBe(`https://showcase-${slug}.ssr-placeholder.invalid`);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,893 @@
|
||||
/**
|
||||
* SEO Redirect Definitions — Single Source of Truth
|
||||
*
|
||||
* Consumed by:
|
||||
* - middleware.ts (matches requests, fires PostHog event, issues 301)
|
||||
* - validate-redirects.ts (verifies 301s + destinations)
|
||||
* - redirect-decommission-report.ts (cross-references PostHog traffic)
|
||||
*
|
||||
* Destinations target the shell-docs routing surface, which serves at
|
||||
* the host root (no `/docs/` prefix) and uses the registry framework
|
||||
* slugs (e.g. `langgraph-python`, `google-adk`, `strands`,
|
||||
* `ms-agent-dotnet`, `crewai-crews`, `built-in-agent`). Legacy upstream
|
||||
* URLs that used the old slugs (`langgraph`, `adk`, `aws-strands`,
|
||||
* `microsoft-agent-framework`, `crewai-flows`, `unselected`) are
|
||||
* rewritten here so the historic SEO surface keeps redirecting cleanly
|
||||
* to the new canonical homes.
|
||||
*
|
||||
* Spec & Inventory: https://www.notion.so/33c3aa38185281d7b243c5cf0a7c14cb
|
||||
*
|
||||
* DOCS-HOST SHADOWING (middleware.ts): the docs-host redirect (step 1
|
||||
* in middleware — /docs, /ag-ui, /reference, /<registry-framework-slug>)
|
||||
* runs BEFORE this table, so several entry classes can never match on
|
||||
* the SHELL host and are effectively docs-host-only / dead here:
|
||||
*
|
||||
* - every `/docs/*` source (R2, DI-*, DOCS-root, DOCS-wild): it 308s
|
||||
* `/docs/:path*` to the docs host with the prefix stripped;
|
||||
* - every `/reference*` source (FI-reference, P9, P10): it 308s
|
||||
* `/reference/:path*` to the docs host with the prefix kept;
|
||||
* - sources whose first segment is a CURRENT registry slug —
|
||||
* today's registry-overlap slugs are `mastra`, `agno`, `ag2`,
|
||||
* `llamaindex`, `pydantic-ai` and `crewai-crews` (e.g. F3-F6,
|
||||
* S1×mastra, P1×agno, and L13/L14 — both /crewai-crews entries are
|
||||
* dead here): the docs-host step forwards `/<slug>/...` verbatim to
|
||||
* the docs host. NOTE (SU4-A6): this list must enumerate EVERY
|
||||
* table source whose FIRST segment is a registry slug — recheck it
|
||||
* against registry.json whenever a slug is added or a source class
|
||||
* is introduced.
|
||||
*
|
||||
* Verified double-hop behavior: such a request 308s to the docs host
|
||||
* with the path unchanged, and the DOCS host's own redirect layer then
|
||||
* applies the rename there (e.g. shell /mastra/quickstart/mastra →
|
||||
* docs-host /mastra/quickstart/mastra → docs-host /mastra/quickstart).
|
||||
* The entries stay in this table because validate-redirects.ts and the
|
||||
* decommission report still consume them, and because non-overlapping
|
||||
* legacy slugs (e.g. `langgraph`, `adk`, `aws-strands`) DO match here.
|
||||
*/
|
||||
|
||||
export interface RedirectEntry {
|
||||
/** Spec ID (e.g., "L3", "S4×agno", "P1×langgraph") for cross-referencing Notion inventory */
|
||||
id: string;
|
||||
/** Source path pattern. Use :path* for wildcard suffix matching. */
|
||||
source: string;
|
||||
/**
|
||||
* Destination path on the DOCS routing surface (shell-docs serves at
|
||||
* the docs host root) — NOT on the showcase shell. Use :path* to
|
||||
* carry over the wildcard. middleware resolves these against the
|
||||
* runtime docsHost; resolving them against the shell origin is the
|
||||
* exact misconception behind the historic self-redirect loop (e.g.
|
||||
* M3 /faq -> shell /faq -> ERR_TOO_MANY_REDIRECTS).
|
||||
*/
|
||||
destination: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Historical framework slugs that appear in legacy upstream URLs.
|
||||
// These are the slugs the SEO surface SAW pre-cutover — destinations are
|
||||
// remapped via SLUG_RENAMES below to the canonical shell-docs slugs.
|
||||
//
|
||||
// DATA-CONTRACT NOTE (SU5-A7): `a2a` and `agent-spec` have no
|
||||
// SLUG_RENAMES entry AND are not registry framework slugs, so their
|
||||
// generated destinations (e.g. /a2a/prebuilt-components) may 404 on the
|
||||
// docs host — the redirects fire correctly but land on nothing. Flagged
|
||||
// for the docs-host inventory check; until then they are kept for
|
||||
// Notion-spec parity like the other generated classes.
|
||||
const FRAMEWORKS = [
|
||||
"langgraph",
|
||||
"adk",
|
||||
"agno",
|
||||
"crewai-flows",
|
||||
"pydantic-ai",
|
||||
"llamaindex",
|
||||
"mastra",
|
||||
"agent-spec",
|
||||
"ag2",
|
||||
"microsoft-agent-framework",
|
||||
"aws-strands",
|
||||
"a2a",
|
||||
"unselected",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Map from legacy framework slug → canonical shell-docs slug.
|
||||
* Slugs not listed here keep the same value in destinations.
|
||||
*/
|
||||
const SLUG_RENAMES: Record<string, string> = {
|
||||
langgraph: "langgraph-python",
|
||||
adk: "google-adk",
|
||||
"aws-strands": "strands",
|
||||
"microsoft-agent-framework": "ms-agent-dotnet",
|
||||
"crewai-flows": "crewai-crews",
|
||||
unselected: "built-in-agent",
|
||||
};
|
||||
|
||||
/** Resolve a legacy framework slug to its canonical shell-docs slug. */
|
||||
function canonicalSlug(legacy: string): string {
|
||||
return SLUG_RENAMES[legacy] ?? legacy;
|
||||
}
|
||||
|
||||
/** Per-framework subpath renames (Category 5 in spec). Applied to ALL 13 frameworks. */
|
||||
const SUBPATH_RENAMES: { specId: string; from: string; to: string }[] = [
|
||||
{ specId: "S1", from: "agentic-chat-ui", to: "prebuilt-components" },
|
||||
{ specId: "S2", from: "use-agent-hook", to: "programmatic-control" },
|
||||
{ specId: "S3", from: "frontend-actions", to: "frontend-tools" },
|
||||
{ specId: "S4", from: "vibe-coding-mcp", to: "coding-agents" },
|
||||
{
|
||||
specId: "S5",
|
||||
from: "generative-ui/agentic",
|
||||
to: "generative-ui/your-components/display-only",
|
||||
},
|
||||
{
|
||||
specId: "S6",
|
||||
from: "generative-ui/backend-tools",
|
||||
to: "generative-ui/tool-rendering",
|
||||
},
|
||||
{ specId: "S7", from: "generative-ui/frontend-tools", to: "frontend-tools" },
|
||||
{
|
||||
specId: "S8",
|
||||
from: "generative-ui/render-only",
|
||||
to: "generative-ui/your-components/display-only",
|
||||
},
|
||||
{
|
||||
specId: "S9",
|
||||
from: "generative-ui/tool-based",
|
||||
to: "generative-ui/tool-rendering",
|
||||
},
|
||||
{
|
||||
specId: "S10",
|
||||
from: "custom-look-and-feel/bring-your-own-components",
|
||||
to: "custom-look-and-feel/slots",
|
||||
},
|
||||
{
|
||||
specId: "S11",
|
||||
from: "custom-look-and-feel/customize-built-in-ui-components",
|
||||
to: "custom-look-and-feel/slots",
|
||||
},
|
||||
{
|
||||
specId: "S12",
|
||||
from: "custom-look-and-feel/markdown-rendering",
|
||||
to: "custom-look-and-feel/slots",
|
||||
},
|
||||
{ specId: "S14", from: "guide", to: "guides" },
|
||||
{ specId: "S15", from: "mcp", to: "coding-agents" },
|
||||
];
|
||||
|
||||
// S13 (concepts/:path* -> framework root) handled separately since it's a wildcard-to-single-page
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generated: Per-framework subpath renames (Category 5)
|
||||
// Each SUBPATH_RENAME × each FRAMEWORK = one redirect entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateFrameworkRenames(): RedirectEntry[] {
|
||||
const entries: RedirectEntry[] = [];
|
||||
for (const fw of FRAMEWORKS) {
|
||||
const fwDest = canonicalSlug(fw);
|
||||
// S13: concepts/* collapses to framework root
|
||||
entries.push({
|
||||
id: `S13w×${fw}`,
|
||||
source: `/${fw}/concepts/:path*`,
|
||||
destination: `/${fwDest}`,
|
||||
});
|
||||
entries.push({
|
||||
id: `S13e×${fw}`,
|
||||
source: `/${fw}/concepts`,
|
||||
destination: `/${fwDest}`,
|
||||
});
|
||||
|
||||
for (const rename of SUBPATH_RENAMES) {
|
||||
entries.push({
|
||||
id: `${rename.specId}×${fw}`,
|
||||
source: `/${fw}/${rename.from}`,
|
||||
destination: `/${fwDest}/${rename.to}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category 3: Deep Coagents Redirects (specific paths)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEEP_COAGENTS: RedirectEntry[] = [
|
||||
{
|
||||
id: "D1",
|
||||
source: "/coagents/tutorials/ai-travel-app/overview",
|
||||
destination: "/langgraph-python/tutorials/ai-travel-app",
|
||||
},
|
||||
{
|
||||
id: "D2",
|
||||
source: "/coagents/chat-ui/hitl/json-hitl",
|
||||
destination: "/langgraph-python/human-in-the-loop/interrupt-flow",
|
||||
},
|
||||
{
|
||||
id: "D3",
|
||||
source: "/coagents/react-ui/frontend-functions",
|
||||
destination: "/langgraph-python/human-in-the-loop/interrupt-flow",
|
||||
},
|
||||
{
|
||||
id: "D4",
|
||||
source: "/coagents/chat-ui/render-agent-state",
|
||||
destination: "/langgraph-python/generative-ui/your-components/display-only",
|
||||
},
|
||||
{
|
||||
id: "D5",
|
||||
source: "/coagents/chat-ui/hitl",
|
||||
destination: "/langgraph-python/human-in-the-loop/interrupt-flow",
|
||||
},
|
||||
{
|
||||
id: "D6",
|
||||
source: "/coagents/chat-ui/hitl/interrupt-flow",
|
||||
destination: "/langgraph-python/human-in-the-loop/interrupt-flow",
|
||||
},
|
||||
{
|
||||
id: "D7",
|
||||
source: "/coagents/chat-ui/loading-message-history",
|
||||
destination:
|
||||
"/langgraph-python/advanced/persistence/loading-message-history",
|
||||
},
|
||||
{
|
||||
id: "D8",
|
||||
source: "/coagents/react-ui/in-app-agent-read",
|
||||
destination: "/langgraph-python/shared-state/in-app-agent-read",
|
||||
},
|
||||
{
|
||||
id: "D9",
|
||||
source: "/coagents/react-ui/in-app-agent-write",
|
||||
destination: "/langgraph-python/shared-state/in-app-agent-write",
|
||||
},
|
||||
{
|
||||
id: "D10",
|
||||
source: "/coagents/react-ui/hitl",
|
||||
destination: "/langgraph-python/human-in-the-loop/interrupt-flow",
|
||||
},
|
||||
{
|
||||
id: "D11",
|
||||
source: "/coagents/advanced/router-mode-agent-lock",
|
||||
destination: "/langgraph-python",
|
||||
},
|
||||
{
|
||||
id: "D12",
|
||||
source: "/coagents/advanced/intermediate-state-streaming",
|
||||
destination: "/langgraph-python/shared-state/predictive-state-updates",
|
||||
},
|
||||
{
|
||||
id: "D13",
|
||||
source: "/coagents/shared-state/intermediate-state-streaming",
|
||||
destination: "/langgraph-python/shared-state/predictive-state-updates",
|
||||
},
|
||||
{
|
||||
id: "D14",
|
||||
source: "/coagents/advanced/manually-emitting-messages",
|
||||
destination: "/langgraph-python/advanced/emit-messages",
|
||||
},
|
||||
{
|
||||
id: "D15",
|
||||
source: "/coagents/advanced/copilotkit-state",
|
||||
destination: "/langgraph-python/frontend-tools",
|
||||
},
|
||||
{
|
||||
id: "D16",
|
||||
source: "/coagents/advanced/message-persistence",
|
||||
destination: "/langgraph-python/advanced/persistence/message-persistence",
|
||||
},
|
||||
{
|
||||
id: "D17",
|
||||
source: "/coagents/advanced/loading-message-history",
|
||||
destination:
|
||||
"/langgraph-python/advanced/persistence/loading-message-history",
|
||||
},
|
||||
{
|
||||
id: "D18",
|
||||
source: "/coagents/advanced/loading-agent-state",
|
||||
destination: "/langgraph-python/advanced/persistence/loading-agent-state",
|
||||
},
|
||||
{
|
||||
id: "D19",
|
||||
source: "/coagents/advanced/state-streaming",
|
||||
destination: "/langgraph-python/shared-state",
|
||||
},
|
||||
{
|
||||
id: "D20",
|
||||
source: "/coagents/concepts/state",
|
||||
destination: "/langgraph-python/shared-state",
|
||||
},
|
||||
{
|
||||
id: "D21",
|
||||
source: "/coagents/concepts/human-in-the-loop",
|
||||
destination: "/langgraph-python/human-in-the-loop",
|
||||
},
|
||||
{
|
||||
id: "D22",
|
||||
source: "/coagents/concepts/multi-agent-flows",
|
||||
destination: "/langgraph-python",
|
||||
},
|
||||
{
|
||||
id: "D23",
|
||||
source: "/coagents/quickstart/langgraph",
|
||||
destination: "/langgraph-python/quickstart",
|
||||
},
|
||||
{
|
||||
id: "D24",
|
||||
source: "/coagents/shared-state/state-inputs-outputs",
|
||||
destination: "/langgraph-python/shared-state/workflow-execution",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category 6: Specific Framework Redirects (one-off path combinations)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SPECIFIC_FRAMEWORK: RedirectEntry[] = [
|
||||
{
|
||||
id: "F1",
|
||||
source: "/langgraph/quickstart/langgraph",
|
||||
destination: "/langgraph-python/quickstart",
|
||||
},
|
||||
{
|
||||
id: "F2",
|
||||
source: "/crewai-flows/quickstart/crewai",
|
||||
destination: "/crewai-crews/quickstart",
|
||||
},
|
||||
{
|
||||
id: "F3",
|
||||
source: "/mastra/quickstart/mastra",
|
||||
destination: "/mastra/quickstart",
|
||||
},
|
||||
{
|
||||
id: "F4",
|
||||
source: "/ag2/quickstart/ag2",
|
||||
destination: "/ag2/quickstart",
|
||||
},
|
||||
{
|
||||
id: "F5",
|
||||
source: "/agno/quickstart/agno",
|
||||
destination: "/agno/quickstart",
|
||||
},
|
||||
{
|
||||
id: "F6",
|
||||
source: "/pydantic-ai/quickstart/pydantic-ai",
|
||||
destination: "/pydantic-ai/quickstart",
|
||||
},
|
||||
{
|
||||
id: "F7",
|
||||
source: "/adk/quickstart/adk",
|
||||
destination: "/google-adk/quickstart",
|
||||
},
|
||||
{
|
||||
id: "F8",
|
||||
source: "/langgraph/generative-ui/display",
|
||||
destination: "/langgraph-python/generative-ui/your-components/display-only",
|
||||
},
|
||||
{
|
||||
id: "F9",
|
||||
source: "/langgraph/generative-ui/interactive/interrupt-based",
|
||||
destination:
|
||||
"/langgraph-python/generative-ui/your-components/interrupt-based",
|
||||
},
|
||||
{
|
||||
id: "F10",
|
||||
source: "/langgraph/generative-ui/interactive/client-side",
|
||||
destination: "/langgraph-python/generative-ui/your-components/interactive",
|
||||
},
|
||||
{
|
||||
id: "F11",
|
||||
source: "/langgraph/human-in-the-loop/node-flow",
|
||||
destination: "/langgraph-python/human-in-the-loop/interrupt-flow",
|
||||
},
|
||||
{
|
||||
id: "F12",
|
||||
source: "/langgraph/human-in-the-loop/prebuilt-agents",
|
||||
destination: "/langgraph-python/prebuilt-components",
|
||||
},
|
||||
{
|
||||
id: "F13",
|
||||
// Keeps the framework segment (aws-strands → canonical `strands`),
|
||||
// matching F11/F12, the S× renames and the P1×aws-strands
|
||||
// catch-all. An earlier revision dropped the segment
|
||||
// (→ /human-in-the-loop) with no spec justification, landing on the
|
||||
// framework-agnostic page instead of the strands one.
|
||||
source: "/aws-strands/human-in-the-loop",
|
||||
destination: "/strands/human-in-the-loop",
|
||||
},
|
||||
{
|
||||
id: "F14",
|
||||
source: "/adk/shared-state/state-inputs-outputs",
|
||||
destination: "/google-adk/shared-state/workflow-execution",
|
||||
},
|
||||
{
|
||||
id: "F15",
|
||||
source: "/langgraph/shared-state/state-inputs-outputs",
|
||||
destination: "/langgraph-python/shared-state/workflow-execution",
|
||||
},
|
||||
{
|
||||
id: "F16",
|
||||
source: "/llamaindex/shared-state/state-inputs-outputs",
|
||||
destination: "/llamaindex/shared-state/workflow-execution",
|
||||
},
|
||||
{
|
||||
id: "F20",
|
||||
source: "/direct-to-llm/guides/mcp",
|
||||
destination: "/built-in-agent/coding-agents",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category 4: Root-Level Renames & Moves
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROOT_RENAMES: RedirectEntry[] = [
|
||||
{ id: "R1", source: "/api", destination: "/reference/v2" },
|
||||
{ id: "R2", source: "/docs/api", destination: "/reference/v2" },
|
||||
{ id: "R3", source: "/api-reference", destination: "/reference/v2" },
|
||||
{ id: "R4", source: "/getting-started", destination: "/" },
|
||||
{ id: "R5", source: "/start", destination: "/" },
|
||||
{
|
||||
id: "R6",
|
||||
source: "/frontend-actions",
|
||||
destination: "/frontend-tools",
|
||||
},
|
||||
{
|
||||
id: "R7",
|
||||
source: "/generative-ui",
|
||||
destination: "/generative-ui/your-components/display-only",
|
||||
},
|
||||
{
|
||||
id: "R8",
|
||||
source: "/generative-ui/display",
|
||||
destination: "/generative-ui/your-components/display-only",
|
||||
},
|
||||
{
|
||||
id: "R9",
|
||||
source: "/generative-ui/interactive",
|
||||
destination: "/generative-ui/your-components/interactive",
|
||||
},
|
||||
{
|
||||
id: "R10",
|
||||
source: "/agentic-chat-ui",
|
||||
destination: "/prebuilt-components",
|
||||
},
|
||||
{
|
||||
id: "R11",
|
||||
source: "/headless",
|
||||
destination: "/custom-look-and-feel/headless-ui",
|
||||
},
|
||||
{
|
||||
id: "R12",
|
||||
source: "/coding-agent-setup",
|
||||
destination: "/coding-agents",
|
||||
},
|
||||
{
|
||||
id: "R13",
|
||||
source: "/copilot-suggestions",
|
||||
destination: "/prebuilt-components",
|
||||
},
|
||||
// /direct-to-llm → built-in-agent (BIA canonical)
|
||||
//
|
||||
// R15 (/integrations/built-in-agent → /built-in-agent) is deliberately
|
||||
// ABSENT from this (shell) copy: it targets legacy DOCS-host URLs
|
||||
// (47 /integrations/built-in-agent/* URLs in the upstream sitemap —
|
||||
// see e2bef7a0b) and lives in the shell-docs copy where it belongs.
|
||||
// On the SHELL host, /integrations/built-in-agent is a LIVE registry
|
||||
// product page (linked from search-modal.tsx and
|
||||
// integration-explorer.tsx) that the entry would hijack — middleware's
|
||||
// namespace guard (its FIRST step, ahead of even the docs-host
|
||||
// redirect — SU4-A1) keeps EVERY redirect step out of /integrations/*,
|
||||
// structurally. Same applies to its wildcard twin R17 below.
|
||||
{ id: "R14", source: "/direct-to-llm", destination: "/built-in-agent" },
|
||||
{ id: "R18", source: "/mcp", destination: "/coding-agents" },
|
||||
{ id: "R19", source: "/vibe-coding-mcp", destination: "/coding-agents" },
|
||||
{
|
||||
id: "R20",
|
||||
source: "/agentic-protocols",
|
||||
destination: "/agentic-protocols",
|
||||
},
|
||||
{
|
||||
id: "R21",
|
||||
source: "/ag-ui-protocol",
|
||||
destination: "/agentic-protocols/ag-ui",
|
||||
},
|
||||
{
|
||||
id: "R22",
|
||||
source: "/connect-mcp-servers",
|
||||
destination: "/agentic-protocols/mcp",
|
||||
},
|
||||
{
|
||||
id: "R23",
|
||||
source: "/a2a-protocol",
|
||||
destination: "/agentic-protocols/a2a",
|
||||
},
|
||||
{
|
||||
id: "R24",
|
||||
source: "/architecture",
|
||||
destination: "/concepts/architecture",
|
||||
},
|
||||
{
|
||||
id: "R25",
|
||||
source: "/runtime-server-adapter",
|
||||
destination: "/backend/copilot-runtime",
|
||||
},
|
||||
{
|
||||
id: "R27",
|
||||
source: "/whats-new/v1-50",
|
||||
destination: "/whats-new/v1-50",
|
||||
},
|
||||
// Manual overrides (Category 7) — root-level doc pages
|
||||
{ id: "M2", source: "/quickstart", destination: "/" },
|
||||
{ id: "M3", source: "/faq", destination: "/faq" },
|
||||
{ id: "M4", source: "/frontend-tools", destination: "/frontend-tools" },
|
||||
{
|
||||
id: "M5",
|
||||
source: "/human-in-the-loop",
|
||||
destination: "/human-in-the-loop",
|
||||
},
|
||||
{
|
||||
id: "M6",
|
||||
source: "/prebuilt-components",
|
||||
destination: "/prebuilt-components",
|
||||
},
|
||||
{ id: "M7", source: "/coding-agents", destination: "/coding-agents" },
|
||||
{ id: "M8", source: "/telemetry", destination: "/telemetry" },
|
||||
// Broken link fixes (B1-B3)
|
||||
{
|
||||
id: "B1",
|
||||
source: "/guides/custom-look-and-feel/bring-your-own-components",
|
||||
destination: "/custom-look-and-feel/slots",
|
||||
},
|
||||
{
|
||||
id: "B2",
|
||||
source: "/guides/self-hosting",
|
||||
destination: "/backend/copilot-runtime",
|
||||
},
|
||||
{
|
||||
id: "B3",
|
||||
source: "/guides/backend-actions/remote-backend-endpoint",
|
||||
destination: "/backend/copilot-runtime",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category 2: Legacy Redirect Chains (coagents -> langgraph-python; the
|
||||
// /crewai-crews entries L13/L14 are SELF-redirects — crewai-crews is the
|
||||
// canonical slug, and they are kept for inventory parity with the Notion
|
||||
// spec, not because anything renames)
|
||||
// Specific entries BEFORE the catch-all wildcards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LEGACY_CHAINS_EXACT: RedirectEntry[] = [
|
||||
{
|
||||
id: "L1",
|
||||
source: "/coagents",
|
||||
destination: "/langgraph-python",
|
||||
},
|
||||
{
|
||||
id: "L2",
|
||||
source: "/coagents/quickstart",
|
||||
destination: "/langgraph-python/quickstart",
|
||||
},
|
||||
{
|
||||
id: "L3",
|
||||
source: "/coagents/frontend-actions",
|
||||
destination: "/langgraph-python/frontend-tools",
|
||||
},
|
||||
{
|
||||
id: "L4",
|
||||
source: "/coagents/generative-ui",
|
||||
destination: "/langgraph-python/generative-ui",
|
||||
},
|
||||
{
|
||||
id: "L5",
|
||||
source: "/coagents/human-in-the-loop",
|
||||
destination: "/langgraph-python/human-in-the-loop",
|
||||
},
|
||||
{
|
||||
id: "L6",
|
||||
source: "/coagents/multi-agent-flows",
|
||||
destination: "/langgraph-python/multi-agent-flows",
|
||||
},
|
||||
{
|
||||
id: "L7",
|
||||
source: "/coagents/persistence",
|
||||
destination: "/langgraph-python/advanced/persistence",
|
||||
},
|
||||
{
|
||||
id: "L8",
|
||||
source: "/coagents/shared-state",
|
||||
destination: "/langgraph-python/shared-state",
|
||||
},
|
||||
{
|
||||
id: "L9",
|
||||
source: "/coagents/concepts",
|
||||
destination: "/langgraph-python",
|
||||
},
|
||||
{
|
||||
id: "L10",
|
||||
source: "/coagents/tutorials",
|
||||
destination: "/langgraph-python/tutorials",
|
||||
},
|
||||
{
|
||||
id: "L11",
|
||||
source: "/coagents/videos",
|
||||
destination: "/langgraph-python/videos",
|
||||
},
|
||||
// /crewai-crews is now the canonical slug — historical /crewai-crews
|
||||
// URLs land directly on the new framework root.
|
||||
{
|
||||
id: "L14",
|
||||
source: "/crewai-crews",
|
||||
destination: "/crewai-crews",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /docs/integrations/* — legacy SHELL routing surface
|
||||
// shell-docs serves at the host root with no /docs/ prefix, so any
|
||||
// upstream URL still pointing at /docs/integrations/{fw}/... must 301
|
||||
// to /{fw-slug}/... (with the slug rename applied).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DOCS_INTEGRATIONS_RENAMES: RedirectEntry[] = FRAMEWORKS.flatMap((fw) => {
|
||||
const fwDest = canonicalSlug(fw);
|
||||
return [
|
||||
{
|
||||
id: `DI-wild×${fw}`,
|
||||
source: `/docs/integrations/${fw}/:path*`,
|
||||
destination: `/${fwDest}/:path*`,
|
||||
},
|
||||
{
|
||||
id: `DI-root×${fw}`,
|
||||
source: `/docs/integrations/${fw}`,
|
||||
destination: `/${fwDest}`,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const DOCS_INTEGRATIONS_INDEX: RedirectEntry[] = [
|
||||
{
|
||||
id: "DI-index",
|
||||
source: "/docs/integrations",
|
||||
destination: "/",
|
||||
},
|
||||
{
|
||||
id: "DI-index-wild",
|
||||
source: "/docs/integrations/:path*",
|
||||
destination: "/:path*",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /docs/* — legacy SHELL routing prefix on root pages
|
||||
// shell-docs has no /docs/ prefix, so /docs/foo → /foo.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DOCS_PREFIX: RedirectEntry[] = [
|
||||
// /docs as a bare path lands on the home.
|
||||
{ id: "DOCS-root", source: "/docs", destination: "/" },
|
||||
// Catch-all comes LAST — see WILDCARD_REDIRECTS for placement.
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /migration-guides/* → /migrate/* (4 URLs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MIGRATION_GUIDES: RedirectEntry[] = [
|
||||
{ id: "MG1", source: "/migration-guides", destination: "/migrate/v2" },
|
||||
{ id: "MG2", source: "/migration-guides/v2", destination: "/migrate/v2" },
|
||||
{
|
||||
id: "MG3",
|
||||
source: "/migration-guides/1.10.X",
|
||||
destination: "/migrate/v2",
|
||||
},
|
||||
{
|
||||
id: "MG4",
|
||||
source: "/migration-guides/1.8.2",
|
||||
destination: "/migrate/1.8.2",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Folder-index redirects for shell-docs folders that lack an index.mdx.
|
||||
// These hit when a user navigates to the bare folder URL — without an
|
||||
// index page Next.js would 404. Each folder URL 301s to a sensible
|
||||
// representative inner page.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FOLDER_INDEX: RedirectEntry[] = [
|
||||
{
|
||||
id: "FI-troubleshooting",
|
||||
source: "/troubleshooting",
|
||||
destination: "/troubleshooting/common-issues",
|
||||
},
|
||||
{
|
||||
id: "FI-migrate",
|
||||
// Mirrors the existing /migrate → /migrate/v2 entry in
|
||||
// showcase/shell-docs/next.config.ts, kept here so the SHELL host's
|
||||
// middleware also covers it during the cutover.
|
||||
source: "/migrate",
|
||||
destination: "/migrate/v2",
|
||||
},
|
||||
{
|
||||
id: "FI-premium",
|
||||
source: "/premium",
|
||||
destination: "/premium/overview",
|
||||
},
|
||||
{
|
||||
id: "FI-concepts",
|
||||
source: "/concepts",
|
||||
destination: "/concepts/architecture",
|
||||
},
|
||||
{
|
||||
id: "FI-reference",
|
||||
// /reference is served by app/reference/[...slug] which has no index;
|
||||
// v2 is the active reference set.
|
||||
source: "/reference",
|
||||
destination: "/reference/v2",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slug-rename catch-alls — bare /{old-slug}/* → /{new-slug}/*
|
||||
// Covers upstream URLs that hit a renamed framework root or any
|
||||
// subpath that isn't already matched by the more specific entries
|
||||
// above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SLUG_RENAME_REDIRECTS: RedirectEntry[] = Object.entries(
|
||||
SLUG_RENAMES,
|
||||
).flatMap(([oldSlug, newSlug]) => [
|
||||
{
|
||||
id: `SR-wild×${oldSlug}`,
|
||||
source: `/${oldSlug}/:path*`,
|
||||
destination: `/${newSlug}/:path*`,
|
||||
},
|
||||
{
|
||||
id: `SR-root×${oldSlug}`,
|
||||
source: `/${oldSlug}`,
|
||||
destination: `/${newSlug}`,
|
||||
},
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wildcard redirects (legacy chains + pattern rules)
|
||||
// Order matters only RELATIVE TO OTHER WILDCARDS: middleware splits the
|
||||
// table into an exact-match map and an ordered wildcard list, so exact
|
||||
// entries always win regardless of where a wildcard sits. Among
|
||||
// wildcards, earlier = higher priority (first matching prefix wins),
|
||||
// which is why the more specific wildcard groups precede the
|
||||
// per-framework P1× catch-alls below.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WILDCARD_REDIRECTS: RedirectEntry[] = [
|
||||
// Category 2 wildcards
|
||||
{
|
||||
id: "L12",
|
||||
source: "/coagents/:path*",
|
||||
destination: "/langgraph-python/:path*",
|
||||
},
|
||||
{
|
||||
id: "L13",
|
||||
source: "/crewai-crews/:path*",
|
||||
destination: "/crewai-crews/:path*",
|
||||
},
|
||||
// Category 4 wildcards — direct-to-llm retires to BIA. R17
|
||||
// (/integrations/built-in-agent/:path*) is docs-host-only and lives in
|
||||
// the shell-docs copy — see the R15 note in ROOT_RENAMES above.
|
||||
{
|
||||
id: "R16",
|
||||
source: "/direct-to-llm/:path*",
|
||||
destination: "/built-in-agent/:path*",
|
||||
},
|
||||
{ id: "R26", source: "/shared/:path*", destination: "/:path*" },
|
||||
// Category 6 wildcards
|
||||
{
|
||||
id: "F17",
|
||||
source: "/generative-ui/direct-to-llm/:path*",
|
||||
destination: "/built-in-agent/:path*",
|
||||
},
|
||||
{
|
||||
id: "F18",
|
||||
source: "/generative-ui/langgraph/:path*",
|
||||
destination: "/langgraph-python/:path*",
|
||||
},
|
||||
{
|
||||
id: "F19",
|
||||
source: "/generative-ui-specs/:path*",
|
||||
destination: "/generative-ui/specs/:path*",
|
||||
},
|
||||
// Category 1: Pattern rules (bulk coverage)
|
||||
{
|
||||
id: "P9",
|
||||
source: "/reference/v2/:path*",
|
||||
destination: "/reference/v2/:path*",
|
||||
},
|
||||
{ id: "P10", source: "/reference/v1/:path*", destination: "/reference/v2" },
|
||||
{
|
||||
id: "P11",
|
||||
source: "/guides/:path*",
|
||||
destination: "/built-in-agent/guides/:path*",
|
||||
},
|
||||
{ id: "P12", source: "/backend/:path*", destination: "/backend/:path*" },
|
||||
// NOTE (SU5-A7): the BARE /learn (zero-segment :path*) lands on
|
||||
// docs-host /concepts, a folder with no index page — reaching content
|
||||
// depends on the DOCS host's own /concepts -> /concepts/architecture
|
||||
// redirect (the docs-host twin of FI-concepts above): a deliberate
|
||||
// double hop. If that docs-host redirect ever disappears, bare /learn
|
||||
// 404s even though this entry still fires.
|
||||
{ id: "P3", source: "/learn/:path*", destination: "/concepts/:path*" },
|
||||
{
|
||||
id: "P4",
|
||||
source: "/troubleshooting/:path*",
|
||||
destination: "/troubleshooting/:path*",
|
||||
},
|
||||
{
|
||||
id: "P5",
|
||||
source: "/custom-look-and-feel/:path*",
|
||||
destination: "/custom-look-and-feel/:path*",
|
||||
},
|
||||
{
|
||||
id: "P6",
|
||||
source: "/generative-ui/:path*",
|
||||
destination: "/generative-ui/:path*",
|
||||
},
|
||||
{ id: "P7", source: "/premium/:path*", destination: "/premium/:path*" },
|
||||
{
|
||||
id: "P8",
|
||||
source: "/contributing/:path*",
|
||||
destination: "/contributing/:path*",
|
||||
},
|
||||
// P1 + P2: Per-framework catch-alls (MUST be last — they match any /{framework}/*)
|
||||
// Source is the legacy slug; destination uses canonical slug.
|
||||
...FRAMEWORKS.map((fw) => ({
|
||||
id: `P1×${fw}`,
|
||||
source: `/${fw}/:path*`,
|
||||
destination: `/${canonicalSlug(fw)}/:path*`,
|
||||
})),
|
||||
...FRAMEWORKS.map((fw) => ({
|
||||
id: `P2×${fw}`,
|
||||
source: `/${fw}`,
|
||||
destination: `/${canonicalSlug(fw)}`,
|
||||
})),
|
||||
// /docs/* generic catch-all (must come AFTER /docs/integrations/* so
|
||||
// the more specific /docs/integrations entries match first).
|
||||
{ id: "DOCS-wild", source: "/docs/:path*", destination: "/:path*" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combined export — ordered most-specific to least-specific
|
||||
//
|
||||
// How middleware ACTUALLY evaluates this table (buildRedirectLookup in
|
||||
// middleware.ts): entries are split into an exact-match map and an
|
||||
// ordered wildcard list, and EVERY exact source is tried before ANY
|
||||
// wildcard — an exact entry beats a wildcard even if the wildcard
|
||||
// appears earlier in this array. "Top-to-bottom, first match wins"
|
||||
// holds in two narrower senses: (a) among WILDCARDS, earlier entries
|
||||
// have higher priority (the linear scan short-circuits), and (b) for
|
||||
// DUPLICATE exact sources or duplicate wildcard prefixes, the first
|
||||
// table entry claims the id (later duplicates are dropped with a
|
||||
// module-load warn).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const seoRedirects: RedirectEntry[] = [
|
||||
// 1. Most-specific exact paths first
|
||||
...DEEP_COAGENTS,
|
||||
...SPECIFIC_FRAMEWORK,
|
||||
...ROOT_RENAMES,
|
||||
...LEGACY_CHAINS_EXACT,
|
||||
...DOCS_INTEGRATIONS_INDEX.filter((e) => !e.source.includes(":path*")),
|
||||
...DOCS_INTEGRATIONS_RENAMES.filter((e) => !e.source.includes(":path*")),
|
||||
...DOCS_PREFIX,
|
||||
...MIGRATION_GUIDES,
|
||||
...FOLDER_INDEX,
|
||||
// 2. Generated per-framework subpath renames (mostly exact paths,
|
||||
// plus the S13w×<fw> concepts/:path* wildcards)
|
||||
...generateFrameworkRenames(),
|
||||
// 3. Wildcard catch-alls last — order matters: most-specific wildcard first
|
||||
...DOCS_INTEGRATIONS_RENAMES.filter((e) => e.source.includes(":path*")),
|
||||
...DOCS_INTEGRATIONS_INDEX.filter((e) => e.source.includes(":path*")),
|
||||
...SLUG_RENAME_REDIRECTS,
|
||||
...WILDCARD_REDIRECTS,
|
||||
];
|
||||
@@ -0,0 +1,971 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextFetchEvent, NextRequest } from "next/server";
|
||||
import { seoRedirects } from "@/lib/seo-redirects";
|
||||
import type { RedirectEntry } from "@/lib/seo-redirects";
|
||||
import {
|
||||
DOCS_REDIRECTS_DISABLED_HOST,
|
||||
getRuntimeConfigForMiddleware,
|
||||
} from "@/lib/runtime-config";
|
||||
import { SCHEME_RE } from "@/lib/backend-url";
|
||||
import {
|
||||
normalizeRedirectPath,
|
||||
resolveDocsHostRedirect,
|
||||
resolveSeoDestination,
|
||||
} from "@/lib/docs-redirects";
|
||||
import registry from "@/data/registry.json";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build lookup structures at module load (once per cold start)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Known deliberate-collapse wildcard entries (SU6-A2, exported for
|
||||
* tests): their destinations carry NO ":path*" token ON PURPOSE — every
|
||||
* matched subpath collapses onto a single page (P10: all of
|
||||
* /reference/v1/* onto the v2 reference root; S13w×<fw>: each
|
||||
* framework's removed concepts/* section onto the framework root). The
|
||||
* tokenless-destination warn in buildRedirectLookup would otherwise
|
||||
* fire for them on every cold start, desensitizing the table-bug
|
||||
* channel exactly like the duplicate twins did before the SU5-A3
|
||||
* same-destination allowlist.
|
||||
*/
|
||||
export const DELIBERATE_COLLAPSE_WILDCARD_IDS = /^(?:P10$|S13w×)/;
|
||||
|
||||
// Printable-ASCII gate for sources and destinations (SU7-F3): request
|
||||
// pathnames are percent-encoded ASCII, so an entry containing a raw
|
||||
// space or any non-ASCII character can never match — a silent dead
|
||||
// entry. It also enforces the length-preservation assumption every
|
||||
// positional-slicing comment in this module relies on: toLowerCase is
|
||||
// only guaranteed length-preserving for ASCII.
|
||||
const PRINTABLE_ASCII_RE = /^[\x21-\x7e]+$/;
|
||||
|
||||
/**
|
||||
* Build the exact-match map and wildcard list from the redirect table
|
||||
* (exported for tests). The table is documented as "first match wins" —
|
||||
* a plain Map.set loop silently inverted that to LAST-write-wins for
|
||||
* the duplicate exact sources (e.g. P2×unselected overrode
|
||||
* SR-root×unselected), skewing PostHog redirect_id attribution. Skip
|
||||
* later duplicates and warn once at module load, naming them — EXCEPT
|
||||
* same-destination duplicates (SU5-A3): the table's deliberate
|
||||
* SR-root×/P2× and SR-wild×/P1× twins redirect to the same place, so
|
||||
* they skip silently and only unexpected duplicates warn.
|
||||
*
|
||||
* PRECEDENCE (deliberate, SU4-A2): an EXACT source always beats a
|
||||
* WILDCARD source, regardless of table order — middleware tries the
|
||||
* exact map before the wildcard scan. "First match wins" therefore only
|
||||
* holds WITHIN each kind (among exacts for duplicate keys; among
|
||||
* wildcards for the linear scan). An exact source that falls under an
|
||||
* earlier wildcard prefix with a DIFFERENT destination contradicts a
|
||||
* naive top-to-bottom reading of the table, so it gets a module-load
|
||||
* warn below.
|
||||
*/
|
||||
export function buildRedirectLookup(entries: readonly RedirectEntry[]): {
|
||||
/** Exact-match map: source path -> { id, destination } */
|
||||
exactMap: Map<string, { id: string; destination: string }>;
|
||||
/** Wildcard entries: source has :path* -- stored as { prefix, id, destination } */
|
||||
wildcardEntries: {
|
||||
prefix: string;
|
||||
id: string;
|
||||
destinationTemplate: string;
|
||||
}[];
|
||||
} {
|
||||
const exactMap = new Map<string, { id: string; destination: string }>();
|
||||
const wildcardEntries: {
|
||||
prefix: string;
|
||||
id: string;
|
||||
destinationTemplate: string;
|
||||
}[] = [];
|
||||
// First-claimed wildcard prefix -> claiming entry (id + destination
|
||||
// template). Matching is by prefix, so two entries with the same
|
||||
// prefix shadow exactly like duplicate exact sources do (the linear
|
||||
// scan would never reach the second) — dedup with the same
|
||||
// first-match-wins + module-load warn, or the shadowed ids show zero
|
||||
// PostHog traffic and the decommission report proposes deleting a
|
||||
// live redirect. The destination template is kept for the
|
||||
// same-destination twin allowlist (SU5-A3) below.
|
||||
const wildcardPrefixOwners = new Map<
|
||||
string,
|
||||
{ id: string; destinationTemplate: string }
|
||||
>();
|
||||
const duplicateSources: string[] = [];
|
||||
const duplicateWildcardSources: string[] = [];
|
||||
const malformedWildcards: string[] = [];
|
||||
const invalidSources: string[] = [];
|
||||
const invalidDestinations: string[] = [];
|
||||
const rootExactSources: string[] = [];
|
||||
const nonAsciiEntries: string[] = [];
|
||||
const unreachableTrailingSlashSources: string[] = [];
|
||||
const exactDestinationsWithToken: string[] = [];
|
||||
const wildcardDestinationsWithMiscasedToken: string[] = [];
|
||||
const tokenlessWildcardDestinations: string[] = [];
|
||||
const shadowedWildcardPrefixes: string[] = [];
|
||||
const exactsUnderEarlierWildcard: string[] = [];
|
||||
|
||||
// Destination comparisons (the same-destination twin allowlists and
|
||||
// the exact-under-wildcard divergence check below) run BOTH sides
|
||||
// through normalizeRedirectPath (docs-redirects): interior slash-run
|
||||
// collapse + trailing-slash strip, root "/" survives. That is EXACTLY
|
||||
// what resolveSeoDestination applies to each destination at request
|
||||
// time (SU5-A7: the normalization lives in normalizeRedirectPath;
|
||||
// resolveSeoDestination merely calls it) — a previous local copy
|
||||
// stripped trailing slashes only (half of the request-time
|
||||
// normalization), so two entries that resolve identically could
|
||||
// still be flagged as a different-destination duplicate or
|
||||
// divergence: a false-positive warn (SU6-A3).
|
||||
|
||||
for (const entry of entries) {
|
||||
// Printable-ASCII gate FIRST (SU7-F3, see PRINTABLE_ASCII_RE): a
|
||||
// non-ASCII or whitespace-bearing source can never match a request
|
||||
// pathname (silent dead entry), and every later check slices by
|
||||
// position under the ASCII length-preservation assumption.
|
||||
if (
|
||||
!PRINTABLE_ASCII_RE.test(entry.source) ||
|
||||
!PRINTABLE_ASCII_RE.test(entry.destination)
|
||||
) {
|
||||
nonAsciiEntries.push(
|
||||
`${entry.source} -> ${entry.destination} (${entry.id})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Sources must be root-relative paths with no query/fragment
|
||||
// (SU4-A2): NextRequest pathnames always start with "/" and never
|
||||
// carry "?"/"#", so a source violating that can never match —
|
||||
// worse, a non-"/" wildcard source would produce a prefix the
|
||||
// matcher could partially collide with. Warn and skip.
|
||||
//
|
||||
// "//" anywhere in the source is rejected too (SU5-A2): middleware
|
||||
// collapses LEADING slash runs before any lookup, so a leading-"//"
|
||||
// source is an unreachable dead entry — and the wildcard source
|
||||
// "//:path*" sailed past every later check (prefix "//" ends with
|
||||
// "/", :path* terminates it, prefix !== "/") only for the matcher's
|
||||
// bareSource (prefix minus one slash) to come out as "/", matching
|
||||
// the HOMEPAGE: the exact hijack the root-wildcard guard rejects.
|
||||
// An INTERIOR "//" (e.g. "/a//b") is technically matchable (only
|
||||
// LEADING runs are collapsed; matchPath keeps interior runs) but no
|
||||
// live URL is authored that way — it is a presumed typo, rejected
|
||||
// under the same check (SU6-A4).
|
||||
if (
|
||||
!entry.source.startsWith("/") ||
|
||||
entry.source.includes("//") ||
|
||||
entry.source.includes("?") ||
|
||||
entry.source.includes("#")
|
||||
) {
|
||||
invalidSources.push(`${entry.source} (${entry.id})`);
|
||||
continue;
|
||||
}
|
||||
// Destinations must be root-relative paths with no query/fragment:
|
||||
// an absolute URL gets appended after the docs-host origin and
|
||||
// mangled into a path ("https://<docsHost>https://..."); a "?"/"#"
|
||||
// suffix is silently wiped by the per-request
|
||||
// `dest.search = request.nextUrl.search` overwrite. Either way the
|
||||
// entry cannot do what its author intended — warn and skip.
|
||||
//
|
||||
// "//" in a destination is a presumed typo, same as in sources
|
||||
// above (SU6-A4): request-time normalizeRedirectPath WOULD collapse
|
||||
// the run — so it can never become a scheme-relative open redirect
|
||||
// (SU-18) — but the author plainly didn't mean to write it; reject
|
||||
// loudly instead of silently papering over it.
|
||||
if (
|
||||
!entry.destination.startsWith("/") ||
|
||||
entry.destination.includes("//") ||
|
||||
entry.destination.includes("?") ||
|
||||
entry.destination.includes("#")
|
||||
) {
|
||||
invalidDestinations.push(
|
||||
`${entry.source} -> ${entry.destination} (${entry.id})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Token detection is case-insensitive (SU5-A3): a typo like
|
||||
// ":PATH*" otherwise slips past indexOf and the source becomes a
|
||||
// silent dead EXACT entry (its key can never be a request path).
|
||||
// toLowerCase is length-preserving on these ASCII sources, so the
|
||||
// index is valid against the original-case string.
|
||||
const wildcardIdx = entry.source.toLowerCase().indexOf(":path*");
|
||||
if (wildcardIdx === -1) {
|
||||
// Keys are lowercased: matching is case-insensitive (parity with
|
||||
// the next.config rules' path-to-regexp sensitive:false — see
|
||||
// matchPath in middleware()). Also makes a source typo like MG3's
|
||||
// "/migration-guides/1.10.X" match the lowercase live URL.
|
||||
const key = entry.source.toLowerCase();
|
||||
// A root EXACT source ("/") would match the HOMEPAGE — the twin
|
||||
// of the root-wildcard guard in the wildcard branch below: it
|
||||
// passes every other source check (root-relative, no "//", no
|
||||
// ?/#, and the trailing-slash guard skips length-1 keys), yet is
|
||||
// never a legitimate SEO entry. Reject it loudly (SU7-F3).
|
||||
if (key === "/") {
|
||||
rootExactSources.push(`${entry.source} (${entry.id})`);
|
||||
continue;
|
||||
}
|
||||
// A trailing-slash exact source is UNREACHABLE (SU4-A2):
|
||||
// middleware strips trailing slashes from matchPath before the
|
||||
// lookup, so a key ending in "/" (other than root) can never be
|
||||
// queried. Warn and skip — the author meant the slashless form.
|
||||
if (key.length > 1 && key.endsWith("/")) {
|
||||
unreachableTrailingSlashSources.push(`${entry.source} (${entry.id})`);
|
||||
continue;
|
||||
}
|
||||
// An exact entry never substitutes — a ":path*" in its destination
|
||||
// would leak the literal token into the Location header (SU4-A2).
|
||||
// Case-insensitive (SU5-A3): a miscased ":PATH*" leaks just the
|
||||
// same.
|
||||
if (entry.destination.toLowerCase().includes(":path*")) {
|
||||
exactDestinationsWithToken.push(
|
||||
`${entry.source} -> ${entry.destination} (${entry.id})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const existing = exactMap.get(key);
|
||||
if (existing) {
|
||||
// Same-destination twin allowlist (SU5-A3): the table carries
|
||||
// DELIBERATE duplicate pairs (the six SR-root×/P2× exacts and
|
||||
// six SR-wild×/P1× wildcards) whose destinations are identical
|
||||
// — warning on them at every cold start desensitized the
|
||||
// table-bug channel. A duplicate that redirects to the SAME
|
||||
// place is harmless (first id keeps the PostHog attribution):
|
||||
// skip silently; only UNEXPECTED (different-destination)
|
||||
// duplicates warn. "Same destination" means same AFTER the
|
||||
// request-time normalization — see the comparator note above
|
||||
// the loop (SU6-A3).
|
||||
if (
|
||||
normalizeRedirectPath(existing.destination) !==
|
||||
normalizeRedirectPath(entry.destination)
|
||||
) {
|
||||
duplicateSources.push(
|
||||
`${entry.source} (${entry.id} shadowed by ${existing.id})`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Exact-beats-wildcard precedence check (SU4-A2, see docstring):
|
||||
// when this exact source falls under a wildcard prefix that
|
||||
// appears EARLIER in the table, a top-to-bottom reading says the
|
||||
// wildcard wins — but the exact map is consulted first, so this
|
||||
// entry does. Only warn when the two would produce DIFFERENT
|
||||
// destinations (same-destination overlap is harmless, e.g. each
|
||||
// S13e×<fw> exact under its own S13w×<fw> wildcard).
|
||||
const coveringWildcard = wildcardEntries.find(
|
||||
(wc) => key.startsWith(wc.prefix) || key === wc.prefix.slice(0, -1),
|
||||
);
|
||||
if (coveringWildcard) {
|
||||
// Slice the remainder from the ORIGINAL-case source (SU5-A3),
|
||||
// not the lowercased key — that is what middleware does at
|
||||
// request time (the rest forwards verbatim from strippedPath),
|
||||
// so an exact destination that preserves the source's case is
|
||||
// NOT a divergence, and the diagnostic prints a
|
||||
// wildcardDestination the author actually recognizes. Offsets
|
||||
// align because toLowerCase is length-preserving here.
|
||||
const rest =
|
||||
key === coveringWildcard.prefix.slice(0, -1)
|
||||
? ""
|
||||
: entry.source.slice(coveringWildcard.prefix.length);
|
||||
const wildcardDestination = substituteWildcardTemplate(
|
||||
coveringWildcard.destinationTemplate,
|
||||
rest,
|
||||
);
|
||||
if (
|
||||
normalizeRedirectPath(wildcardDestination) !==
|
||||
normalizeRedirectPath(entry.destination)
|
||||
) {
|
||||
exactsUnderEarlierWildcard.push(
|
||||
`${entry.source} (${entry.id} wins over earlier wildcard ` +
|
||||
`${coveringWildcard.id}: ${entry.destination} vs ` +
|
||||
`${wildcardDestination})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
exactMap.set(key, {
|
||||
id: entry.id,
|
||||
destination: entry.destination,
|
||||
});
|
||||
} else {
|
||||
// Lowercased for case-insensitive prefix matching — see above.
|
||||
const prefix = entry.source.slice(0, wildcardIdx).toLowerCase();
|
||||
// A wildcard prefix MUST end with "/" — the matcher's
|
||||
// `startsWith(prefix)` would otherwise match prefix LOOKALIKES
|
||||
// (e.g. a source "/x:path*" matching "/xylophone"). And :path*
|
||||
// must TERMINATE the source: the lookup keeps only the prefix, so
|
||||
// segments after the token (e.g. "/x/:path*/y") would silently
|
||||
// over-match every /x/* path. Treat both as table bugs: warn
|
||||
// loudly and skip the entry.
|
||||
if (
|
||||
!prefix.endsWith("/") ||
|
||||
wildcardIdx + ":path*".length !== entry.source.length
|
||||
) {
|
||||
malformedWildcards.push(`${entry.source} (${entry.id})`);
|
||||
continue;
|
||||
}
|
||||
// A root wildcard ("/:path*", prefix "/") would match EVERY path
|
||||
// on the site — that is never a legitimate SEO entry; reject it
|
||||
// before it can hijack all traffic.
|
||||
if (prefix === "/") {
|
||||
malformedWildcards.push(
|
||||
`${entry.source} (${entry.id}: root wildcard would match every path)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Duplicate-prefix owner check FIRST (SU7-F3): a later entry for
|
||||
// an already-claimed prefix is DISCARDED whatever its destination
|
||||
// looks like, so it must classify as a duplicate — running the
|
||||
// miscased/tokenless destination checks below on it would fire
|
||||
// table-bug warns for an entry that never ships (a harmless
|
||||
// same-destination tokenless twin stayed loud, desensitizing the
|
||||
// channel exactly like the duplicate twins before SU5-A3).
|
||||
const owner = wildcardPrefixOwners.get(prefix);
|
||||
if (owner) {
|
||||
// Same-destination twin allowlist — see the exact-map branch
|
||||
// above (SU5-A3); same request-time-normalized comparison
|
||||
// (SU6-A3).
|
||||
if (
|
||||
normalizeRedirectPath(owner.destinationTemplate) !==
|
||||
normalizeRedirectPath(entry.destination)
|
||||
) {
|
||||
duplicateWildcardSources.push(
|
||||
`${entry.source} (${entry.id} shadowed by ${owner.id})`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Substitution is CASE-SENSITIVE — substituteWildcardTemplate
|
||||
// replaces the literal lowercase ":path*" only — so a miscased
|
||||
// token in a WILDCARD destination (":PATH*") never substitutes
|
||||
// and leaks verbatim into the Location header: the same leak
|
||||
// class the exact-branch literal-token check above rejects.
|
||||
// Mirror its case-insensitive detection here (SU6-A1) by
|
||||
// comparing the case-insensitive occurrence count against the
|
||||
// literal count — any mismatch means at least one token survives
|
||||
// substitution (a mixed-case template leaks its miscased token
|
||||
// even though the lowercase one substitutes).
|
||||
const tokenCount = entry.destination.match(/:path\*/gi)?.length ?? 0;
|
||||
const literalTokenCount =
|
||||
entry.destination.match(/:path\*/g)?.length ?? 0;
|
||||
if (tokenCount !== literalTokenCount) {
|
||||
wildcardDestinationsWithMiscasedToken.push(
|
||||
`${entry.source} -> ${entry.destination} (${entry.id})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// A wildcard destination with NO token drops the matched
|
||||
// remainder entirely — every subpath collapses onto one page.
|
||||
// That is sometimes DELIBERATE (the allowlisted entries above
|
||||
// buildRedirectLookup) and sometimes a forgotten token, so warn
|
||||
// WITHOUT skipping: the collapse is well-defined behavior either
|
||||
// way (SU6-A2).
|
||||
if (
|
||||
tokenCount === 0 &&
|
||||
!DELIBERATE_COLLAPSE_WILDCARD_IDS.test(entry.id)
|
||||
) {
|
||||
tokenlessWildcardDestinations.push(
|
||||
`${entry.source} -> ${entry.destination} (${entry.id})`,
|
||||
);
|
||||
}
|
||||
// Overlapping wildcard prefixes (SU4-A2): the scan is first-match-
|
||||
// wins, so when an EARLIER wildcard's prefix is a prefix of this
|
||||
// one, every path this entry could match is already claimed — the
|
||||
// entry is unreachable, shows zero PostHog traffic, and the
|
||||
// decommission report would wrongly propose deleting it. (The
|
||||
// inverse — an earlier LONGER prefix — is the normal
|
||||
// most-specific-first ordering and is fine.)
|
||||
const shadowingEntry = wildcardEntries.find((wc) =>
|
||||
prefix.startsWith(wc.prefix),
|
||||
);
|
||||
if (shadowingEntry) {
|
||||
shadowedWildcardPrefixes.push(
|
||||
`${entry.source} (${entry.id} unreachable behind ` +
|
||||
`${shadowingEntry.id})`,
|
||||
);
|
||||
}
|
||||
wildcardPrefixOwners.set(prefix, {
|
||||
id: entry.id,
|
||||
destinationTemplate: entry.destination,
|
||||
});
|
||||
wildcardEntries.push({
|
||||
prefix,
|
||||
id: entry.id,
|
||||
destinationTemplate: entry.destination,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicateSources.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[middleware] seo-redirects has duplicate exact sources — first " +
|
||||
`match wins, later entries are ignored: ${duplicateSources.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (duplicateWildcardSources.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[middleware] seo-redirects has duplicate wildcard prefixes — first " +
|
||||
"match wins, later entries are ignored: " +
|
||||
duplicateWildcardSources.join(", "),
|
||||
);
|
||||
}
|
||||
if (malformedWildcards.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[middleware] seo-redirects has malformed wildcard sources — the " +
|
||||
'prefix must end on a "/" boundary (no prefix lookalikes), :path* ' +
|
||||
"must be the final token, and a bare /:path* is never legal; " +
|
||||
`they are ignored: ${malformedWildcards.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (invalidDestinations.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[middleware] seo-redirects has entries with an invalid destination " +
|
||||
'(must be a root-relative path starting with "/", with no "//", ' +
|
||||
`"?" or "#") — they are ignored: ${invalidDestinations.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (invalidSources.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[middleware] seo-redirects has entries with an invalid source " +
|
||||
'(must be a root-relative path starting with "/", with no "//", ' +
|
||||
`"?" or "#") — they are ignored: ${invalidSources.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (nonAsciiEntries.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[middleware] seo-redirects has entries whose source or " +
|
||||
"destination contains non-printable-ASCII characters (raw " +
|
||||
"whitespace or non-ASCII) — request pathnames are " +
|
||||
"percent-encoded ASCII, so they can never match; they are " +
|
||||
`ignored: ${nonAsciiEntries.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (rootExactSources.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[middleware] seo-redirects has a root ("/") EXACT source — it ' +
|
||||
"would hijack the homepage (the root-wildcard guard's exact " +
|
||||
`twin); it is ignored: ${rootExactSources.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (unreachableTrailingSlashSources.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[middleware] seo-redirects has exact sources with a trailing " +
|
||||
"slash — matching strips trailing slashes before the lookup, so " +
|
||||
"they can never match; they are ignored: " +
|
||||
unreachableTrailingSlashSources.join(", "),
|
||||
);
|
||||
}
|
||||
if (exactDestinationsWithToken.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[middleware] seo-redirects has EXACT sources whose destination " +
|
||||
'contains ":path*" — exact matches never substitute, so the ' +
|
||||
"literal token would leak into the Location header; they are " +
|
||||
`ignored: ${exactDestinationsWithToken.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (wildcardDestinationsWithMiscasedToken.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[middleware] seo-redirects has WILDCARD sources whose destination " +
|
||||
'contains a miscased ":path*" token — substitution replaces the ' +
|
||||
"literal lowercase token only, so the miscased token would leak " +
|
||||
"into the Location header; they are ignored: " +
|
||||
wildcardDestinationsWithMiscasedToken.join(", "),
|
||||
);
|
||||
}
|
||||
if (tokenlessWildcardDestinations.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[middleware] seo-redirects has WILDCARD sources whose destination " +
|
||||
'has no ":path*" token — the matched remainder is silently ' +
|
||||
"dropped (every subpath collapses onto the same destination). " +
|
||||
"If deliberate, add the id to DELIBERATE_COLLAPSE_WILDCARD_IDS; " +
|
||||
`otherwise add the token: ${tokenlessWildcardDestinations.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (shadowedWildcardPrefixes.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[middleware] seo-redirects has wildcard sources whose prefix " +
|
||||
"falls under an EARLIER wildcard prefix — the scan is first-" +
|
||||
"match-wins, so they are unreachable (zero PostHog traffic; the " +
|
||||
"decommission report would wrongly propose deleting them): " +
|
||||
shadowedWildcardPrefixes.join(", "),
|
||||
);
|
||||
}
|
||||
if (exactsUnderEarlierWildcard.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[middleware] seo-redirects has exact sources under an EARLIER " +
|
||||
"wildcard prefix with a DIFFERENT destination — exact beats " +
|
||||
"wildcard regardless of table order (see buildRedirectLookup), " +
|
||||
"contradicting a top-to-bottom reading: " +
|
||||
exactsUnderEarlierWildcard.join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
return { exactMap, wildcardEntries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute the matched path remainder into a destination template
|
||||
* (exported for tests). replaceAll, not a single replace — a template
|
||||
* with multiple :path* tokens would otherwise leak the literal token
|
||||
* into the Location header. Function replacer because `rest` is
|
||||
* user-controlled and the string form of String.prototype.replace
|
||||
* expands `$&`, "$`", `$'`, `$$` in the replacement (e.g.
|
||||
* /coagents/$&foo would re-insert the matched ":path*" token).
|
||||
* replaceAll never rescans inserted text, so a rest of ":path*" cannot
|
||||
* loop or double-substitute.
|
||||
*/
|
||||
export function substituteWildcardTemplate(
|
||||
template: string,
|
||||
rest: string,
|
||||
): string {
|
||||
if (!template.includes(":path*")) return template;
|
||||
return template.replaceAll(":path*", () => rest);
|
||||
}
|
||||
|
||||
const { exactMap, wildcardEntries } = buildRedirectLookup(seoRedirects);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PostHog tracking via fetch (Edge Runtime compatible — no posthog-node SDK)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Warn-once latch. NOTE: all the once-guards in this module
|
||||
// (posthogKeyWarned, captureFailureWarnings, the module-load table
|
||||
// warns above) are per-ISOLATE, not per-process — the Edge runtime can
|
||||
// run several isolates side by side and recycles them, so "once" means
|
||||
// once per isolate cold start. Expect occasional repeats in production
|
||||
// logs; that's the mechanism, not a bug.
|
||||
let posthogKeyWarned = false;
|
||||
|
||||
/**
|
||||
* Once-guarded missing-POSTHOG_KEY signal, fired at config-resolution
|
||||
* time on EVERY middleware invocation — NOT lazily inside trackRedirect
|
||||
* (SU6-A5): the lazy check only ran when a redirect MATCHED, so a prod
|
||||
* deploy whose traffic never hit a redirect source got ZERO signal that
|
||||
* every seo_redirect would go uncaptured.
|
||||
*
|
||||
* Mirrors warnIfNoFrameworkSlugs's NODE_ENV branching (SU4-A5): a
|
||||
* missing key is legitimate on dev/preview deploys (warn), but in
|
||||
* production it is a wiring bug — every redirect goes uncaptured and
|
||||
* the decommission report silently under-counts live traffic, the
|
||||
* wrongful-deletion class.
|
||||
*/
|
||||
function warnIfPosthogKeyMissing(posthogKey: string | undefined): void {
|
||||
if (posthogKey || posthogKeyWarned) return;
|
||||
posthogKeyWarned = true;
|
||||
const message =
|
||||
"[middleware] POSTHOG_KEY is not set — redirect tracking disabled";
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`${message}. In production this is a wiring bug: seo_redirect ` +
|
||||
"events are not recorded, so the redirect decommission report " +
|
||||
"under-counts this deploy's traffic.",
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(message);
|
||||
}
|
||||
}
|
||||
|
||||
// One loud log per distinct capture-failure class (`http:<status>` /
|
||||
// `net:<error name>`), not per request (and per isolate — see the note
|
||||
// on posthogKeyWarned above) — mirrors the patternWarnings
|
||||
// pattern in lib/backend-url.ts. Observability here is load-bearing:
|
||||
// the redirect-decommission report deletes redirects that show ZERO
|
||||
// PostHog traffic, so silently-broken capture (a swallowed rejection or
|
||||
// an unchecked 4xx/5xx) leads to wrongful deletions.
|
||||
const captureFailureWarnings = new Set<string>();
|
||||
|
||||
function warnCaptureFailureOnce(failureClass: string, detail: string): void {
|
||||
if (captureFailureWarnings.has(failureClass)) return;
|
||||
captureFailureWarnings.add(failureClass);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[middleware] PostHog redirect capture failing (${failureClass}): ` +
|
||||
`${detail} — seo_redirect events are not being recorded, so the ` +
|
||||
"redirect decommission report will under-count this traffic.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defense-in-depth use-site normalization (exported for tests —
|
||||
* SU4-A7): runtime-config already ensures a scheme on POSTHOG_HOST
|
||||
* (getRuntimeConfig → ensureScheme, same hardening as readDocsHost), so
|
||||
* by the time middleware() threads the value here it is normally
|
||||
* absolute — the scheme-less branch is UNREACHABLE through middleware()
|
||||
* and only unit tests can exercise it. The guard keeps the capture
|
||||
* fetch safe if a future caller ever hands trackRedirect a raw env
|
||||
* value — a scheme-less host would make `fetch()` reject a relative
|
||||
* URL on EVERY redirect, silently zeroing the decommission report.
|
||||
*/
|
||||
export function normalizePosthogHost(host: string): string {
|
||||
// Trailing slashes are stripped FIRST (SU7-F3) so the caller's
|
||||
// `${host}/capture/` concatenation never yields "host//capture/" —
|
||||
// the same defense-in-depth posture as the scheme guard below
|
||||
// (runtime-config's readUrl already strips them for values threaded
|
||||
// through middleware(), so like the scheme branch this is only
|
||||
// reachable by future direct callers).
|
||||
const stripped = host.replace(/\/+$/, "");
|
||||
return SCHEME_RE.test(stripped) ? stripped : `https://${stripped}`;
|
||||
}
|
||||
|
||||
function trackRedirect(
|
||||
event: NextFetchEvent,
|
||||
// posthogHost/posthogKey are resolved ONCE per request in middleware()
|
||||
// and passed in — trackRedirect used to call
|
||||
// getRuntimeConfigForMiddleware() again, resolving the config twice
|
||||
// per redirected request.
|
||||
posthogHost: string,
|
||||
posthogKey: string | undefined,
|
||||
id: string,
|
||||
fromPath: string,
|
||||
dest: URL,
|
||||
): void {
|
||||
// The missing-key signal fires at config-resolution time in
|
||||
// middleware() (warnIfPosthogKeyMissing, SU6-A5) — here we only skip
|
||||
// the capture.
|
||||
if (!posthogKey) return;
|
||||
|
||||
const captureUrl = `${normalizePosthogHost(posthogHost)}/capture/`;
|
||||
|
||||
// Don't await (never block the redirect), but DO hand the promise to
|
||||
// event.waitUntil — the Edge runtime may otherwise terminate as soon
|
||||
// as the redirect response is returned, dropping the in-flight capture.
|
||||
const capture = fetch(captureUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: posthogKey,
|
||||
event: "seo_redirect",
|
||||
distinct_id: "seo-redirect-tracker",
|
||||
properties: {
|
||||
redirect_id: id,
|
||||
from_path: fromPath,
|
||||
// to_path alone is ambiguous for self-referential entries (e.g.
|
||||
// M3 /faq -> docs-host /faq emits from_path === to_path) — the
|
||||
// decommission report needs the destination HOST to tell them
|
||||
// apart. to_path is kept for continuity with historic events;
|
||||
// to_url adds the host but excludes the query string (it varies
|
||||
// per request and would explode property cardinality).
|
||||
to_path: dest.pathname,
|
||||
to_url: `${dest.origin}${dest.pathname}`,
|
||||
},
|
||||
}),
|
||||
})
|
||||
.then((res) => {
|
||||
// A 4xx/5xx resolves the fetch promise — without this check a
|
||||
// misconfigured key/host fails capture silently forever.
|
||||
if (!res.ok) {
|
||||
warnCaptureFailureOnce(
|
||||
`http:${res.status}`,
|
||||
`POST ${captureUrl} returned ${res.status}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Never block or fail the redirect on tracking errors — but DO
|
||||
// surface them (once per class) instead of swallowing.
|
||||
const name = err instanceof Error ? err.name : typeof err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
warnCaptureFailureOnce(`net:${name}`, message);
|
||||
});
|
||||
event.waitUntil(capture);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Middleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Framework slugs owned by the docs shell. Any path whose first
|
||||
// segment matches one of these is a framework-scoped docs URL — it
|
||||
// 308s to the docs host BEFORE the SEO-redirect table runs, so legacy
|
||||
// redirects (e.g. S1×mastra: `/mastra/agentic-chat-ui` →
|
||||
// `/mastra/prebuilt-components`) can never hijack it even when legacy
|
||||
// framework keys overlap with registry slugs.
|
||||
// Lowercased at construction (SU4-A4): docs-redirects matches the
|
||||
// LOWERCASED first path segment against this set (case-insensitive
|
||||
// parity, SU3-A4). Registry slugs are canonical lowercase today, but a
|
||||
// future mixed-case slug would otherwise silently never match — its
|
||||
// docs-host redirect would just be disabled with no signal.
|
||||
// Defensive extraction (SU5-A1, exported for tests): the previous
|
||||
// construction reached through a bare `as` cast straight to
|
||||
// `i.slug.toLowerCase()` — a malformed registry (null, a non-array
|
||||
// `integrations`, entries missing `slug` or carrying a non-string slug)
|
||||
// would TypeError at MODULE LOAD: the exact 500-every-request failure
|
||||
// warnIfNoFrameworkSlugs exists to prevent. Extract structurally
|
||||
// instead, counting dropped entries so the module-load guard below can
|
||||
// warn alongside warnIfNoFrameworkSlugs.
|
||||
export function extractFrameworkSlugs(registryData: unknown): {
|
||||
slugs: Set<string>;
|
||||
dropped: number;
|
||||
} {
|
||||
const integrations = (
|
||||
registryData as { integrations?: unknown } | null | undefined
|
||||
)?.integrations;
|
||||
if (!Array.isArray(integrations)) {
|
||||
// Zero slugs — warnIfNoFrameworkSlugs screams about this shape.
|
||||
return { slugs: new Set(), dropped: 0 };
|
||||
}
|
||||
const slugs = new Set<string>();
|
||||
let dropped = 0;
|
||||
for (const entry of integrations) {
|
||||
const slug = (entry as { slug?: unknown } | null | undefined)?.slug;
|
||||
if (typeof slug === "string") {
|
||||
slugs.add(slug.toLowerCase());
|
||||
} else {
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
return { slugs, dropped };
|
||||
}
|
||||
|
||||
const { slugs: registrySlugs, dropped: droppedRegistryEntries } =
|
||||
extractFrameworkSlugs(registry);
|
||||
|
||||
export const REGISTRY_FRAMEWORK_SLUGS: Set<string> = registrySlugs;
|
||||
|
||||
/**
|
||||
* Loud guard (exported for tests): the old next.config build THREW when
|
||||
* registry.json was missing or corrupt in production. Middleware cannot
|
||||
* afford to throw at module load — that would 500 every request — so it
|
||||
* screams in the logs instead: an empty slug set silently disables every
|
||||
* /<framework-slug> docs-host redirect.
|
||||
*/
|
||||
export function warnIfNoFrameworkSlugs(slugs: ReadonlySet<string>): void {
|
||||
if (slugs.size > 0) return;
|
||||
const message =
|
||||
"[middleware] registry.json produced ZERO framework slugs — " +
|
||||
"/<framework-slug> docs-host redirects are DISABLED. The registry is " +
|
||||
"missing or corrupt; run generate-registry.ts before building (the " +
|
||||
"old next.config build failed loudly here).";
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(message);
|
||||
}
|
||||
}
|
||||
|
||||
warnIfNoFrameworkSlugs(REGISTRY_FRAMEWORK_SLUGS);
|
||||
if (droppedRegistryEntries > 0) {
|
||||
// Companion to warnIfNoFrameworkSlugs (SU5-A1): a PARTIALLY corrupt
|
||||
// registry yields a non-empty set that sails past the zero-slug guard
|
||||
// while the malformed integrations silently lose their docs-host
|
||||
// redirects — same failure class, so it gets the same loud signal.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[middleware] registry.json has ${droppedRegistryEntries} integration ` +
|
||||
"entry(ies) with a missing or non-string slug — they were dropped " +
|
||||
"from the framework-slug set, so their /<framework-slug> docs-host " +
|
||||
"redirects are DISABLED. The registry is corrupt; run " +
|
||||
"generate-registry.ts before building.",
|
||||
);
|
||||
}
|
||||
|
||||
// Warn-once latch for the docs-redirects-disabled sentinel (per-isolate,
|
||||
// like posthogKeyWarned — see the note above it). runtime-config already
|
||||
// console.errors the FATAL-CONFIG diagnosis once; this is the
|
||||
// middleware-side breadcrumb that the redirect steps are being skipped.
|
||||
let docsRedirectsDisabledWarned = false;
|
||||
|
||||
export function middleware(request: NextRequest, event: NextFetchEvent) {
|
||||
const { pathname } = request.nextUrl;
|
||||
// Resolve the Edge-safe runtime config (live process.env at request
|
||||
// time, no `next/cache` import) ONCE per request and thread the
|
||||
// values through — see getRuntimeConfigForMiddleware in
|
||||
// src/lib/runtime-config.ts.
|
||||
const { docsHost, posthogHost, posthogKey } = getRuntimeConfigForMiddleware();
|
||||
// Surface a missing POSTHOG_KEY on the FIRST request through the
|
||||
// middleware — see warnIfPosthogKeyMissing (SU6-A5).
|
||||
warnIfPosthogKeyMissing(posthogKey);
|
||||
|
||||
// Case-insensitive matching parity (SU3-A4): the next.config rules
|
||||
// this layer replaced were compiled by path-to-regexp with
|
||||
// sensitive:false, so /FAQ and /Mastra/quickstart matched. The ported
|
||||
// Map/startsWith lookups are case-sensitive and regressed those URLs
|
||||
// to 404. Lowercase ONCE here for MATCHING only — matchPath feeds the
|
||||
// namespace guard (step 0) and the SEO steps (2-3); the docs-host
|
||||
// resolver (step 1) deliberately receives the ORIGINAL-case collapsed
|
||||
// path and lowercases INTERNALLY, because it needs the original case
|
||||
// to preserve the matched remainder (SU4-A6). The ORIGINAL-case
|
||||
// pathname likewise provides the wildcard remainder (path-to-regexp
|
||||
// preserves matched-param case in destinations) and the PostHog
|
||||
// from_path. Lowercasing is length-preserving here: NextRequest
|
||||
// pathnames are percent-encoded ASCII, so positional slicing against
|
||||
// `strippedPath` with `matchPath`-derived offsets is safe.
|
||||
//
|
||||
// Leading-slash normalization (SU4-A3): "//docs/foo" and "//ag-ui/x"
|
||||
// fell through the docs-host step's strict ===/startsWith branches AND
|
||||
// missed the SEO wildcard scan (only the framework-slug branch's
|
||||
// /^\/+/ regex tolerated runs) → 404. Collapse the LEADING run exactly
|
||||
// ONCE, HERE — this is the single normalization point for every
|
||||
// matching step below: the docs-host resolver receives the collapsed
|
||||
// (original-case) path, and strippedPath/matchPath derive from it, so
|
||||
// positional slicing stays aligned. Interior runs are left alone for
|
||||
// matching; destinations collapse them via normalizeRedirectPath
|
||||
// (SU-18/SU-13).
|
||||
const collapsedPath = pathname.replace(/^\/{2,}/, "/");
|
||||
// Trailing-slash normalization (SU3-A5, hardened SU4-A3): "/faq/" used
|
||||
// to miss the exact map and detour through Next's own trailing-slash
|
||||
// 308 (an extra hop), and "/coagents/" mis-attributed to the wildcard
|
||||
// L12 instead of the exact L1. Strip the WHOLE trailing-slash run
|
||||
// (a single slice(0, -1) left "/faq//" matching nothing); root "/"
|
||||
// survives because the guard requires length > 1 and the leading
|
||||
// collapse already reduced all-slash paths to "/". The wildcard
|
||||
// remainder is sliced from the stripped ORIGINAL-case path so a
|
||||
// trailing slash never leaks into destinations either.
|
||||
const strippedPath =
|
||||
collapsedPath.length > 1 && collapsedPath.endsWith("/")
|
||||
? collapsedPath.replace(/\/+$/, "")
|
||||
: collapsedPath;
|
||||
const matchPath = strippedPath.toLowerCase();
|
||||
|
||||
// 0. Shell-owned namespace guard: /integrations/* hosts LIVE shell
|
||||
// product pages. It runs BEFORE the docs-host redirect (SU4-A1): when
|
||||
// the guard sat after it, "no redirect may ever fire under
|
||||
// /integrations" was only DATA-dependent — true while no registry slug
|
||||
// happened to be named "integrations", but a future slug with that
|
||||
// name would have let the docs-host step hijack live shell pages
|
||||
// before the guard executed. Hoisted above every redirect step, the
|
||||
// protection is structural: neither the docs-host enumeration nor any
|
||||
// SEO-table entry can match under /integrations, regardless of what
|
||||
// the registry or the table contains (R15/R17 once hijacked
|
||||
// /integrations/built-in-agent, a deployed page with internal links).
|
||||
if (matchPath === "/integrations" || matchPath.startsWith("/integrations/")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 1. Docs-host routes (/docs, /ag-ui, /reference, /<framework-slug>).
|
||||
// These permanent (308) redirects used to live in next.config.ts
|
||||
// `redirects()` (as `permanent: true`, which Next emits as 308) — which
|
||||
// runs BEFORE middleware, hence this check precedes the SEO table to
|
||||
// preserve the exact same precedence. They moved here so the
|
||||
// destination host resolves from the runtime config (DOCS_HOST env
|
||||
// var) at request time instead of being baked into the image.
|
||||
// Receives collapsedPath (NOT matchPath): the resolver lowercases
|
||||
// internally to preserve the remainder's original case, and it
|
||||
// handles trailing slashes itself via normalizeRedirectPath.
|
||||
//
|
||||
// Sentinel consumer (SU4-B2): when runtime-config could not find ANY
|
||||
// usable docs host (configured value self-hosts AND the default
|
||||
// fallback collides too), it returns DOCS_REDIRECTS_DISABLED_HOST
|
||||
// instead of a looping host. Honor the contract documented on the
|
||||
// constant: skip the docs-host redirect step — and the SEO steps (2-3)
|
||||
// below too, because resolveSeoDestination composes EVERY table
|
||||
// destination against this same docsHost. Issuing any redirect here
|
||||
// would trade a redirect loop for a guaranteed-dead `.invalid` host.
|
||||
// Pass through instead; runtime-config already console.errored the
|
||||
// FATAL-CONFIG diagnosis with the fix.
|
||||
if (docsHost === DOCS_REDIRECTS_DISABLED_HOST) {
|
||||
if (!docsRedirectsDisabledWarned) {
|
||||
docsRedirectsDisabledWarned = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[middleware] docs redirects are DISABLED for this deploy " +
|
||||
`(docsHost is the ${DOCS_REDIRECTS_DISABLED_HOST} sentinel) — ` +
|
||||
"skipping the docs-host and SEO redirect steps. See the " +
|
||||
"FATAL-CONFIG error from runtime-config for the root cause.",
|
||||
);
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
const docsDestination = resolveDocsHostRedirect(
|
||||
collapsedPath,
|
||||
docsHost,
|
||||
REGISTRY_FRAMEWORK_SLUGS,
|
||||
);
|
||||
if (docsDestination) {
|
||||
const dest = new URL(docsDestination);
|
||||
// next.config redirects forward the query string by default — keep
|
||||
// that behavior.
|
||||
dest.search = request.nextUrl.search;
|
||||
// Parity choice: docs-host redirects are deliberately NOT tracked in
|
||||
// PostHog — the next.config `redirects()` rules they replace never
|
||||
// were either; only the SEO table below calls trackRedirect.
|
||||
//
|
||||
// 308, not 301: the old rules used `permanent: true`, which Next
|
||||
// emits as 308 (permanent, method-preserving).
|
||||
return NextResponse.redirect(dest, 308);
|
||||
}
|
||||
|
||||
// 2. Exact match (O(1) Map lookup)
|
||||
//
|
||||
// The SEO table's destinations target the DOCS routing surface
|
||||
// (shell-docs serves at the docs host root), NOT the shell. Resolving
|
||||
// them against `request.url` (the shell origin) made self-referential
|
||||
// entries (e.g. /faq -> /faq) 301 to themselves forever
|
||||
// (ERR_TOO_MANY_REDIRECTS) and sent everything else to a shell 404 or
|
||||
// through a needless double hop — so resolve against the docs host.
|
||||
const exact = exactMap.get(matchPath);
|
||||
if (exact) {
|
||||
const dest = resolveSeoDestination(exact.destination, docsHost);
|
||||
// Forward the query string, matching the docs-host step (and
|
||||
// next.config redirects' default behavior).
|
||||
dest.search = request.nextUrl.search;
|
||||
trackRedirect(event, posthogHost, posthogKey, exact.id, pathname, dest);
|
||||
return NextResponse.redirect(dest, 301);
|
||||
}
|
||||
|
||||
// 3. Wildcard match (linear scan — short-circuits on first match).
|
||||
// Destinations resolve against the docs host — see step 2.
|
||||
//
|
||||
// next.config `:path*` semantics are ZERO or more segments: a source
|
||||
// `/x/:path*` also matches the bare `/x` (rest = ""), so e.g.
|
||||
// /backend, /guides, /learn keep redirecting instead of 404ing.
|
||||
// resolveSeoDestination collapses the trailing slash a zero-segment
|
||||
// substitution leaves behind.
|
||||
for (const wc of wildcardEntries) {
|
||||
// Builder invariant: every wildcard prefix ends with "/" —
|
||||
// buildRedirectLookup skips sources without the "/" boundary as
|
||||
// malformedWildcards — so the bare (zero-segment) source is always
|
||||
// the prefix minus exactly that one slash (SU4-A6).
|
||||
const bareSource = wc.prefix.slice(0, -1);
|
||||
if (matchPath.startsWith(wc.prefix) || matchPath === bareSource) {
|
||||
// Sliced from the ORIGINAL-case (trailing-slash-stripped) path —
|
||||
// only the prefix match is case-insensitive; the remainder
|
||||
// forwards verbatim.
|
||||
const rest =
|
||||
matchPath === bareSource ? "" : strippedPath.slice(wc.prefix.length);
|
||||
const destination = substituteWildcardTemplate(
|
||||
wc.destinationTemplate,
|
||||
rest,
|
||||
);
|
||||
const dest = resolveSeoDestination(destination, docsHost);
|
||||
dest.search = request.nextUrl.search;
|
||||
trackRedirect(event, posthogHost, posthogKey, wc.id, pathname, dest);
|
||||
return NextResponse.redirect(dest, 301);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. No match — pass through
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Exclusions, in order (SU4-A6 — prose kept in sync with the
|
||||
// pattern):
|
||||
// - `api/.+`: real API routes only — /api/<anything>. The bare
|
||||
// /api and prefix lookalikes like /api-reference are SEO
|
||||
// sources (R1/R3) and must reach the middleware; the bare
|
||||
// trailing-slash "/api/" must too (SU5-A4), so R1 redirects it
|
||||
// in ONE hop instead of detouring through Next's own
|
||||
// trailing-slash 308 (a double hop).
|
||||
// - `_next/`: ALL Next.js internals, wholesale. The previous
|
||||
// `_next/static|_next/image` pair let /_next/data and every
|
||||
// other /_next/* path run the middleware for nothing — no table
|
||||
// source or registry slug starts with `_next` (verified against
|
||||
// seo-redirects.ts and registry.json).
|
||||
// - `favicon\.ico`: PREFIX-based like every alternative here — it
|
||||
// also excludes e.g. /favicon.ico.png; that's fine, nothing
|
||||
// under that prefix is a redirect source.
|
||||
// - `previews/`: the shell's preview-asset namespace; never a
|
||||
// redirect source.
|
||||
"/((?!api/.+|_next/|favicon\\.ico|previews/).*)",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
// Minimal typing for Next's vendored path-to-regexp — the compiled
|
||||
// module (next/dist/compiled/path-to-regexp) ships no .d.ts. Used by
|
||||
// middleware.test.ts to compile the middleware `matcher` exactly the
|
||||
// way Next does (SU6-A6): tryToParsePath (next/dist/lib/
|
||||
// try-to-parse-path.ts) calls parse() + tokensToRegexp() with NO
|
||||
// options and keeps only the regex source, which the runtime
|
||||
// re-hydrates with `new RegExp(...)`.
|
||||
declare module "next/dist/compiled/path-to-regexp" {
|
||||
/** Opaque parse result — only ever passed back to tokensToRegexp. */
|
||||
export type Token = string | object;
|
||||
export function parse(path: string): Token[];
|
||||
export function tokensToRegexp(tokens: Token[]): RegExp;
|
||||
export function pathToRegexp(
|
||||
path: string,
|
||||
keys?: unknown[],
|
||||
options?: { delimiter?: string; sensitive?: boolean; strict?: boolean },
|
||||
): RegExp;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { configDefaults, defineConfig } from "vitest/config";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
// Vitest's DEFAULT excludes (node_modules, dist, .git, ...) — the
|
||||
// previous hand-rolled ["node_modules/**"] silently REPLACED the
|
||||
// defaults instead of extending them (SU5-A5).
|
||||
exclude: [...configDefaults.exclude],
|
||||
// Generates the gitignored registry.json (statically imported by
|
||||
// src/middleware.ts) before any worker transforms a test module —
|
||||
// see vitest.global-setup.ts.
|
||||
globalSetup: "./vitest.global-setup.ts",
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
// `server-only` (imported by src/lib/runtime-config.ts as a
|
||||
// client-bundle guard) THROWS under the plain-Node `default`
|
||||
// export condition vitest resolves with — point it at the
|
||||
// package's own empty `react-server` marker instead, exactly
|
||||
// what Next's server/middleware layers resolve. Located via
|
||||
// require.resolve (SU5-A5): a hard-coded ./node_modules path
|
||||
// breaks when the package manager hoists the package.
|
||||
"server-only": path.join(
|
||||
path.dirname(require.resolve("server-only")),
|
||||
"empty.js",
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// Vitest globalSetup: generate the gitignored registry.json BEFORE any
|
||||
// test worker starts.
|
||||
//
|
||||
// src/middleware.ts statically imports `@/data/registry.json`, a generated
|
||||
// artifact (see showcase/.gitignore) that `npm run dev`/`build` produce.
|
||||
// On a fresh checkout it doesn't exist, and vitest workers have no
|
||||
// ordering guarantee — so generation must happen here, once, before
|
||||
// module transform, not in any single test file's beforeAll (which both
|
||||
// races other workers and leaves every other file broken when it doesn't
|
||||
// run first).
|
||||
//
|
||||
// Idempotent: if the registry already exists AND is valid (dev/build
|
||||
// ran, or a prior test run generated it), this is a no-op.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const SHELL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REGISTRY_PATH = path.join(SHELL_ROOT, "src", "data", "registry.json");
|
||||
|
||||
// Validate before the early return (SU4-A7): a corrupt or stale
|
||||
// registry.json (interrupted generator, truncated write) used to pass
|
||||
// the bare existsSync check and then break EVERY middleware test at
|
||||
// import with an unrelated-looking JSON/transform error. Parse it and
|
||||
// require a non-empty integrations array; regenerate otherwise.
|
||||
function isValidRegistry(registryPath: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(registryPath, "utf-8")) as {
|
||||
integrations?: unknown;
|
||||
};
|
||||
return Array.isArray(parsed.integrations) && parsed.integrations.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function setup(): void {
|
||||
if (fs.existsSync(REGISTRY_PATH)) {
|
||||
if (isValidRegistry(REGISTRY_PATH)) return;
|
||||
console.warn(
|
||||
`[vitest.global-setup] ${REGISTRY_PATH} exists but is corrupt or ` +
|
||||
"has no integrations — regenerating.",
|
||||
);
|
||||
}
|
||||
|
||||
// Run the local generator through the current node binary + the locally
|
||||
// installed tsx CLI — NOT `npx tsx`: npx without -y can prompt-hang when
|
||||
// the package isn't cached, and `npx` itself isn't directly spawnable on
|
||||
// Windows (execFile needs the .cmd shim).
|
||||
const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
|
||||
const generator = path.join(
|
||||
SHELL_ROOT,
|
||||
"..",
|
||||
"scripts",
|
||||
"generate-registry.ts",
|
||||
);
|
||||
|
||||
// Generous timeout: the generator validates every manifest and emits
|
||||
// catalogs for all shells. Keep stdout quiet but surface stderr — with
|
||||
// stdio "ignore" a generator failure is a bare exit-code-1 with nothing
|
||||
// to debug on CI.
|
||||
execFileSync(process.execPath, [tsxCli, generator], {
|
||||
cwd: SHELL_ROOT,
|
||||
stdio: ["ignore", "ignore", "inherit"],
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
// Re-validate AFTER regeneration (SU5-A5): a generator that exits 0
|
||||
// but emits an unusable registry (schema drift, empty integrations,
|
||||
// output path moved) would otherwise resurface as the exact baffling
|
||||
// transform error in whichever test file imports middleware first —
|
||||
// the failure this setup exists to prevent. Fail HERE, loudly.
|
||||
if (!isValidRegistry(REGISTRY_PATH)) {
|
||||
throw new Error(
|
||||
`[vitest.global-setup] generate-registry.ts completed but ` +
|
||||
`${REGISTRY_PATH} is still missing, corrupt, or has no ` +
|
||||
"integrations — the registry generator (or its output path) is " +
|
||||
"broken; fix it before running the shell test suite.",
|
||||
);
|
||||
}
|
||||
}
|
||||