From 4d5bb17ddae2dc0fc0bebeb560a80ebe2fe769a5 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 14:29:37 +0200 Subject: [PATCH 1/2] add website --- .../src/components/Home/CodePane/index.tsx | 101 ++ .../Home/CodePane/styles.module.css | 108 ++ .../src/components/Home/FinalCTA/index.tsx | 44 + .../Home/FinalCTA/styles.module.css | 137 ++ .../src/components/Home/Generators/index.tsx | 169 +++ .../Home/Generators/styles.module.css | 167 +++ website/src/components/Home/Hero/index.tsx | 121 ++ .../components/Home/Hero/styles.module.css | 361 +++++ .../src/components/Home/HowItWorks/index.tsx | 99 ++ .../Home/HowItWorks/styles.module.css | 112 ++ .../src/components/Home/Protocols/index.tsx | 74 + .../Home/Protocols/styles.module.css | 126 ++ .../src/components/Home/SpecToCode/index.tsx | 302 +++++ .../Home/SpecToCode/styles.module.css | 499 +++++++ website/src/components/Home/demos.ts | 1206 +++++++++++++++++ .../src/components/HomepageFeatures/index.tsx | 70 - .../HomepageFeatures/styles.module.css | 11 - .../src/components/Sponsors/styles.module.css | 3 + website/src/css/custom.css | 69 + website/src/pages/index.module.css | 36 - website/src/pages/index.tsx | 45 +- 21 files changed, 3712 insertions(+), 148 deletions(-) create mode 100644 website/src/components/Home/CodePane/index.tsx create mode 100644 website/src/components/Home/CodePane/styles.module.css create mode 100644 website/src/components/Home/FinalCTA/index.tsx create mode 100644 website/src/components/Home/FinalCTA/styles.module.css create mode 100644 website/src/components/Home/Generators/index.tsx create mode 100644 website/src/components/Home/Generators/styles.module.css create mode 100644 website/src/components/Home/Hero/index.tsx create mode 100644 website/src/components/Home/Hero/styles.module.css create mode 100644 website/src/components/Home/HowItWorks/index.tsx create mode 100644 website/src/components/Home/HowItWorks/styles.module.css create mode 100644 website/src/components/Home/Protocols/index.tsx create mode 100644 website/src/components/Home/Protocols/styles.module.css create mode 100644 website/src/components/Home/SpecToCode/index.tsx create mode 100644 website/src/components/Home/SpecToCode/styles.module.css create mode 100644 website/src/components/Home/demos.ts delete mode 100644 website/src/components/HomepageFeatures/index.tsx delete mode 100644 website/src/components/HomepageFeatures/styles.module.css delete mode 100644 website/src/pages/index.module.css diff --git a/website/src/components/Home/CodePane/index.tsx b/website/src/components/Home/CodePane/index.tsx new file mode 100644 index 00000000..6e11a096 --- /dev/null +++ b/website/src/components/Home/CodePane/index.tsx @@ -0,0 +1,101 @@ +import clsx from 'clsx'; +import {Highlight, themes} from 'prism-react-renderer'; +import type {Language} from '../demos'; +import styles from './styles.module.css'; + +/** + * A read-only, editor-looking code surface with a "streaming in" reveal. + * + * The reveal is done with a per-line CSS animation delay rather than by + * re-rendering a growing substring: highlighting runs once, the browser handles + * the 60fps part on the compositor, and there is nothing to throttle. Replaying + * it means remounting the element, which restarts the CSS animations from zero. + * + * The remount key is derived from `code` itself, so a pane only re-animates when + * what it shows actually changed. Callers do not have to work out which of their + * controls affect which pane - and a pane sitting next to one the reader just + * switched stays still instead of flickering for no reason. + */ +export default function CodePane({ + code, + language, + replayToken, + animate = true, + showLineNumbers = true, + panelLabel, + className +}: { + code: string; + language: Language; + /** + * Changes to this force a replay even when `code` is unchanged - for an + * explicit "run it again" control. Leave unset to animate on content change + * only. + */ + replayToken?: string | number; + animate?: boolean; + /** Off for shell snippets, where numbered "lines" are meaningless. */ + showLineNumbers?: boolean; + /** + * Set when the pane is the content a tablist switches between: it makes the + * pane the tabpanel those tabs are missing, named after the current file. + */ + panelLabel?: string; + className?: string; +}): JSX.Element { + return ( +
+ {/* `oneDark` over the more obvious `vsDark`: vsDark has no style for the + `atrule` token type, which is what Prism's YAML grammar tags every key + as - so a whole YAML document would render as flat, unhighlighted + text. Keep `--cg-code-plain` in sync with this theme's plain colour. */} + + {({tokens, getLineProps, getTokenProps}) => ( +
+            
+              {tokens.map((line, i) => {
+                const {style: _ignoredLineStyle, ...lineProps} = getLineProps({
+                  line
+                });
+                return (
+                  
+                    {showLineNumbers && (
+                      
+                    )}
+                    
+                      {line.map((token, key) => {
+                        const {style, ...rest} = getTokenProps({token});
+                        return ;
+                      })}
+                    
+                  
+                );
+              })}
+            
+          
+ )} +
+
+ ); +} diff --git a/website/src/components/Home/CodePane/styles.module.css b/website/src/components/Home/CodePane/styles.module.css new file mode 100644 index 00000000..295d2ac9 --- /dev/null +++ b/website/src/components/Home/CodePane/styles.module.css @@ -0,0 +1,108 @@ +.pane { + position: relative; + min-height: 0; + flex: 1; + overflow: hidden; +} + +.pre { + margin: 0; + height: 100%; + overflow: auto; + padding: 0.85rem 0 1.5rem; + background: transparent; + /* Prism's theme carries both a background and a base text colour in the + `style` prop we deliberately drop (its background would fight the panel). + The base colour has to be restored here, or unstyled "plain" tokens - most + of a YAML file, and every shell line - inherit the page's font colour and + vanish against the dark panel in light mode. */ + color: var(--cg-code-plain); + font-size: 0.78rem; + line-height: 1.55; + font-family: var(--cg-mono); + tab-size: 2; + /* The pane is scrollable, so it must be reachable and focusable by keyboard. */ + scrollbar-width: thin; + scrollbar-color: var(--cg-scrollbar) transparent; +} + +.pre:focus-visible { + outline: 2px solid var(--cg-accent); + outline-offset: -2px; +} + +.pre::-webkit-scrollbar { + width: 9px; + height: 9px; +} + +.pre::-webkit-scrollbar-thumb { + background: var(--cg-scrollbar); + border-radius: 99px; +} + +.pre code { + display: block; + min-width: max-content; + background: none; + font-family: inherit; +} + +.line { + display: flex; + gap: 0.9rem; + padding-right: 1.25rem; +} + +/* Without a gutter the content needs its own left inset. */ +.line > .lineContent:first-child { + padding-left: 1rem; +} + +/* No gutter means no line numbers to keep aligned, so long commands can wrap + instead of clipping at the panel edge. */ +.noGutter code { + min-width: 0; +} + +.noGutter .lineContent { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.animated .line { + opacity: 0; + animation: lineIn 260ms ease-out forwards; +} + +.lineNo { + flex: 0 0 auto; + width: 2.35rem; + padding-left: 0.6rem; + text-align: right; + color: var(--cg-line-number); + user-select: none; +} + +.lineContent { + white-space: pre; +} + +@keyframes lineIn { + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: none; + } +} + +/* Motion here is decoration - the code is the content, so drop the reveal. */ +@media (prefers-reduced-motion: reduce) { + .animated .line { + opacity: 1; + animation: none; + } +} diff --git a/website/src/components/Home/FinalCTA/index.tsx b/website/src/components/Home/FinalCTA/index.tsx new file mode 100644 index 00000000..1fc2b567 --- /dev/null +++ b/website/src/components/Home/FinalCTA/index.tsx @@ -0,0 +1,44 @@ +import clsx from 'clsx'; +import Link from '@docusaurus/Link'; +import styles from './styles.module.css'; + +export default function FinalCTA(): JSX.Element { + return ( +
+ +
+

+ Delete your hand-written models today +

+

+ Apache-2.0, free forever, and built in the open. Bring an AsyncAPI, + OpenAPI or JSON Schema document and see what falls out. +

+
+ + Read the getting started guide + + + Star it on GitHub + +
+

+ Need it inside an existing app? There are worked{' '} + + examples + {' '} + for TypeScript libraries, Next.js, and every protocol. +

+
+
+ ); +} diff --git a/website/src/components/Home/FinalCTA/styles.module.css b/website/src/components/Home/FinalCTA/styles.module.css new file mode 100644 index 00000000..daf3a66a --- /dev/null +++ b/website/src/components/Home/FinalCTA/styles.module.css @@ -0,0 +1,137 @@ +.section { + position: relative; + overflow: hidden; + padding: 5.5rem 0; + background: var(--cg-hero-bg); + color: var(--cg-panel-text); + isolation: isolate; +} + +.backdrop { + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; +} + +.glow { + position: absolute; + left: 50%; + bottom: -60%; + width: 60rem; + height: 40rem; + transform: translateX(-50%); + border-radius: 50%; + filter: blur(90px); + opacity: 0.5; + background: radial-gradient( + circle, + var(--cg-accent-60), + transparent 68% + ); +} + +.grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(to right, rgb(255 255 255 / 5%) 1px, transparent 1px), + linear-gradient(to bottom, rgb(255 255 255 / 5%) 1px, transparent 1px); + background-size: 58px 58px; + mask-image: radial-gradient( + ellipse 70% 80% at 50% 100%, + #000 30%, + transparent 100% + ); +} + +.inner { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.title { + margin: 0 0 1rem; + font-size: clamp(1.9rem, 5vw, 3rem); + line-height: 1.1; + letter-spacing: -0.03em; +} + +.blurb { + max-width: 38rem; + margin: 0 0 2rem; + font-size: 1.08rem; + line-height: 1.6; + color: var(--cg-panel-text-dim); +} + +.actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.85rem; +} + +.cta { + display: inline-flex; + align-items: center; + padding: 0.85rem 1.6rem; + border-radius: 12px; + font-size: 1rem; + font-weight: 700; + transition: + transform 160ms ease, + box-shadow 160ms ease, + background 160ms ease, + border-color 160ms ease; +} + +.cta:hover { + text-decoration: none; + transform: translateY(-2px); +} + +.ctaPrimary { + background: linear-gradient( + 100deg, + var(--cg-accent) 0%, + var(--cg-accent-2) 100% + ); + color: #04120d; + box-shadow: 0 10px 30px -12px var(--cg-accent-60); +} + +.ctaPrimary:hover { + color: #04120d; +} + +.ctaGhost { + border: 1px solid var(--cg-panel-border-strong); + background: rgb(255 255 255 / 4%); + color: var(--cg-panel-text); +} + +.ctaGhost:hover { + color: var(--cg-panel-text); + border-color: var(--cg-accent-55); + background: var(--cg-accent-12); +} + +.meta { + margin: 2rem 0 0; + font-size: 0.9rem; + color: var(--cg-panel-text-faint); +} + +.meta a { + color: var(--cg-accent); +} + +@media (prefers-reduced-motion: reduce) { + .cta:hover { + transform: none; + } +} diff --git a/website/src/components/Home/Generators/index.tsx b/website/src/components/Home/Generators/index.tsx new file mode 100644 index 00000000..2f0e22a7 --- /dev/null +++ b/website/src/components/Home/Generators/index.tsx @@ -0,0 +1,169 @@ +import Link from '@docusaurus/Link'; +import styles from './styles.module.css'; + +/** + * The eight presets, described the way the config schema describes them. + * + * Wording is condensed from the `preset` field's own Zod `.describe()` text in + * `src/codegen/generators/**`, which is the single source of truth for what a + * generator does. If a preset's purpose changes there, change it here too. + */ +const GENERATORS: { + preset: string; + blurb: string; + href: string; + /** Rendered inside a 24x24 viewBox, `currentColor` stroked. */ + icon: JSX.Element; +}[] = [ + { + preset: 'payloads', + blurb: + 'Typed payload and message models that serialize straight into your wire format.', + href: '/docs/generators/payloads', + icon: ( + <> + + + + ) + }, + { + preset: 'parameters', + blurb: + 'Parameter models that interpolate values into subjects, topics and URL paths.', + href: '/docs/generators/parameters', + icon: ( + <> + + + + + ) + }, + { + preset: 'headers', + blurb: + 'Message header models, with optional runtime validation of what arrives.', + href: '/docs/generators/headers', + icon: ( + <> + + + + ) + }, + { + preset: 'types', + blurb: + 'Type aliases and enums derived from the constraints already in your document.', + href: '/docs/generators/types', + icon: ( + <> + + + + ) + }, + { + preset: 'channels', + blurb: + 'Protocol-specific publish, subscribe, request and reply functions per operation.', + href: '/docs/generators/channels', + icon: ( + <> + + + + + + ) + }, + { + preset: 'client', + blurb: + 'One class wrapping the channel functions, with connection handling built in.', + href: '/docs/generators/client', + icon: ( + <> + + + + ) + }, + { + preset: 'models', + blurb: + 'Plain typed models via Modelina, with none of the messaging machinery.', + href: '/docs/generators/models', + icon: ( + <> + + + + ) + }, + { + preset: 'custom', + blurb: + 'Your own render function, fed the parsed document and other generators’ output.', + href: '/docs/generators/custom', + icon: ( + <> + + + ) + } +]; + +export default function Generators(): JSX.Element { + return ( +
+
+
+

Eight presets

+

Generate exactly as much as you want

+

+ Presets compose. Take just the models, or wire up the whole client — + the renderer resolves the dependency order for you. +

+
+ +
+ {GENERATORS.map((generator) => ( + + +

+ {generator.preset} +

+

{generator.blurb}

+ + + ))} +
+ +

+ AsyncAPI and OpenAPI support all eight. JSON Schema supports{' '} + models and custom.{' '} + See the full support matrix → +

+
+
+ ); +} diff --git a/website/src/components/Home/Generators/styles.module.css b/website/src/components/Home/Generators/styles.module.css new file mode 100644 index 00000000..ac70c26f --- /dev/null +++ b/website/src/components/Home/Generators/styles.module.css @@ -0,0 +1,167 @@ +.section { + padding: 5rem 0; + border-top: 1px solid var(--cg-hairline); +} + +.intro { + max-width: 42rem; + margin: 0 auto 2.75rem; + text-align: center; +} + +.eyebrow { + margin: 0 0 0.85rem; + font-family: var(--cg-mono); + font-size: 0.78rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--cg-accent-strong); +} + +.title { + margin: 0 0 0.9rem; + font-size: clamp(1.7rem, 4vw, 2.4rem); + letter-spacing: -0.02em; +} + +.lede { + margin: 0; + color: var(--ifm-color-emphasis-700); +} + +.grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; +} + +.card { + position: relative; + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 1.35rem; + border-radius: 14px; + border: 1px solid var(--ifm-color-emphasis-200); + background: var(--ifm-card-background-color); + color: var(--ifm-font-color-base); + overflow: hidden; + transition: + transform 180ms ease, + border-color 180ms ease, + box-shadow 180ms ease; +} + +/* Accent wash that grows on hover - keeps the grid calm until you engage. */ +.card::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + 120% 90% at 0% 0%, + var(--cg-accent-12), + transparent 60% + ); + opacity: 0; + transition: opacity 180ms ease; +} + +.card:hover { + text-decoration: none; + color: var(--ifm-font-color-base); + transform: translateY(-3px); + border-color: var(--cg-accent-45); + box-shadow: 0 16px 34px -22px var(--cg-accent-60); +} + +.card:hover::before { + opacity: 1; +} + +.iconWrap { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.4rem; + height: 2.4rem; + border-radius: 10px; + border: 1px solid var(--cg-accent-30); + background: var(--cg-accent-10); + color: var(--cg-accent-strong); +} + +.iconWrap svg { + width: 1.25rem; + height: 1.25rem; +} + +.cardTitle { + position: relative; + margin: 0; + font-size: 1rem; +} + +.cardTitle code { + padding: 0.1rem 0.4rem; + border: 0; + background: var(--ifm-color-emphasis-100); + font-size: 0.9rem; +} + +.cardBlurb { + position: relative; + margin: 0; + flex: 1; + font-size: 0.88rem; + line-height: 1.55; + color: var(--ifm-color-emphasis-700); +} + +.cardMore { + position: relative; + font-family: var(--cg-mono); + font-size: 0.72rem; + font-weight: 600; + color: var(--cg-accent-strong); + opacity: 0; + transform: translateX(-4px); + transition: + opacity 180ms ease, + transform 180ms ease; +} + +.card:hover .cardMore { + opacity: 1; + transform: none; +} + +.footnote { + margin: 2rem 0 0; + text-align: center; + font-size: 0.9rem; + color: var(--ifm-color-emphasis-700); +} + +@media screen and (max-width: 996px) { + .grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media screen and (max-width: 576px) { + .grid { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + .card:hover { + transform: none; + } + + .cardMore { + opacity: 1; + transform: none; + } +} diff --git a/website/src/components/Home/Hero/index.tsx b/website/src/components/Home/Hero/index.tsx new file mode 100644 index 00000000..db9c5f56 --- /dev/null +++ b/website/src/components/Home/Hero/index.tsx @@ -0,0 +1,121 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import clsx from 'clsx'; +import Link from '@docusaurus/Link'; +import styles from './styles.module.css'; + +const INSTALL_COMMAND = 'npm install --save-dev @the-codegen-project/cli'; + +const STATS: {value: string; label: string}[] = [ + {value: '3', label: 'input formats'}, + {value: '8', label: 'generators'}, + {value: '7', label: 'protocols'}, + {value: '0', label: 'hand-written models'} +]; + +/** Copy-to-clipboard that falls back to selecting the text it could not copy. */ +function CopyButton({value}: {value: string}): JSX.Element { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | null>(null); + + useEffect( + () => () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }, + [] + ); + + const copy = useCallback(async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => setCopied(false), 1800); + } catch { + // Clipboard access can be denied (insecure context, permissions). Saying + // nothing would look like the button is broken. + setCopied(false); + } + }, [value]); + + return ( + + ); +} + +export default function Hero(): JSX.Element { + return ( +
+ {/* Decorative: an animated gradient wash plus a faint grid. */} + + +
+

+

+ +

+ Ship the API. +
+ Skip the boilerplate. +

+ +

+ The Codegen Project reads the API document you already maintain and + writes the TypeScript you were going to hand-type: payload and + parameter models, typed publish/subscribe functions for NATS, Kafka, + MQTT, AMQP, WebSocket and SSE, and complete HTTP clients. +

+ +
+ + Get started + 5 min + + + Open the playground + +
+ +
+ + {INSTALL_COMMAND} + +
+ +
+ {STATS.map((stat) => ( +
+
{stat.value}
+
{stat.label}
+
+ ))} +
+
+
+ ); +} diff --git a/website/src/components/Home/Hero/styles.module.css b/website/src/components/Home/Hero/styles.module.css new file mode 100644 index 00000000..e35cfeca --- /dev/null +++ b/website/src/components/Home/Hero/styles.module.css @@ -0,0 +1,361 @@ +.hero { + position: relative; + overflow: hidden; + padding: 6rem 0 5rem; + /* The hero is a self-contained dark stage in both colour modes: the code + panels below it only read well on a dark surface, and a landing page that + flips its centrepiece between themes reads as two different products. */ + background: var(--cg-hero-bg); + color: var(--cg-panel-text); + isolation: isolate; +} + +/* --- backdrop ------------------------------------------------------------ */ + +.backdrop { + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; +} + +.blob { + position: absolute; + border-radius: 50%; + filter: blur(90px); + opacity: 0.55; + will-change: transform; +} + +.blobOne { + top: -18%; + left: -8%; + width: 46rem; + height: 30rem; + background: radial-gradient( + circle, + var(--cg-accent-60), + transparent 70% + ); + animation: drift 26s ease-in-out infinite; +} + +.blobTwo { + top: 8%; + right: -12%; + width: 40rem; + height: 34rem; + background: radial-gradient( + circle, + var(--cg-accent-2-55), + transparent 70% + ); + animation: drift 32s ease-in-out infinite reverse; +} + +.blobThree { + bottom: -26%; + left: 32%; + width: 34rem; + height: 26rem; + background: radial-gradient( + circle, + var(--cg-accent-3-45), + transparent 70% + ); + animation: drift 38s ease-in-out infinite; +} + +.grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(to right, rgb(255 255 255 / 5%) 1px, transparent 1px), + linear-gradient(to bottom, rgb(255 255 255 / 5%) 1px, transparent 1px); + background-size: 58px 58px; + /* Fades the grid out towards the edges so it reads as texture, not a table. */ + mask-image: radial-gradient( + ellipse 85% 70% at 50% 35%, + #000 40%, + transparent 100% + ); +} + +@keyframes drift { + 0%, + 100% { + transform: translate3d(0, 0, 0) scale(1); + } + 33% { + transform: translate3d(4%, 6%, 0) scale(1.08); + } + 66% { + transform: translate3d(-5%, -3%, 0) scale(0.95); + } +} + +/* --- content ------------------------------------------------------------- */ + +.inner { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 0.55rem; + margin: 0 0 1.75rem; + padding: 0.4rem 0.95rem; + border-radius: 999px; + border: 1px solid var(--cg-panel-border-strong); + background: rgb(255 255 255 / 4%); + backdrop-filter: blur(6px); + font-family: var(--cg-mono); + font-size: 0.76rem; + letter-spacing: 0.01em; + color: var(--cg-panel-text-dim); +} + +.badgeDot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--cg-accent); + box-shadow: 0 0 0 0 var(--cg-accent-60); + animation: ping 2.4s ease-out infinite; +} + +@keyframes ping { + 0% { + box-shadow: 0 0 0 0 var(--cg-accent-60); + } + 70%, + 100% { + box-shadow: 0 0 0 9px transparent; + } +} + +.title { + margin: 0 0 1.35rem; + font-size: clamp(2.5rem, 8vw, 4.75rem); + line-height: 1.03; + letter-spacing: -0.035em; + font-weight: 800; +} + +.titleAccent { + background: linear-gradient( + 100deg, + var(--cg-accent) 0%, + var(--cg-accent-2) 50%, + var(--cg-accent-3) 100% + ); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; +} + +.subtitle { + max-width: 44rem; + margin: 0 0 2.25rem; + font-size: 1.12rem; + line-height: 1.65; + color: var(--cg-panel-text-dim); +} + +/* --- calls to action ----------------------------------------------------- */ + +.actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.85rem; + margin-bottom: 2rem; +} + +.cta { + display: inline-flex; + align-items: center; + gap: 0.6rem; + padding: 0.85rem 1.6rem; + border-radius: 12px; + font-size: 1rem; + font-weight: 700; + transition: + transform 160ms ease, + box-shadow 160ms ease, + background 160ms ease, + border-color 160ms ease; +} + +.cta:hover { + text-decoration: none; + transform: translateY(-2px); +} + +.ctaPrimary { + background: linear-gradient( + 100deg, + var(--cg-accent) 0%, + var(--cg-accent-2) 100% + ); + color: #04120d; + box-shadow: 0 10px 30px -12px var(--cg-accent-60); +} + +.ctaPrimary:hover { + color: #04120d; + box-shadow: 0 16px 38px -12px var(--cg-accent-80); +} + +.ctaHint { + padding: 0.1rem 0.45rem; + border-radius: 999px; + background: rgb(0 0 0 / 18%); + font-family: var(--cg-mono); + font-size: 0.7rem; + font-weight: 600; +} + +.ctaGhost { + border: 1px solid var(--cg-panel-border-strong); + background: rgb(255 255 255 / 4%); + color: var(--cg-panel-text); +} + +.ctaGhost:hover { + color: var(--cg-panel-text); + border-color: var(--cg-accent-55); + background: var(--cg-accent-12); +} + +/* --- install line -------------------------------------------------------- */ + +.install { + display: flex; + align-items: center; + gap: 0.7rem; + max-width: 100%; + padding: 0.55rem 0.55rem 0.55rem 1rem; + border-radius: 12px; + border: 1px solid var(--cg-panel-border); + background: var(--cg-panel); + box-shadow: 0 12px 34px -22px rgb(0 0 0 / 80%); +} + +.installPrompt { + color: var(--cg-accent); + font-family: var(--cg-mono); + font-size: 0.85rem; +} + +.installCommand { + overflow-x: auto; + padding: 0; + border: 0; + background: none; + color: var(--cg-panel-text); + font-family: var(--cg-mono); + font-size: 0.85rem; + white-space: nowrap; + scrollbar-width: none; +} + +.installCommand::-webkit-scrollbar { + display: none; +} + +.copyButton { + flex: 0 0 auto; + padding: 0.35rem 0.7rem; + border-radius: 8px; + border: 1px solid var(--cg-panel-border-strong); + background: transparent; + color: var(--cg-panel-text-dim); + font-size: 0.76rem; + font-weight: 600; + cursor: pointer; + transition: + color 150ms ease, + border-color 150ms ease, + background 150ms ease; +} + +.copyButton:hover { + color: var(--cg-accent); + border-color: var(--cg-accent-55); + background: var(--cg-accent-10); +} + +/* --- stats --------------------------------------------------------------- */ + +.stats { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1rem 2.75rem; + margin: 2.75rem 0 0; +} + +.stat { + display: flex; + flex-direction: column; + align-items: center; +} + +.statValue { + font-size: 2rem; + font-weight: 800; + line-height: 1; + letter-spacing: -0.03em; + background: linear-gradient( + 160deg, + var(--cg-panel-text) 0%, + var(--cg-accent) 130% + ); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; +} + +.statLabel { + margin: 0.4rem 0 0; + font-family: var(--cg-mono); + font-size: 0.7rem; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--cg-panel-text-faint); +} + +/* --- responsive & motion ------------------------------------------------- */ + +@media screen and (max-width: 996px) { + .hero { + padding: 4rem 0 3.5rem; + } + + .subtitle { + font-size: 1.02rem; + } + + .install { + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .blob, + .badgeDot { + animation: none; + } + + .cta:hover { + transform: none; + } +} diff --git a/website/src/components/Home/HowItWorks/index.tsx b/website/src/components/Home/HowItWorks/index.tsx new file mode 100644 index 00000000..94e2907c --- /dev/null +++ b/website/src/components/Home/HowItWorks/index.tsx @@ -0,0 +1,99 @@ +import Link from '@docusaurus/Link'; +import CodePane from '../CodePane'; +import type {Language} from '../demos'; +import styles from './styles.module.css'; + +const STEPS: { + title: string; + blurb: JSX.Element; + language: Language; + code: string; +}[] = [ + { + title: 'Install', + blurb: ( + <> + A dev dependency, a global binary, or a signed installer per platform. + Node.js 22+. + + ), + language: 'plaintext', + code: `npm install --save-dev @the-codegen-project/cli` + }, + { + title: 'Initialize', + blurb: ( + <> + codegen init walks you through it interactively, or takes + flags for CI. It writes the config file — JSON, YAML, TS, ESM or CJS. + + ), + language: 'plaintext', + code: `codegen init + +# or non-interactively +codegen init --no-tty \\ + --input-type asyncapi \\ + --input-file ./asyncapi.yml \\ + --include-payloads \\ + --include-channels \\ + --channels-protocols nats` + }, + { + title: 'Generate', + blurb: ( + <> + Once, or on every change with --watch. Commit the output or + generate it in CI — both work. + + ), + language: 'plaintext', + code: `codegen generate + +# keep it in sync while you edit the spec +codegen generate --watch` + } +]; + +export default function HowItWorks(): JSX.Element { + return ( +
+
+
+

Three commands

+

From spec to typed code in one sitting

+
+ +
    + {STEPS.map((step, index) => ( +
  1. +
    + +

    {step.title}

    +
    +

    {step.blurb}

    +
    + +
    +
  2. + ))} +
+ +

+ Working with an AI assistant?{' '} + + There is an MCP server and a rules file for that + + . +

+
+
+ ); +} diff --git a/website/src/components/Home/HowItWorks/styles.module.css b/website/src/components/Home/HowItWorks/styles.module.css new file mode 100644 index 00000000..b61ba604 --- /dev/null +++ b/website/src/components/Home/HowItWorks/styles.module.css @@ -0,0 +1,112 @@ +.section { + padding: 5rem 0; + border-top: 1px solid var(--cg-hairline); +} + +.intro { + max-width: 40rem; + margin: 0 auto 2.75rem; + text-align: center; +} + +.eyebrow { + margin: 0 0 0.85rem; + font-family: var(--cg-mono); + font-size: 0.78rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--cg-accent-strong); +} + +.title { + margin: 0; + font-size: clamp(1.7rem, 4vw, 2.4rem); + letter-spacing: -0.02em; +} + +.steps { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + /* Cards take their natural height: the snippets differ by seven lines, and + stretching them to match would leave the short ones with a dead gap + between the prose and the terminal block. */ + align-items: start; + gap: 1.25rem; + margin: 0; + padding: 0; + list-style: none; +} + +.step { + display: flex; + flex-direction: column; + gap: 0.7rem; + padding: 1.4rem; + border-radius: 14px; + border: 1px solid var(--ifm-color-emphasis-200); + background: var(--ifm-card-background-color); +} + +.stepHead { + display: flex; + align-items: center; + gap: 0.7rem; +} + +.stepNumber { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.85rem; + height: 1.85rem; + border-radius: 50%; + background: linear-gradient( + 140deg, + var(--cg-accent) 0%, + var(--cg-accent-2) 100% + ); + color: #04120d; + font-family: var(--cg-mono); + font-size: 0.85rem; + font-weight: 700; +} + +.stepTitle { + margin: 0; + font-size: 1.15rem; +} + +.stepBlurb { + margin: 0; + font-size: 0.9rem; + line-height: 1.6; + color: var(--ifm-color-emphasis-700); +} + +.stepBlurb code { + border: 0; + background: var(--ifm-color-emphasis-200); + font-size: 0.82rem; +} + +/* The panes are dark in both themes - they read as terminal output. */ +.stepCode { + display: flex; + border-radius: 10px; + border: 1px solid var(--cg-panel-border); + background: var(--cg-panel); + overflow: hidden; +} + +.footnote { + margin: 2rem 0 0; + text-align: center; + font-size: 0.9rem; + color: var(--ifm-color-emphasis-700); +} + +@media screen and (max-width: 996px) { + .steps { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/website/src/components/Home/Protocols/index.tsx b/website/src/components/Home/Protocols/index.tsx new file mode 100644 index 00000000..aed646e7 --- /dev/null +++ b/website/src/components/Home/Protocols/index.tsx @@ -0,0 +1,74 @@ +import Link from '@docusaurus/Link'; +import styles from './styles.module.css'; + +/** The seven protocols the `channels` preset can emit, with what they run on. */ +const PROTOCOLS: {name: string; runtime: string; href: string}[] = [ + {name: 'NATS', runtime: 'nats', href: '/docs/protocols/nats'}, + {name: 'Kafka', runtime: 'kafkajs', href: '/docs/protocols/kafka'}, + {name: 'MQTT', runtime: 'mqtt v5', href: '/docs/protocols/mqtt'}, + {name: 'AMQP', runtime: 'amqplib', href: '/docs/protocols/amqp'}, + {name: 'WebSocket', runtime: 'ws', href: '/docs/protocols/websocket'}, + { + name: 'EventSource', + runtime: 'SSE', + href: '/docs/protocols/eventsource' + }, + {name: 'HTTP', runtime: 'fetch', href: '/docs/protocols/http_client'} +]; + +function Chip({ + name, + runtime, + href, + ariaHidden = false +}: { + name: string; + runtime: string; + href: string; + ariaHidden?: boolean; +}): JSX.Element { + return ( + + {name} + {runtime} + + ); +} + +export default function Protocols(): JSX.Element { + return ( +
+
+
+

One document, every transport

+

+ Swap the broker, keep the call sites +

+

+ The channels preset emits idiomatic code per protocol — + JetStream for NATS, consumer groups for Kafka, user properties for + MQTT v5, exchanges and queues for AMQP. +

+
+
+ + {/* The track is duplicated so the loop has no visible seam; the copy is + hidden from assistive tech and taken out of the tab order. */} +
+
+ {PROTOCOLS.map((protocol) => ( + + ))} + {PROTOCOLS.map((protocol) => ( + + ))} +
+
+
+ ); +} diff --git a/website/src/components/Home/Protocols/styles.module.css b/website/src/components/Home/Protocols/styles.module.css new file mode 100644 index 00000000..2ef96634 --- /dev/null +++ b/website/src/components/Home/Protocols/styles.module.css @@ -0,0 +1,126 @@ +.section { + padding: 4.5rem 0 5rem; + background: var(--cg-section-bg); + border-top: 1px solid var(--cg-hairline); + overflow: hidden; +} + +.intro { + max-width: 44rem; + margin: 0 auto 2.5rem; + text-align: center; +} + +.eyebrow { + margin: 0 0 0.85rem; + font-family: var(--cg-mono); + font-size: 0.78rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--cg-accent-strong); +} + +.title { + margin: 0 0 0.9rem; + font-size: clamp(1.7rem, 4vw, 2.4rem); + letter-spacing: -0.02em; +} + +.lede { + margin: 0; + color: var(--ifm-color-emphasis-700); +} + +.lede code { + border: 0; + background: var(--ifm-color-emphasis-200); +} + +/* --- marquee ------------------------------------------------------------- */ + +.marquee { + position: relative; + /* Fade both ends so the loop reads as continuous motion, not a cut. */ + mask-image: linear-gradient( + to right, + transparent, + #000 8%, + #000 92%, + transparent + ); +} + +.track { + display: flex; + gap: 0.85rem; + width: max-content; + padding: 0.5rem 0; + animation: scroll 34s linear infinite; +} + +.marquee:hover .track, +.marquee:focus-within .track { + animation-play-state: paused; +} + +@keyframes scroll { + from { + transform: translate3d(0, 0, 0); + } + /* The track holds two identical copies, so half a loop is a seamless wrap. */ + to { + transform: translate3d(-50%, 0, 0); + } +} + +.chip { + display: flex; + flex-direction: column; + gap: 0.15rem; + flex: 0 0 auto; + padding: 0.85rem 1.5rem; + border-radius: 12px; + border: 1px solid var(--ifm-color-emphasis-200); + background: var(--ifm-card-background-color); + color: var(--ifm-font-color-base); + transition: + transform 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease; +} + +.chip:hover { + text-decoration: none; + color: var(--ifm-font-color-base); + transform: translateY(-3px); + border-color: var(--cg-accent-55); + box-shadow: 0 14px 30px -20px var(--cg-accent-60); +} + +.chipName { + font-size: 1.05rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +.chipRuntime { + font-family: var(--cg-mono); + font-size: 0.7rem; + color: var(--ifm-color-emphasis-600); +} + +@media (prefers-reduced-motion: reduce) { + .marquee { + mask-image: none; + overflow-x: auto; + } + + .track { + animation: none; + padding: 0.5rem 1rem; + } + + .chip:hover { + transform: none; + } +} diff --git a/website/src/components/Home/SpecToCode/index.tsx b/website/src/components/Home/SpecToCode/index.tsx new file mode 100644 index 00000000..687f1bf3 --- /dev/null +++ b/website/src/components/Home/SpecToCode/index.tsx @@ -0,0 +1,302 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import clsx from 'clsx'; +import Link from '@docusaurus/Link'; +import CodePane from '../CodePane'; +import {demos, type CodeFile} from '../demos'; +import styles from './styles.module.css'; + +/** + * Moves focus between tabs with the arrow keys, as the tab role expects. + * + * The buttons are siblings inside the tablist, so the DOM order is the tab + * order and we can walk it directly instead of threading refs per tab. + */ +function handleTablistKeys(event: React.KeyboardEvent): void { + if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') { + return; + } + const tabs = Array.from( + event.currentTarget.querySelectorAll('[role="tab"]') + ); + const current = tabs.indexOf(document.activeElement as HTMLButtonElement); + if (current === -1) { + return; + } + event.preventDefault(); + const offset = event.key === 'ArrowRight' ? 1 : -1; + tabs[(current + offset + tabs.length) % tabs.length].focus(); +} + +export default function SpecToCode(): JSX.Element { + const [demoIndex, setDemoIndex] = useState(0); + const [variantIndex, setVariantIndex] = useState(0); + const [outputIndex, setOutputIndex] = useState(0); + const [showConfig, setShowConfig] = useState(false); + /** + * Only the Regenerate button touches this. Switching tabs needs nothing here: + * a pane replays its reveal when its own content changes, so the pane the + * reader did not touch stays still. + */ + const [replayToken, setReplayToken] = useState(0); + + const demo = demos[demoIndex]; + const variant = demo.variants[variantIndex]; + const output = variant.outputs[outputIndex]; + + const inputFile: CodeFile = useMemo( + () => + showConfig + ? { + path: 'codegen.config.js', + label: 'codegen.config.js', + language: 'typescript', + code: variant.config + } + : demo.spec, + [demo.spec, showConfig, variant.config] + ); + + const selectDemo = useCallback((index: number) => { + setDemoIndex(index); + // Variants and outputs are per-demo, so anything held over would be stale. + setVariantIndex(0); + setOutputIndex(0); + }, []); + + const selectVariant = useCallback( + (index: number) => { + setVariantIndex(index); + // Output tabs line up 1:1 across a demo's variants, so the reader keeps + // looking at the same kind of file when they switch protocol. + setOutputIndex((current) => + Math.min(current, demo.variants[index].outputs.length - 1) + ); + }, + [demo.variants] + ); + + const stageRef = useRef(null); + const frameRef = useRef(null); + + /** + * Track the pointer for the border glow. The values go straight onto the + * element as custom properties - putting them in state would re-render the + * whole stage (and re-highlight both panes) on every mouse move. + */ + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + const stage = stageRef.current; + if (!stage || frameRef.current !== null) { + return; + } + const {clientX, clientY} = event; + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + const rect = stage.getBoundingClientRect(); + stage.style.setProperty('--cg-px', `${clientX - rect.left}px`); + stage.style.setProperty('--cg-py', `${clientY - rect.top}px`); + }); + }, + [] + ); + + useEffect( + () => () => { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + } + }, + [] + ); + + return ( +
+
+
+

Spec in, code out

+

+ Your document is already the source of truth. +
+ Stop typing it twice. +

+

+ Pick an input, pick a protocol, and see the code you would write — + plus every generated file standing behind it. Nothing here is mocked + up. +

+
+ +
+
+
+ {demos.map((item, index) => ( + + ))} +
+ + +
+ +
+
+
+
+ + +
+
+ +
+ + + +
+
+
+ {variant.outputs.map((file, index) => ( + + ))} +
+
+
+ {output.path} + + {output.handWritten ? 'you write this' : 'generated'} + +
+ +
+
+ +
+ {demo.variants.length > 1 && ( +
+ {demo.variantLabel} + {demo.variants.map((item, index) => ( + + ))} +
+ )} +
+ + runs on {variant.runtime} + + + Try your own spec in the playground → + +
+
+
+
+
+ ); +} diff --git a/website/src/components/Home/SpecToCode/styles.module.css b/website/src/components/Home/SpecToCode/styles.module.css new file mode 100644 index 00000000..73359ffb --- /dev/null +++ b/website/src/components/Home/SpecToCode/styles.module.css @@ -0,0 +1,499 @@ +.section { + padding: 5rem 0 6rem; + background: var(--cg-section-bg); + border-top: 1px solid var(--cg-hairline); +} + +/* --- intro copy ---------------------------------------------------------- */ + +.intro { + max-width: 46rem; + margin: 0 auto 2.75rem; + text-align: center; +} + +.eyebrow { + margin: 0 0 0.85rem; + font-family: var(--cg-mono); + font-size: 0.78rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--cg-accent-strong); +} + +.title { + margin: 0 0 1rem; + font-size: clamp(1.7rem, 4vw, 2.6rem); + line-height: 1.15; + letter-spacing: -0.02em; +} + +.titleAccent { + background: linear-gradient( + 100deg, + var(--cg-accent) 0%, + var(--cg-accent-2) 55%, + var(--cg-accent-3) 100% + ); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; +} + +.lede { + margin: 0; + font-size: 1.05rem; + color: var(--ifm-color-emphasis-700); +} + +/* --- the stage ----------------------------------------------------------- */ + +.stage { + position: relative; + border-radius: 18px; + background: var(--cg-panel); + border: 1px solid var(--cg-panel-border); + box-shadow: + 0 1px 0 rgb(255 255 255 / 6%) inset, + 0 24px 70px -28px rgb(0 0 0 / 65%); + overflow: hidden; + isolation: isolate; +} + +/* Pointer-tracked glow. Purely decorative, sits under the content. */ +.stage::before { + content: ''; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background: radial-gradient( + 340px circle at var(--cg-px, 50%) var(--cg-py, 0%), + var(--cg-accent-16), + transparent 70% + ); + opacity: 0; + transition: opacity 300ms ease; +} + +.stage:hover::before { + opacity: 1; +} + +.stageTop, +.stageBottom { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + padding: 0.85rem 1rem; +} + +.stageTop { + justify-content: space-between; + border-bottom: 1px solid var(--cg-panel-border); + background: var(--cg-panel-raised); +} + +.stageBottom { + justify-content: space-between; + border-top: 1px solid var(--cg-panel-border); + background: var(--cg-panel-raised); +} + +/* --- input tabs ---------------------------------------------------------- */ + +.inputTabs { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} + +.inputTab { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.1rem; + padding: 0.45rem 0.85rem; + border: 1px solid transparent; + border-radius: 10px; + background: transparent; + color: var(--cg-panel-text-dim); + font-size: 0.92rem; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + transition: + background 160ms ease, + color 160ms ease, + border-color 160ms ease; +} + +.inputTab:hover { + color: var(--cg-panel-text); + background: var(--cg-panel-hover); +} + +.inputTabActive, +.inputTabActive:hover { + color: var(--cg-panel-text); + background: var(--cg-accent-16); + border-color: var(--cg-accent-45); +} + +.inputTabVersions { + font-family: var(--cg-mono); + font-size: 0.66rem; + font-weight: 400; + letter-spacing: 0.02em; + color: var(--cg-panel-text-faint); +} + +.replay { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.85rem; + border-radius: 999px; + border: 1px solid var(--cg-panel-border-strong); + background: transparent; + color: var(--cg-panel-text-dim); + font-family: var(--cg-mono); + font-size: 0.76rem; + cursor: pointer; + transition: + color 160ms ease, + border-color 160ms ease, + background 160ms ease; +} + +.replay:hover { + color: var(--cg-accent); + border-color: var(--cg-accent-55); + background: var(--cg-accent-10); +} + +/* --- panels -------------------------------------------------------------- */ + +.panels { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1.05fr); + align-items: stretch; +} + +.panel { + display: flex; + flex-direction: column; + min-width: 0; + height: 27rem; +} + +.panelOut { + border-left: 1px solid var(--cg-panel-border); + background: var(--cg-panel-out); +} + +.panelHead { + display: flex; + align-items: center; + min-height: 2.4rem; + padding: 0 0.5rem; + border-bottom: 1px solid var(--cg-panel-border); + overflow-x: auto; + scrollbar-width: none; +} + +.panelHead::-webkit-scrollbar { + display: none; +} + +.fileTabs { + display: flex; + gap: 0.15rem; +} + +.fileTab { + position: relative; + padding: 0.55rem 0.7rem; + border: 0; + background: transparent; + color: var(--cg-panel-text-faint); + font-family: var(--cg-mono); + font-size: 0.74rem; + white-space: nowrap; + cursor: pointer; + transition: color 140ms ease; +} + +.fileTab:hover { + color: var(--cg-panel-text); +} + +.fileTabActive, +.fileTabActive:hover { + color: var(--cg-panel-text); +} + +.fileTabActive::after { + content: ''; + position: absolute; + inset: auto 0.5rem -1px; + height: 2px; + border-radius: 2px 2px 0 0; + background: var(--cg-accent); +} + +.fileTabYours.fileTabActive::after { + background: var(--cg-accent-3); +} + +.pathBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem 0.85rem 0; +} + +.pathBar code { + background: none; + border: 0; + padding: 0; + font-family: var(--cg-mono); + font-size: 0.7rem; + color: var(--cg-panel-text-faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pathTag { + flex: 0 0 auto; + padding: 0.1rem 0.5rem; + border-radius: 999px; + border: 1px solid var(--cg-accent-45); + background: var(--cg-accent-12); + color: var(--cg-accent); + font-family: var(--cg-mono); + font-size: 0.62rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.pathTagYours { + border-color: var(--cg-accent-3-45); + background: var(--cg-accent-3-14); + color: var(--cg-accent-3); +} + +/* --- the bridge between panels ------------------------------------------- */ + +.bridge { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + width: 3.25rem; + padding: 1.5rem 0; + border-left: 1px solid var(--cg-panel-border); + background: var(--cg-panel-raised); +} + +.bridgeLine { + flex: 1; + width: 1px; + background: linear-gradient( + to bottom, + transparent, + var(--cg-panel-border-strong), + transparent + ); +} + +.bridgeChip { + writing-mode: vertical-rl; + padding: 0.6rem 0.25rem; + border-radius: 999px; + border: 1px solid var(--cg-accent-45); + background: var(--cg-accent-12); + color: var(--cg-accent); + font-family: var(--cg-mono); + font-size: 0.64rem; + letter-spacing: 0.14em; + text-transform: uppercase; + animation: bridgePulse 3.2s ease-in-out infinite; +} + +@keyframes bridgePulse { + 0%, + 100% { + box-shadow: 0 0 0 0 var(--cg-accent-30); + } + 50% { + box-shadow: 0 0 0 7px transparent; + } +} + +/* --- variant pills ------------------------------------------------------- */ + +.pills { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.pillsLabel { + margin-right: 0.25rem; + font-family: var(--cg-mono); + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--cg-panel-text-faint); +} + +.pill { + padding: 0.32rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--cg-panel-border-strong); + background: transparent; + color: var(--cg-panel-text-dim); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: + color 150ms ease, + background 150ms ease, + border-color 150ms ease, + transform 150ms ease; +} + +.pill:hover { + color: var(--cg-panel-text); + border-color: var(--cg-accent-45); + transform: translateY(-1px); +} + +.pillActive, +.pillActive:hover { + color: #04120d; + background: var(--cg-accent); + border-color: var(--cg-accent); + transform: none; +} + +.stageMeta { + display: flex; + align-items: center; + gap: 1.1rem; + flex-wrap: wrap; +} + +.runtime { + font-size: 0.8rem; + color: var(--cg-panel-text-faint); +} + +.runtime code { + background: var(--cg-panel-hover); + border: 1px solid var(--cg-panel-border); + color: var(--cg-panel-text-dim); + font-size: 0.72rem; + padding: 0.1rem 0.4rem; +} + +.playgroundLink { + font-size: 0.85rem; + font-weight: 600; + color: var(--cg-accent); +} + +.playgroundLink:hover { + color: var(--cg-accent-2); + text-decoration: none; +} + +/* --- responsive ---------------------------------------------------------- */ + +@media screen and (max-width: 996px) { + .panels { + grid-template-columns: minmax(0, 1fr); + } + + .panel { + height: 21rem; + } + + .panelOut { + border-left: 0; + border-top: 1px solid var(--cg-panel-border); + } + + .bridge { + flex-direction: row; + width: auto; + padding: 0 1rem; + border-left: 0; + border-top: 1px solid var(--cg-panel-border); + } + + .bridgeLine { + width: auto; + height: 1px; + background: linear-gradient( + to right, + transparent, + var(--cg-panel-border-strong), + transparent + ); + } + + .bridgeChip { + writing-mode: horizontal-tb; + padding: 0.25rem 0.7rem; + } + + .stageBottom { + flex-direction: column; + align-items: flex-start; + } +} + +@media screen and (max-width: 576px) { + /* Three stacked two-line tabs eat most of a phone screen before any code is + visible, so drop to a single scrollable row of labels. */ + .inputTabs { + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: none; + } + + .inputTabs::-webkit-scrollbar { + display: none; + } + + .inputTab { + flex: 0 0 auto; + white-space: nowrap; + } + + .inputTabVersions { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .bridgeChip { + animation: none; + } + + .pill:hover, + .inputTab:hover { + transform: none; + } +} diff --git a/website/src/components/Home/demos.ts b/website/src/components/Home/demos.ts new file mode 100644 index 00000000..e89ca0d9 --- /dev/null +++ b/website/src/components/Home/demos.ts @@ -0,0 +1,1206 @@ +/** + * Content for the homepage "spec in -> code out" stage. + * + * Everything here is checked against reality rather than written from memory: + * the specs and configs below are run through the CLI, the generated snippets + * must appear verbatim in what it emits, and the hand-written `index.ts` + * snippets must compile against that output. Long bodies are elided with + * `// ...`; nothing is invented. + * + * Keep it that way. This is the first API surface most people ever read, so a + * signature that drifts from what the generator emits is a broken promise. Note + * how specific the output is to the document: drop the `headers` off a message + * and the `headers` parameter disappears from every channel function, so a + * snippet cannot be copied across from another spec. + */ + +/** + * Languages the code panes can highlight. + * + * Limited to what `prism-react-renderer` bundles - notably there is no `bash` + * grammar, so shell snippets use `plaintext` rather than being mislabelled as + * something that highlights `#` as an operator. + */ +export type Language = 'yaml' | 'json' | 'typescript' | 'plaintext'; + +export interface CodeFile { + /** Path shown in the pane's tab strip. */ + path: string; + /** Short tab label - the file name is often too long to fit. */ + label: string; + language: Language; + code: string; + /** + * Marks a tab as hand-written rather than generated, so the stage can label it + * "you write this" - and so the verification script knows to compile it rather + * than match it line-for-line against generated output. + */ + handWritten?: boolean; +} + +/** A switchable output flavour: a protocol for AsyncAPI, a preset for OpenAPI. */ +export interface Variant { + id: string; + label: string; + /** What the generated code talks to, shown next to the pills. */ + runtime: string; + config: string; + outputs: CodeFile[]; +} + +export interface Demo { + id: 'asyncapi' | 'openapi' | 'jsonschema'; + label: string; + /** Versions supported for this input, shown as a caption under the tabs. */ + versions: string; + spec: CodeFile; + /** What the pill row is selecting - "Protocol" or "Preset". */ + variantLabel: string; + variants: Variant[]; +} + +// --------------------------------------------------------------------------- +// AsyncAPI +// --------------------------------------------------------------------------- + +const ASYNCAPI_SPEC: CodeFile = { + path: 'asyncapi.yaml', + label: 'asyncapi.yaml', + language: 'yaml', + code: `asyncapi: 3.0.0 +info: + title: E-commerce Order Events + version: 1.0.0 + +channels: + order-lifecycle: + address: orders.{action} + parameters: + action: + enum: [created, updated, cancelled] + description: Order lifecycle action + messages: + OrderCreated: + payload: + type: object + required: [orderId, customerId, items, totalAmount] + properties: + orderId: + type: string + format: uuid + customerId: + type: string + format: uuid + items: + type: array + items: + $ref: '#/components/schemas/OrderItem' + totalAmount: + $ref: '#/components/schemas/Money' + createdAt: + type: string + format: date-time + +operations: + publishOrderCreated: + action: send + channel: + $ref: '#/channels/order-lifecycle' + subscribeToOrderEvents: + action: receive + channel: + $ref: '#/channels/order-lifecycle' + +components: + schemas: + Money: + type: object + required: [amount, currency] + properties: + amount: + type: number + currency: + type: string + enum: [USD, EUR, GBP] + OrderItem: + type: object + required: [productId, quantity, unitPrice] + properties: + productId: + type: string + quantity: + type: integer + unitPrice: + $ref: '#/components/schemas/Money'` +}; + +function asyncapiConfig(protocol: string): string { + return `export default { + inputType: 'asyncapi', + inputPath: './asyncapi.yaml', + generators: [ + { + preset: 'channels', + outputPath: './src/__gen__', + language: 'typescript', + protocols: ['${protocol}'] + } + ] +};`; +} + +/** Shared by every protocol - the payload model does not vary with transport. */ +const ORDER_CREATED_MODEL: CodeFile = { + path: 'src/__gen__/payload/OrderCreated.ts', + label: 'OrderCreated.ts', + language: 'typescript', + code: `import {OrderItem} from './OrderItem'; +import {Money} from './Money'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface OrderCreatedInterface { + orderId: string + customerId: string + items: OrderItem[] + totalAmount: Money + createdAt?: Date + additionalProperties?: Record +} +class OrderCreated { + private _orderId: string; + private _customerId: string; + private _items: OrderItem[]; + private _totalAmount: Money; + private _createdAt?: Date; + private _additionalProperties?: Record; + + constructor(input: OrderCreatedInterface) { + this._orderId = input.orderId; + this._customerId = input.customerId; + this._items = input.items; + this._totalAmount = input.totalAmount; + // ... + } + + get orderId(): string { return this._orderId; } + set orderId(orderId: string) { this._orderId = orderId; } + // ... + + public toJson(): Record { + // ... + public marshal(): string { + // ... + public static unmarshal(json: string | object): OrderCreated { + // ... + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + // ... +} +export { OrderCreated }; +export type { OrderCreatedInterface };` +}; + +/** Shared by every protocol - parameters come from the channel address. */ +const ORDER_PARAMETERS_MODEL: CodeFile = { + path: 'src/__gen__/parameter/OrderLifecycleParameters.ts', + label: 'OrderLifecycleParameters.ts', + language: 'typescript', + code: `import {Action} from './Action'; +interface OrderLifecycleParametersInterface { + action: Action +} +class OrderLifecycleParameters { + private _action: Action; + + constructor(input: OrderLifecycleParametersInterface) { + this._action = input.action; + } + + /** + * Order lifecycle action + */ + get action(): Action { return this._action; } + set action(action: Action) { this._action = action; } + + + /** + * Realize the channel/topic with the parameters added to this class. + */ + public getChannelWithParameters(channel: string) { + channel = channel.replace(/\\{action\\}/g, this.action); + return channel; + } + + public static createFromChannel(msgSubject: string, channel: string, regex: RegExp): OrderLifecycleParameters { + // ... +} +export { OrderLifecycleParameters }; +export type { OrderLifecycleParametersInterface };` +}; + +/** + * The publish payload every AsyncAPI usage snippet builds. + * + * `items` and `totalAmount` are typed as the generated *classes*, so they have + * to be constructed - an object literal will not satisfy a type with private + * fields. That is the kind of detail these snippets exist to show. + */ +const ORDER_MESSAGE_LITERAL = ` message: { + orderId: '3f0c9e1a-6f1e-4a5b-9c22-8c0a7f1d2e33', + customerId: '9ab1c7d4-2e55-4f01-8a0f-1d4b6e9c0a12', + items: [ + new OrderItem({productId: 'CG-1', quantity: 2, unitPrice: eur(21)}) + ], + totalAmount: eur(42) + }, + // type Action = 'created' | 'updated' | 'cancelled' + parameters: {action: 'created'},`; + +const PAYLOAD_IMPORTS = `import {OrderItem} from './__gen__/payload/OrderItem'; +import {Money} from './__gen__/payload/Money'; +import {MoneyCurrencyEnum} from './__gen__/payload/MoneyCurrencyEnum';`; + +/** Keeps the nested model construction from burying the call being demonstrated. */ +const MONEY_HELPER = `// \`items\` and \`totalAmount\` are typed as the generated models, not as plain +// objects - a type with private fields will not accept an object literal. +const eur = (amount: number) => + new Money({amount, currency: MoneyCurrencyEnum.EUR});`; + +const ASYNCAPI_VARIANTS: Variant[] = [ + { + id: 'nats', + label: 'NATS', + runtime: 'nats', + config: asyncapiConfig('nats'), + outputs: [ + { + path: 'src/__gen__/nats.ts', + label: 'nats.ts', + language: 'typescript', + code: `import {OrderCreated, OrderCreatedInterface} from './payload/OrderCreated'; +import {OrderItem, OrderItemInterface} from './payload/OrderItem'; +import {Money, MoneyInterface} from './payload/Money'; +import {MoneyCurrencyEnum} from './payload/MoneyCurrencyEnum'; +import {OrderLifecycleParameters, OrderLifecycleParametersInterface} from './parameter/OrderLifecycleParameters'; +import * as Nats from 'nats'; + +/** + * NATS publish operation for \`orders.{action}\` + * + * @param message to publish + * @param parameters for topic substitution + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function publishToPublishOrderCreated({ + message, + parameters, + nc, + codec = Nats.JSONCodec(), + options +}: { + message: OrderCreatedInterface | OrderCreated, + parameters: OrderLifecycleParametersInterface | OrderLifecycleParameters, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.PublishOptions +}): Promise { + // ... +} + +export { publishToPublishOrderCreated, jetStreamPublishToPublishOrderCreated, subscribeToSubscribeToOrderEvents, jetStreamPullSubscribeToSubscribeToOrderEvents, jetStreamPushSubscriptionFromSubscribeToOrderEvents };` + }, + ORDER_CREATED_MODEL, + ORDER_PARAMETERS_MODEL, + { + path: 'src/index.ts', + label: 'index.ts', + language: 'typescript', + handWritten: true, + code: `import {connect} from 'nats'; +import {publishToPublishOrderCreated} from './__gen__/nats'; +${PAYLOAD_IMPORTS} + +${MONEY_HELPER} + +const nc = await connect({servers: 'localhost:4222'}); + +// Fully typed. Rename a field in the spec and this stops compiling. +await publishToPublishOrderCreated({ +${ORDER_MESSAGE_LITERAL} + nc +});` + } + ] + }, + { + id: 'kafka', + label: 'Kafka', + runtime: 'kafkajs', + config: asyncapiConfig('kafka'), + outputs: [ + { + path: 'src/__gen__/kafka.ts', + label: 'kafka.ts', + language: 'typescript', + code: `import {OrderCreated, OrderCreatedInterface} from './payload/OrderCreated'; +import {OrderItem, OrderItemInterface} from './payload/OrderItem'; +import {Money, MoneyInterface} from './payload/Money'; +import {MoneyCurrencyEnum} from './payload/MoneyCurrencyEnum'; +import {OrderLifecycleParameters, OrderLifecycleParametersInterface} from './parameter/OrderLifecycleParameters'; +import * as Kafka from 'kafkajs'; + +/** + * Kafka publish operation for \`orders.{action}\` + * + * @param message to publish + * @param parameters for topic substitution + * @param kafka the KafkaJS client to publish from + */ +function produceToPublishOrderCreated({ + message, + parameters, + kafka +}: { + message: OrderCreatedInterface | OrderCreated, + parameters: OrderLifecycleParametersInterface | OrderLifecycleParameters, + kafka: Kafka.Kafka +}): Promise { + // ... +} + +export { produceToPublishOrderCreated, consumeFromSubscribeToOrderEvents };` + }, + ORDER_CREATED_MODEL, + ORDER_PARAMETERS_MODEL, + { + path: 'src/index.ts', + label: 'index.ts', + language: 'typescript', + handWritten: true, + code: `import {Kafka} from 'kafkajs'; +import {produceToPublishOrderCreated} from './__gen__/kafka'; +${PAYLOAD_IMPORTS} + +${MONEY_HELPER} + +const kafka = new Kafka({brokers: ['localhost:9092']}); + +// The channel address \`orders.{action}\` becomes the topic \`orders.created\`. +const producer = await produceToPublishOrderCreated({ +${ORDER_MESSAGE_LITERAL} + kafka +}); + +await producer.disconnect();` + } + ] + }, + { + id: 'mqtt', + label: 'MQTT', + runtime: 'mqtt v5', + config: asyncapiConfig('mqtt'), + outputs: [ + { + path: 'src/__gen__/mqtt.ts', + label: 'mqtt.ts', + language: 'typescript', + code: `import {OrderCreated, OrderCreatedInterface} from './payload/OrderCreated'; +import {OrderItem, OrderItemInterface} from './payload/OrderItem'; +import {Money, MoneyInterface} from './payload/Money'; +import {MoneyCurrencyEnum} from './payload/MoneyCurrencyEnum'; +import {OrderLifecycleParameters, OrderLifecycleParametersInterface} from './parameter/OrderLifecycleParameters'; +import * as Mqtt from 'mqtt'; + +/** + * MQTT subscription for \`orders.{action}\` + * + * @param onDataCallback to call when messages are received + * @param parameters for topic substitution + * @param mqtt the MQTT client to subscribe with + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function subscribeToSubscribeToOrderEvents({ + onDataCallback, + parameters, + mqtt, + skipMessageValidation = false +}: { + onDataCallback: (params: {err?: Error, msg?: OrderCreated, parameters?: OrderLifecycleParameters, mqttMsg?: Mqtt.IPublishPacket}) => void, + parameters: OrderLifecycleParametersInterface | OrderLifecycleParameters, + mqtt: Mqtt.MqttClient, + skipMessageValidation?: boolean +}): Promise { + // Check if the received topic matches this subscription's pattern + const topicPattern = /^orders.([^.]*)$/; + // ... +} + +export { publishToPublishOrderCreated, subscribeToSubscribeToOrderEvents };` + }, + ORDER_CREATED_MODEL, + ORDER_PARAMETERS_MODEL, + { + path: 'src/index.ts', + label: 'index.ts', + language: 'typescript', + handWritten: true, + code: `import {connectAsync} from 'mqtt'; +import {subscribeToSubscribeToOrderEvents} from './__gen__/mqtt'; + +// MQTT channel code requires protocol v5. +const mqtt = await connectAsync('mqtt://localhost:1883', {protocolVersion: 5}); + +await subscribeToSubscribeToOrderEvents({ + onDataCallback: ({err, msg, parameters}) => { + if (err) { + return console.error(err); + } + // Already unmarshalled into the payload model, and validated on the way in. + console.log(parameters?.action, msg?.orderId, msg?.totalAmount.amount); + }, + parameters: {action: 'created'}, + mqtt +});` + } + ] + }, + { + id: 'amqp', + label: 'AMQP', + runtime: 'amqplib', + config: asyncapiConfig('amqp'), + outputs: [ + { + path: 'src/__gen__/amqp.ts', + label: 'amqp.ts', + language: 'typescript', + code: `import {OrderCreated, OrderCreatedInterface} from './payload/OrderCreated'; +import {OrderItem, OrderItemInterface} from './payload/OrderItem'; +import {Money, MoneyInterface} from './payload/Money'; +import {MoneyCurrencyEnum} from './payload/MoneyCurrencyEnum'; +import {OrderLifecycleParameters, OrderLifecycleParametersInterface} from './parameter/OrderLifecycleParameters'; +import * as Amqp from 'amqplib'; + +/** + * AMQP publish operation for exchange \`orders.{action}\` + * + * @param message to publish + * @param parameters for topic substitution + * @param amqp the AMQP connection to send over + * @param options for the AMQP publish exchange operation + */ +function publishToPublishOrderCreatedExchange({ + message, + parameters, + amqp, + options +}: { + message: OrderCreatedInterface | OrderCreated, + parameters: OrderLifecycleParametersInterface | OrderLifecycleParameters, + amqp: Amqp.Connection, + options?: {exchange: string | undefined} & Amqp.Options.Publish +}): Promise { + // ... +} + +export { publishToPublishOrderCreatedExchange, publishToPublishOrderCreatedQueue, subscribeToSubscribeToOrderEventsQueue };` + }, + ORDER_CREATED_MODEL, + ORDER_PARAMETERS_MODEL, + { + path: 'src/index.ts', + label: 'index.ts', + language: 'typescript', + handWritten: true, + code: `import * as Amqp from 'amqplib'; +import {publishToPublishOrderCreatedExchange} from './__gen__/amqp'; +${PAYLOAD_IMPORTS} + +${MONEY_HELPER} + +const amqp = await Amqp.connect('amqp://localhost:5672'); + +// The channel address becomes the routing key; you pick the exchange. +await publishToPublishOrderCreatedExchange({ +${ORDER_MESSAGE_LITERAL} + amqp, + options: {exchange: 'orders'} +});` + } + ] + }, + { + id: 'websocket', + label: 'WebSocket', + runtime: 'ws', + config: asyncapiConfig('websocket'), + outputs: [ + { + path: 'src/__gen__/websocket.ts', + label: 'websocket.ts', + language: 'typescript', + code: `import {OrderCreated, OrderCreatedInterface} from './payload/OrderCreated'; +import {OrderItem, OrderItemInterface} from './payload/OrderItem'; +import {Money, MoneyInterface} from './payload/Money'; +import {MoneyCurrencyEnum} from './payload/MoneyCurrencyEnum'; +import {OrderLifecycleParameters, OrderLifecycleParametersInterface} from './parameter/OrderLifecycleParameters'; +import * as WebSocket from 'ws'; +import { IncomingMessage } from 'http'; + +/** + * WebSocket client-side function to subscribe to messages from \`/orders.{action}\` + * + * @param onDataCallback callback when messages are received + * @param parameters for URL path substitution + * @param ws the WebSocket connection (assumed to be already connected) + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function subscribeToSubscribeToOrderEvents({ + onDataCallback, + parameters, + ws, + skipMessageValidation = false +}: { + onDataCallback: (params: {err?: Error, msg?: OrderCreated, parameters?: OrderLifecycleParameters, ws?: WebSocket.WebSocket}) => void, + parameters: OrderLifecycleParametersInterface | OrderLifecycleParameters, + ws: WebSocket.WebSocket, + skipMessageValidation?: boolean +}): void { + // ... +} + +export { publishToPublishOrderCreated, registerPublishOrderCreated, subscribeToSubscribeToOrderEvents };` + }, + ORDER_CREATED_MODEL, + ORDER_PARAMETERS_MODEL, + { + path: 'src/index.ts', + label: 'index.ts', + language: 'typescript', + handWritten: true, + code: `import * as WebSocket from 'ws'; +import {subscribeToSubscribeToOrderEvents} from './__gen__/websocket'; + +const ws = new WebSocket.WebSocket('ws://localhost:8080/orders.created'); + +ws.on('open', () => { + subscribeToSubscribeToOrderEvents({ + onDataCallback: ({err, msg}) => { + if (err) { + return console.error(err); + } + console.log(msg?.orderId, msg?.totalAmount.amount); + }, + parameters: {action: 'created'}, + ws + }); +});` + } + ] + }, + { + id: 'event_source', + label: 'EventSource', + runtime: 'fetch-event-source', + config: asyncapiConfig('event_source'), + outputs: [ + { + path: 'src/__gen__/event_source.ts', + label: 'event_source.ts', + language: 'typescript', + code: `import {OrderCreated, OrderCreatedInterface} from './payload/OrderCreated'; +import {OrderItem, OrderItemInterface} from './payload/OrderItem'; +import {Money, MoneyInterface} from './payload/Money'; +import {MoneyCurrencyEnum} from './payload/MoneyCurrencyEnum'; +import {OrderLifecycleParameters, OrderLifecycleParametersInterface} from './parameter/OrderLifecycleParameters'; +import { NextFunction, Request, Response, Router } from 'express'; +import { fetchEventSource, EventStreamContentType, EventSourceMessage } from '@microsoft/fetch-event-source'; + +/** + * Event source fetch for \`orders.{action}\` + * + * @param callback to call when receiving events + * @param parameters for listening + * @param options additionally used to handle the event source + * @param skipMessageValidation turn off runtime validation of incoming messages + * @returns A cleanup function to abort the connection + */ +function listenForSubscribeToOrderEvents({ + callback, + parameters, + options, + skipMessageValidation = false +}: { + callback: (params: {error?: Error, messageEvent?: OrderCreated}) => void, + parameters: OrderLifecycleParametersInterface | OrderLifecycleParameters, + options: {authorization?: string, onClose?: (err?: string) => void, baseUrl: string, headers?: Record}, + skipMessageValidation?: boolean +}): (() => void) { + // ... +} + +export { registerPublishOrderCreated, listenForSubscribeToOrderEvents };` + }, + ORDER_CREATED_MODEL, + ORDER_PARAMETERS_MODEL, + { + path: 'src/index.ts', + label: 'index.ts', + language: 'typescript', + handWritten: true, + code: `import {listenForSubscribeToOrderEvents} from './__gen__/event_source'; + +// Returns a cleanup function - call it to abort the stream. +const stop = listenForSubscribeToOrderEvents({ + callback: ({error, messageEvent}) => { + if (error) { + return console.error(error); + } + console.log('order', messageEvent?.orderId); + }, + parameters: {action: 'created'}, + options: { + baseUrl: 'https://api.example.com', + authorization: process.env.API_TOKEN + } +}); + +process.on('SIGINT', stop);` + } + ] + } +]; + +// --------------------------------------------------------------------------- +// OpenAPI +// --------------------------------------------------------------------------- + +const OPENAPI_SPEC: CodeFile = { + path: 'openapi.json', + label: 'openapi.json', + language: 'json', + code: `{ + "openapi": "3.1.0", + "info": { + "title": "Orders API", + "version": "1.0.0" + }, + "paths": { + "/orders": { + "post": { + "operationId": "createOrder", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["customerId", "items"], + "properties": { + "customerId": {"type": "string", "format": "uuid"}, + "items": { + "type": "array", + "items": {"$ref": "#/components/schemas/OrderItem"} + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The created order", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Order"} + } + } + } + } + } + }, + "/orders/{orderId}": { + "get": { + "operationId": "getOrder", + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": {"type": "string", "format": "uuid"} + } + ], + "responses": { + "200": { + "description": "The order", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Order"} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "OrderItem": { + "type": "object", + "required": ["productId", "quantity"], + "properties": { + "productId": {"type": "string"}, + "quantity": {"type": "integer"} + } + }, + "Order": { + "type": "object", + "required": ["orderId", "status"], + "properties": { + "orderId": {"type": "string", "format": "uuid"}, + "status": { + "type": "string", + "enum": ["pending", "paid", "shipped"] + } + } + } + } + } +}` +}; + +const OPENAPI_CHANNELS_CONFIG = `export default { + inputType: 'openapi', + inputPath: './openapi.json', + generators: [ + { + preset: 'channels', + outputPath: './src/__gen__', + language: 'typescript', + protocols: ['http_client'] + } + ] +};`; + +/** + * The `client` preset wraps the channel functions, so it needs the `channels` + * generator alongside it and a `channelsGeneratorId` pointing at it. Drop either + * and the client generator writes "No protocols generated" instead of a class - + * so both entries have to stay in this snippet. + */ +const OPENAPI_CLIENT_CONFIG = `export default { + inputType: 'openapi', + inputPath: './openapi.json', + generators: [ + { + preset: 'channels', + outputPath: './src/__gen__', + language: 'typescript', + protocols: ['http_client'] + }, + { + preset: 'client', + outputPath: './src/__gen__/client', + language: 'typescript', + protocols: ['http'], + channelsGeneratorId: 'channels-typescript' + } + ] +};`; + +const OPENAPI_REQUEST_MODEL: CodeFile = { + path: 'src/__gen__/payload/CreateOrderRequest.ts', + label: 'CreateOrderRequest.ts', + language: 'typescript', + code: `import {OrderItem} from './OrderItem'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface CreateOrderRequestInterface { + customerId: string + items: OrderItem[] + additionalProperties?: Record +} +class CreateOrderRequest { + private _customerId: string; + private _items: OrderItem[]; + private _additionalProperties?: Record; + + constructor(input: CreateOrderRequestInterface) { + this._customerId = input.customerId; + this._items = input.items; + this._additionalProperties = input.additionalProperties; + } + + get customerId(): string { return this._customerId; } + set customerId(customerId: string) { this._customerId = customerId; } + + get items(): OrderItem[] { return this._items; } + set items(items: OrderItem[]) { this._items = items; } + // ... +} +export { CreateOrderRequest }; +export type { CreateOrderRequestInterface };` +}; + +const OPENAPI_VARIANTS: Variant[] = [ + { + id: 'client', + label: 'client (one class)', + runtime: 'fetch', + config: OPENAPI_CLIENT_CONFIG, + outputs: [ + { + path: 'src/__gen__/client/OrdersClient.ts', + label: 'OrdersClient.ts', + language: 'typescript', + code: `//Import channel functions +import * as http_client from './../http_client'; + +/** + * @class OrdersClient + * + * A fully-typed HTTP client for the Orders API. Construct it once with the shared request configuration + * (baseUrl, auth, hooks, ...) and call the operation methods; every method + * forwards to the underlying channel function with that configuration applied. + */ +export class OrdersClient { + /** + * @param config shared HTTP configuration applied to every request. Any field + * can be overridden per call through the method's context argument. + */ + constructor(private readonly config: http_client.HttpClientContext = {}) {} + + /** + * Invokes the \`createOrder\` operation using this client's shared configuration. + * + * @param context per-call request context; overrides any field set on the client. + */ + public async createOrder(context: http_client.CreateOrderContext): Promise>> { + return http_client.createOrder({...this.config, ...context}); + } + + /** + * Invokes the \`getOrder\` operation using this client's shared configuration. + * + * @param context per-call request context; overrides any field set on the client. + */ + public async getOrder(context: http_client.GetOrderContext): Promise>> { + return http_client.getOrder({...this.config, ...context}); + } +}` + }, + OPENAPI_REQUEST_MODEL, + { + path: 'src/index.ts', + label: 'index.ts', + language: 'typescript', + handWritten: true, + code: `import {OrdersClient} from './__gen__/client/OrdersClient'; +import {OrderItem} from './__gen__/payload/OrderItem'; + +// Configure once - baseUrl, auth, retries and hooks apply to every call. +const client = new OrdersClient({ + baseUrl: 'https://api.example.com', + auth: {type: 'bearer', token: process.env.API_TOKEN!}, + retry: {maxRetries: 3} +}); + +const {data, status} = await client.createOrder({ + payload: { + customerId: '9ab1c7d4-2e55-4f01-8a0f-1d4b6e9c0a12', + items: [new OrderItem({productId: 'CG-1', quantity: 2})] + } +}); + +console.log(status, data.orderId); + +// Path parameters go through the generated parameter model. +const order = await client.getOrder({ + parameters: {orderId: data.orderId} +}); + +console.log(order.data.status);` + } + ] + }, + { + id: 'channels', + label: 'channels (functions)', + runtime: 'fetch', + config: OPENAPI_CHANNELS_CONFIG, + outputs: [ + { + path: 'src/__gen__/http_client.ts', + label: 'http_client.ts', + language: 'typescript', + code: `/** + * Rich response wrapper returned by HTTP client functions + */ +export interface HttpClientResponse { + /** The deserialized response payload */ + data: T; + /** HTTP status code */ + status: number; + /** HTTP status text */ + statusText: string; + /** Response headers */ + headers: Record; + /** Raw JSON response before deserialization */ + rawData: Record; +} + // ... +export interface HttpClientContext { + baseUrl?: string; + + // Authentication - grouped for better autocomplete + auth?: AuthConfig; + + // Retry configuration + retry?: RetryConfig; + + // Hooks for extensibility + hooks?: HttpHooks; + + // Additional options + additionalHeaders?: Record; + + // Extra query parameters not covered by the typed parameters interface + additionalQueryParams?: Record; +} + // ... +export interface CreateOrderContext extends HttpClientContext { + payload: CreateOrderRequestInterface | CreateOrderRequest; +} + // ... +async function createOrder(context: CreateOrderContext): Promise> { + // ... +} + +export { createOrder, getOrder };` + }, + OPENAPI_REQUEST_MODEL, + { + path: 'src/index.ts', + label: 'index.ts', + language: 'typescript', + handWritten: true, + code: `import {createOrder, getOrder} from './__gen__/http_client'; +import {OrderItem} from './__gen__/payload/OrderItem'; + +// Standalone functions - each call carries its own context. No client object. +const {data} = await createOrder({ + baseUrl: 'https://api.example.com', + auth: {type: 'bearer', token: process.env.API_TOKEN!}, + payload: { + customerId: '9ab1c7d4-2e55-4f01-8a0f-1d4b6e9c0a12', + items: [new OrderItem({productId: 'CG-1', quantity: 2})] + } +}); + +const order = await getOrder({ + baseUrl: 'https://api.example.com', + parameters: {orderId: data.orderId} +}); + +console.log(order.data.status);` + } + ] + } +]; + +// --------------------------------------------------------------------------- +// JSON Schema +// --------------------------------------------------------------------------- + +const JSONSCHEMA_SPEC: CodeFile = { + path: 'user-schema.json', + label: 'user-schema.json', + language: 'json', + code: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "user-schema", + "title": "User", + "description": "A user in the system", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the user" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Full name of the user" + }, + "email": { + "type": "string", + "format": "email", + "description": "Email address of the user" + }, + "age": { + "type": "integer", + "minimum": 0, + "maximum": 150 + }, + "isActive": { + "type": "boolean", + "default": true + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["admin", "user", "moderator", "guest"] + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": ["id", "name", "email", "createdAt"] +}` +}; + +const JSONSCHEMA_VARIANTS: Variant[] = [ + { + id: 'models', + label: 'models', + runtime: 'zero dependencies', + config: `export default { + inputType: 'jsonschema', + inputPath: './user-schema.json', + generators: [ + { + preset: 'models', + outputPath: './src/models', + language: 'typescript' + } + ] +};`, + outputs: [ + { + path: 'src/models/User.ts', + label: 'User.ts', + language: 'typescript', + code: `import {RolesItem} from './RolesItem'; +class User { + private _id: string; + private _reservedName: string; + private _email: string; + private _age?: number; + private _isActive?: boolean; + private _roles?: RolesItem[]; + private _createdAt: Date; + private _additionalProperties?: Map; + + constructor(input: { + id: string, + reservedName: string, + email: string, + age?: number, + isActive?: boolean, + roles?: RolesItem[], + createdAt: Date, + additionalProperties?: Map, + }) { + this._id = input.id; + this._reservedName = input.reservedName; + this._email = input.email; + // ... + } + + get id(): string { return this._id; } + set id(id: string) { this._id = id; } + + get reservedName(): string { return this._reservedName; } + set reservedName(reservedName: string) { this._reservedName = reservedName; } + // ... +} +export { User };` + }, + { + path: 'src/models/RolesItem.ts', + label: 'RolesItem.ts', + language: 'typescript', + code: `enum RolesItem { + ADMIN = "admin", + USER = "user", + MODERATOR = "moderator", + GUEST = "guest", +} +export { RolesItem };` + }, + { + path: 'src/index.ts', + label: 'index.ts', + language: 'typescript', + handWritten: true, + code: `import {User} from './models/User'; +import {RolesItem} from './models/RolesItem'; + +// \`name\` is a reserved word here, so it is emitted as \`reservedName\`. +const user = new User({ + id: '9ab1c7d4-2e55-4f01-8a0f-1d4b6e9c0a12', + reservedName: 'Ada Lovelace', + email: 'ada@example.com', + roles: [RolesItem.ADMIN], + createdAt: new Date() +}); + +// \`models\` is the plain-data preset: typed accessors, no messaging machinery +// and no serialisation helpers. Use \`payloads\` when you want those. +console.log(user.reservedName, user.roles?.[0]);` + } + ] + } +]; + +/** + * Puts each variant's hand-written `index.ts` first. + * + * The variants above list their generated files in dependency order and end with + * the `index.ts` a reader would write. On screen the priority is the other way + * round - what you would actually type is what people came to see, and it is the + * tab that opens by default - so the lift happens here rather than by shuffling + * nine literal arrays out of the order they are natural to author in. + */ +function usageFirst(variants: Variant[]): Variant[] { + return variants.map((variant) => ({ + ...variant, + outputs: [ + ...variant.outputs.filter((file) => file.handWritten), + ...variant.outputs.filter((file) => !file.handWritten) + ] + })); +} + +export const demos: Demo[] = [ + { + id: 'asyncapi', + label: 'AsyncAPI', + versions: 'v2.0 - v3.0', + spec: ASYNCAPI_SPEC, + variantLabel: 'Protocol', + variants: usageFirst(ASYNCAPI_VARIANTS) + }, + { + id: 'openapi', + label: 'OpenAPI', + versions: '2.0 (Swagger), 3.0, 3.1', + spec: OPENAPI_SPEC, + variantLabel: 'Preset', + variants: usageFirst(OPENAPI_VARIANTS) + }, + { + id: 'jsonschema', + label: 'JSON Schema', + versions: 'Draft 4, 6, 7', + spec: JSONSCHEMA_SPEC, + variantLabel: 'Preset', + variants: usageFirst(JSONSCHEMA_VARIANTS) + } +]; diff --git a/website/src/components/HomepageFeatures/index.tsx b/website/src/components/HomepageFeatures/index.tsx deleted file mode 100644 index f7789f8e..00000000 --- a/website/src/components/HomepageFeatures/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import clsx from 'clsx'; -import Heading from '@theme/Heading'; -import styles from './styles.module.css'; - -type FeatureItem = { - title: string; - Svg: React.ComponentType>; - description: JSX.Element; -}; - -const FeatureList: FeatureItem[] = [ - { - title: 'Easy to Use', - Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default, - description: ( - <> - The Codegen Project was designed from the ground up to be easily installed and used to kickstart your implementation - - ), - }, - { - title: 'Focus on What Matters', - Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default, - description: ( - <> - The Codegen Project lets you focus on your business logic instead of duplicating your efforts. - - ), - }, - { - title: 'Powered by Open Source', - Svg: require('@site/static/img/undraw_docusaurus_react.svg').default, - description: ( - <> - Everything is powered by Open source, entirely for you to use how you see fit. - - ), - }, -]; - -function Feature({title, Svg, description}: FeatureItem) { - return ( -
-
- {/* Label from the card title: the stock illustrations carry unrelated - internal titles, so without this a screen reader announces the wrong - thing (e.g. "Powered by React" for "Powered by Open Source"). */} - -
-
- {title} -

{description}

-
-
- ); -} - -export default function HomepageFeatures(): JSX.Element { - return ( -
-
-
- {FeatureList.map((props, idx) => ( - - ))} -
-
-
- ); -} diff --git a/website/src/components/HomepageFeatures/styles.module.css b/website/src/components/HomepageFeatures/styles.module.css deleted file mode 100644 index b248eb2e..00000000 --- a/website/src/components/HomepageFeatures/styles.module.css +++ /dev/null @@ -1,11 +0,0 @@ -.features { - display: flex; - align-items: center; - padding: 2rem 0; - width: 100%; -} - -.featureSvg { - height: 200px; - width: 200px; -} diff --git a/website/src/components/Sponsors/styles.module.css b/website/src/components/Sponsors/styles.module.css index b111a6d0..509b7c64 100644 --- a/website/src/components/Sponsors/styles.module.css +++ b/website/src/components/Sponsors/styles.module.css @@ -1,6 +1,9 @@ .sponsors { padding: 4rem 0; background-color: var(--ifm-background-surface-color); + /* Matches the hairline every other homepage section is separated by, so this + band reads as part of the page rather than a floating panel. */ + border-top: 1px solid var(--cg-hairline); } .sponsorHeader { diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 2bc6a4cf..53ff6238 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -28,3 +28,72 @@ --ifm-color-primary-lightest: #4fddbf; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); } + +/** + * Homepage design tokens (`--cg-*`). + * + * The homepage hero, the spec-to-code stage and the closing CTA are dark in + * both colour modes: they are built around code panels, which only read well on + * a dark surface, and a landing page that flips its centrepiece between themes + * reads as two different products. Everything else on the page follows the + * Infima theme, which is why the panel tokens below are mode-independent while + * the section/hairline/accent-text tokens are not. + * + * Alpha variants are spelled out as `rgba()` rather than composed with + * `color-mix()`: this site's browserslist still includes browsers without it, + * and there the whole declaration would be dropped - taking pill backgrounds + * and borders with it. + */ +:root { + --cg-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, + 'Liberation Mono', monospace; + + --cg-accent: #22d3a6; + --cg-accent-2: #38bdf8; + --cg-accent-3: #a78bfa; + + --cg-accent-10: rgba(34, 211, 166, 0.1); + --cg-accent-12: rgba(34, 211, 166, 0.12); + --cg-accent-16: rgba(34, 211, 166, 0.16); + --cg-accent-30: rgba(34, 211, 166, 0.3); + --cg-accent-45: rgba(34, 211, 166, 0.45); + --cg-accent-55: rgba(34, 211, 166, 0.55); + --cg-accent-60: rgba(34, 211, 166, 0.6); + --cg-accent-80: rgba(34, 211, 166, 0.8); + --cg-accent-2-55: rgba(56, 189, 248, 0.55); + --cg-accent-3-14: rgba(167, 139, 250, 0.14); + --cg-accent-3-45: rgba(167, 139, 250, 0.45); + + /* Dark surfaces - identical in both modes, see the note above. */ + --cg-hero-bg: radial-gradient( + ellipse 120% 90% at 50% -10%, + #101c2b 0%, + transparent 60% + ), + #070c13; + --cg-panel: #0b1220; + --cg-panel-raised: #0e1727; + --cg-panel-out: #090f1b; + --cg-panel-hover: rgba(255, 255, 255, 0.05); + --cg-panel-border: rgba(255, 255, 255, 0.08); + --cg-panel-border-strong: rgba(255, 255, 255, 0.17); + --cg-panel-text: #e7eef8; + --cg-panel-text-dim: #a8b6ca; + --cg-panel-text-faint: #74849b; + --cg-line-number: #3c4a5f; + /* Base text colour for code panes - matches the `oneDark` Prism theme's plain + colour, since that is the theme the panes highlight with. */ + --cg-code-plain: hsl(220deg 14% 71%); + --cg-scrollbar: rgba(255, 255, 255, 0.16); + + /* Theme-following: accent text needs contrast against the page background. */ + --cg-accent-strong: #0c8e72; + --cg-section-bg: #f6f8fb; + --cg-hairline: var(--ifm-color-emphasis-200); +} + +[data-theme='dark'] { + --cg-accent-strong: #34d399; + --cg-section-bg: #14171b; + --cg-hairline: var(--ifm-color-emphasis-200); +} diff --git a/website/src/pages/index.module.css b/website/src/pages/index.module.css deleted file mode 100644 index 49846489..00000000 --- a/website/src/pages/index.module.css +++ /dev/null @@ -1,36 +0,0 @@ -/** - * CSS files with the .module.css suffix will be treated as CSS modules - * and scoped locally. - */ - -.heroBannerColor { - padding: 8rem 0; - text-align: center; - position: relative; - overflow: hidden; - background-color: #D5BD91; - background-size: cover; -} - - -.heroBanner { - padding: 15rem 0; - text-align: center; - position: relative; - overflow: hidden; - background-image: url('../../static/img/banner.webp'); - background-repeat: no-repeat; - background-size: cover; -} - -@media screen and (max-width: 996px) { - .heroBannerColor .heroBanner { - padding: 2rem; - } -} - -.buttons { - display: flex; - align-items: center; - justify-content: center; -} diff --git a/website/src/pages/index.tsx b/website/src/pages/index.tsx index 235b6cff..f41d10a8 100644 --- a/website/src/pages/index.tsx +++ b/website/src/pages/index.tsx @@ -1,45 +1,28 @@ -import clsx from 'clsx'; -import Link from '@docusaurus/Link'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import Layout from '@theme/Layout'; -import HomepageFeatures from '@site/src/components/HomepageFeatures'; +import Hero from '@site/src/components/Home/Hero'; +import SpecToCode from '@site/src/components/Home/SpecToCode'; +import Generators from '@site/src/components/Home/Generators'; +import Protocols from '@site/src/components/Home/Protocols'; +import HowItWorks from '@site/src/components/Home/HowItWorks'; +import FinalCTA from '@site/src/components/Home/FinalCTA'; import Sponsors from '@site/src/components/Sponsors'; -import Heading from '@theme/Heading'; -import styles from './index.module.css'; - -function HomepageHeader() { - const {siteConfig} = useDocusaurusContext(); - return ( -
-
- - {siteConfig.title} - -

{siteConfig.tagline}

-
- - Get started - 5min ⏱️ - -
-
-
- ); -} export default function Home(): JSX.Element { const {siteConfig} = useDocusaurusContext(); return ( - + description="Generate TypeScript models, protocol helpers and full clients from your AsyncAPI, OpenAPI and JSON Schema documents." + > +
- + + + + -
-
+
); From edb2b3fca5318aa54eaeaaa8a63c0c0f462b35a2 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 15:02:01 +0200 Subject: [PATCH 2/2] wip --- docs/generators/channels.md | 2 + docs/generators/client.md | 2 + docs/generators/custom.md | 2 + docs/generators/headers.md | 2 + docs/generators/models.md | 4 +- docs/generators/parameters.md | 2 + docs/generators/payloads.md | 4 +- docs/generators/types.md | 2 + website/scripts/move_docs.js | 44 ++++- .../src/components/GeneratorCards/index.tsx | 31 ++++ .../GeneratorCards/styles.module.css | 99 +++++++++++ .../src/components/Home/Generators/index.tsx | 131 +-------------- website/src/data/generatorPresets.tsx | 154 ++++++++++++++++++ website/src/theme/DocItem/Content/index.tsx | 38 +++++ .../theme/DocItem/Content/styles.module.css | 49 ++++++ .../src/theme/DocSidebarItem/Link/index.tsx | 45 +++++ .../DocSidebarItem/Link/styles.module.css | 23 +++ website/src/theme/MDXComponents.tsx | 14 ++ 18 files changed, 518 insertions(+), 130 deletions(-) create mode 100644 website/src/components/GeneratorCards/index.tsx create mode 100644 website/src/components/GeneratorCards/styles.module.css create mode 100644 website/src/data/generatorPresets.tsx create mode 100644 website/src/theme/DocItem/Content/index.tsx create mode 100644 website/src/theme/DocItem/Content/styles.module.css create mode 100644 website/src/theme/DocSidebarItem/Link/index.tsx create mode 100644 website/src/theme/DocSidebarItem/Link/styles.module.css create mode 100644 website/src/theme/MDXComponents.tsx diff --git a/docs/generators/channels.md b/docs/generators/channels.md index a1608eaf..02277598 100644 --- a/docs/generators/channels.md +++ b/docs/generators/channels.md @@ -1,5 +1,7 @@ --- sidebar_position: 99 +sidebar_custom_props: + generatorPreset: channels --- # Channels diff --git a/docs/generators/client.md b/docs/generators/client.md index 65ad8406..72dd9f05 100644 --- a/docs/generators/client.md +++ b/docs/generators/client.md @@ -1,5 +1,7 @@ --- sidebar_position: 99 +sidebar_custom_props: + generatorPreset: client --- # Client diff --git a/docs/generators/custom.md b/docs/generators/custom.md index 2a63570a..17851ef5 100644 --- a/docs/generators/custom.md +++ b/docs/generators/custom.md @@ -1,5 +1,7 @@ --- sidebar_position: 99 +sidebar_custom_props: + generatorPreset: custom --- # Custom generator diff --git a/docs/generators/headers.md b/docs/generators/headers.md index 1a78a179..811820b0 100644 --- a/docs/generators/headers.md +++ b/docs/generators/headers.md @@ -1,5 +1,7 @@ --- sidebar_position: 99 +sidebar_custom_props: + generatorPreset: headers --- # Headers diff --git a/docs/generators/models.md b/docs/generators/models.md index 223f7bdd..8655fab3 100644 --- a/docs/generators/models.md +++ b/docs/generators/models.md @@ -1,8 +1,10 @@ --- sidebar_position: 99 +sidebar_custom_props: + generatorPreset: models --- -# 🏗️ Models +# Models ```js export default { diff --git a/docs/generators/parameters.md b/docs/generators/parameters.md index 263c5e02..21000517 100644 --- a/docs/generators/parameters.md +++ b/docs/generators/parameters.md @@ -1,5 +1,7 @@ --- sidebar_position: 99 +sidebar_custom_props: + generatorPreset: parameters --- # Parameters diff --git a/docs/generators/payloads.md b/docs/generators/payloads.md index 7685cbee..0ae21c8c 100644 --- a/docs/generators/payloads.md +++ b/docs/generators/payloads.md @@ -1,8 +1,10 @@ --- sidebar_position: 99 +sidebar_custom_props: + generatorPreset: payloads --- -# 🐔 Payloads +# Payloads ```js export default { diff --git a/docs/generators/types.md b/docs/generators/types.md index 0f30b0ca..45ed0ace 100644 --- a/docs/generators/types.md +++ b/docs/generators/types.md @@ -1,5 +1,7 @@ --- sidebar_position: 99 +sidebar_custom_props: + generatorPreset: types --- # Types diff --git a/website/scripts/move_docs.js b/website/scripts/move_docs.js index 23cd5ad2..1199e0d9 100644 --- a/website/scripts/move_docs.js +++ b/website/scripts/move_docs.js @@ -1,10 +1,48 @@ const path = require('path'); -const { cp } = require('fs/promises'); +const { cp, readFile, writeFile } = require('fs/promises'); const DOCS_ROOT_PATH = path.join(__dirname, '../../docs'); const DOCS_DOCU_PATH = path.join(__dirname, '../docs'); const ASSETS_ROOT_PATH = path.join(__dirname, '../../assets'); const ASSETS_DOCU_PATH = path.join(__dirname, '../static/assets'); -cp(DOCS_ROOT_PATH, DOCS_DOCU_PATH, {recursive: true}); -cp(ASSETS_ROOT_PATH, ASSETS_DOCU_PATH, {recursive: true}); \ No newline at end of file +/** + * The plain list of presets in `docs/generators/README.md`. + * + * The docs also have to render on GitHub, so the repo copy stays plain markdown + * with no imports or JSX. On the website we swap that list for the same preset + * card grid the landing page uses - `` is registered globally + * in `src/theme/MDXComponents.tsx`, so the injected tag needs no import. + */ +const GENERATOR_LIST_PATTERN = + /^All available generators, across languages and inputs:\r?\n(?:- \[`[a-z]+`\]\([^)]+\)\r?\n)+/m; + +async function replaceGeneratorList() { + const readmePath = path.join(DOCS_DOCU_PATH, 'generators/README.md'); + const content = await readFile(readmePath, 'utf-8'); + + if (!GENERATOR_LIST_PATTERN.test(content)) { + throw new Error( + `Could not find the generator list in ${readmePath} to replace with . ` + + 'If the wording of that list changed, update GENERATOR_LIST_PATTERN in website/scripts/move_docs.js.' + ); + } + + await writeFile( + readmePath, + content.replace(GENERATOR_LIST_PATTERN, '\n') + ); +} + +async function main() { + await Promise.all([ + cp(DOCS_ROOT_PATH, DOCS_DOCU_PATH, { recursive: true }), + cp(ASSETS_ROOT_PATH, ASSETS_DOCU_PATH, { recursive: true }), + ]); + await replaceGeneratorList(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/website/src/components/GeneratorCards/index.tsx b/website/src/components/GeneratorCards/index.tsx new file mode 100644 index 00000000..8eee3402 --- /dev/null +++ b/website/src/components/GeneratorCards/index.tsx @@ -0,0 +1,31 @@ +import Link from '@docusaurus/Link'; +import { + GENERATOR_PRESETS, + GeneratorIcon +} from '@site/src/data/generatorPresets'; +import styles from './styles.module.css'; + +/** + * The preset grid from the landing page, sized for a docs column. + * + * Rendered into `docs/generators/README.md` by `scripts/move_docs.js`, which + * swaps the plain markdown list for this component when it copies the docs in - + * the list stays in the repo so the file still reads on GitHub. + */ +export default function GeneratorCards(): JSX.Element { + return ( +
+ {GENERATOR_PRESETS.map((generator) => ( + + + + {generator.preset} + + {generator.blurb} + + ))} +
+ ); +} diff --git a/website/src/components/GeneratorCards/styles.module.css b/website/src/components/GeneratorCards/styles.module.css new file mode 100644 index 00000000..0d39f33b --- /dev/null +++ b/website/src/components/GeneratorCards/styles.module.css @@ -0,0 +1,99 @@ +/* Same card language as the landing page grid, narrowed for the docs column. */ +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; + margin: 1.5rem 0 2rem; +} + +.card { + position: relative; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1.1rem; + border-radius: 14px; + border: 1px solid var(--ifm-color-emphasis-200); + background: var(--ifm-card-background-color); + color: var(--ifm-font-color-base); + overflow: hidden; + transition: + transform 180ms ease, + border-color 180ms ease, + box-shadow 180ms ease; +} + +/* Accent wash that grows on hover - keeps the grid calm until you engage. */ +.card::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + 120% 90% at 0% 0%, + var(--cg-accent-12), + transparent 60% + ); + opacity: 0; + transition: opacity 180ms ease; +} + +.card:hover { + text-decoration: none; + color: var(--ifm-font-color-base); + transform: translateY(-3px); + border-color: var(--cg-accent-45); + box-shadow: 0 16px 34px -22px var(--cg-accent-60); +} + +.card:hover::before { + opacity: 1; +} + +.iconWrap { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.1rem; + height: 2.1rem; + border-radius: 10px; + border: 1px solid var(--cg-accent-30); + background: var(--cg-accent-10); + color: var(--cg-accent-strong); +} + +.iconWrap svg { + width: 1.1rem; + height: 1.1rem; +} + +.cardTitle { + position: relative; + font-weight: 600; +} + +.cardTitle code { + padding: 0.1rem 0.4rem; + border: 0; + background: var(--ifm-color-emphasis-100); + font-size: 0.9rem; +} + +.cardBlurb { + position: relative; + font-size: 0.85rem; + line-height: 1.55; + color: var(--ifm-color-emphasis-700); +} + +@media screen and (max-width: 576px) { + .grid { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + .card:hover { + transform: none; + } +} diff --git a/website/src/components/Home/Generators/index.tsx b/website/src/components/Home/Generators/index.tsx index 2f0e22a7..cacd32e8 100644 --- a/website/src/components/Home/Generators/index.tsx +++ b/website/src/components/Home/Generators/index.tsx @@ -1,120 +1,10 @@ import Link from '@docusaurus/Link'; +import { + GENERATOR_PRESETS, + GeneratorIcon +} from '@site/src/data/generatorPresets'; import styles from './styles.module.css'; -/** - * The eight presets, described the way the config schema describes them. - * - * Wording is condensed from the `preset` field's own Zod `.describe()` text in - * `src/codegen/generators/**`, which is the single source of truth for what a - * generator does. If a preset's purpose changes there, change it here too. - */ -const GENERATORS: { - preset: string; - blurb: string; - href: string; - /** Rendered inside a 24x24 viewBox, `currentColor` stroked. */ - icon: JSX.Element; -}[] = [ - { - preset: 'payloads', - blurb: - 'Typed payload and message models that serialize straight into your wire format.', - href: '/docs/generators/payloads', - icon: ( - <> - - - - ) - }, - { - preset: 'parameters', - blurb: - 'Parameter models that interpolate values into subjects, topics and URL paths.', - href: '/docs/generators/parameters', - icon: ( - <> - - - - - ) - }, - { - preset: 'headers', - blurb: - 'Message header models, with optional runtime validation of what arrives.', - href: '/docs/generators/headers', - icon: ( - <> - - - - ) - }, - { - preset: 'types', - blurb: - 'Type aliases and enums derived from the constraints already in your document.', - href: '/docs/generators/types', - icon: ( - <> - - - - ) - }, - { - preset: 'channels', - blurb: - 'Protocol-specific publish, subscribe, request and reply functions per operation.', - href: '/docs/generators/channels', - icon: ( - <> - - - - - - ) - }, - { - preset: 'client', - blurb: - 'One class wrapping the channel functions, with connection handling built in.', - href: '/docs/generators/client', - icon: ( - <> - - - - ) - }, - { - preset: 'models', - blurb: - 'Plain typed models via Modelina, with none of the messaging machinery.', - href: '/docs/generators/models', - icon: ( - <> - - - - ) - }, - { - preset: 'custom', - blurb: - 'Your own render function, fed the parsed document and other generators’ output.', - href: '/docs/generators/custom', - icon: ( - <> - - - ) - } -]; - export default function Generators(): JSX.Element { return (
@@ -129,23 +19,14 @@ export default function Generators(): JSX.Element {
- {GENERATORS.map((generator) => ( + {GENERATOR_PRESETS.map((generator) => (

{generator.preset} diff --git a/website/src/data/generatorPresets.tsx b/website/src/data/generatorPresets.tsx new file mode 100644 index 00000000..f1768e8a --- /dev/null +++ b/website/src/data/generatorPresets.tsx @@ -0,0 +1,154 @@ +import type {ReactNode} from 'react'; + +/** + * The eight presets, described the way the config schema describes them. + * + * Wording is condensed from the `preset` field's own Zod `.describe()` text in + * `src/codegen/generators/**`, which is the single source of truth for what a + * generator does. If a preset's purpose changes there, change it here too. + * + * This is the one place the preset icons live - the landing page, the docs + * sidebar and the generator doc pages all render them from here, so they can't + * drift apart. + */ +export type GeneratorPreset = { + preset: string; + blurb: string; + href: string; + /** Rendered inside a 24x24 viewBox, `currentColor` stroked. */ + icon: ReactNode; +}; + +export const GENERATOR_PRESETS: GeneratorPreset[] = [ + { + preset: 'payloads', + blurb: + 'Typed payload and message models that serialize straight into your wire format.', + href: '/docs/generators/payloads', + icon: ( + <> + + + + ) + }, + { + preset: 'parameters', + blurb: + 'Parameter models that interpolate values into subjects, topics and URL paths.', + href: '/docs/generators/parameters', + icon: ( + <> + + + + + ) + }, + { + preset: 'headers', + blurb: + 'Message header models, with optional runtime validation of what arrives.', + href: '/docs/generators/headers', + icon: ( + <> + + + + ) + }, + { + preset: 'types', + blurb: + 'Type aliases and enums derived from the constraints already in your document.', + href: '/docs/generators/types', + icon: ( + <> + + + + ) + }, + { + preset: 'channels', + blurb: + 'Protocol-specific publish, subscribe, request and reply functions per operation.', + href: '/docs/generators/channels', + icon: ( + <> + + + + + + ) + }, + { + preset: 'client', + blurb: + 'One class wrapping the channel functions, with connection handling built in.', + href: '/docs/generators/client', + icon: ( + <> + + + + ) + }, + { + preset: 'models', + blurb: + 'Plain typed models via Modelina, with none of the messaging machinery.', + href: '/docs/generators/models', + icon: ( + <> + + + + ) + }, + { + preset: 'custom', + blurb: + 'Your own render function, fed the parsed document and other generators’ output.', + href: '/docs/generators/custom', + icon: ( + <> + + + ) + } +]; + +const BY_PRESET = new Map( + GENERATOR_PRESETS.map((generator) => [generator.preset, generator]) +); + +export function getGeneratorPreset(preset: string): GeneratorPreset | undefined { + return BY_PRESET.get(preset); +} + +/** + * The preset's icon, sized by whatever `font-size`/`width` the caller sets and + * stroked in `currentColor`. Renders nothing for an unknown preset so a typo in + * a doc's front matter degrades to "no icon" rather than a broken page. + */ +export function GeneratorIcon({preset}: {preset: string}): JSX.Element | null { + const generator = getGeneratorPreset(preset); + if (!generator) { + return null; + } + return ( + + ); +} diff --git a/website/src/theme/DocItem/Content/index.tsx b/website/src/theme/DocItem/Content/index.tsx new file mode 100644 index 00000000..1a2e2f61 --- /dev/null +++ b/website/src/theme/DocItem/Content/index.tsx @@ -0,0 +1,38 @@ +import Content from '@theme-original/DocItem/Content'; +import type ContentType from '@theme/DocItem/Content'; +import type {WrapperProps} from '@docusaurus/types'; +import {useDoc} from '@docusaurus/plugin-content-docs/client'; +import { + GeneratorIcon, + getGeneratorPreset +} from '@site/src/data/generatorPresets'; +import styles from './styles.module.css'; + +type Props = WrapperProps; + +/** + * Badges a generator's doc page with the same icon its card and sidebar entry + * use, driven by `sidebar_custom_props: {generatorPreset: }`. + */ +export default function ContentWrapper(props: Props): JSX.Element { + const {frontMatter} = useDoc(); + const preset = ( + frontMatter.sidebar_custom_props as {generatorPreset?: string} | undefined + )?.generatorPreset; + const generator = preset ? getGeneratorPreset(preset) : undefined; + + return ( + <> + {generator && ( +

+ + {generator.preset} + {generator.blurb} +

+ )} + + + ); +} diff --git a/website/src/theme/DocItem/Content/styles.module.css b/website/src/theme/DocItem/Content/styles.module.css new file mode 100644 index 00000000..55d8ad1f --- /dev/null +++ b/website/src/theme/DocItem/Content/styles.module.css @@ -0,0 +1,49 @@ +/* Sits above the page title as a kicker, mirroring the preset cards. */ +.badge { + display: flex; + align-items: center; + gap: 0.65rem; + margin: 0 0 1.25rem; + padding: 0.7rem 0.9rem; + border-radius: 12px; + border: 1px solid var(--ifm-color-emphasis-200); + background: var(--ifm-card-background-color); + font-size: 0.88rem; + line-height: 1.45; +} + +.iconWrap { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 2rem; + height: 2rem; + border-radius: 9px; + border: 1px solid var(--cg-accent-30); + background: var(--cg-accent-10); + color: var(--cg-accent-strong); +} + +.iconWrap svg { + width: 1.05rem; + height: 1.05rem; +} + +.preset { + flex: none; + padding: 0.1rem 0.4rem; + border: 0; + background: var(--ifm-color-emphasis-100); + font-size: 0.85rem; +} + +.blurb { + color: var(--ifm-color-emphasis-700); +} + +@media screen and (max-width: 576px) { + .blurb { + display: none; + } +} diff --git a/website/src/theme/DocSidebarItem/Link/index.tsx b/website/src/theme/DocSidebarItem/Link/index.tsx new file mode 100644 index 00000000..df742d82 --- /dev/null +++ b/website/src/theme/DocSidebarItem/Link/index.tsx @@ -0,0 +1,45 @@ +import Link from '@theme-original/DocSidebarItem/Link'; +import type LinkType from '@theme/DocSidebarItem/Link'; +import type {WrapperProps} from '@docusaurus/types'; +import {GeneratorIcon} from '@site/src/data/generatorPresets'; +import styles from './styles.module.css'; + +type Props = WrapperProps; + +/** + * Puts a preset's icon in front of its sidebar entry, so the docs read with the + * same iconography as the landing page's preset grid. + * + * Opt in per doc with `sidebar_custom_props: {generatorPreset: }`; the + * front matter is invisible when the same file renders on GitHub. + */ +export default function LinkWrapper(props: Props): JSX.Element { + const preset = ( + props.item.customProps as {generatorPreset?: string} | undefined + )?.generatorPreset; + + if (!preset) { + return ; + } + + const label = ( + <> + + {props.item.label} + + ); + + return ( + + ); +} diff --git a/website/src/theme/DocSidebarItem/Link/styles.module.css b/website/src/theme/DocSidebarItem/Link/styles.module.css new file mode 100644 index 00000000..e71ce97f --- /dev/null +++ b/website/src/theme/DocSidebarItem/Link/styles.module.css @@ -0,0 +1,23 @@ +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.05em; + height: 1.05em; + margin-right: 0.5rem; + vertical-align: -0.18em; + color: var(--cg-accent-strong); + opacity: 0.85; +} + +.icon svg { + width: 100%; + height: 100%; +} + +/* The active entry already carries the accent colour - let the icon inherit it + so it doesn't fight the highlighted link. */ +:global(.menu__link--active) .icon { + color: inherit; + opacity: 1; +} diff --git a/website/src/theme/MDXComponents.tsx b/website/src/theme/MDXComponents.tsx new file mode 100644 index 00000000..520617cf --- /dev/null +++ b/website/src/theme/MDXComponents.tsx @@ -0,0 +1,14 @@ +import MDXComponents from '@theme-original/MDXComponents'; +import GeneratorCards from '@site/src/components/GeneratorCards'; + +/** + * Components usable from any doc without an `import` line. + * + * The docs are copied in from the repo root, where they also have to render on + * GitHub - so nothing in them may carry an import statement. Registering here + * lets `scripts/move_docs.js` inject a bare `` tag instead. + */ +export default { + ...MDXComponents, + GeneratorCards +};