Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
a2746ab
feat: add Shiny bindings and <ggsql-viz> web component
cpsievert Apr 20, 2026
9938611
style: apply air formatting
cpsievert Apr 20, 2026
a929f0e
chore: add build script for vendored Vega dependencies
cpsievert Apr 21, 2026
eb6e34e
fix: add display:block and overflow:hidden to ggsql-viz component
cpsievert Apr 21, 2026
9815937
feat: add responsive scale-to-fit and JSDoc types to ggsql-viz
cpsievert Apr 21, 2026
9492b41
fix: guard async ggsql-viz rerenders
cpsievert Apr 21, 2026
fb4c95b
fix: remove implicit knitr dependency from resolve_data_refs
cpsievert Apr 21, 2026
8bce55b
feat: adopt htmlwidgets for all HTML rendering
cpsievert Apr 21, 2026
fed4e90
refactor: simplify renderGgsql shiny API
cpsievert Apr 21, 2026
5788e57
refactor: restore custom element widget lifecycle
cpsievert Apr 22, 2026
d3129ad
chore: keep local-only test artifacts out of branch
cpsievert Apr 22, 2026
97cbd18
refactor: move static ggsql-viz styles to dedicated CSS file
cpsievert Apr 22, 2026
55920f0
refactor: replace embedFn() wrapper with direct window.vegaEmbed call
cpsievert Apr 22, 2026
de46bfd
refactor: simplify .ggsql-container selector to single descendant rule
cpsievert Apr 22, 2026
0d395d5
feat: add compound Vega-Lite spec sizing helpers
cpsievert Apr 22, 2026
146b5d5
refactor: remove caption/align from widget, delegate sizing to htmlwi…
cpsievert Apr 22, 2026
0f56295
Run air format
cpsievert Apr 22, 2026
e577c01
fix: pass explicit pixel dimensions to Vega instead of width/height: …
cpsievert Apr 22, 2026
b1d10b1
refactor: move widget dependencies from YAML to R code
cpsievert Apr 22, 2026
fd017fe
refactor: split vega_dependencies() from widget_dependencies()
cpsievert Apr 22, 2026
93f1ca2
feat: add compound Vega-Lite spec sizing for multi-view charts
cpsievert Apr 22, 2026
7ee3ff2
Fix ggsql widget sizing behavior
cpsievert Apr 22, 2026
81e7296
refactor widget resize strategies
cpsievert Apr 22, 2026
1beafd8
move JS tests to inst/htmlwidgets alongside source
cpsievert Apr 22, 2026
b1c628d
refactor widget layout sizing contract
cpsievert Apr 22, 2026
0dc55de
test: lock fixed output box sizing contract
cpsievert Apr 23, 2026
a7c06ae
test: add legend-insensitive sizing check
cpsievert Apr 23, 2026
f463e92
test: guard allocator against encoding access
cpsievert Apr 23, 2026
c966a31
test: tighten legend guard coverage
cpsievert Apr 23, 2026
d6ec2f7
test: tidy allocator assertions
cpsievert Apr 23, 2026
760db1f
test: normalize autosize across realms
cpsievert Apr 23, 2026
8ce7bf3
refactor: keep widget host size fixed
cpsievert Apr 23, 2026
0777d07
fix: harden widget embed lifecycle
cpsievert Apr 23, 2026
349c492
refactor: allocate compound charts from host viewport
cpsievert Apr 23, 2026
66e9680
test: document fixed output box sizing behavior
cpsievert Apr 23, 2026
f62ad81
fix: keep viewport and facet sizing in bounds
cpsievert Apr 23, 2026
1cd66b3
fix: clamp compound cell dimensions
cpsievert Apr 23, 2026
06c2cf6
refactor: simplify widget resize lifecycle
cpsievert Apr 23, 2026
bb71f92
chore: drop local-only tests and sandbox artifacts
cpsievert Apr 23, 2026
75f0d03
refactor: migrate widget runtime to srcts with TypeScript build toolc…
cpsievert Apr 24, 2026
ab3de04
fix: handle row/column grid facets and prefer clientHeight in readHos…
cpsievert Apr 24, 2026
34bcc70
Fail fast for plain SQL in renderGgsql
cpsievert Apr 24, 2026
a9b89a1
ci: add dedicated js check workflow
cpsievert Apr 24, 2026
137ff49
Merge branch 'main' into feat/shiny-bindings
cpsievert Apr 24, 2026
3ab71cd
Run air format
cpsievert Apr 24, 2026
1f63f69
test: run widget tests against TypeScript sources
cpsievert Apr 24, 2026
1a9ed0c
docs: add JS widget workflow notes
cpsievert Apr 24, 2026
31a4213
Remove spec doc; simplify gitignore
cpsievert Apr 24, 2026
73928db
fix: exclude dev-only files from CRAN tarball, deduplicate Vega versions
cpsievert Apr 24, 2026
7a196a7
chore: remove redundant tsc-bin.cjs wrapper
cpsievert Apr 24, 2026
917b71f
Merge commit '490314de9fce70c005014c6904f4702e09226482'
thomasp85 Apr 27, 2026
2c09407
formatting
thomasp85 Apr 27, 2026
0862faf
only use portable shell commands
thomasp85 Apr 27, 2026
6afa19f
fix bad copy-paste
thomasp85 Apr 27, 2026
44dde82
don't force integer
thomasp85 Apr 27, 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
9 changes: 9 additions & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,12 @@
^README\.Rmd$
^[.]?air[.]toml$
^\.vscode$
^node_modules(/|$)
^package\.json$
^package-lock\.json$
^tsconfig\.json$
^\.worktrees(/|$)
^srcts(/|$)
^tools/check-js\.mjs$
^tools/update-vega\.sh$
CLAUDE\.md
28 changes: 28 additions & 0 deletions .github/workflows/js-check.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
on:
push:
branches: [main, master]
pull_request:
workflow_dispatch:

name: js-check.yaml

permissions: read-all

jobs:
js-check:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm

- name: Install dependencies
run: npm ci

- name: Run JS checks
run: npm run check
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
docs
node_modules
.worktrees
.claude
src/vendor
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Rust crate src/rust/ (depends on `ggsql` crate + polars + duckdb + extendr-api)
| `tools/config.R` | Detects `DEBUG`, `NOT_CRAN`, webR/wasm target, vendor presence, and emits `src/Makevars{,.win}`. |
| `tools/msrv.R` | Enforces Rust MSRV (read from DESCRIPTION `SystemRequirements`). |
| `inst/ggsql.xml` | KDE-syntax highlighting definition installed for the knitr engine (added to Pandoc via `--syntax-definition`). |
| `srcts/` | TypeScript source of truth for the htmlwidgets runtime. `srcts/index.ts` bundles to `inst/htmlwidgets/ggsql_vega.js`; `srcts/vega/*.test.ts` covers widget behavior in Node. |
| `inst/test_chunks.qmd` | Fixture Quarto doc used by engine tests. |
| `vignettes/` | Quarto vignettes (`ggsql.qmd`, `engine.qmd`). `VignetteBuilder: quarto`. |
| `tests/testthat/` | `test-engine.R`, `test-reader.R`, `test-spec.R`, `test-validate.R`, `test-writer.R` plus `_snaps/`. |
Expand Down Expand Up @@ -80,6 +81,22 @@ recompiles) or `rextendr::document()` (also regenerates
Offline/CRAN builds rely on `src/rust/vendor.tar.xz` (cargo vendor archive).
If you bump Rust deps, regenerate the archive so CRAN builds keep working.

## TypeScript / JavaScript assets

`srcts/` is the source of truth for the browser-side widget code. The built
artifact checked into the package is `inst/htmlwidgets/ggsql_vega.js`.

The browser-side visualization runtime lives in `srcts/`, while `R/widget.R`,
`R/shiny.R`, and `R/engine.R` are the main R entry points that hand specs off
to that runtime. Files under `inst/htmlwidgets/` are generated package assets
or vendored browser dependencies, so prefer editing `srcts/` and rebuilding
rather than patching built assets by hand.

After making any change under `srcts/`, rebuild the generated asset with
`npm run build`. Before committing JS/TS changes, run `npm run check`; it
typechecks, rebuilds the bundle, runs the Node tests, and fails if the
checked-in generated asset drifted from the TypeScript source.

## The FFI contract

When adding or changing a Rust-exposed function:
Expand Down
3 changes: 3 additions & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ RoxygenNote: 7.3.3
Imports:
cli,
htmltools,
htmlwidgets,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, the next release of htmlwidgets will move rmarkdown from Suggests to Import

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm... I guess this is not too horrible given the prevalence of RMarkdown and the fact that this package provides a knitr engine for it

jsonlite,
knitr,
nanoarrow,
Expand All @@ -35,6 +36,7 @@ Suggests:
reticulate,
rmarkdown,
rsvg,
shiny,
testthat (>= 3.0.0),
V8,
withr
Expand All @@ -45,4 +47,5 @@ Depends:
VignetteBuilder: quarto
URL: https://r.ggsql.org, https://github.com/posit-dev/ggsql-r
Config/Needs/website: tidyverse/tidytemplate
Config/Needs/js: node
BugReports: https://github.com/posit-dev/ggsql-r/issues
3 changes: 3 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ S3method(print,ggsql_validated)
S3method(str,Spec)
export(custom_reader)
export(duckdb_reader)
export(ggsqlOutput)
export(ggsql_execute)
export(ggsql_execute_sql)
export(ggsql_has_visual)
Expand All @@ -26,6 +27,7 @@ export(ggsql_metadata)
export(ggsql_register)
export(ggsql_render)
export(ggsql_save)
export(ggsql_session_reader)
export(ggsql_sql)
export(ggsql_stat_data)
export(ggsql_stat_sql)
Expand All @@ -36,6 +38,7 @@ export(ggsql_validate)
export(ggsql_visual)
export(ggsql_warnings)
export(odbc_reader)
export(renderGgsql)
export(snowflake_reader)
export(vegalite_writer)
import(rlang)
Expand Down
6 changes: 5 additions & 1 deletion R/aaa.R
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,9 @@ check_r6 <- function(
arg = caller_arg(x),
call = caller_env()
) {
check_custom(x, function(x) R6::is.R6(x) && inherits(x, class), paste0("a ", class, "/R6 object"))
check_custom(
x,
function(x) R6::is.R6(x) && inherits(x, class),
paste0("a ", class, "/R6 object")
)
}
159 changes: 24 additions & 135 deletions R/engine.R
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ names.ggsql_tables <- function(x) {
# Data reference resolution (r: and py: prefixes)
# ---------------------------------------------------------------------------

resolve_data_refs <- function(query, reader) {
resolve_data_refs <- function(query, reader, envir) {
refs <- gregexpr(
"(?:r|py):[a-zA-Z_][a-zA-Z0-9_.]*",
query,
Expand All @@ -182,10 +182,10 @@ resolve_data_refs <- function(query, reader) {
df <- switch(
tolower(prefix),
r = try_fetch(
get(name, envir = knitr::knit_global()),
get(name, envir = envir),
error = function(cnd) {
cli::cli_abort(
"Column reference {.code {ref}}: object {.val {name}} not found in R environment."
"Data reference {.code {ref}}: object {.val {name}} not found in R environment."
)
}
),
Expand All @@ -197,134 +197,33 @@ resolve_data_refs <- function(query, reader) {
obj <- reticulate::py[[name]]
if (is.null(obj)) {
cli::cli_abort(
"Column reference {.code {ref}}: object {.val {name}} not found in Python environment."
"Data reference {.code {ref}}: object {.val {name}} not found in Python environment."
)
}
obj
}
)

if (!is.data.frame(df)) {
cli::cli_abort("{.code {ref}} does not refer to a data frame.")
cli::cli_abort(
"{.code {ref}} refers to a {.cls {class(df)}} object, not a data frame."
)
}

internal_name <- paste0("__", prefix, "_", gsub(".", "_ggsqldot_", name, fixed = TRUE), "__")
internal_name <- paste0(
"__",
prefix,
"_",
gsub(".", "_ggsqldot_", name, fixed = TRUE),
"__"
)
ggsql_register(reader, df, internal_name, replace = TRUE)
query <- gsub(ref, internal_name, query, fixed = TRUE)
}

query
}

# ---------------------------------------------------------------------------
# Vega-Lite HTML rendering
# ---------------------------------------------------------------------------

vegalite_html <- function(
spec_json,
width = NULL,
height = NULL,
asp = NULL,
caption = NULL,
align = "center"
) {
ggsql_env$vis_counter <- (ggsql_env$vis_counter %||% 0L) + 1L
vis_id <- paste0("ggsql-vis-", ggsql_env$vis_counter)

# Convert fig.width/fig.height (inches) to pixels at 96 dpi,
# or use defaults if not specified
css_width <- if (!is.null(width)) {
if (is.numeric(width)) paste0(round(width * 96), "px") else width
} else {
"100%"
}
css_height <- if (!is.null(height)) {
if (is.numeric(height)) paste0(round(height * 96), "px") else height
}
css_height <- if (is.null(asp)) {
paste0("height: ", css_height %||% "400px")
} else {
paste0("aspect-ratio: ", asp)
}

margin_style <- switch(
align %||% "center",
center = "margin-left: auto; margin-right: auto;",
right = "margin-left: auto;",
""
)

html <- sprintf(
'<div id="%s-outer" style="width: %s; overflow: hidden; %s">
<div id="%s" style="width: 100%%; min-width: 450px; %s;"></div>
</div>

<script type="text/javascript">
(function() {
const spec = %s;
const visId = "%s";
const minWidth = 450;

if (!window.__ggsql_vega_ready) {
const loadScript = (src) => new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
window.__ggsql_vega_ready = loadScript("https://cdn.jsdelivr.net/npm/vega@6/build/vega.min.js")
.then(() => loadScript("https://cdn.jsdelivr.net/npm/vega-lite@6/build/vega-lite.min.js"))
.then(() => loadScript("https://cdn.jsdelivr.net/npm/vega-embed@7/build/vega-embed.min.js"));
}

function scaleToFit(outer, inner) {
const available = outer.clientWidth;
if (available < minWidth) {
const scale = available / minWidth;
inner.style.transform = "scale(" + scale + ")";
inner.style.transformOrigin = "top left";
outer.style.height = (inner.scrollHeight * scale) + "px";
} else {
inner.style.transform = "";
outer.style.height = "";
}
}

window.__ggsql_vega_ready
.then(() => vegaEmbed("#" + visId, spec, {"actions": true}))
.then(() => {
const outer = document.getElementById(visId + "-outer");
const inner = document.getElementById(visId);
scaleToFit(outer, inner);
const ro = new ResizeObserver(() => scaleToFit(outer, inner));
ro.observe(outer);
})
.catch(err => {
document.getElementById(visId).innerText = "Failed to load Vega: " + err;
});
})();
</script>',
vis_id,
css_width,
margin_style,
vis_id,
css_height,
spec_json,
vis_id
)

if (!is.null(caption) && nzchar(caption)) {
html <- sprintf(
'<figure>\n%s\n<figcaption>%s</figcaption>\n</figure>',
html,
htmltools::htmlEscape(caption)
)
}

html
}

# ---------------------------------------------------------------------------
# Inline chunk options (--| and #| prefix support)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -413,7 +312,7 @@ ggsql_engine <- function(options) {
}

ggsql_engine_eval <- function(query, reader, options) {
query <- resolve_data_refs(query, reader)
query <- resolve_data_refs(query, reader, envir = knitr::knit_global())
validated <- ggsql_validate(query)

if (!validated$has_visual) {
Expand Down Expand Up @@ -455,27 +354,17 @@ ggsql_engine_eval <- function(query, reader, options) {
switch(
writer_type,
vegalite = {
# Embed Vega-Lite spec directly with vega-embed from CDN.
# This avoids vegawidget version constraints (ggsql uses Vega-Lite v6).
if (!is.null(options$fig.cap) && nzchar(options$fig.cap)) {
cli::cli_warn(c(
"{.code fig.cap} is not supported for interactive HTML output.",
i = "Use {.code writer = \"vegalite_svg\"} or {.code writer = \"vegalite_png\"} for captioned figures."
))
}
writer <- vegalite_writer()
json <- ggsql_render(writer, spec)
if (is.null(options$fig.dim)) {
width <- options$fig.width
height <- options$fig.height
asp <- options$fig.asp
} else {
width <- options$fig.dim[1]
height <- options$fig.dim[2]
asp <- NULL
}
out <- vegalite_html(
json,
width = width,
height = height,
asp = asp,
caption = options$fig.cap,
align = options$fig.align
)
widget <- ggsql_widget(json)
out <- knitr::knit_print(widget, options = options)
knitr::knit_meta_add(attr(out, "knit_meta"))
knitr::engine_output(options, options$code, out = out)
},
vegalite_svg = render_static_figure(spec, "svg", options),
Expand Down
9 changes: 5 additions & 4 deletions R/reader.R
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ build_odbc_uri <- function(
}
parts <- c(
if (!is.null(dsn)) paste0("DSN=", dsn),
if (!is.null(driver)) paste0("Driver={", gsub("^\\{|\\}$", "", driver), "}"),
if (!is.null(driver)) {
paste0("Driver={", gsub("^\\{|\\}$", "", driver), "}")
},
if (!is.null(server)) paste0("Server=", server),
if (!is.null(database)) paste0("Database=", database),
if (!is.null(uid)) paste0("UID=", uid),
Expand Down Expand Up @@ -482,7 +484,7 @@ ggsql_table_names <- function(reader) {
#'
ggsql_execute <- function(reader, query) {
check_r6(reader, "Reader")
check_string(name, allow_empty = FALSE)
check_string(query, allow_empty = FALSE)
spec_ptr <- reader$.ptr$execute(query)
Spec$new(spec_ptr)
}
Expand All @@ -491,8 +493,7 @@ ggsql_execute <- function(reader, query) {
#' @export
ggsql_execute_sql <- function(reader, query) {
check_r6(reader, "Reader")
check_string(name, allow_empty = FALSE)
check_string(query, allow_empty = FALSE)
ipc_bytes <- reader$.ptr$execute_sql_ipc(query)
ipc_to_df(ipc_bytes)
}

Loading
Loading