From 727102f1d46d046d750f561ca2226f79e47f3f07 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 15 Oct 2026 01:28:39 +0530 Subject: [PATCH 1/2] feat(website/home): hero redesign with solid headline, formatted-SQL demo and proof line - Solid h1 with the brand gradient (brand -> accent tokens, light/dark aware) only on "speed of Go"; drop hyphens-auto, add text-balance. - Hero fills 100svh minus the 4rem navbar and is top-aligned so the live demo starts above the fold at 1440x900 and phone widths. - Demo defaults to formatted SQL with chips (measured parse time, dialect, statement type); the AST tab becomes "Tree" and drops null/false/empty fields. gen-sample-ast now also emits the formatted sample so no raw JSON is shown before WASM loads. - Proof line under the CTAs built only from src/data/metrics.ts. Closes #662 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 --- website/scripts/gen-sample-ast/main.go | 20 +- website/src/components/home/Hero.tsx | 51 ++++- .../src/components/home/MiniPlayground.tsx | 186 ++++++++++++------ website/src/components/ui/GradientText.tsx | 7 +- website/src/generated/sample-ast.json | 1 + 5 files changed, 193 insertions(+), 72 deletions(-) diff --git a/website/scripts/gen-sample-ast/main.go b/website/scripts/gen-sample-ast/main.go index ac37c70d..b01ff5a9 100644 --- a/website/scripts/gen-sample-ast/main.go +++ b/website/scripts/gen-sample-ast/main.go @@ -1,5 +1,6 @@ -// Command gen-sample-ast writes the real GoSQLX AST JSON for the homepage -// demo's sample query (the same encoding/json output wasm/main.go returns). +// Command gen-sample-ast writes the real GoSQLX AST JSON and formatted SQL for +// the homepage demo's sample query (the same encoding/json and formatter output +// wasm/main.go returns), so the demo renders real output before WASM loads. // // Usage: go run ./website/scripts/gen-sample-ast package main @@ -10,6 +11,7 @@ import ( "os" "strings" + "github.com/ajitpratap0/GoSQLX/pkg/formatter" "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" ) @@ -30,11 +32,17 @@ func run() error { if err != nil { return fmt.Errorf("marshal ast: %w", err) } - // Emit the query alongside its AST so the UI has one source for both. + formatted, err := formatter.FormatString(sql) + if err != nil { + return fmt.Errorf("format: %w", err) + } + // Emit the query alongside its AST and formatted form so the UI has one + // source for all three. b, err := json.MarshalIndent(struct { - SQL string `json:"sql"` - AST json.RawMessage `json:"ast"` - }{SQL: sql, AST: astJSON}, "", " ") + SQL string `json:"sql"` + Formatted string `json:"formatted"` + AST json.RawMessage `json:"ast"` + }{SQL: sql, Formatted: formatted, AST: astJSON}, "", " ") if err != nil { return fmt.Errorf("marshal: %w", err) } diff --git a/website/src/components/home/Hero.tsx b/website/src/components/home/Hero.tsx index 1d901964..7b2aec5b 100644 --- a/website/src/components/home/Hero.tsx +++ b/website/src/components/home/Hero.tsx @@ -1,4 +1,11 @@ -import { PROJECT } from '@/data/metrics'; +import Link from 'next/link'; +import { + PROJECT, + HEADLINE_OPS_PER_SEC, + HEADLINE_CAPTION, + formatOps, + formatCount, +} from '@/data/metrics'; import { GlassCard } from '@/components/ui/GlassCard'; import { GradientText } from '@/components/ui/GradientText'; import { VersionBadge } from '@/components/ui/VersionBadge'; @@ -8,7 +15,9 @@ import { MiniPlayground } from '@/components/home/MiniPlayground'; export function Hero({ stars }: { stars: number | null }) { return ( -
+ // Fill the viewport below the fixed 4rem navbar, but top-align content so + // the live demo starts above the fold instead of being centred below it. +
{/* Background gradient mesh */} {/* Content */} -
+
{/* Version badge */} -
+
- {/* Headline */} + {/* Headline: solid text, brand gradient only on the key phrase */}

- Parse SQL at the speed of Go + Parse SQL at the speed of Go

{/* Subtitle */} -

+

Production-ready SQL parsing with zero-copy tokenization, object pooling, and multi-dialect support

{/* Buttons */} -
+
@@ -60,11 +69,33 @@ export function Hero({ stars }: { stars: number | null }) {
+ {/* Proof line: every figure comes from src/data/metrics.ts */} +
    +
  • + + {formatOps(HEADLINE_OPS_PER_SEC)} ops/sec{' '} + ({HEADLINE_CAPTION}) + +
  • + {[ + `${PROJECT.dialectCount} dialects`, + `${PROJECT.lintRuleCount} lint rules`, + `${formatCount(PROJECT.testFunctions)} tests`, + ].map((fact) => ( +
  • + + {fact} +
  • + ))} +
+ {/* Live mini playground */} -
); diff --git a/website/src/components/home/MiniPlayground.tsx b/website/src/components/home/MiniPlayground.tsx index 11ac7331..5a0f0cd3 100644 --- a/website/src/components/home/MiniPlayground.tsx +++ b/website/src/components/home/MiniPlayground.tsx @@ -4,9 +4,11 @@ import Link from 'next/link'; import sample from '@/generated/sample-ast.json'; import { useWasm, type GoSQLXApi } from '@/components/playground/WasmLoader'; -// Query and AST are generated from sample.sql by the real parser (scripts/gen-sample-ast). +// Query, formatted SQL and AST are generated from sample.sql by the real +// parser and formatter (scripts/gen-sample-ast), so the demo shows real output +// before WASM loads. const SAMPLE_SQL = sample.sql; -const SAMPLE_AST = JSON.stringify(sample.ast, null, 2); +const SAMPLE_FORMATTED = sample.formatted; const DIALECTS = [ { value: 'generic', label: 'Generic' }, @@ -17,43 +19,109 @@ const DIALECTS = [ { value: 'clickhouse', label: 'ClickHouse' }, ]; -type TabId = 'ast' | 'format' | 'lint'; +type TabId = 'format' | 'tree' | 'lint'; + +const TABS: { id: TabId; label: string }[] = [ + { id: 'format', label: 'Formatted' }, + { id: 'tree', label: 'Tree' }, + { id: 'lint', label: 'Lint' }, +]; function formatDuration(us: number): string { if (us < 1000) return `${us.toFixed(1)}\u00B5s`; return `${(us / 1000).toFixed(2)}ms`; } -function runParse(api: GoSQLXApi, sql: string, dialect: string, tab: TabId) { +/** + * Drops null, false, empty-string, empty-array and empty-object fields so the + * Tree tab shows the shape of the query rather than every unset struct field. + * Returns undefined when the value itself is empty. + */ +function pruneTree(value: unknown): unknown { + if (value === null || value === false || value === '' || value === undefined) return undefined; + if (Array.isArray(value)) { + const items = value.map(pruneTree).filter((v) => v !== undefined); + return items.length > 0 ? items : undefined; + } + if (typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + const pruned = pruneTree(v); + if (pruned !== undefined) out[k] = pruned; + } + return Object.keys(out).length > 0 ? out : undefined; + } + return value; +} + +function treeText(ast: unknown): string { + return JSON.stringify(pruneTree(ast) ?? {}, null, 2); +} + +const STATEMENT_KEYWORDS = [ + 'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'MERGE', 'CREATE', 'ALTER', 'DROP', + 'TRUNCATE', 'WITH', 'VALUES', 'EXPLAIN', 'SHOW', 'DESCRIBE', 'GRANT', 'REVOKE', +]; + +/** Statement-type chip for a query the parser accepted, e.g. "SELECT" or "3 statements". */ +function statementLabel(sql: string, ast: unknown): string | null { + const stmts = (ast as { Statements?: unknown[] } | null)?.Statements; + const count = Array.isArray(stmts) ? stmts.length : 0; + if (count === 0) return null; + if (count > 1) return `${count} statements`; + const stripped = sql.replace(/--[^\n]*|\/\*[\s\S]*?\*\//g, ' ').trimStart(); + const first = stripped.match(/^[A-Za-z]+/)?.[0]?.toUpperCase(); + if (!first || !STATEMENT_KEYWORDS.includes(first)) return 'statement'; + return first === 'WITH' ? 'WITH (CTE)' : first; +} + +const SAMPLE_TREE = treeText(sample.ast); +const SAMPLE_STATEMENT = statementLabel(SAMPLE_SQL, sample.ast); + +interface DemoResult { + output: string; + error: string | null; + /** Time spent in the WASM parse call only, in microseconds. */ + parseUs: number | null; + statement: string | null; +} + +function runDemo(api: GoSQLXApi, sql: string, dialect: string, tab: TabId): DemoResult { + let ast: unknown; const start = performance.now(); - let result: unknown; - let error: string | null = null; try { - if (tab === 'ast') { - result = api.parse(sql, dialect); - } else if (tab === 'format') { + ast = api.parse(sql, dialect); + } catch (e: unknown) { + return { output: '', error: e instanceof Error ? e.message : String(e), parseUs: null, statement: null }; + } + const parseUs = (performance.now() - start) * 1000; + const statement = statementLabel(sql, ast); + try { + if (tab === 'tree') return { output: treeText(ast), error: null, parseUs, statement }; + if (tab === 'format') { const f = api.format(sql, dialect); - result = typeof f === 'string' ? f : (f as { result?: string }).result ?? JSON.stringify(f, null, 2); - } else { - result = api.lint(sql, dialect); + const text = typeof f === 'string' ? f : (f as { result?: string }).result ?? ''; + return { output: text, error: null, parseUs, statement }; } + const lint = api.lint(sql, dialect); + const violations = Array.isArray(lint) + ? lint + : (lint as { violations?: unknown[] })?.violations ?? []; + const output = Array.isArray(violations) && violations.length === 0 + ? 'No lint violations found.' + : JSON.stringify(violations, null, 2); + return { output, error: null, parseUs, statement }; } catch (e: unknown) { - error = e instanceof Error ? e.message : String(e); + return { output: '', error: e instanceof Error ? e.message : String(e), parseUs, statement }; } - const elapsed = (performance.now() - start) * 1000; // to microseconds - return { result, error, elapsed }; } -function formatOutput(result: unknown, tab: TabId): string { - if (tab === 'format') return typeof result === 'string' ? result : ''; - if (tab === 'lint') { - const violations = Array.isArray(result) - ? result - : (result as { violations?: unknown[] })?.violations ?? []; - if (Array.isArray(violations) && violations.length === 0) return 'No lint violations found.'; - return JSON.stringify(violations, null, 2); - } - return JSON.stringify(result, null, 2); +function Chip({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); } export function MiniPlayground() { @@ -63,10 +131,11 @@ export function MiniPlayground() { const { loading, ready, api, progress } = useWasm(wasmRequested); const [sql, setSql] = useState(SAMPLE_SQL); const [dialect, setDialect] = useState('generic'); - const [activeTab, setActiveTab] = useState('ast'); + const [activeTab, setActiveTab] = useState('format'); const [output, setOutput] = useState(''); const [parseError, setParseError] = useState(null); const [elapsed, setElapsed] = useState(null); + const [statement, setStatement] = useState(SAMPLE_STATEMENT); const debounceRef = useRef>(null); const parse = useCallback( @@ -75,17 +144,14 @@ export function MiniPlayground() { setOutput(''); setParseError(null); setElapsed(null); + setStatement(null); return; } - const { result, error, elapsed: us } = runParse(api, query, dial, tab); - setElapsed(us); - if (error) { - setParseError(error); - setOutput(''); - } else { - setParseError(null); - setOutput(formatOutput(result, tab)); - } + const res = runDemo(api, query, dial, tab); + setElapsed(res.parseUs); + setStatement(res.statement); + setParseError(res.error); + setOutput(res.error ? '' : res.output); }, [api], ); @@ -113,7 +179,18 @@ export function MiniPlayground() { [parse, dialect, activeTab], ); - const tabLabel = activeTab === 'ast' ? 'AST Output' : activeTab === 'format' ? 'Formatted' : 'Lint'; + const tabLabel = TABS.find((t) => t.id === activeTab)?.label ?? ''; + const dialectLabel = DIALECTS.find((d) => d.value === dialect)?.label ?? dialect; + // Before WASM is ready, show the precomputed output for the untouched sample. + const precomputed = activeTab === 'format' ? SAMPLE_FORMATTED : activeTab === 'tree' ? SAMPLE_TREE : ''; + const shown = output || (!ready && sql === SAMPLE_SQL ? precomputed : ''); + const status = !wasmRequested + ? 'Precomputed sample' + : loading + ? progress < 1 + ? `Loading... ${Math.round(progress * 100)}%` + : 'Initializing...' + : ''; return (
@@ -166,33 +243,32 @@ export function MiniPlayground() {
- {(['ast', 'format', 'lint'] as TabId[]).map((tab) => ( + {TABS.map((tab) => ( ))}
- - {!wasmRequested - ? 'Precomputed sample output' - : loading - ? progress < 1 - ? `Loading... ${Math.round(progress * 100)}%` - : 'Initializing...' - : elapsed !== null - ? `Parsed in ${formatDuration(elapsed)}` - : ''} - + {status && {status}} +
+
+ {elapsed !== null && !parseError && ( + + parsed in {formatDuration(elapsed)} + + )} + {dialectLabel} + {statement && !parseError && (ready || sql === SAMPLE_SQL) && {statement}}
) : (
-              {output || (activeTab === 'ast' && !ready ? SAMPLE_AST : '')}
+              {shown}
             
)}
diff --git a/website/src/components/ui/GradientText.tsx b/website/src/components/ui/GradientText.tsx index ac4369b3..9785121b 100644 --- a/website/src/components/ui/GradientText.tsx +++ b/website/src/components/ui/GradientText.tsx @@ -1,6 +1,11 @@ +/** + * Brand gradient for a short highlighted phrase inside a solid heading. + * Uses the semantic brand/accent tokens, so html.light swaps it to the darker + * light-theme values automatically (both stops meet AA on --bg). + */ export function GradientText({ children, className = '' }: { children: React.ReactNode; className?: string }) { return ( - + {children} ); diff --git a/website/src/generated/sample-ast.json b/website/src/generated/sample-ast.json index c057c44f..08359683 100644 --- a/website/src/generated/sample-ast.json +++ b/website/src/generated/sample-ast.json @@ -1,5 +1,6 @@ { "sql": "SELECT\n u.name,\n u.email,\n COUNT(o.id) AS order_count,\n SUM(o.total) AS lifetime_value\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id\nWHERE u.created_at \u003e '2024-01-01'\nGROUP BY u.name, u.email\nHAVING COUNT(o.id) \u003e 5\nORDER BY lifetime_value DESC\nLIMIT 20;", + "formatted": "SELECT u.name, u.email, COUNT(o.id) AS order_count, SUM(o.total) AS lifetime_value\nFROM users u\nLEFT JOIN orders o ON o.user_id = u.id\nWHERE u.created_at \u003e '2024-01-01'\nGROUP BY u.name, u.email\nHAVING COUNT(o.id) \u003e 5\nORDER BY lifetime_value DESC\nLIMIT 20;", "ast": { "Statements": [ { From 7d169b1c8a01f7fbcddf3d51ed3349f39f9aba40 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 15 Oct 2026 01:31:31 +0530 Subject: [PATCH 2/2] fix(website): remove aria-live from MiniPlayground chip row The parsed-in timing chip changes on every debounced keystroke, causing screen readers to announce it repeatedly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 --- website/src/components/home/MiniPlayground.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/components/home/MiniPlayground.tsx b/website/src/components/home/MiniPlayground.tsx index 5a0f0cd3..755c5e70 100644 --- a/website/src/components/home/MiniPlayground.tsx +++ b/website/src/components/home/MiniPlayground.tsx @@ -261,7 +261,7 @@ export function MiniPlayground() {
{status && {status}}
-
+
{elapsed !== null && !parseError && ( parsed in {formatDuration(elapsed)}