Skip to content

Add Quarto and Jupyter (R kernel) rendering support#39

Merged
yihui merged 15 commits intomainfrom
copilot/verify-quarto-and-jupyter-support
Apr 9, 2026
Merged

Add Quarto and Jupyter (R kernel) rendering support#39
yihui merged 15 commits intomainfrom
copilot/verify-quarto-and-jupyter-support

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 9, 2026

gglite charts were silently broken in Quarto and produced no output in Jupyter notebooks via IRkernel.

Root causes

  • Quarto: knit_print.g2 used htmltools::htmlDependency(src = c(href = '')) to deduplicate CDN script injection. Quarto's pipeline rejects non-disk-based dependencies with a hard error.
  • Jupyter: No repr_html.g2 or repr_text.g2 methods. IRkernel uses the repr package for MIME-bundle display; it also skips all output (including HTML) when text/plain is empty.

Changes

R/render.R

  • g2_html_page(body): New helper that constructs a complete HTML page (CDN scripts in <head>, chart body); shared between print.g2 and repr_html.g2.
  • .knitr.flag: Package-level constant 'gglite.scripts_added' for CDN deduplication, usable in tests without hard-coding the string.
  • knit_print.g2: Drops htmltools::htmlDependency. CDN scripts are now inlined once per document via a knitr::opts_knit flag (automatically cleared when opts_knit is restored at document end). Works in both R Markdown and Quarto.
  • repr_html.g2: Returns a complete HTML page via g2_html_page(). JupyterLab renders this in a sandboxed iframe.
  • repr_text.g2: Returns a brief label (e.g. "G2 chart (point; 32 rows)") so IRkernel emits a non-empty text/plain MIME entry — required for any rich display to be sent.
  • register_methods(pkgs, generics): DRY helper that registers S3 methods in external namespaces using asNamespace('gglite')[[paste0(generic, '.g2')]] (immediately if loaded, via setHook otherwise). Replaces the old register_repr_html() / register_knit_print() pair.
  • .onLoad: Simplified to a single register_methods(c('knitr', 'repr', 'repr'), c('knit_print', 'repr_html', 'repr_text')) call.
  • Histogram bug fix: Auto-mark for a single numeric variable now uses type = 'rect' instead of type = 'interval'. G2's interval mark forces a categorical band scale for x, causing bins to display in data-appearance order. The rect mark uses quantitative x/x1 encodings from binX with a linear scale, giving correct numeric ordering. Added style = list(stroke = 'white') for visual bar separation with no gaps.

DESCRIPTION

  • repr added to Suggests; version bumped to 0.0.22.

Tests and examples

  • tests/test-gglite.qmd — scatter, histogram, and time-series examples using the formula interface (g2(mtcars, hp ~ mpg), g2(mtcars, ~ mpg)) with auto-inferred marks.
  • tests/test-gglite.ipynb — same three charts with embedded text/html outputs.
  • tests/test-quarto.py — standalone Playwright script for browser-testing the Quarto HTML output.
  • tests/test-jupyter.py — standalone Playwright script for browser-testing notebook HTML outputs in iframes.
  • tests/normalize-notebook.py — helper that strips execution counts and timestamps from an executed notebook for clean diffs.
  • All test files covered by wildcard .Rbuildignore patterns (tests/.*\.qmd$, tests/.*\.ipynb$, tests/.*\.py$); tests/*.html added to .gitignore.
  • New unit tests covering CDN-once deduplication (using .knitr.flag), repr_text.g2, and repr_html.g2.

.github/copilot-instructions.md

  • New "Variables and Formula Interface" section: prefer formula syntax (g2(mtcars, hp ~ mpg)), drop explicit marks that auto_mark() can infer, with examples showing color = ~ Species, size = ~ wt, and shape = ~ Species for other aesthetic channels.

.github/workflows/test-quarto-jupyter.yml

Weekly (Sunday midnight UTC) + workflow_dispatch + push/pull_request on tests/*.qmd, tests/*.ipynb, and tests/*.py changes. The job:

  1. Renders tests/test-gglite.qmd via Quarto.
  2. Executes tests/test-gglite.ipynb via jupyter nbconvert.
  3. Browser-tests both outputs by running tests/test-quarto.py and tests/test-jupyter.py — asserts canvas elements render and no G2 errors appear.
  4. When running on main and the executed notebook outputs differ from the committed file, automatically opens a PR to update tests/test-gglite.ipynb.
  5. Uploads screenshots, the rendered Quarto HTML, and the executed notebook as artifacts.

Copilot AI and others added 3 commits April 9, 2026 15:42
Co-authored-by: yihui <163582+yihui@users.noreply.github.com>
Comment thread R/render.R Outdated
Comment on lines +355 to +365
repr_html.g2 = function(obj, ...) {
html = c(
'<!DOCTYPE html>', '<html>', '<head>',
'<meta charset="utf-8">',
cdn_scripts(),
'</head>', '<body>',
chart_html(obj),
'</body>', '</html>'
)
paste(html, collapse = '\n')
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

this function is highly similar to print.g2(); refactor and reuse!

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.

Done in f4f29ed. Extracted g2_html_page(body) helper that constructs the full HTML page from a chart body. Both print.g2 and repr_html.g2 now delegate to it — repr_html.g2 is reduced to a one-liner: function(obj, ...) g2_html_page(chart_html(obj)).

Comment thread R/render.R Outdated
Comment on lines 390 to 404
register_repr_html = function() {
registerS3method('repr_html', 'g2', repr_html.g2, envir = asNamespace('repr'))
registerS3method('repr_text', 'g2', repr_text.g2, envir = asNamespace('repr'))
}

register_knit_print = function() {
registerS3method('knit_print', 'g2', knit_print.g2, envir = asNamespace('knitr'))
}

.onLoad = function(...) {
if (isNamespaceLoaded('knitr')) register_knit_print()
setHook(packageEvent('knitr', 'onLoad'), function(...) register_knit_print())
if (isNamespaceLoaded('repr')) register_repr_html()
setHook(packageEvent('repr', 'onLoad'), function(...) register_repr_html())
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

too much repetition; refactor and simplify, e.g., write a register_methods(pkg, generic) function so you can

register_methods(c('knitr', 'repr', 'repr'), c('knit_print', 'repr_html', 'repr_text'))

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.

Done in cf2be2b. Added register_methods(pkgs, generics) that loops over parallel vectors, looks up each method by name from the gglite namespace, and both registers immediately (if the package is already loaded) and installs an onLoad hook. .onLoad is now:

register_methods(
  c('knitr', 'repr', 'repr'),
  c('knit_print', 'repr_html', 'repr_text')
)

Comment on lines +3 to +6
on:
schedule:
- cron: '0 0 * * 0' # weekly on Sunday at midnight UTC
workflow_dispatch:
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

the workflow should also run when we make changes to .ipynb or .qmd

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.

Added in cf2be2b. The workflow now also triggers on push and pull_request when tests/test-gglite.qmd or tests/test-gglite.ipynb change.

Comment on lines +46 to +148
- name: Browser-test Quarto output
run: |
python - << 'EOF'
import asyncio
from playwright.async_api import async_playwright
import os

async def test():
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()

g2_errors = []
page.on("pageerror", lambda e: g2_errors.append(str(e)))

html = os.path.abspath("tests/test-gglite.html")
await page.goto(f"file://{html}")
await page.wait_for_timeout(6000)

canvases = await page.query_selector_all("canvas")
print(f"Quarto: {len(canvases)} canvas elements")
g2_errs = [e for e in g2_errors if "G2" in e or "chart" in e.lower()]
if g2_errs:
raise AssertionError(f"G2 errors: {g2_errs}")
assert len(canvases) >= 3, f"Expected >= 3 canvases, got {len(canvases)}"
print("Quarto test PASSED")

await page.screenshot(path="/tmp/quarto-test.png", full_page=True)
await browser.close()

asyncio.run(test())
EOF

- name: Browser-test Jupyter output (iframe simulation)
run: |
python - << 'EOF'
import asyncio, json, os, tempfile
from playwright.async_api import async_playwright

# Load the executed notebook and build an iframe-based test page
with open("/tmp/test-gglite-executed.ipynb") as f:
nb = json.load(f)

# Collect all text/html cell outputs (charts)
chart_htmls = []
for cell in nb["cells"]:
for out in cell.get("outputs", []):
if "text/html" in out.get("data", {}):
chart_htmls.append("".join(out["data"]["text/html"]))

print(f"Found {len(chart_htmls)} chart outputs in notebook")
assert len(chart_htmls) >= 3, f"Expected >= 3 chart outputs, got {len(chart_htmls)}"

# Write each chart as a separate file for iframe loading
tmp_dir = tempfile.mkdtemp()
iframe_tags = []
for i, html in enumerate(chart_htmls):
path = os.path.join(tmp_dir, f"chart{i}.html")
with open(path, "w") as f:
f.write(html)
iframe_tags.append(
f'<iframe src="file://{path}" width="800" height="520" '
f'frameborder="0" id="frame{i}"></iframe>'
)

# Wrapper page
wrapper_path = os.path.join(tmp_dir, "wrapper.html")
with open(wrapper_path, "w") as f:
f.write("<!DOCTYPE html><html><body>\n")
f.write("\n".join(iframe_tags))
f.write("\n</body></html>")

async def test():
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()

errors = []
page.on("pageerror", lambda e: errors.append(str(e)))

await page.goto(f"file://{wrapper_path}")
await page.wait_for_timeout(8000)

iframe_canvases = 0
for iframe_el in await page.query_selector_all("iframe"):
frame = await iframe_el.content_frame()
if frame:
cs = await frame.query_selector_all("canvas")
iframe_canvases += len(cs)

print(f"Jupyter: {iframe_canvases} canvas elements across iframes")
g2_errs = [e for e in errors if "G2" in e]
if g2_errs:
raise AssertionError(f"G2 errors in Jupyter output: {g2_errs}")
assert iframe_canvases >= 3, \
f"Expected >= 3 canvases, got {iframe_canvases}"
print("Jupyter test PASSED")

await page.screenshot(path="/tmp/jupyter-test.png", full_page=True)
await browser.close()

asyncio.run(test())
EOF
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

write these python scripts into files and run them, instead of inlining them in the workflow

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.

Done in cf2be2b. Created tests/test-quarto.py and tests/test-jupyter.py as standalone scripts. The workflow now runs:

- run: python tests/test-quarto.py tests/test-gglite.html
- run: python tests/test-jupyter.py /tmp/test-gglite-executed.ipynb

Comment thread R/render.R Outdated
# knit session (opts_knit is restored between documents). This avoids the
# disk-based dependency requirement that htmltools::htmlDependency imposes,
# which would cause Quarto to error.
key = 'gglite.scripts_added'
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

move it outside the function so we can reuse it elsewhere like in tests without using a hard-coded string, e.g.,

.knitr.flag = 'gglite.scripts_added'

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.

Done in cf2be2b. Moved it to a package-level constant: .knitr.flag = 'gglite.scripts_added'. knit_print.g2 now references .knitr.flag instead of a hard-coded string, and tests also use it.

Comment thread tests/testit/test-gglite.R Outdated
(grepl('unpkg.com', out1))
(!grepl('unpkg.com', out2))
# Reset for other tests
knitr::opts_knit$set('gglite.scripts_added' = NULL)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Suggested change
knitr::opts_knit$set('gglite.scripts_added' = NULL)
knitr::opts_knit$delete(.knitr.flag)

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.

Done in cf2be2b: knitr::opts_knit$delete(.knitr.flag).

Comment thread tests/testit/test-gglite.R Outdated
Comment on lines +505 to +506
assert('repr methods are registered when repr is loaded', {
loadNamespace('repr')
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

run tests conditionally:

Suggested change
assert('repr methods are registered when repr is loaded', {
loadNamespace('repr')
if (xfun::loadable('repr')) assert('repr methods are registered when repr is loaded', {

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.

Done in cf2be2b: if (xfun::loadable('repr')) assert('repr methods...', { ... }).

Comment thread tests/test-gglite.qmd Outdated

```{r}
#| label: scatter
g2(mtcars, x = 'mpg', y = 'hp') |> mark_point()
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

use the formula interface instead of character strings for variable names when possible, e.g.,

g2(mtcars, hp ~ mpg) |> mark_point()

instead of

g2(mtcars, x = 'mpg', y = 'hp') |> mark_point()

also drop marks that can be automatically inferred, e.g.,

g2(mtcars, hp ~ mpg)

instead of

g2(mtcars, hp ~ mpg) |> mark_point()

write these rules in copilot instructions so future examples follow them

also apply these rules to the examples you created

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.

Done in cf2be2b. Both .qmd and .ipynb examples now use the formula interface and drop explicit auto-inferable marks:

  • Scatter: g2(mtcars, hp ~ mpg) (was g2(mtcars, x = 'mpg', y = 'hp') |> mark_point())
  • Histogram: g2(mtcars, ~ mpg) (replaces the broken bar-chart example)
  • Time series: g2(sunspot.year) (unchanged)

Also added rules to the copilot instructions: prefer formula interface, drop marks that can be auto-inferred.

Comment thread tests/test-gglite.ipynb
"outputs": [
{
"data": {
"text/html": [
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

is it possible to make these plots show on github? while viewing https://github.com/yihui/gglite/blob/copilot/verify-quarto-and-jupyter-support/tests/test-gglite.ipynb, the plots are stripped off

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.

Unfortunately no — GitHub's notebook viewer strips all <script> tags for security reasons, so interactive JavaScript charts can't render in GitHub's static viewer regardless of the isolated: true metadata. The charts work in JupyterLab/Jupyter Notebook and on nbviewer.org (which allows trusted notebooks), but not in GitHub's built-in preview. Adding base64 PNG screenshots as image/png fallback outputs would make GitHub show something, but would roughly triple the file size.

Comment thread tests/test-gglite.qmd Outdated

```{r}
#| label: bar
g2(mtcars, x = 'cyl') |> mark_interval()
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

this example doesn't work and the plot is blank; how is it missed by your testing?

cyl is a numeric variable; you can't use interval marks on a single numeric variable

btw, while testing, i discovered a serious bug for the automatic histograms for single numeric variables, e.g., g2(mtcars, ~cyl) generates a histogram but the order of bars is wrong: on the x axis, we see tick labels 6, 4, 8, 7, 5, 9 instead of 4, 5, 6, 7, 8, 9; this bug is not unique to this example, e.g., g2(penguins, ~ bill_len) also shows wrong order; besides, for histograms, there shouldn't be gaps between bars, and you may set white borders on the bars as the visual separator between bars

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.

Fixed in cf2be2b:

  1. The broken g2(mtcars, x = 'cyl') |> mark_interval() example is replaced with g2(mtcars, ~ mpg) (histogram — no explicit mark needed).

  2. The histogram ordering bug is fixed by switching the auto-mark from type = 'interval' to type = 'rect'. G2's interval mark forces a band (categorical) scale for x, so bins were displayed in data-appearance order. The rect mark uses quantitative x/x1 values from binX and renders them on a linear scale in the correct numeric order. Added style = list(stroke = 'white') for visual separation between bars (no gaps).

Copilot AI and others added 2 commits April 9, 2026 17:22
Copilot finished work on behalf of yihui April 9, 2026 17:27
Copilot AI requested a review from yihui April 9, 2026 17:27
Comment thread .Rbuildignore Outdated
Comment on lines +11 to +14
^tests/test-gglite\.qmd$
^tests/test-gglite\.ipynb$
^tests/test-quarto\.py$
^tests/test-jupyter\.py$
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

just ignore all *.qmd, *.ipynb, and *.py under tests/ without listing their basenames in case we need to add more tests in future

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.

Done in ca0b4f4. Replaced the four specific entries with three wildcard patterns: ^tests/.*\.qmd$, ^tests/.*\.ipynb$, ^tests/.*\.py$.

Comment on lines +8 to +14
paths:
- 'tests/test-gglite.qmd'
- 'tests/test-gglite.ipynb'
pull_request:
paths:
- 'tests/test-gglite.qmd'
- 'tests/test-gglite.ipynb'
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

use wildcards in paths: *.qmd, *.ipynb, and *.py

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.

Done in ca0b4f4. Paths now use tests/*.qmd, tests/*.ipynb, tests/*.py.

Comment on lines +47 to +48
jupyter nbconvert --to notebook --execute tests/test-gglite.ipynb \
--output /tmp/test-gglite-executed.ipynb
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

when running on the main branch and the output differs from original .ipynb, send a PR to update the original

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.

Done in ca0b4f4 (refined in 4c23667). Added a step that runs only when github.ref == 'refs/heads/main': normalizes the executed notebook to /tmp/test-gglite-normalized.ipynb (strips execution counts/timestamps via tests/normalize-notebook.py), diffs it against the committed file, and if different, commits to a new branch (auto/update-notebook-<run_id>) and opens a PR.

uses: actions/upload-artifact@HEAD
with:
name: browser-screenshots
path: /tmp/*.png
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

also upload outputs from quarto and jupyter

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.

Done in ca0b4f4. The upload artifact step now collects /tmp/*.png (screenshots), tests/test-gglite.html (Quarto output), and /tmp/test-gglite-executed.ipynb (executed notebook).

```r
g2(mtcars, ~ mpg) # histogram
g2(mtcars, ~ cyl) # histogram (cyl is numeric in mtcars)
```
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

add an examplesof using formula in other arguments, e.g., color, size, etc (see PR #36)

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.

Done in ca0b4f4. Added examples showing color = ~ Species, color = ~ cyl, size = ~ wt, and shape = ~ Species.

Comment thread R/render.R Outdated
register_knit_print = function() {
registerS3method('knit_print', 'g2', knit_print.g2, envir = asNamespace('knitr'))
register_methods = function(pkgs, generics) {
stopifnot(length(pkgs) == length(generics))
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

no need to verify; this function is called by ourselves, not user-facing, and we'll ensure lengths are equal

Suggested change
stopifnot(length(pkgs) == length(generics))

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.

Done in ca0b4f4. Removed the stopifnot line.

Comment thread R/render.R Outdated
hook = function(...) {
registerS3method(
generic, 'g2',
get(method_name, envir = asNamespace('gglite')),
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

avoid default inheritance

Suggested change
get(method_name, envir = asNamespace('gglite')),
asNamespace('gglite')[[paste0(generic, '.g2')]],

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.

Done in ca0b4f4: asNamespace('gglite')[[paste0(generic, '.g2')]].

Comment thread R/render.R Outdated
stopifnot(length(pkgs) == length(generics))
for (i in seq_along(pkgs)) local({
pkg = pkgs[[i]]; generic = generics[[i]]
method_name = paste0(generic, '.g2')
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Suggested change
method_name = paste0(generic, '.g2')

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.

Done in ca0b4f4. Removed the method_name local variable; the expression is now inlined directly in the registerS3method() call.

Comment thread tests/testit/test-gglite.R Outdated
})

if (xfun::loadable('repr')) assert('repr methods are registered when repr is loaded', {
loadNamespace('repr')
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

xfun::loadable() will load the package

Suggested change
loadNamespace('repr')

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.

Done in ca0b4f4. Removed the if (xfun::loadable('repr')) wrapper — the assert runs unconditionally.

@yihui yihui marked this pull request as ready for review April 9, 2026 17:54
Copilot AI and others added 2 commits April 9, 2026 17:58
…ister_methods, formula docs

Agent-Logs-Url: https://github.com/yihui/gglite/sessions/42090aea-3171-40d2-b40b-07840c78eada

Co-authored-by: yihui <163582+yihui@users.noreply.github.com>
…e, safe temp file compare

Agent-Logs-Url: https://github.com/yihui/gglite/sessions/42090aea-3171-40d2-b40b-07840c78eada

Co-authored-by: yihui <163582+yihui@users.noreply.github.com>
Copilot finished work on behalf of yihui April 9, 2026 18:03
Copilot AI requested a review from yihui April 9, 2026 18:03
@yihui yihui force-pushed the copilot/verify-quarto-and-jupyter-support branch from 3b88a34 to fc2d75e Compare April 9, 2026 18:28
@yihui yihui merged commit 50c81db into main Apr 9, 2026
9 checks passed
@yihui yihui deleted the copilot/verify-quarto-and-jupyter-support branch April 9, 2026 18:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants