From 12563aac5c84cfd1b8ee485ec1fb4720e2e3b9b9 Mon Sep 17 00:00:00 2001 From: Kresna Date: Mon, 3 Aug 2026 16:25:13 +0700 Subject: [PATCH] =?UTF-8?q?feat(dev):=20SQL=20Formatter=20=E2=80=94=20beau?= =?UTF-8?q?tify=20SQL=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format and pretty-print SQL entirely in the browser via sql-formatter (MIT). Choose dialect (PostgreSQL, MySQL, SQLite, SQL Server, Oracle, BigQuery, Snowflake and more), keyword case (upper/lower/preserve) and indentation (2/4 spaces or tab). Live preview, copy, download .sql, load-example. Nothing is uploaded. Pure wrapper lib src/tools/dev/sql-format.lib.ts (maps UI options to sql-formatter, unknown dialect falls back to standard SQL) with 7 unit tests. The ~290KB engine is a lazy dynamic import. Bilingual (EN + ID) UI, SEO and OG. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ubfx4XocHcECaL8twp9zsr --- package-lock.json | 82 ++++++++++++++++ package.json | 1 + src/islands/dev/SqlFormat.tsx | 134 +++++++++++++++++++++++++++ src/registry/tool-seo.ts | 34 +++++++ src/registry/tools.ts | 11 +++ src/tools/dev/sql-format.lib.test.ts | 50 ++++++++++ src/tools/dev/sql-format.lib.ts | 35 +++++++ 7 files changed, 347 insertions(+) create mode 100644 src/islands/dev/SqlFormat.tsx create mode 100644 src/tools/dev/sql-format.lib.test.ts create mode 100644 src/tools/dev/sql-format.lib.ts diff --git a/package-lock.json b/package-lock.json index 3120c34..ea8ed32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "react-dom": "^18.3.1", "signature_pad": "^5.1.3", "smol-toml": "^1.7.0", + "sql-formatter": "^15.8.2", "tailwindcss": "^3.4.19", "turndown": "^7.2.4", "upscaler": "^1.0.0", @@ -10901,6 +10902,12 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -16141,6 +16148,12 @@ "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", "license": "MIT" }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "license": "BSD-3-Clause" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -16239,6 +16252,34 @@ "is-buffer": "^1.0.2" } }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/nearley/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/neotraverse": { "version": "0.6.18", "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", @@ -17602,6 +17643,25 @@ "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", "license": "ISC" }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -18158,6 +18218,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, "node_modules/retext": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", @@ -19026,6 +19095,19 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/sql-formatter": { + "version": "15.8.2", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.8.2.tgz", + "integrity": "sha512-kTYRg5FIcvsDtYUG2Qn9pYT6xKwiLJN5TTIvc5Mur6hIg4pSfdpHu8Yyu5bqESLHnVM3mXzD446cb2+uEaKZXg==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "nearley": "^2.20.1" + }, + "bin": { + "sql-formatter": "bin/sql-formatter-cli.cjs" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", diff --git a/package.json b/package.json index 0d20f38..9581c8b 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "react-dom": "^18.3.1", "signature_pad": "^5.1.3", "smol-toml": "^1.7.0", + "sql-formatter": "^15.8.2", "tailwindcss": "^3.4.19", "turndown": "^7.2.4", "upscaler": "^1.0.0", diff --git a/src/islands/dev/SqlFormat.tsx b/src/islands/dev/SqlFormat.tsx new file mode 100644 index 0000000..04abb30 --- /dev/null +++ b/src/islands/dev/SqlFormat.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState } from 'react'; +import { Download, Sparkles } from 'lucide-react'; +import { TextArea } from '@/components/ui/TextArea'; +import { Button } from '@/components/ui/Button'; +import { CopyButton } from '@/components/ui/CopyButton'; +import { Alert } from '@/components/ui/Alert'; +import { downloadService } from '@/services/download.service'; +import type { KeywordCase, IndentKind, SqlFormatOptions } from '@/tools/dev/sql-format.lib'; +import type { Lang } from '@/i18n/config'; + +const DIALECTS: { value: string; label: string }[] = [ + { value: 'sql', label: 'Standard SQL' }, + { value: 'postgresql', label: 'PostgreSQL' }, + { value: 'mysql', label: 'MySQL' }, + { value: 'mariadb', label: 'MariaDB' }, + { value: 'sqlite', label: 'SQLite' }, + { value: 'tsql', label: 'SQL Server (T-SQL)' }, + { value: 'plsql', label: 'Oracle (PL/SQL)' }, + { value: 'bigquery', label: 'BigQuery' }, + { value: 'snowflake', label: 'Snowflake' }, + { value: 'redshift', label: 'Redshift' }, + { value: 'spark', label: 'Spark SQL' }, + { value: 'duckdb', label: 'DuckDB' }, + { value: 'clickhouse', label: 'ClickHouse' }, + { value: 'db2', label: 'Db2' }, + { value: 'hive', label: 'Hive' }, + { value: 'trino', label: 'Trino' }, +]; + +const CASES: KeywordCase[] = ['upper', 'lower', 'preserve']; +const INDENTS: IndentKind[] = ['2', '4', 'tab']; + +const EXAMPLE = "select u.id, u.name, count(o.id) as orders from users u left join orders o on o.user_id=u.id where u.active=true and o.created_at > '2024-01-01' group by u.id, u.name having count(o.id) > 3 order by orders desc limit 10;"; + +const TR: Record; indentLabels: Record; + example: string; download: string; errParse: string; empty: string; +}> = { + en: { + intro: 'Format and beautify SQL queries in your browser — pick your database dialect, keyword case and indentation. Everything runs on your device; nothing is uploaded.', + input: 'SQL', placeholder: 'Paste your SQL query here…', output: 'Formatted', dialect: 'Dialect', + keywordCase: 'Keywords', indent: 'Indent', + caseLabels: { upper: 'UPPER', lower: 'lower', preserve: 'Keep' }, + indentLabels: { '2': '2 spaces', '4': '4 spaces', tab: 'Tab' }, + example: 'Load example', download: 'Download .sql', errParse: 'Could not parse this SQL — check the query and the selected dialect.', empty: 'Formatted SQL will appear here.', + }, + id: { + intro: 'Format dan rapikan kueri SQL di browser Anda — pilih dialek basis data, huruf kata kunci, dan indentasi. Semuanya berjalan di perangkat Anda; tidak ada yang diunggah.', + input: 'SQL', placeholder: 'Tempel kueri SQL Anda di sini…', output: 'Terformat', dialect: 'Dialek', + keywordCase: 'Kata kunci', indent: 'Indentasi', + caseLabels: { upper: 'BESAR', lower: 'kecil', preserve: 'Biarkan' }, + indentLabels: { '2': '2 spasi', '4': '4 spasi', tab: 'Tab' }, + example: 'Muat contoh', download: 'Unduh .sql', errParse: 'Tidak dapat mengurai SQL ini — periksa kueri dan dialek yang dipilih.', empty: 'SQL terformat akan muncul di sini.', + }, +}; + +export default function SqlFormat({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [input, setInput] = useState(''); + const [language, setLanguage] = useState('sql'); + const [keywordCase, setKeywordCase] = useState('upper'); + const [indent, setIndent] = useState('2'); + const [output, setOutput] = useState(''); + const [error, setError] = useState(''); + const fmtRef = useRef<((sql: string, opts: SqlFormatOptions) => string) | null>(null); + + useEffect(() => { + let cancelled = false; + (async () => { + if (!input.trim()) { setOutput(''); setError(''); return; } + try { + if (!fmtRef.current) fmtRef.current = (await import('@/tools/dev/sql-format.lib')).formatSql; + if (cancelled) return; + setOutput(fmtRef.current(input, { language, keywordCase, indent })); + setError(''); + } catch { + if (!cancelled) setError(t.errParse); + } + })(); + return () => { cancelled = true; }; + }, [input, language, keywordCase, indent, t.errParse]); + + const download = () => downloadService.download(new Blob([output], { type: 'application/sql' }), 'formatted.sql'); + + const segClass = (active: boolean) => + `border-2 px-3 py-1 text-sm font-medium transition-all ${active ? 'border-border bg-accent text-accent-foreground shadow-brutal' : 'border-border hover:shadow-brutal'}`; + + return ( +
+

{t.intro}

+ +
+ +
+ {t.keywordCase} +
+ {CASES.map((c) => )} +
+
+
+ {t.indent} +
+ {INDENTS.map((i) => )} +
+
+
+ +
+
+
+ {t.input} + +
+