diff --git a/apps/landing/devup.json b/apps/landing/devup.json index b100ac5f..b6db3ff7 100644 --- a/apps/landing/devup.json +++ b/apps/landing/devup.json @@ -492,7 +492,21 @@ }, null, null - ] + ], + "code": { + "fontFamily": "D2Coding", + "fontWeight": 400, + "fontSize": "13px", + "lineHeight": 1.5, + "letterSpacing": "0em" + }, + "eyebrow": { + "fontFamily": "D2Coding", + "fontWeight": 400, + "fontSize": "11px", + "lineHeight": 1.4, + "letterSpacing": "0.14em" + } } } } \ No newline at end of file diff --git a/apps/landing/src/app/_components/code-tabs.tsx b/apps/landing/src/app/_components/code-tabs.tsx new file mode 100644 index 00000000..506b1d7d --- /dev/null +++ b/apps/landing/src/app/_components/code-tabs.tsx @@ -0,0 +1,238 @@ +import { Box, Text } from '@devup-ui/react' + +import { CodeWindow, HighlightedCode } from './code-window' + +export interface CodeExample { + key: string + label: string + file: string + html: string +} + +export type CodeExampleQuad = [CodeExample, CodeExample, CodeExample, CodeExample] + +// devup-ui extracts `selectors` strings at build time, so each of the 4 +// fixed tabs is written out literally rather than generated by a .map() +// over a runtime key — a template-literal selector key can't be statically +// extracted into CSS. +// +// Each target element (label / title / panel) declares its own "when am I +// shown" condition anchored on the checkbox's id (`#code-tab-N:checked ~ * +// &`), rather than the checkbox declaring rules for elements elsewhere in +// the tree. The checkbox's own `:checked` state isn't observable from the +// checkbox's position in isolation, so ownership of the visibility rule +// belongs on the element it affects. +export function CodeTabs({ examples: [a, b, c, d] }: { examples: CodeExampleQuad }) { + return ( + + + + + + + + {a.label} + + + {b.label} + + + {c.label} + + + {d.label} + + + } + title={ + <> + + {a.file} + + + {b.file} + + + {c.file} + + + {d.file} + + + } + > + + + + + + + + + + + + + + + ) +} diff --git a/apps/landing/src/app/_components/code-window.tsx b/apps/landing/src/app/_components/code-window.tsx new file mode 100644 index 00000000..2200a9ff --- /dev/null +++ b/apps/landing/src/app/_components/code-window.tsx @@ -0,0 +1,108 @@ +import { Box, Flex, globalCss, Text } from '@devup-ui/react' +import type { ComponentProps, ReactNode } from 'react' + +globalCss({ + '.shiki, .shiki span': { + fontFamily: 'D2Coding', + fontSize: '13px', + lineHeight: '1.65', + }, + '.shiki': { + background: 'transparent !important', + padding: '0', + margin: '0', + overflowX: 'auto', + }, + '[data-theme="dark"] .shiki, [data-theme="dark"] .shiki span': { + color: 'var(--shiki-dark) !important', + backgroundColor: 'transparent !important', + }, +}) + +export function CodeWindow({ + title, + tabs, + children, + ...props +}: { + title: ReactNode + tabs?: ReactNode + children: ReactNode +} & Omit>, 'title'>) { + return ( + + + + {Array.from({ length: 3 }, (_, i) => ( + + ))} + + + {title} + + {tabs && ( + + {tabs} + + )} + + + {children} + + + ) +} + +export function HighlightedCode({ html }: { html: string }) { + return
+} + +export function StaticCodeBlock({ + title, + html, +}: { + title: string + html: string +}) { + return ( + + + + ) +} + +export function HeroCodeWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/landing/src/app/_components/copy-install.tsx b/apps/landing/src/app/_components/copy-install.tsx new file mode 100644 index 00000000..b602c7f6 --- /dev/null +++ b/apps/landing/src/app/_components/copy-install.tsx @@ -0,0 +1,60 @@ +'use client' + +import { Box, Flex, Text } from '@devup-ui/react' +import { useState } from 'react' + +export function CopyInstall({ + command = 'cargo install vespertide-cli', +}: { + command?: string +}) { + const [copied, setCopied] = useState(false) + + const copy = () => { + navigator.clipboard?.writeText(command) + setCopied(true) + setTimeout(() => setCopied(false), 1400) + } + + return ( + + + $ + + + {command} + + { + e.stopPropagation() + copy() + }} + px="9px" + py="5px" + transition="color .15s, border-color .15s" + typography="eyebrow" + > + {copied ? 'copied' : 'copy'} + + + ) +} diff --git a/apps/landing/src/app/_components/example.tsx b/apps/landing/src/app/_components/example.tsx deleted file mode 100644 index 6e69244b..00000000 --- a/apps/landing/src/app/_components/example.tsx +++ /dev/null @@ -1,102 +0,0 @@ -'use client' - -import { Flex, Image } from '@devup-ui/react' -import { ComponentProps, createContext, useContext, useState } from 'react' - -const ExampleContext = createContext<{ - selected: string - setSelected: (selected: string) => void - selectedExample?: { - id: string - title: string - description: string - imageUrl: string - } -} | null>(null) - -export function useExample() { - const context = useContext(ExampleContext) - if (!context) { - throw new Error('useExample must be used within a ExampleProvider') - } - return context -} - -export function ExampleProvider({ - defaultSelected = '', - examples, - children, -}: { - defaultSelected?: string - examples: { - id: string - title: string - description: string - imageUrl: string - }[] - children: React.ReactNode -}) { - const [selected, setSelected] = useState(defaultSelected) - const selectedExample = examples.find((example) => example.id === selected) - return ( - - {children} - - ) -} - -export function ExampleContainer({ - value, - ...props -}: ComponentProps> & { value?: string }) { - const { selected, setSelected } = useExample() - const isSelected = selected === value - return ( - setSelected(value) : undefined} - overflow="hidden" - px="$spacingSpacing24" - py="$spacingSpacing20" - styleOrder={1} - transition="all .1s" - {...props} - /> - ) -} - -export function ExampleImage({ - ...props -}: Omit>, 'src'>) { - const { selectedExample } = useExample() - return ( - {selectedExample?.title - ) -} - -export function Example() {} diff --git a/apps/landing/src/app/_components/join-icon-button.tsx b/apps/landing/src/app/_components/join-icon-button.tsx deleted file mode 100644 index 24128d88..00000000 --- a/apps/landing/src/app/_components/join-icon-button.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Flex } from '@devup-ui/react' -import { ComponentProps } from 'react' - -export function JoinIconButton(props: ComponentProps>) { - return ( - - ) -} diff --git a/apps/landing/src/app/_lib/highlight.ts b/apps/landing/src/app/_lib/highlight.ts new file mode 100644 index 00000000..82e8cf23 --- /dev/null +++ b/apps/landing/src/app/_lib/highlight.ts @@ -0,0 +1,19 @@ +import { codeToHtml } from 'shiki' + +export type CodeLang = 'json' | 'shell' | 'rust' + +const LANG_MAP: Record = { + json: 'json', + shell: 'bash', + rust: 'rust', +} + +export async function highlight(code: string, lang: CodeLang): Promise { + return codeToHtml(code, { + lang: LANG_MAP[lang], + themes: { + light: 'github-light', + dark: 'github-dark', + }, + }) +} diff --git a/apps/landing/src/app/page.tsx b/apps/landing/src/app/page.tsx index b61eaf4c..d7c80551 100644 --- a/apps/landing/src/app/page.tsx +++ b/apps/landing/src/app/page.tsx @@ -1,18 +1,14 @@ -import { JoinIconButton } from '@app/_components/join-icon-button' -import { Box, Center, css, Flex, Text, VStack } from '@devup-ui/react' -import { Image } from '@devup-ui/react' +import { Box, Flex, Text, VStack } from '@devup-ui/react' import type { Metadata } from 'next' import Link from 'next/link' +import type { ComponentProps } from 'react' import { Button } from '@/components/button' -import { GnbIcon } from '@/components/header/gnb-icon' -import { HeaderSentinel } from '@/components/header/header-sentinel' -import { - ExampleContainer, - ExampleImage, - ExampleProvider, -} from './_components/example' +import { CodeTabs, type CodeExampleQuad } from './_components/code-tabs' +import { CodeWindow, HighlightedCode } from './_components/code-window' +import { CopyInstall } from './_components/copy-install' +import { highlight } from './_lib/highlight' export const metadata: Metadata = { alternates: { @@ -20,290 +16,976 @@ export const metadata: Metadata = { }, } -const EXAMPLES = [ +const VERSION = '0.1.61' +const GITHUB_URL = 'https://github.com/dev-five-git/vespertide' +const DOCS_URL = '/documentation' +const DISCORD_URL = 'https://discord.com/invite/8zjcGc7cWh' +const KAKAO_URL = 'https://open.kakao.com/o/giONwVAh' +const CRATES_URL = 'https://crates.io/crates/vespertide-cli' + +const HERO_MODEL_JSON = `{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/main/schemas/model.schema.json", + "name": "user", + "columns": [ + { "name": "id", "type": "integer", "primary_key": true }, + { "name": "email", "type": "text", "unique": true, "index": true }, + { "name": "name", "type": { "kind": "varchar", "length": 100 } }, + { + "name": "status", + "type": { "kind": "enum", "name": "user_status", + "values": ["active", "inactive", "banned"] }, + "default": "'active'" + }, + { "name": "created_at", "type": "timestamptz", "default": "NOW()" } + ] +}` + +const EXAMPLE_MODEL = `{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/main/schemas/model.schema.json", + "name": "post", + "columns": [ + { "name": "id", "type": "integer", "primary_key": true }, + { "name": "title", "type": { "kind": "varchar", "length": 200 } }, + { "name": "body", "type": "text" }, + { + "name": "author_id", + "type": "integer", + "foreign_key": { + "ref_table": "user", + "ref_columns": ["id"], + "on_delete": "cascade" + }, + "index": true + }, + { + "name": "status", + "type": { "kind": "enum", "name": "post_status", + "values": ["draft", "published", "archived"] }, + "default": "'draft'" + } + ] +}` + +const EXAMPLE_CLI = `# Initialize a new project +$ vespertide init + +# Scaffold a model +$ vespertide new post + +# Edit models/post.json, then preview the diff +$ vespertide diff ++ create_table post (id, title, body, author_id, status) ++ create_enum post_status [draft, published, archived] ++ create_index ix_post_author_id ON post (author_id) + +# Inspect dialect-specific SQL +$ vespertide sql --backend postgres + +# Persist as a migration file +$ vespertide revision -m "create post table"` + +const EXAMPLE_RUNTIME = `use sea_orm::Database; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let db = Database::connect("postgres://user:pass@localhost/mydb").await?; + + // Generated at compile time, run on startup. + vespertide::vespertide_migration!(db).await?; + + Ok(()) +}` + +const EXAMPLE_EXPORT = `# Generate Rust SeaORM entities +$ vespertide export --orm seaorm + +# Or Python — SQLAlchemy +$ vespertide export --orm sqlalchemy + +# Or FastAPI-flavoured SQLModel +$ vespertide export --orm sqlmodel + +# Or Go — GORM +$ vespertide export --orm gorm + +# Or Django +$ vespertide export --orm django` + +type FeatureIconName = + | 'hamburger' + | 'arrow-up-right' + | 'chevron' + | 'logo-image' + | 'devfive' + | 'theme-dark' + | 'search' + | 'external-link' + | 'github' + +const FEATURES: { icon: FeatureIconName; title: string; desc: string }[] = [ + { + icon: 'hamburger', + title: 'Declarative schema', + desc: 'Describe your desired database state in JSON files. The current model is the source of truth.', + }, + { + icon: 'arrow-up-right', + title: 'Automatic diffing', + desc: 'Vespertide replays applied migrations and compares them to your models to compute changes.', + }, + { + icon: 'chevron', + title: 'Typed migration plans', + desc: 'Generates safe, portable MigrationAction enums — not raw SQL. Review before you commit.', + }, + { + icon: 'logo-image', + title: 'Multi-database', + desc: 'PostgreSQL, MySQL, and SQLite — same schema, identical semantics, backend-aware quoting.', + }, + { + icon: 'devfive', + title: 'Native enums', + desc: 'First-class string and integer enums. Add new integer values without ever touching the DB.', + }, + { + icon: 'theme-dark', + title: 'Zero-runtime macro', + desc: 'vespertide_migration!() generates database-specific SQL at compile time. Nothing to ship at runtime.', + }, { - id: '1', - title: 'How to Use', - description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/hero-figure.webp', + icon: 'search', + title: 'JSON Schema validation', + desc: 'Ships with JSON Schemas — autocomplete, hover docs, and instant errors in your editor.', }, { - id: '2', - title: 'How to Use', - description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/join-us-bg.webp', + icon: 'external-link', + title: 'ORM export', + desc: 'One command emits SeaORM, SQLAlchemy, Django, GORM and more — entities stay in lockstep with schema.', }, { - id: '3', - title: 'How to Use', - description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/code.webp', + icon: 'github', + title: 'Built in Rust', + desc: "Single binary CLI, no Node, no Python, no JVM. cargo install vespertide-cli and you're done.", }, ] -export default function HomePage() { +const STEPS: { title: string; desc: string; mono: string }[] = [ + { + title: 'Define', + desc: 'Author JSON models in your editor with full IDE validation via JSON Schema.', + mono: 'models/user.json', + }, + { + title: 'Replay', + desc: 'Vespertide reconstructs the baseline schema by replaying applied migrations.', + mono: 'migrations/*.sql', + }, + { + title: 'Diff', + desc: 'Current models are compared to the baseline to find what changed.', + mono: 'vespertide diff', + }, + { + title: 'Plan', + desc: 'Differences are converted into typed MigrationAction enums.', + mono: 'MigrationAction', + }, + { + title: 'Emit', + desc: 'Actions translate to dialect-specific SQL — Postgres, MySQL, or SQLite.', + mono: 'vespertide sql', + }, +] + +const DBS = [ + { + key: 'PG', + name: 'PostgreSQL', + quote: '"identifier"', + note: 'Full feature support — native enums, JSONB, INET, CIDR, TSVECTOR.', + }, + { + key: 'MY', + name: 'MySQL', + quote: '`identifier`', + note: 'Full feature support with MySQL-aware identifier quoting and types.', + }, + { + key: 'SL', + name: 'SQLite', + quote: '"identifier"', + note: 'Full feature support — perfect for tests, CLIs, and embedded apps.', + }, +] + +const ORMS = [ + { lang: 'Rust', name: 'SeaORM' }, + { lang: 'Python', name: 'SQLAlchemy' }, + { lang: 'Python', name: 'SQLModel · FastAPI' }, + { lang: 'Python', name: 'Django' }, + { lang: 'Java', name: 'JPA · Hibernate' }, + { lang: 'TypeScript', name: 'Prisma' }, + { lang: 'TypeScript', name: 'Drizzle' }, + { lang: 'Go', name: 'GORM' }, +] + +function MaskIcon({ + icon, + size = '24px', + color = '$vespertidePrimary', + ...props +}: { + icon: FeatureIconName | 'discord' | 'kakao' + size?: string + color?: string +} & ComponentProps>) { return ( - <> - -
+ ) +} + +function SectionHead({ + eyebrow, + title, + emphasis, + lede, +}: { + eyebrow: string + title: string + emphasis?: string + lede?: string +}) { + return ( + + + — {eyebrow} + + + {title} + {emphasis && ( + + {emphasis} + + )} + + {lede && ( + + {lede} + + )} + + ) +} + +function HeroSection({ codeHtml }: { codeHtml: string }) { + return ( + + + + + + + + + v{VERSION} · Apache-2.0 · Rust + + + + + Define schemas. +
+ + Forget migrations. + +
+ + + + Vespertide is a declarative database schema manager for Rust. Write + your tables in JSON, and let it diff, plan, and emit type-safe + migrations to Postgres, MySQL, and SQLite — automatically. + + + + + + + + + + + + View on GitHub + + + + + + + + + {[ + { num: `v${VERSION}`, lbl: 'Version' }, + { num: '3', lbl: 'Databases' }, + { num: '4', lbl: 'ORM exports' }, + { num: '0ms', lbl: 'Runtime cost' }, + ].map((s) => ( + + + {s.num} + + + {s.lbl} + + + ))} + +
+ + + + + + +
+
+
+ ) +} + +function FeaturesSection() { + return ( + + + + + + - + {FEATURES.map((f) => ( - - Lorem ipsum dolor sit amet,
- consectetur adipiscing elit. + + + {f.title} - - Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum - sodales non ut ex.
- Morbi diam turpis, fringilla vitae enim et, egestas consequat - nibh.
- Etiam auctor cursus urna sit amet elementum. + + {f.desc}
- - -
-
+ ))} +
+ + + ) +} -
+ + + + + - - - - Title + {STEPS.map((s, i) => ( + + + {String(i + 1).padStart(2, '0')} - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam - venenatis, elit in hendrerit porta, augue ante scelerisque diam,{' '} -
- ac egestas lacus est nec urna. Cras commodo risus hendrerit, - suscipit nibh at, porttitor dui. + + {s.title} -
- - {[0, 1, 2, 3].map((i) => ( - + {s.desc} +
+ + - + +
+ ))} +
+
+ + ) +} + +function ExamplesSection({ examples }: { examples: CodeExampleQuad }) { + return ( + + + + + + — Examples + + + One source of truth, +
+ four ways to use it. +
+ + Your JSON models drive the diff, the SQL, the ORM entities, and the + runtime macro. Pick the workflow that fits your team — Vespertide + stays consistent. + + + {[ + { + k: 'Models.', + v: 'Inline foreign keys, enums, and constraints — no separate schema language.', + }, + { + k: 'CLI.', + v: 'diff, sql, revision, status, log — every step is a plain command.', + }, + { + k: 'Runtime.', + v: 'Compile-time macro, zero overhead, no migrations folder shipped to prod.', + }, + { + k: 'Export.', + v: 'SeaORM, SQLAlchemy, Django, GORM and more — typed entities, generated.', + }, + ].map((it) => ( + + - - Feature title - - - Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. - Proin nec ante a sem vestibulum sodales non ut ex.{' '} + → + + + + {it.k} - + {it.v} + ))}
-
- - -
- - - - Title - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Nullam venenatis ac egestas lacus est nec urna.{' '} - - - + + + + + + + ) +} + +function CompatibilitySection() { + return ( + + + + + + + + {DBS.map((db) => ( + + - - - - + {db.key} + - - {EXAMPLES.map(({ id, title, description }) => ( - - - - {title} - - - {description} - - - - ))} - - + + {db.name} + + + + {db.quote} + + + {db.note} + -
-
- - + + + + — ORM export + + + Generate typed entities for the runtime you use. + + + + vespertide export --orm <target> + {' '} + emits up-to-date entities from your current models. + + + {ORMS.map((orm) => ( + + + {orm.lang} + + {orm.name} + + ))} + + + + + ) +} + +function ChannelRow({ + href, + icon, + name, + meta, +}: { + href: string + icon: 'github' | 'discord' | 'kakao' + name: string + meta: string +}) { + return ( + + + + + + + {name} + + - + + + ) +} + +function CommunitySection() { + return ( + + + + + - - - - Join our community - - - Join our Discord and help build the future of frontend with - CSS-in-JS!{' '} + + + — Get started + + + Install once.{' '} + + Iterate forever. - - - - - - - - - - - + + + Vespertide is open source under Apache-2.0 and built in public. + Join the community, file an issue, or pair with us in Discord. + + + + + - - - + + + + Star on GitHub + + - join us background image - - + + + + + +
+ + + + + Apache-2.0 · v{VERSION} ·{' '} + + crates.io + + + + built with Rust · maintained in Seoul + + - + + ) +} + +export default async function HomePage() { + const [heroHtml, modelHtml, cliHtml, runtimeHtml, exportHtml] = + await Promise.all([ + highlight(HERO_MODEL_JSON, 'json'), + highlight(EXAMPLE_MODEL, 'json'), + highlight(EXAMPLE_CLI, 'shell'), + highlight(EXAMPLE_RUNTIME, 'rust'), + highlight(EXAMPLE_EXPORT, 'shell'), + ]) + + const examples: CodeExampleQuad = [ + { key: 'model', label: 'Model', file: 'models/post.json', html: modelHtml }, + { key: 'cli', label: 'CLI', file: '~/projects/blog', html: cliHtml }, + { key: 'runtime', label: 'Runtime', file: 'src/main.rs', html: runtimeHtml }, + { + key: 'export', + label: 'ORM export', + file: '$ vespertide export', + html: exportHtml, + }, + ] + + return ( + + + + + + + + ) }