Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1ae4dc6
docs(website): show the real-world dialect corpus on the homepage and…
Oct 27, 2026
0593e51
feat(playground): dialect-aware Format, Transpile tab and transpile e…
Oct 27, 2026
4342789
docs: document comment-preserving format, CLI additions and new API/A…
Oct 27, 2026
7dd0d58
Merge branch 'docs/website-feature-docs-formatter-cli-api' into docs/…
Oct 27, 2026
6254350
Merge branch 'docs/website-dialect-corpus-homepage' into docs/website…
Oct 27, 2026
26d0429
Merge branch 'docs/website-playground-dialect-format-transpile' into …
Oct 27, 2026
8991a7f
docs(website): correct watch, transpile, AST and corpus wording for n…
Oct 28, 2026
e8c53ac
Merge remote-tracking branch 'origin/main' into docs/website-next-rel…
Oct 28, 2026
db6415d
docs(cli): describe watch validate/format modes and --dialect
Oct 28, 2026
c793978
Merge remote-tracking branch 'origin/main' into docs/website-next-rel…
ajitpratap0 Oct 29, 2026
8884522
chore(release): bump version surfaces to 1.17.0 [vercel skip]
ajitpratap0 Oct 29, 2026
29c1e72
docs(release): v1.17.0 changelog, migration guide and API reference […
ajitpratap0 Oct 29, 2026
cc803c3
docs(release): mark the real-world corpus released and re-measure it …
ajitpratap0 Oct 29, 2026
01147e5
docs(release): describe merged behaviour as released in v1.17.0 [verc…
ajitpratap0 Oct 29, 2026
433d7da
docs(changelog): record transpile and parse costs of the 1.17.0 corre…
ajitpratap0 Oct 29, 2026
94aae0c
fix(release): keep tag-tracked version banners at v1.16.0 until the tag
ajitpratap0 Oct 29, 2026
99612aa
ci(lighthouse): give /playground/ its own total-size budget
ajitpratap0 Oct 29, 2026
fd2b7c7
fix(playground): resolve the #877 review items
ajitpratap0 Oct 29, 2026
99023e3
chore: merge main after #877 squash
ajitpratap0 Oct 29, 2026
312ae22
Merge branch 'main' into fix/playground-review-items
ajitpratap0 Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/HIGHLIGHTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,20 @@ The result has the shape `{"spans":[{"start":0,"end":6,"category":"keyword"}]}`.
The [gosqlx.dev playground](https://gosqlx.dev/playground/) uses it for editor
highlighting.

### Dialect arguments

The WASM bridge accepts dialect names for these calls:

| Call | Unknown dialect | Empty or omitted dialect |
|------|-----------------|--------------------------|
| `gosqlxParse`, `gosqlxFormat`, `gosqlxLint`, `gosqlxAnalyze`, `gosqlxHighlight` | Generic rules | Generic rules |
| `gosqlxTranspile` | Error response for an unsupported source or target dialect | Empty means generic; both arguments are required |

Transpile validates both dialects because silently choosing another dialect
could produce misleading SQL. Its raw JSON response includes an `error` such
as `unsupported source dialect "nope"`; the playground wrapper throws that
error message. The other calls retain their lenient fallback.

## Performance

Highlighting is one tokenizer pass plus classification. `BenchmarkSpans` (about
Expand Down
28 changes: 10 additions & 18 deletions wasm/internal/bridge/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,24 +59,16 @@ func Parse(sql, dialect string) (*ast.AST, error) {
// TextResponse is the result of Format and Transpile: the rewritten SQL, or
// the reason there is none.
//
// Exactly one of Result and Error is set. ErrorSpan locates Error in the
// submitted SQL when the error carries a position (a tokenizer or parser
// error); errors such as formatter.ErrUnplaceableComment, an unsupported
// dialect or a construct the transpile target cannot express have none.
//
// ErrorSpan is part of the JSON contract for callers that want it. The
// website's WasmLoader.parseResult treats a non-empty "error" like every other
// export does and throws it as an exception, so the playground's Format and
// Transpile tabs show only the error message, not the span.
// Exactly one of Result and Error is set. Parse-error positions are reported
// separately by Lint and Analyze through their ParseErrorSpan fields.
type TextResponse struct {
Result string `json:"result"`
Error string `json:"error,omitempty"`
ErrorSpan *ErrorSpan `json:"error_span,omitempty"`
Result string `json:"result"`
Error string `json:"error,omitempty"`
}

func textResponse(sql, result string, err error) TextResponse {
func textResponse(result string, err error) TextResponse {
if err != nil {
return TextResponse{Error: err.Error(), ErrorSpan: errorSpan(sql, err)}
return TextResponse{Error: err.Error()}
}
return TextResponse{Result: result}
}
Expand All @@ -88,7 +80,7 @@ func textResponse(sql, result string, err error) TextResponse {
// faithfully is refused with an error wrapping formatter.ErrUnplaceableComment.
func Format(sql, dialect string) TextResponse {
out, err := gosqlx.FormatWithDialect(sql, gosqlx.DefaultFormatOptions(), Dialect(dialect))
return textResponse(sql, out, err)
return textResponse(out, err)
}

// transpileDialect resolves a transpile source or target. Unlike Dialect, an
Expand All @@ -110,14 +102,14 @@ func transpileDialect(role, name string) (sqlkeywords.SQLDialect, error) {
func Transpile(sql, from, to string) TextResponse {
src, err := transpileDialect("source", from)
if err != nil {
return textResponse(sql, "", err)
return textResponse("", err)
}
dst, err := transpileDialect("target", to)
if err != nil {
return textResponse(sql, "", err)
return textResponse("", err)
}
out, err := gosqlx.Transpile(sql, src, dst)
return textResponse(sql, out, err)
return textResponse(out, err)
}

// LintViolation is one lint finding as the playground renders it.
Expand Down
50 changes: 27 additions & 23 deletions wasm/internal/bridge/bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ func TestFormat(t *testing.T) {
{name: "sqlserver keeps TOP", sql: "SELECT TOP 10 * FROM t", dialect: "sqlserver", want: "SELECT TOP 10 *\nFROM t"},
{name: "leading comment kept", sql: "-- c\nSELECT 1", dialect: "generic", want: "-- c\nSELECT 1"},
{name: "inline comment moves above the statement", sql: "-- top\nSELECT /* inline */ a FROM t", dialect: "sqlserver", want: "-- top\n/* inline */\nSELECT a\nFROM t"},
{name: "unknown dialect is generic", sql: "SELECT 1", dialect: "nope", want: "SELECT 1"},
{name: "empty dialect is generic", sql: "select id,name from users where id>5", dialect: "", want: "SELECT id, name\nFROM users\nWHERE id > 5"},
}
for _, tt := range tests {
Expand All @@ -257,9 +258,6 @@ func TestFormat_UsesDialect(t *testing.T) {
if resp.Error == "" {
t.Fatalf("generic should reject TOP; got %q", resp.Result)
}
if resp.ErrorSpan == nil || resp.ErrorSpan.Line != 1 {
t.Fatalf("parse error should carry a span, got %+v", resp.ErrorSpan)
}
}

func TestFormat_UnplaceableComment(t *testing.T) {
Expand All @@ -268,9 +266,6 @@ func TestFormat_UnplaceableComment(t *testing.T) {
if resp.Result != "" || !strings.Contains(resp.Error, formatter.ErrUnplaceableComment.Error()) {
t.Fatalf("expected an unplaceable-comment error, got %+v", resp)
}
if resp.ErrorSpan != nil {
t.Fatalf("a comment error has no parser position, got span %+v", resp.ErrorSpan)
}
}

func TestTranspile(t *testing.T) {
Expand Down Expand Up @@ -304,10 +299,9 @@ func TestTranspile_Errors(t *testing.T) {
sql string
from, to string
contains string
span bool
}{
{name: "xor has no oracle form", sql: "SELECT a ^ b FROM t", from: "mysql", to: "oracle", contains: "no equivalent in oracle"},
{name: "source parse error", sql: invalidSQL, from: "mysql", to: "postgresql", contains: "E2002", span: true},
{name: "source parse error", sql: invalidSQL, from: "mysql", to: "postgresql", contains: "E2002"},
{name: "unknown source", sql: "SELECT 1", from: "nope", to: "postgresql", contains: `source dialect "nope"`},
{name: "unknown target", sql: "SELECT 1", from: "mysql", to: "nope", contains: `target dialect "nope"`},
}
Expand All @@ -317,9 +311,6 @@ func TestTranspile_Errors(t *testing.T) {
if resp.Result != "" || !strings.Contains(resp.Error, tt.contains) {
t.Fatalf("expected an error containing %q, got %+v", tt.contains, resp)
}
if (resp.ErrorSpan != nil) != tt.span {
t.Fatalf("span = %+v, want present=%v", resp.ErrorSpan, tt.span)
}
})
}
}
Expand All @@ -332,17 +323,30 @@ func TestTextResponse_JSONContract(t *testing.T) {
if string(ok) != `{"result":"SELECT 1"}` {
t.Fatalf("success payload = %s", ok)
}
bad, err := json.Marshal(Format(invalidSQL, "generic"))
if err != nil {
t.Fatalf("marshal: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(bad, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, key := range []string{"result", "error", "error_span"} {
if _, ok := raw[key]; !ok {
t.Errorf("error payload missing %q: %s", key, bad)
}
for _, tt := range []struct {
name string
resp TextResponse
}{
{"format", Format(invalidSQL, "generic")},
{"transpile", Transpile(invalidSQL, "mysql", "postgresql")},
} {
t.Run(tt.name, func(t *testing.T) {
bad, err := json.Marshal(tt.resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(bad, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, key := range []string{"result", "error"} {
if _, ok := raw[key]; !ok {
t.Errorf("error payload missing %q: %s", key, bad)
}
}
if _, ok := raw["error_span"]; ok {
t.Errorf("error payload contains unused error_span: %s", bad)
}
})
}
}
4 changes: 2 additions & 2 deletions wasm/internal/bridge/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ func contractSample() any {
Highlight: HighlightResponse{
Spans: []HighlightSpan{{Start: 0, End: 6, Category: "keyword"}},
},
Format: TextResponse{Result: "SELECT 1", Error: "format error", ErrorSpan: sampleSpan},
Transpile: TextResponse{Result: "SELECT 1", Error: "transpile error", ErrorSpan: sampleSpan},
Format: TextResponse{Result: "SELECT 1", Error: "format error"},
Transpile: TextResponse{Result: "SELECT 1", Error: "transpile error"},
}
}

Expand Down
9 changes: 6 additions & 3 deletions wasm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ func parse(_ js.Value, args []js.Value) any {
}

// format formats the SQL for the selected dialect, keeping comments and
// optimizer hints. It returns {"result": "..."} or {"error": "...",
// "error_span": {...}} (bridge.TextResponse).
// optimizer hints. Unknown or omitted dialects use generic rules, as do parse,
// lint, analyze and highlight. It returns {"result": "..."} on success or
// {"result": "", "error": "..."} on failure (bridge.TextResponse).
func format(_ js.Value, args []js.Value) any {
if len(args) == 0 {
return jsonError("no SQL provided")
Expand All @@ -59,7 +60,9 @@ func format(_ js.Value, args []js.Value) any {
}

// transpile rewrites the SQL from the dialect in the second argument to the
// one in the third. It returns the same envelope as format.
// one in the third. It returns the same envelope as format. Unlike the other
// dialect-aware exports, an unknown source or target dialect returns an error
// containing unsupported source|target dialect "x". An empty string means generic.
func transpile(_ js.Value, args []js.Value) any {
if len(args) < 3 {
return jsonError("transpile needs SQL, a source dialect and a target dialect")
Expand Down
2 changes: 1 addition & 1 deletion website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"wasm": "bash scripts/build-wasm.sh",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/changelog.test.mjs scripts/editorDiagnostics.test.mjs scripts/editorHighlight.test.mjs scripts/bench-format.test.mjs",
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/changelog.test.mjs scripts/editorDiagnostics.test.mjs scripts/editorHighlight.test.mjs scripts/bench-format.test.mjs scripts/sqlOutput.test.mjs scripts/copyFeedback.test.mjs",
"check:metrics": "node scripts/check-metrics-drift.mjs",
"check:docs": "node scripts/check-docs-content.mjs --self-test && node scripts/check-docs-content.mjs",
"check:anchors": "node scripts/check-dialect-anchors.mjs",
Expand Down
44 changes: 44 additions & 0 deletions website/scripts/copyFeedback.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createCopyFeedback } from '../src/components/playground/useCopyFeedback.ts';

test('copy success and failure reset to idle after the feedback duration', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const states = [];
const feedback = createCopyFeedback((state) => states.push(state), 2000);
for (const state of ['copied', 'failed']) {
feedback.flash(state);
assert.equal(states.at(-1), state);
t.mock.timers.tick(1999);
assert.equal(states.at(-1), state);
t.mock.timers.tick(1);
assert.equal(states.at(-1), 'idle');
}
});

test('a second flash replaces feedback and restarts its reset timer', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const states = [];
const feedback = createCopyFeedback((state) => states.push(state), 2000);
feedback.flash('copied');
t.mock.timers.tick(1500);
feedback.flash('failed');
t.mock.timers.tick(500);
assert.deepEqual(states, ['copied', 'failed']);
t.mock.timers.tick(1500);
assert.deepEqual(states, ['copied', 'failed', 'idle']);
t.mock.timers.runAll();
assert.equal(states.length, 3);
});

test('dispose on unmount cancels the reset and ignores late clipboard completion', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const states = [];
const feedback = createCopyFeedback((state) => states.push(state), 2000);
feedback.flash('copied');
feedback.dispose();
feedback.dispose();
feedback.flash('failed');
t.mock.timers.runAll();
assert.deepEqual(states, ['copied']);
});
106 changes: 106 additions & 0 deletions website/scripts/sqlOutput.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { after, test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';

const components = new URL('../src/components/playground/', import.meta.url).href;
// Transpile the real component graph in process. Only the CodeMirror boundary
// is replaced: server rendering needs no DOM or editor implementation.
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (context.parentURL?.startsWith(components) && specifier.startsWith('./')) {
const url = new URL(specifier, context.parentURL);
for (const extension of ['.tsx', '.ts']) {
if (existsSync(new URL(url.href + extension))) {
return nextResolve(url.href + extension, context);
}
}
}
return nextResolve(specifier, context);
},
load(url, context, nextLoad) {
if (!url.startsWith(components) || !/\.tsx?$/.test(url)) {
return nextLoad(url, context);
}
if (url.endsWith('/SqlEditor.tsx')) {
return {
format: 'module', shortCircuit: true,
source: `import { createElement } from 'react';
export default function SqlEditor({ value, readOnly }) {
return createElement('pre', { 'data-editor-stub': true, 'data-read-only': readOnly }, value);
}`,
};
}
const { outputText } = ts.transpileModule(readFileSync(new URL(url), 'utf8'), {
fileName: new URL(url).pathname,
compilerOptions: {
jsx: ts.JsxEmit.ReactJSX,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
});
return { format: 'module', shortCircuit: true, source: outputText };
},
});
after(() => hooks.deregister());

const { SqlOutput } = await import('../src/components/playground/FormatTab.tsx');
const { default: TranspileTab } = await import('../src/components/playground/TranspileTab.tsx');
const renderOutput = (data) => renderToStaticMarkup(createElement(SqlOutput, {
data, label: 'Formatted SQL', errorTitle: 'Format Error', emptyText: 'Waiting for SQL',
}));
const renderTranspile = (data) => renderToStaticMarkup(createElement(TranspileTab, {
data,
dialects: [{ value: 'generic', label: 'Generic' }, { value: 'postgresql', label: 'PostgreSQL' }],
from: 'generic', to: 'postgresql', onFromChange() {}, onToChange() {},
}));

for (const [name, render, title, copyLabel, emptyText] of [
['SqlOutput', renderOutput, 'Format Error', 'formatted sql', 'Waiting for SQL'],
['TranspileTab', renderTranspile, 'Transpile Error', 'transpiled sql', 'No data available. Enter a SQL query to transpile it.'],
]) {
test(`${name} renders a polite error with hint and help, without SQL output controls`, () => {
const markup = render({ error: 'boom Hint: fix it Help: https://x.dev/e', result: '' });
assert.ok(markup.includes(title));
assert.match(markup, /role="status"/);
assert.match(markup, /aria-live="polite"/);
assert.match(markup, />boom<\/p>/);
assert.match(markup, /Hint: /);
assert.match(markup, /fix it/);
assert.match(markup, /href="https:\/\/x.dev\/e"/);
assert.ok(!markup.includes(`Copy ${copyLabel}`));
assert.ok(!markup.includes('read-only'));
assert.ok(!markup.includes('data-editor-stub'));
if (name === 'TranspileTab') {
assert.match(markup, /<select id="transpile-from"/);
assert.match(markup, /<select id="transpile-to"/);
}
});

test(`${name} renders successful SQL with copy controls`, () => {
const markup = render({ result: 'SELECT 1' });
assert.ok(markup.includes(`aria-label="Copy ${copyLabel} to clipboard"`));
assert.match(markup, /data-read-only="true"/);
assert.match(markup, />SELECT 1<\/pre>/);
assert.ok(!markup.includes('role="status"'));
assert.ok(!markup.includes(title));
});

test(`${name} renders its empty state without SQL output controls`, () => {
const markup = render(null);
assert.ok(markup.includes(emptyText));
assert.ok(!markup.includes(`Copy ${copyLabel}`));
assert.ok(!markup.includes('data-editor-stub'));
});
}

test('SqlOutput renders an error without a hint or help link', () => {
const markup = renderOutput({ error: 'x', result: '' });
assert.match(markup, /Format Error/);
assert.match(markup, />x<\/p>/);
assert.ok(!markup.includes('Hint:'));
assert.ok(!markup.includes('Error code help'));
});
12 changes: 5 additions & 7 deletions website/src/components/playground/AstTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
'use client';
import { useCopyFeedback } from "./useCopyFeedback";
import { useState, useCallback } from "react";
import { ErrorNotice } from "./ResultParts";

Expand Down Expand Up @@ -158,18 +159,15 @@ interface AstTabProps {

export default function AstTab({ data }: AstTabProps) {
const [showEmpty, setShowEmpty] = useState(false);
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const { copied, copyFailed, flash } = useCopyFeedback();
const handleCopy = useCallback(() => {
if (!data) return;
navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
flash("copied");
}).catch(() => {
setCopyFailed(true);
setTimeout(() => setCopyFailed(false), 2000);
flash("failed");
});
}, [data]);
}, [data, flash]);

if (!data) {
return (
Expand Down
Loading
Loading