Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
62 changes: 62 additions & 0 deletions .github/workflows/docs-cleanup.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Cleanup Netlify deploys

on:
# Trigger when a branch is deleted
delete:
# Trigger when a PR is closed (merged or dismissed)
pull_request_target:
types: [closed]

jobs:
delete-netlify-deploys:
# Run only if it's a PR close event OR a branch delete event (ignore tag deletions)
if: >
github.event_name == 'pull_request_target' ||
(github.event_name == 'delete' && github.event.ref_type == 'branch')
runs-on: ubuntu-latest
steps:
- name: Delete deploys
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
run: |
# 1. Determine the branch name based on the event type
if [ "${{ github.event_name }}" == "delete" ]; then
TARGET_BRANCH="${{ github.event.ref }}"
echo "Event: branch deletion. Target: $TARGET_BRANCH"
else
# For PRs, we target the head (source) branch of the PR
TARGET_BRANCH="${{ github.event.pull_request.head.ref }}"
echo "Event: PR closed. Target: $TARGET_BRANCH"
fi

# 2. Fetch all deploys matching this branch name. This catches both
# branch deploys and deploy previews, because Netlify indexes deploy
# previews by their source branch.
RESPONSE=$(curl -s -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \
"https://api.netlify.com/api/v1/sites/$NETLIFY_SITE_ID/deploys?branch=$TARGET_BRANCH&per_page=100")

# 3. Validate response is a JSON array
if ! echo "$RESPONSE" | jq -e 'type == "array"' > /dev/null 2>&1; then
echo "Unexpected API response (not a JSON array):"
echo "$RESPONSE" | head -5
echo "No deploys deleted for branch: $TARGET_BRANCH"
exit 0
fi

# 4. Parse IDs
DEPLOY_IDS=$(echo "$RESPONSE" | jq -r '.[].id')

if [ -z "$DEPLOY_IDS" ] || [ "$DEPLOY_IDS" == "null" ]; then
echo "No deploys found for branch: $TARGET_BRANCH"
exit 0
fi

# 5. Delete them
for ID in $DEPLOY_IDS; do
echo "Deleting deploy ID: $ID"
curl -s -X DELETE -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \
"https://api.netlify.com/api/v1/deploys/$ID"
done

echo "All deploys for $TARGET_BRANCH have been deleted."
79 changes: 79 additions & 0 deletions .github/workflows/docs-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Build and deploy the website

on:
workflow_call:
inputs:
ref:
description: "Git ref to check out. Defaults to the SHA that triggered the workflow."
type: string
default: ""
deploy-context:
description: "'production' or 'preview'"
type: string
required: true
secrets:
NETLIFY_AUTH_TOKEN:
required: true
NETLIFY_SITE_ID:
required: true
outputs:
deploy-url:
description: "Netlify deploy URL"
value: ${{ jobs.build-and-deploy.outputs.deploy-url }}

env:
UV_VERSION: "0.13.x"
PYTHON_VERSION: "3.12"
QUARTO_VERSION: "1.9.37"

jobs:
build-and-deploy:
runs-on: ubuntu-latest
outputs:
deploy-url: ${{ steps.netlify.outputs.deploy-url }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.ref || github.sha }}

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: ${{ env.UV_VERSION }}
enable-cache: true
cache-suffix: docs

- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}

- name: Set up Quarto
uses: quarto-dev/quarto-actions/setup@v2
with:
version: ${{ env.QUARTO_VERSION }}

# The build needs only the project (quartodoc imports it) plus the docs
# group; the site's code blocks are listings, never executed.
- name: Install dependencies
run: uv sync --python ${{ env.PYTHON_VERSION }} --no-default-groups --group docs

- name: Check website content
run: uv run --no-sync python website/_tools/check_docs.py

- name: Build the website
run: make docs

- name: Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@v4
with:
publish-dir: "./website/_site"
production-deploy: ${{ inputs.deploy-context == 'production' }}
github-token: ${{ secrets.GITHUB_TOKEN }}
deploy-message: "Deploy from GitHub Actions"
enable-pull-request-comment: ${{ inputs.deploy-context == 'preview' }}
enable-commit-comment: false
overwrites-pull-request-comment: true
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
timeout-minutes: 15
68 changes: 68 additions & 0 deletions .github/workflows/docs-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Website deploy preview

on:
pull_request:
branches: ["main"]
pull_request_target:
branches: ["main"]
issue_comment:
types: [created]

permissions:
contents: read
pull-requests: write
# nwtgck/actions-netlify creates a GitHub deployment and commit status
deployments: write
statuses: write

jobs:
notify-fork-pr:
# Post a sticky comment on every fork PR explaining /deploy-preview.
# Fork pushes get a read-only GITHUB_TOKEN with no access to secrets, so
# auto-deploy isn't possible regardless of the author's role. Every
# fork-PR author needs the same prompt — including members, who can
# self-trigger by commenting.
if: >
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.fork == true
runs-on: ubuntu-latest
steps:
- name: Post fork preview notice
uses: marocchino/sticky-pull-request-comment@v2
with:
header: fork-docs-preview-notice
message: |
## Website preview not available

This PR is from a fork and cannot deploy a website preview automatically.
A repository member can comment `/deploy-preview` to trigger one
(including you, if you have member access). Only trigger it after
reviewing the PR's changes: the build runs with deploy credentials.

build-and-deploy:
# Two paths to a preview deploy:
# 1. pull_request for non-fork PRs (auto-deploy)
# 2. /deploy-preview comment from a member (covers fork PRs and anyone
# without push access; members with fork PRs self-trigger this way)
concurrency:
group: docs-preview-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: true
if: >
(
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork != true
) ||
(
github.event.issue.pull_request &&
(
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'COLLABORATOR'
) &&
startsWith(github.event.comment.body, '/deploy-preview')
)
uses: ./.github/workflows/docs-deploy.yml
with:
ref: ${{ format('refs/pull/{0}/merge', github.event.pull_request.number || github.event.issue.number) }}
deploy-context: "preview"
secrets: inherit
27 changes: 27 additions & 0 deletions .github/workflows/docs-production.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Website production deploy

on:
push:
branches: ["main"]
paths:
# shinywidgets/** because quartodoc builds the API reference from the
# package's docstrings.
- "website/**/*"
- "shinywidgets/**/*"
- "Makefile"
- "pyproject.toml"
- ".github/workflows/docs-*.yml"
workflow_dispatch:

permissions:
contents: read
# nwtgck/actions-netlify creates a GitHub deployment and commit status
deployments: write
statuses: write

jobs:
build-and-deploy:
uses: ./.github/workflows/docs-deploy.yml
with:
deploy-context: "production"
secrets: inherit
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,5 @@ sandbox/

# uv lockfile: library CI resolves fresh against PyPI (see #319)
uv.lock

.superpowers/
14 changes: 13 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ cd js && yarn install && yarn watch
- **Build package**: `make dist`
- **Build JS**: `cd js && yarn build`
- **Watch JS**: `cd js && yarn watch`
- **Build the website**: `make docs` (quartodoc + Quarto into `website/_site`; fails on unresolved links; log in `website/_render.log`). Needs `uv sync --group docs` once.
- **Preview the website**: `make docs-preview` (serves `website/_site` at http://localhost:8780/)
- **Check the website**: `make docs-check` (browser checks against the built site, served under `/py-shinywidgets/`)

## Website

The docs site is a Quarto project in `website/`: widget guides in `website/widgets/<slug>/index.md`, articles in `website/guide/<slug>/index.md`, gallery apps in `website/gallery/`, the API reference generated by quartodoc through `website/_renderer.py`, and the landing page in `website/index.qmd` + `website/assets/landing/`. Each page keeps its runnable `examples/` and `img/` beside it.

- `website/_tools/check_docs.py` checks that code snippets match their example files and that links resolve; it must report nothing.
- Example code blocks name their file: ` ```{.python file="examples/x.py" files="helper.py" collapse="true"} ` (`files` = extra files the app needs, `collapse` defaults to expanded). `website/_filters/example-header/` renders the header bar (open / copy / Shinylive) and turns prose links to example `.py` files into Shinylive links. Shinylive installs the page's `extra` front matter; set `shinylive-requirements: [...]` on pages without one.
- Landing images are generated from the pages' own screenshots by `website/_tools/make_landing_images.py`; re-run it after re-shooting a screenshot it uses.
- vitessce's guide is `draft: true` until `render_vitessce` ships. To publish it, remove `draft`, add it to the Scientific sidebar group and the quartodoc sections in `website/_quarto.yml`, and bump the sidebar-link count in `website/_tools/check_site.py`.

## Architecture

Expand Down Expand Up @@ -71,7 +83,7 @@ Not all visualization objects are ipywidgets. `as_widget()` converts:
Handles dynamic loading of 3rd party widget JavaScript:
- `require_dependencies()` locates widget's nbextension files and creates HTMLDependencies
- Configures require.js paths so widgets can load their JS modules
- Falls back to CDN (unpkg.com) if local extension not found
- Falls back to a CDN (jsdelivr by default; `SHINYWIDGETS_CDN` overrides it) if the local extension isn't found

### Layout Integration

Expand Down
17 changes: 16 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help clean clean-build clean-pyc clean-test setup check-format check-types check-tests check check-tox build format coverage check-build test test-unit test-reactable test-playwright test-playwright-reactable test-playwright-coverage test-all-local coverage-ci coverage-combine coverage-html dist install pyright
.PHONY: help clean clean-build clean-pyc clean-test setup check-format check-types check-tests check check-tox build format coverage check-build test test-unit test-reactable test-playwright test-playwright-reactable test-playwright-coverage test-all-local coverage-ci coverage-combine coverage-html dist install pyright docs docs-preview docs-check

.DEFAULT_GOAL := help

Expand Down Expand Up @@ -133,3 +133,18 @@ install: build ## install the built wheel into the uv environment
uv run pip install dist/shinywidgets*.whl

pyright: check-types ## run pyright type checks

DOCS_PORT ?= 8780

docs: ## build the website into website/_site (log: website/_render.log)
cd website && uv run --no-sync quartodoc build --config _quarto.yml > _render.log 2>&1 || { tail -40 _render.log; exit 1; }
cd website && uv run --no-sync quarto render >> _render.log 2>&1 || { tail -40 _render.log; exit 1; }
@if grep -q "Unable to resolve link target" website/_render.log; then \
grep -B1 "Unable to resolve link target" website/_render.log | head -40; \
echo "Unresolved links; see website/_render.log"; exit 1; fi

docs-preview: ## serve the built website at http://localhost:$(DOCS_PORT)/
uv run --no-sync python -m http.server $(DOCS_PORT) --bind 127.0.0.1 --directory website/_site

docs-check: ## run the website's browser checks against website/_site
uv run --no-sync python website/_tools/check_site.py
7 changes: 7 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# The website is built in GitHub Actions (.github/workflows/docs-deploy.yml)
# and pushed to Netlify through its API. Netlify itself never builds this
# repo: if the site is ever Git-connected, `ignore` makes every Netlify-side
# build a no-op.
[build]
publish = "website/_site"
ignore = "exit 0"
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,12 @@ dev = [
"ruff>=0.6.5",
{ include-group = "test" },
]
# Builds the website's API reference. quartodoc 0.11.1 crashes on griffe 2.x
# (parse_numpy() got an unexpected keyword argument 'allow_section_blank_line').
docs = [
"quartodoc==0.11.1",
"griffe<2",
]

[tool.uv]
# mosaic-widget 0.33.0 declares narwhals>=2.8 but imports is_into_lazyframe,
Expand Down
13 changes: 13 additions & 0 deletions website/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/_site/
/.quarto/
/_render.log
/objects.json
/reference/*.qmd
/reference/_sidebar.yml
**/*.quarto_ipynb

# Stray output from rendering a single page directly (quarto render page.md)
# instead of the whole project: HTML lands beside the source, not in _site/.
**/*.html
/site_libs/
**/*-listing.json
62 changes: 62 additions & 0 deletions website/_filters/example-header/example-header.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Collapse, copy, and open-in-Shinylive for example code headers (see example-header.lua).
(function () {
const SHINYLIVE = "https://shinylive.io/py/editor/#code=";
let bundles;

function shinyliveUrl(key) {
if (bundles === undefined) {
const el = document.getElementById("sw-example-bundles");
bundles = el ? JSON.parse(el.textContent) : null;
}
const files = bundles && bundles[key];
if (!files || typeof LZString === "undefined") return null;
return SHINYLIVE + LZString.compressToEncodedURIComponent(JSON.stringify(files));
}

function toggle(example) {
const collapsed = example.classList.toggle("sw-collapsed");
example.querySelector(".sw-example-toggle").setAttribute("aria-expanded", String(!collapsed));
}

function flash(button, text) {
const label = button.getAttribute("title");
button.setAttribute("title", text);
button.setAttribute("aria-label", text);
setTimeout(() => {
button.setAttribute("title", label);
button.setAttribute("aria-label", label);
}, 1200);
}

document.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;

const live = target.closest('[data-action="shinylive"], a.sw-shinylive-link');
if (live) {
// No bundle or LZString: fall through to the raw .py href.
const url = shinyliveUrl(live.getAttribute("data-example"));
if (url) {
event.preventDefault();
window.open(url, "_blank", "noopener");
}
return;
}

const example = target.closest(".sw-example");
if (!example) return;

const copy = target.closest('[data-action="copy"]');
if (copy) {
const code = example.querySelector(".sw-example-code code");
navigator.clipboard.writeText(code ? code.textContent : "").then(() => flash(copy, "Copied!"));
return;
}

if (target.closest(".sw-example-toggle")) {
toggle(example);
} else if (target.closest(".sw-example-bar") && !target.closest("a, button")) {
toggle(example);
}
});
})();
Loading
Loading