Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
19f7ce5
ci: correct the docs version badge from pyproject.toml
jat255 Sep 14, 2026
ac3bdc3
docs(py): build the site with great-docs
jat255 Sep 14, 2026
d2ce68f
docs(py): carry the site's icons, styles, links and R logo across
jat255 Sep 14, 2026
c601c3d
ci: correct the version in the logo tooltip too, and drop the release…
jat255 Sep 14, 2026
0801441
docs(py): publish versioned docs and a beta notice
jat255 Sep 14, 2026
e5b9bcc
ci: build the Python site with great-docs
jat255 Sep 14, 2026
d7e744f
docs: describe the Python site's new toolchain
jat255 Sep 14, 2026
5de980b
ci: rebuild the docs when the badge helper changes
jat255 Sep 14, 2026
4681a55
docs(py): fix the navbar link, the logo, the bolding and the banner
jat255 Sep 14, 2026
0a16caa
docs(py): link the author, and fix the favicons
jat255 Sep 14, 2026
9d69dd6
docs(py): mark the current page with weight, and calm two more styles
jat255 Sep 14, 2026
71d6532
docs(py): label the navbar entry "API Reference"
jat255 Sep 14, 2026
f407af2
docs(py): take the orange out of the navbar hover
jat255 Sep 14, 2026
0093058
docs(py): make the navbar hover visible in light mode
jat255 Sep 14, 2026
4e99582
docs(py): fix the navbar hover text and the active underline
jat255 Sep 14, 2026
e3bb4c8
docs(py): fix the dark sidebar's current item and hover alignment
jat255 Sep 14, 2026
dfd5e86
docs(py): order the user guide, and land on the introduction
jat255 Sep 14, 2026
98ff680
docs(py): reword the beta banner
jat255 Sep 14, 2026
c5400d0
docs(py): make the beta banner link work off the landing page
jat255 Sep 14, 2026
2975a37
docs(py): read the banner's path from the meta, not from the link
jat255 Sep 14, 2026
6f701b3
docs(py): put the Python mark and the package name in the navbar
jat255 Sep 14, 2026
e01a4aa
docs(py): fix the favicon, the navbar name and the hero logo
jat255 Sep 14, 2026
aa7490c
docs(py): scope the icon rewrite to link tags, and drop a duplicate key
jat255 Sep 14, 2026
689b30b
docs(py): stop Quarto restoring the banner's un-prefixed link
jat255 Sep 14, 2026
5c6fe51
docs(py): point the API reference source links inside pkg-py
jat255 Sep 14, 2026
e03917f
ci: finish the workflow rename, and scope the source rewrite to this …
jat255 Sep 14, 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
123 changes: 123 additions & 0 deletions .github/scripts/docs-postprocess.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Correct two things great-docs bakes into the built Python docs site.

**Absolute icon links.** great-docs writes the favicon `<link>` hrefs against
`site_url`, so every page points at the published site. They resolve to the
wrong place, or to nothing, when the same build is served from anywhere else:
a pull request preview under `pr-<n>/py/`, a local preview, or a versioned copy
under `v/<tag>/`. The generated icons sit at the root of each copy, so Quarto's
own per-page offset is the right prefix.

**Source links missing the package directory.** They are built relative to the
package root, which in this monorepo is `pkg-py/` rather than the repository
root, so every "source" button pointed at a path that does not exist.

**The navbar version badge.** great-docs derives it from the newest GitHub
Release for the whole repository and cannot filter by tag prefix, so in this
monorepo it shows whichever of the two release series published last. The
version selector already names the version, from the list in great-docs.yml,
so the badge is removed rather than corrected.

Remove the corresponding half of this script if great-docs gains relative icon
links, or a way to suppress the badge.

Usage: docs-postprocess.py <site-directory> <site-url> <repo-url> <package-root>
"""

from __future__ import annotations

import pathlib
import re
import sys

# The closing quote after the class name is what stops a longer class that
# merely starts the same way, such as version-badge-legend, from matching.
BADGE = re.compile(r'\s*<span class="version-badge"[^>]*>[^<]*</span>')

OFFSET = re.compile(r'<meta name="quarto:offset" content="([^"]*)"')

# Only <link> tags are rewritten. great-docs also injects a canonical-URL
# script whose base is the site URL, which it concatenates with a path at
# runtime; turning that into a page-relative prefix would yield a bogus
# canonical URL on every non-latest version.
LINK = re.compile(r"<link\b[^>]*>")

# Source links are built relative to the package root, which in a monorepo is
# not the repository root, so they arrive missing the package directory. The
# `source.path` config key cannot do this: it replaces the whole path with the
# file's basename, which flattens nested modules such as commons/_ui/_server.py.
# Scoped to this repository, so a documentation link into another project's
# src/ tree is left alone. The non-greedy group is the git ref, which may
# itself contain slashes.
def source_pattern(repo_url: str) -> re.Pattern[str]:
repo = repo_url.rstrip("/")
return re.compile(re.escape(repo) + r'/blob/([^"]+?)(/src/)')


def fix(
text: str, site_url: str, repo_url: str, package_root: str, offset: str
) -> str | None:
"""Return `text` post-processed, or None when it needs no change.

Returns None when the page needs neither, which keeps the caller from
rewriting a file it does not have to touch and makes this idempotent.
"""
base = site_url if site_url.endswith("/") else site_url + "/"
prefix = offset if offset.endswith("/") else offset + "/"

icons = 0

def relativize(match: re.Match[str]) -> str:
nonlocal icons
tag, n = re.subn(
r'(?<=href=")' + re.escape(base), prefix, match.group(0)
)
icons += n
return tag

repo = repo_url.rstrip("/")

def relocate(match: re.Match[str]) -> str:
ref = match.group(1)
# Already carries the package directory, so leave it be.
if ref == package_root or ref.endswith("/" + package_root):
return match.group(0)
return f"{repo}/blob/{ref}/{package_root}{match.group(2)}"

patched = LINK.sub(relativize, text)
patched, badges = BADGE.subn("", patched)
patched, sources = source_pattern(repo_url).subn(relocate, patched)

if not (icons or badges or sources) or patched == text:
return None
return patched


def main(root: str, site_url: str, repo_url: str, package_root: str) -> int:
directory = pathlib.Path(root)
if not directory.is_dir():
print(f"{root} is not a directory", file=sys.stderr)
return 1

changed = skipped = 0
for path in sorted(directory.rglob("*.html")):
text = path.read_text(encoding="utf-8", errors="surrogateescape")
# Each page carries the offset to its own copy's root, which is what
# makes this correct inside a versioned copy as well as at the top.
found = OFFSET.search(text)
offset = found.group(1) if found else "./"
patched = fix(text, site_url, repo_url, package_root, offset)
if patched is None:
skipped += 1
continue
path.write_text(patched, encoding="utf-8", errors="surrogateescape")
changed += 1

print(f"Post-processed {changed} page(s), skipped {skipped}.")
return 0


if __name__ == "__main__":
if len(sys.argv) != 5:
print(__doc__, file=sys.stderr)
raise SystemExit(2)
raise SystemExit(main(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]))
166 changes: 166 additions & 0 deletions .github/scripts/test_docs_postprocess.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Tests for docs-postprocess.py. Run: python3 test_docs_postprocess.py"""

import importlib.util
import pathlib
import unittest

spec = importlib.util.spec_from_file_location(
"docs_postprocess", pathlib.Path(__file__).parent / "docs-postprocess.py"
)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)

fix = mod.fix
BASE = "https://posit-dev.github.io/commons/py/"
ROOT = "pkg-py"
REPO = "https://github.com/posit-dev/commons"
BLOB = REPO + "/blob"


class StripVersionBadgeTest(unittest.TestCase):
def test_removes_the_badge(self):
page = '<span class="navbar-title">commons <span class="version-badge">v0.1.0.dev1</span></span>'
out = fix(page, BASE, REPO, ROOT, "./")
assert out is not None
self.assertNotIn("version-badge", out)
self.assertNotIn("v0.1.0.dev1", out)
# The space before the badge goes with it, leaving no dangling gap.
self.assertIn(">commons</span>", out)

def test_removes_the_badge_with_attributes(self):
page = '<span class="version-badge" title="Released 2026-09-11">v0.1.0.dev1</span>'
out = fix(page, BASE, REPO, ROOT, "./")
assert out is not None
self.assertEqual(out.strip(), "")

def test_leaves_a_longer_class_alone(self):
# Anchored on the closing quote, so version-badge-legend is not a badge.
page = '<span class="version-badge-legend">v1</span>'
self.assertIsNone(fix(page, BASE, REPO, ROOT, "./"))


class RelativizeIconsTest(unittest.TestCase):
def test_rewrites_an_absolute_icon_link_at_the_root(self):
page = f'<link rel="icon" href="{BASE}favicon.ico" sizes="48x48">'
out = fix(page, BASE, REPO, ROOT, "./")
assert out is not None
self.assertIn('href="./favicon.ico"', out)
self.assertNotIn(BASE, out)

def test_rewrites_with_a_nested_offset(self):
page = f'<link rel="apple-touch-icon" href="{BASE}apple-touch-icon.png">'
out = fix(page, BASE, REPO, ROOT, "../")
assert out is not None
self.assertIn('href="../apple-touch-icon.png"', out)

def test_rewrites_every_occurrence(self):
page = (
f'<link rel="icon" href="{BASE}favicon-16x16.png">'
f'<link rel="icon" href="{BASE}favicon-32x32.png">'
)
out = fix(page, BASE, REPO, ROOT, "../")
assert out is not None
self.assertEqual(out.count("../favicon-"), 2)
self.assertNotIn(BASE, out)

def test_tolerates_a_base_without_a_trailing_slash(self):
page = f'<link rel="icon" href="{BASE}favicon.ico">'
out = fix(page, BASE.rstrip("/"), REPO, ROOT, "../")
assert out is not None
self.assertIn('href="../favicon.ico"', out)

def test_leaves_the_versioned_canonical_script_alone(self):
# great-docs injects a canonical-URL script whose base is the site URL
# without a trailing slash, and builds a path onto it at runtime.
# Rewriting it to a page-relative prefix would produce a bogus
# canonical URL on every non-latest version.
page = '<script>var base="https://posit-dev.github.io/commons/py";</script>'
self.assertIsNone(fix(page, BASE, REPO, ROOT, "../"))

def test_leaves_a_script_using_the_trailing_slash_form_alone(self):
# Same risk, one character different: only <link> hrefs are rewritten.
page = f'<script>var base="{BASE}";</script>'
self.assertIsNone(fix(page, BASE, REPO, ROOT, "../"))

def test_rewrites_the_link_but_not_a_neighbouring_script(self):
page = (
f'<link rel="icon" href="{BASE}favicon.ico">'
f'<script>var base="{BASE}";</script>'
)
out = fix(page, BASE, REPO, ROOT, "../")
assert out is not None
self.assertIn('href="../favicon.ico"', out)
self.assertIn(f'var base="{BASE}";', out)

def test_leaves_an_unrelated_absolute_url_alone(self):
page = '<a href="https://pypi.org/project/commons/">PyPI</a>'
self.assertIsNone(fix(page, BASE, REPO, ROOT, "../"))

def test_leaves_a_page_needing_nothing(self):
self.assertIsNone(fix("<html><body>plain</body></html>", BASE, REPO, ROOT, "./"))

def test_is_idempotent(self):
page = (
f'<link rel="icon" href="{BASE}favicon.ico">'
'<span class="version-badge">v1</span>'
)
once = fix(page, BASE, REPO, ROOT, "../")
assert once is not None
self.assertIsNone(fix(once, BASE, REPO, ROOT, "../"))

def test_does_both_jobs_in_one_pass(self):
page = (
f'<link rel="icon" href="{BASE}favicon.ico">'
'<span class="version-badge">v0.1.0.dev1</span>'
)
out = fix(page, BASE, REPO, ROOT, "../")
assert out is not None
self.assertIn('href="../favicon.ico"', out)
self.assertNotIn("version-badge", out)


class SourceLinkTest(unittest.TestCase):
def test_inserts_the_package_root(self):
page = f'<a href="{BLOB}/main/src/commons/_agent.py#L58-L368">source</a>'
out = fix(page, BASE, REPO, ROOT, "./")
assert out is not None
self.assertIn(f"{BLOB}/main/pkg-py/src/commons/_agent.py#L58-L368", out)

def test_keeps_a_nested_module_path(self):
# source.path would have flattened this to the basename, which is why
# the prefix is inserted here instead of configured there.
page = f'<a href="{BLOB}/main/src/commons/_ui/_server.py#L1-L2">source</a>'
out = fix(page, BASE, REPO, ROOT, "./")
assert out is not None
self.assertIn(f"{BLOB}/main/pkg-py/src/commons/_ui/_server.py", out)

def test_handles_a_ref_containing_slashes(self):
page = f'<a href="{BLOB}/jat255/some-branch/src/commons/_agent.py">source</a>'
out = fix(page, BASE, REPO, ROOT, "./")
assert out is not None
self.assertIn(f"{BLOB}/jat255/some-branch/pkg-py/src/commons/_agent.py", out)

def test_is_idempotent_on_source_links(self):
page = f'<a href="{BLOB}/jat255/some-branch/pkg-py/src/commons/_agent.py">source</a>'
self.assertIsNone(fix(page, BASE, REPO, ROOT, "./"))

def test_leaves_another_repository_alone(self):
# Only this repository's generated links need the package directory;
# a link into someone else's src/ tree must be untouched.
page = '<a href="https://github.com/tidyverse/dplyr/blob/main/src/init.c">x</a>'
self.assertIsNone(fix(page, BASE, REPO, ROOT, "./"))

def test_tolerates_a_repo_url_with_a_trailing_slash(self):
page = f'<a href="{BLOB}/main/src/commons/_agent.py">source</a>'
out = fix(page, BASE, REPO + "/", ROOT, "./")
assert out is not None
self.assertIn("/main/pkg-py/src/commons/_agent.py", out)

def test_leaves_a_non_source_repo_link_alone(self):
page = f'<a href="https://github.com/posit-dev/commons/issues/12">issue</a>'
self.assertIsNone(fix(page, BASE, REPO, ROOT, "./"))


if __name__ == "__main__":
unittest.main(verbosity=2)
2 changes: 1 addition & 1 deletion .github/workflows/pkgdown.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
run:
working-directory: pkg-r
# Only restrict concurrency for non-PR jobs: those share gh-pages, and a
# PR build deploys nothing. Races against quartodoc.yaml's deploy are
# PR build deploys nothing. Races against py-docs.yaml's deploy are
# handled at the push instead, by the deploy action's rebase.
concurrency:
group: pkgdown-${{ github.event_name != 'pull_request' || github.run_id }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/preview-cleanup.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Removes the pr-<number>/ preview folders that quartodoc.yaml publishes to
# Removes the pr-<number>/ preview folders that py-docs.yaml publishes to
# gh-pages.
#
# Closing a pull request handles the ordinary case immediately. The schedule
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/preview-fork-notice.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Tells a fork's author why their pull request has no documentation preview.
#
# quartodoc.yaml skips the preview deploy for forks, and a fork's
# py-docs.yaml skips the preview deploy for forks, and a fork's
# GITHUB_TOKEN is read-only, so that workflow cannot post this itself.
# pull_request_target runs with the base repository's token, which can. This workflow never checks out the pull request, and asks for no
# permission beyond writing the comment, so none of what makes
Expand Down
33 changes: 19 additions & 14 deletions .github/workflows/quartodoc.yaml → .github/workflows/py-docs.yaml
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
on:
push:
branches: [main]
paths: [pkg-py/**, docs/**, .github/workflows/quartodoc.yaml]
# paths does not filter a tag push, so a release rebuilds the site
# whatever it touched, which is what a release should do.
tags: ["py-v*"]
paths: [pkg-py/**, docs/**, .github/scripts/docs-postprocess.py, .github/workflows/py-docs.yaml]
pull_request:
paths: [pkg-py/**, docs/**, .github/workflows/quartodoc.yaml]
release:
types: [published]
paths: [pkg-py/**, docs/**, .github/scripts/docs-postprocess.py, .github/workflows/py-docs.yaml]
workflow_dispatch:

name: quartodoc.yaml
name: Python documentation build

permissions: read-all

jobs:
quartodoc:
py-docs:
runs-on: ubuntu-latest
defaults:
run:
Expand All @@ -25,7 +26,7 @@ jobs:
# Races between the two sites are handled at the push instead, by the
# deploy action's rebase.
concurrency:
group: quartodoc-${{ github.event.number || github.ref }}
group: py-docs-${{ github.event.number || github.ref }}
permissions:
contents: write
pull-requests: write
Expand All @@ -41,21 +42,25 @@ jobs:
- name: Install the package and its docs dependencies
run: uv sync --extra shiny --group docs

- name: Build the reference pages
working-directory: pkg-py/docs
run: uv run quartodoc build
- name: Build the site
working-directory: pkg-py
run: uv run great-docs build

- name: Render the site
working-directory: pkg-py/docs
run: uv run quarto render
# Relativizes the favicon links great-docs writes against site_url, and
# drops its version badge. See the script for why each is needed.
- name: Post-process the built site
working-directory: pkg-py
run: |
read -r site_url repo_url < <(uv run python -c 'import yaml; c = yaml.safe_load(open("great-docs.yml")); print(c["site_url"], c["repo"])')
python3 ../.github/scripts/docs-postprocess.py great-docs/_site "$site_url" "$repo_url" pkg-py

# The deploy publishes the whole of docs/, so the rendered site has to
# sit at the path it is served from.
- name: Move the site into place
working-directory: .
run: |
rm -rf docs/py
cp -r pkg-py/docs/_site docs/py
cp -r pkg-py/great-docs/_site docs/py

# `clean: false` so this and the pkgdown deploy can each own a
# subdirectory of the same branch without erasing the other.
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/scripts-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ jobs:

- name: Test noindex-preview.py
run: python3 .github/scripts/test_noindex_preview.py

- name: Test docs-postprocess.py
run: python3 .github/scripts/test_docs_postprocess.py
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ This repository is a monorepo holding two implementations of commons: the R pack

Work from the relevant package's directory, not the repository root: `pkg-r/` for R (`devtools::load_all()`, `R CMD check`) and `pkg-py/` for Python (`uv run ruff check`, `uv run pyrefly check src tests`, `uv run pytest`). CI is scoped the same way. Run all of a package's checks before pushing; the pyrefly invocation needs its explicit `src tests` paths, because with none it consults the repo's git ignore files and a worktree checked out under an ignored directory silently type-checks nothing.

The documentation sites are the exception, because they are published as one tree: the landing page at the root, the R site under `r/`, and the Python site under `py/`. `scripts/preview-docs.sh` builds both into that tree and serves it, so a change can be read in place. It skips a site whose toolchain is missing rather than failing. Only the Python site gets a pull request preview; the R site follows the usual R package arrangement, where `r/dev/` tracks the development version and `r/` is redeployed by hand at release.
The documentation sites are the exception, because they are published as one tree: the landing page at the root, the R site under `r/`, and the Python site under `py/`. `scripts/preview-docs.sh` builds both into that tree and serves it, so a change can be read in place. It skips a site whose toolchain is missing rather than failing. Only the Python site gets a pull request preview; the R site follows the usual R package arrangement, where `r/dev/` tracks the development version and `r/` is redeployed by hand at release. The Python site is assembled by [great-docs](https://posit-dev.github.io/great-docs/) from `pkg-py/great-docs.yml`, which wraps Quarto rather than replacing it, and publishes a copy per release: the current one at `py/` and the others under `py/v/`. Adding a release to that list is a step in cutting one, not something the build infers.

Neither package has been widely adopted or publicly released; changes can be made without a deprecation cycle (or even reference to the way that it used to work).

Expand Down
Loading
Loading