diff --git a/.github/scripts/link-r-dev.sh b/.github/scripts/link-r-dev.sh new file mode 100755 index 00000000..32ec240e --- /dev/null +++ b/.github/scripts/link-r-dev.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Point /r/ at the development site when that is the only site built. +# +# pkgdown's automatic development mode sends a released version's site to the +# root of its destination and a development version's to dev/ below it. Every +# build from a branch is a development version, so r/ is empty and the landing +# page's link to it lands on nothing. +# +# Moving dev/ up is the obvious fix and the wrong one: the build writes its own +# absolute address into search.json, sitemap.xml, and the pages themselves, so +# a moved tree keeps telling the browser to go to r/dev/, and site search +# breaks. A redirect leaves the build untouched. +# +# Only for local previews and pull request previews. On the published site r/ +# is the released documentation, and overwriting its index with a redirect to +# the development docs would be a regression for every reader. +set -euo pipefail + +docs="${1:?usage: link-r-dev.sh }" + +if [ -f "$docs/r/index.html" ]; then + echo "r/ already has an index, leaving it alone." + exit 0 +fi +if [ ! -f "$docs/r/dev/index.html" ]; then + echo "Neither r/ nor r/dev/ has an index, nothing to point at." >&2 + exit 0 +fi + +cat > "$docs/r/index.html" <<'HTML' + + + + + + +Redirecting to the development documentation + + +

This preview contains the development documentation. Redirecting to dev/.

+ + +HTML + +echo "Wrote $docs/r/index.html, redirecting to dev/." diff --git a/.github/scripts/noindex-preview.py b/.github/scripts/noindex-preview.py new file mode 100755 index 00000000..1e8b7500 --- /dev/null +++ b/.github/scripts/noindex-preview.py @@ -0,0 +1,73 @@ +"""Mark every page of a built site as noindex, in place. + +Pull request previews live under posit-dev.github.io/commons/pr-/. +Crawlers read robots.txt only at the origin root, and this repository does not +own posit-dev.github.io, so a robots.txt shipped with the site is never +consulted. A meta tag travels with the page instead. + +Usage: noindex-preview.py +""" + +from __future__ import annotations + +import pathlib +import re +import sys + +TAG = '' +HEAD = re.compile(r"]*>", re.IGNORECASE) +HTML = re.compile(r"]*>", re.IGNORECASE) +# Anchored, and tolerant of a leading byte order mark, because a doctype +# counts as one only when nothing but that precedes it. The anchor is also +# what keeps a fragment that merely quotes a doctype from passing as a +# document. Written as an escape: a literal mark here would be invisible. +DOCTYPE = re.compile("\\A\\ufeff?\\s*]*>", re.IGNORECASE) + + +def mark(text: str) -> str | None: + """Return `text` with the tag added, or None if it needs no change. + + A file with no head, no html tag, and no leading doctype is a fragment + rather than a page. Nothing indexes it on its own, and giving it a head + would only corrupt whatever embeds it. + """ + if 'name="robots"' in text: + return None + + patched, count = HEAD.subn(lambda m: m.group(0) + TAG, text, count=1) + if count: + return patched + + # The html tag is tried before the doctype because a head belongs inside + # html. Taking whichever appears first in the text would put the head + # between the two on every page that has both. + head = f"{TAG}" + for pattern in (HTML, DOCTYPE): + patched, count = pattern.subn(lambda m: m.group(0) + head, text, count=1) + if count: + return patched + return None + + +def main(root: str) -> int: + directory = pathlib.Path(root) + if not directory.is_dir(): + print(f"{root} is not a directory", file=sys.stderr) + return 1 + + marked = skipped = 0 + for path in sorted(directory.rglob("*.html")): + text = path.read_text(encoding="utf-8", errors="surrogateescape") + patched = mark(text) + if patched is None: + skipped += 1 + continue + path.write_text(patched, encoding="utf-8", errors="surrogateescape") + marked += 1 + + print(f"Marked {marked} page(s) noindex, skipped {skipped}.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else ".")) diff --git a/.github/scripts/test_noindex_preview.py b/.github/scripts/test_noindex_preview.py new file mode 100644 index 00000000..5fafe222 --- /dev/null +++ b/.github/scripts/test_noindex_preview.py @@ -0,0 +1,82 @@ +"""Tests for noindex-preview.py. Run: python3 test_noindex_preview.py""" + +import importlib.util +import pathlib +import unittest + +spec = importlib.util.spec_from_file_location( + "noindex_preview", pathlib.Path(__file__).parent / "noindex-preview.py" +) +assert spec and spec.loader +noindex = importlib.util.module_from_spec(spec) +spec.loader.exec_module(noindex) + +mark = noindex.mark +TAG = noindex.TAG + + +class MarkTest(unittest.TestCase): + def test_adds_the_tag_inside_an_existing_head(self): + out = mark("\na") + assert out is not None + self.assertIn(f"{TAG}", out) + + def test_matches_head_case_insensitively_and_with_attributes(self): + out = mark('b') + assert out is not None + self.assertIn(f'{TAG}', out) + + def test_leaves_a_page_that_already_declares_robots(self): + page = '' + self.assertIsNone(mark(page)) + + def test_synthesizes_a_head_after_the_html_tag(self): + out = mark('no head') + assert out is not None + self.assertTrue(out.startswith(f'{TAG}')) + + # The next three are the regressions this script actually shipped: a + # doctype has to stay first, or the browser renders the preview in quirks + # mode and it no longer resembles the page it previews. + def test_keeps_a_doctype_first_when_there_is_no_html_tag(self): + out = mark("\nx") + assert out is not None + self.assertTrue(out.startswith("")) + self.assertIn(TAG, out) + + def test_keeps_a_lowercase_doctype_first(self): + out = mark("\nx") + assert out is not None + self.assertTrue(out.startswith("")) + + def test_keeps_a_byte_order_mark_and_doctype_first(self): + out = mark("\nx") + assert out is not None + self.assertTrue(out.startswith("")) + self.assertIn(TAG, out) + + def test_leaves_a_fragment_alone(self): + self.assertIsNone(mark("
bare fragment
\n")) + + def test_leaves_a_fragment_that_merely_quotes_a_doctype(self): + # Only a doctype at the very start makes the file a document. An + # unanchored match treated this snippet as one and rewrote it. + self.assertIsNone(mark("

Start a page with <!DOCTYPE html>

")) + self.assertIsNone(mark("
example: 
")) + + def test_puts_the_head_inside_html_when_a_doctype_precedes_it(self): + # The head belongs inside html. Matching whichever token came first + # put it between the doctype and the html tag. + out = mark("\nx") + assert out is not None + self.assertIn(f'{TAG}', out) + self.assertTrue(out.startswith("")) + + def test_is_idempotent(self): + once = mark("") + assert once is not None + self.assertIsNone(mark(once)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index 232fe25d..bfd916f9 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -20,14 +20,19 @@ jobs: defaults: run: working-directory: pkg-r - # Shared with the sibling site's workflow: both deploy to gh-pages, and - # two pushes to one branch race. + # Per workflow and per pull request, so a run supersedes an older pending + # run of the same site. Serializing every gh-pages push in one group would + # be wrong: Actions keeps only one pending run per group, so a third + # arrival cancels the queued one outright and its preview never publishes. + # Races between the two sites are handled at the push instead, by the + # deploy action's rebase. concurrency: - group: gh-pages-${{ github.event_name != 'pull_request' || github.run_id }} + group: pkgdown-${{ github.event.number || github.ref }} env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} permissions: contents: write + pull-requests: write steps: - uses: actions/checkout@v6 @@ -52,3 +57,73 @@ jobs: clean: false branch: gh-pages folder: docs + # Both sites and the preview deploys push to this one branch, so a + # deploy has to rebase onto whatever landed while it was building. + # Forcing would silently drop that other deploy. + force: false + attempt-limit: 10 + + # A fork's GITHUB_TOKEN is read-only, so the deploy and the comment both + # fail there. Fork previews are not worth the alternative: publishing + # fork-authored HTML to posit-dev.github.io means serving unreviewed + # content from our own origin. + # Preview only. The production deploy above publishes r/ as the released + # documentation, and this would replace its index with a redirect. + - name: Point r/ at the development site + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + working-directory: . + run: .github/scripts/link-r-dev.sh docs + + # pkgdown's automatic development mode puts a released version's site at + # the root of destination and a development version's in dev/ below it, + # so the entry point moves with the version in DESCRIPTION. Linking to + # r/ unconditionally hands the reviewer a directory listing. + - name: Find the built site's entry point + id: entry + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + working-directory: . + run: | + if [ -f docs/r/index.html ]; then + echo "path=r/" >> "$GITHUB_OUTPUT" + elif [ -f docs/r/dev/index.html ]; then + echo "path=r/dev/" >> "$GITHUB_OUTPUT" + else + echo "The R site has no index.html to link to." >&2 + exit 1 + fi + + - name: Mark the preview noindex + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + working-directory: . + run: python3 .github/scripts/noindex-preview.py docs + + - name: Deploy the pull request preview 🔎 + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0 + with: + clean: false + branch: gh-pages + folder: docs + target-folder: pr-${{ github.event.number }} + force: false + attempt-limit: 10 + + - name: Comment the preview link + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: preview-r + message: | + **R site preview:** [https://posit-dev.github.io/commons/pr-${{ github.event.number }}/${{ steps.entry.outputs.path }}](https://posit-dev.github.io/commons/pr-${{ github.event.number }}/${{ steps.entry.outputs.path }}) + + Built from the latest commit on this branch. Use the link above rather than the landing page's, which points at the released site. The landing page's Python link resolves only if this pull request also rebuilt the Python site. diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml new file mode 100644 index 00000000..7fc5d3db --- /dev/null +++ b/.github/workflows/preview-cleanup.yml @@ -0,0 +1,124 @@ +# Removes the pr-/ preview folders that pkgdown.yaml and +# quartodoc.yaml publish to gh-pages. +# +# Closing a pull request handles the ordinary case immediately. The schedule +# is what makes the branch converge: it does not care whether a close event +# ever fired, whether this workflow existed when the pull request was opened, +# or whether an earlier run failed, because it derives the whole answer from +# the branch and the API each time. +# +# workflow_dispatch exists because a schedule only ever runs the copy of this +# file on the default branch, so this job cannot run at all until it merges. +# Its dry run defaults to true so the first manual run reports rather than +# deletes. +name: preview-cleanup.yml + +on: + pull_request: + types: [closed] + schedule: + # Weekly. Stale previews cost nothing but repository size, so this only + # needs to be faster than the rate at which they accumulate. + - cron: "17 4 * * 1" + workflow_dispatch: + inputs: + dry-run: + description: List what would be removed without removing it + type: boolean + default: true + +permissions: read-all + +jobs: + cleanup: + runs-on: ubuntu-latest + # Only against other cleanup runs. This job cannot be serialized against + # the deploys: Actions keeps one pending run per group, so sharing theirs + # would cancel a queued preview. It rebases onto a concurrent deploy + # below, and the schedule collects anything the two still interleave. + concurrency: + group: preview-cleanup + permissions: + contents: write + pull-requests: read + steps: + - uses: actions/checkout@v6 + with: + ref: gh-pages + # A shallow clone cannot be pushed back. + fetch-depth: 0 + + - name: Remove preview folders + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + EVENT: ${{ github.event_name }} + CLOSED_PR: ${{ github.event.number }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + + if [ "$EVENT" = "pull_request" ]; then + targets="pr-$CLOSED_PR" + else + # Ask once for the open pull requests, rather than asking about + # each folder. Per-folder queries have to decide what a failed + # query means, and the safe answer ("leave it") is the same as + # the answer for an open pull request, so the query earns + # nothing. One list also fails loudly: a rate limit or a bad + # token aborts the run here, where deleting nothing is correct, + # instead of reading as "no pull request is open" and collecting + # every live preview. + open_prs=$(gh api "repos/$REPO/pulls?state=open&per_page=100" --paginate --jq '.[].number') + + targets="" + # `-printf` would be shorter, but it is GNU-only, and a portable + # listing is one that can be exercised outside the runner. + for dir in $(find . -maxdepth 1 -type d -name 'pr-*' | sed 's|^\./||' | sort); do + number="${dir#pr-}" + if ! printf '%s' "$number" | grep -qE '^[0-9]+$'; then + echo "Skipping $dir: not a pull request number" + continue + fi + if printf '%s\n' "$open_prs" | grep -qx "$number"; then + echo "Keeping $dir: pull request is open" + else + echo "Collecting $dir: pull request is not open" + targets="$targets $dir" + fi + done + fi + + removed="" + for dir in $targets; do + [ -d "$dir" ] || continue + removed="$removed $dir" + done + + if [ -z "$removed" ]; then + echo "Nothing to remove." + exit 0 + fi + + if [ "${DRY_RUN:-false}" = "true" ]; then + echo "Dry run. Would remove:$removed" + exit 0 + fi + + git rm -r --quiet $removed + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "Remove preview folders:$removed" + + # A deploy may have landed on gh-pages while this job ran. Rebase + # onto it and try again rather than forcing, which would drop it. + for attempt in 1 2 3 4 5; do + if git push origin gh-pages; then + exit 0 + fi + echo "Push rejected, rebasing (attempt $attempt)" + git pull --rebase origin gh-pages + done + + echo "Could not push after 5 attempts." >&2 + exit 1 diff --git a/.github/workflows/preview-fork-notice.yml b/.github/workflows/preview-fork-notice.yml new file mode 100644 index 00000000..85f09ab0 --- /dev/null +++ b/.github/workflows/preview-fork-notice.yml @@ -0,0 +1,35 @@ +# Tells a fork's author why their pull request has no documentation preview. +# +# pkgdown.yaml and quartodoc.yaml skip the preview deploy for forks, and a +# fork's GITHUB_TOKEN is read-only, so those workflows cannot post this +# themselves. 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 +# pull_request_target dangerous applies: it reads no fork code and runs none. +name: preview-fork-notice.yml + +on: + pull_request_target: + types: [opened, reopened] + +permissions: {} + +jobs: + notice: + if: github.event.pull_request.head.repo.fork == true + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Post the fork preview notice + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: preview-fork + message: | + **No documentation preview for this pull request.** + + Previews are published to this repository's own GitHub Pages site, + so building one from a fork would serve unreviewed HTML from + `posit-dev.github.io`. A maintainer who wants to see the rendered + docs can push this branch to the repository, which produces a + preview automatically. diff --git a/.github/workflows/quartodoc.yaml b/.github/workflows/quartodoc.yaml index 105fab85..5c6f8163 100644 --- a/.github/workflows/quartodoc.yaml +++ b/.github/workflows/quartodoc.yaml @@ -18,12 +18,17 @@ jobs: defaults: run: working-directory: pkg-py - # Shared with the sibling site's workflow: both deploy to gh-pages, and - # two pushes to one branch race. + # Per workflow and per pull request, so a run supersedes an older pending + # run of the same site. Serializing every gh-pages push in one group would + # be wrong: Actions keeps only one pending run per group, so a third + # arrival cancels the queued one outright and its preview never publishes. + # Races between the two sites are handled at the push instead, by the + # deploy action's rebase. concurrency: - group: gh-pages-${{ github.event_name != 'pull_request' || github.run_id }} + group: quartodoc-${{ github.event.number || github.ref }} permissions: contents: write + pull-requests: write steps: - uses: actions/checkout@v6 @@ -61,3 +66,44 @@ jobs: clean: false branch: gh-pages folder: docs + # Both sites and the preview deploys push to this one branch, so a + # deploy has to rebase onto whatever landed while it was building. + # Forcing would silently drop that other deploy. + force: false + attempt-limit: 10 + + # A fork's GITHUB_TOKEN is read-only, so the deploy and the comment both + # fail there. Fork previews are not worth the alternative: publishing + # fork-authored HTML to posit-dev.github.io means serving unreviewed + # content from our own origin. + - name: Mark the preview noindex + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + working-directory: . + run: python3 .github/scripts/noindex-preview.py docs + + - name: Deploy the pull request preview 🔎 + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0 + with: + clean: false + branch: gh-pages + folder: docs + target-folder: pr-${{ github.event.number }} + force: false + attempt-limit: 10 + + - name: Comment the preview link + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: preview-py + message: | + **Python site preview:** [https://posit-dev.github.io/commons/pr-${{ github.event.number }}/py/](https://posit-dev.github.io/commons/pr-${{ github.event.number }}/py/) + + Built from the latest commit on this branch. The landing page's R link resolves inside the preview only if this pull request also rebuilt the R site. diff --git a/.github/workflows/scripts-check.yaml b/.github/workflows/scripts-check.yaml new file mode 100644 index 00000000..5364d8d2 --- /dev/null +++ b/.github/workflows/scripts-check.yaml @@ -0,0 +1,22 @@ +# The CI helpers under .github/scripts/ have no package to be tested by, so +# they are tested here. +name: scripts-check.yaml + +on: + push: + branches: [main] + paths: [.github/scripts/**, .github/workflows/scripts-check.yaml] + pull_request: + paths: [.github/scripts/**, .github/workflows/scripts-check.yaml] + workflow_dispatch: + +permissions: read-all + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Test noindex-preview.py + run: python3 .github/scripts/test_noindex_preview.py diff --git a/.github/workflows/verify-shared-synced.yaml b/.github/workflows/verify-shared-synced.yaml index c6851da7..195eb4ae 100644 --- a/.github/workflows/verify-shared-synced.yaml +++ b/.github/workflows/verify-shared-synced.yaml @@ -10,12 +10,16 @@ on: - tests/shared/** - prompts/** - www/** + - favicon/** - logos/** - pkg-r/tests/testthat/fixtures/shared/** - pkg-r/inst/prompts/** - pkg-r/inst/www/** - pkg-py/src/commons/prompts/** - pkg-py/src/commons/www/** + - pkg-r/pkgdown/favicon/** + - pkg-py/docs/favicon/** + - docs/favicon/** - pkg-py/docs/assets/logos/** - pkg-py/docs/assets/figs/** - pkg-r/pkgdown/assets/logos/** @@ -26,12 +30,16 @@ on: - tests/shared/** - prompts/** - www/** + - favicon/** - logos/** - pkg-r/tests/testthat/fixtures/shared/** - pkg-r/inst/prompts/** - pkg-r/inst/www/** - pkg-py/src/commons/prompts/** - pkg-py/src/commons/www/** + - pkg-r/pkgdown/favicon/** + - pkg-py/docs/favicon/** + - docs/favicon/** - pkg-py/docs/assets/logos/** - pkg-py/docs/assets/figs/** - pkg-r/pkgdown/assets/logos/** diff --git a/.gitignore b/.gitignore index 56d938b2..a22767f2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # in the package's own .gitignore (see pkg-r/.gitignore). .Rproj.user .Rprofile +__pycache__/ /docs/r/ /docs/py/ plans/ diff --git a/AGENTS.md b/AGENTS.md index 11eaf78a..d66f5601 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ 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 and their links to each other are relative. `scripts/preview-docs.sh` builds them into that tree and serves it, which is the layout a pull request preview publishes. It skips a site whose toolchain is missing rather than failing. + 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). Use soft wrapping for prose in Markdown files, including skills and vignettes. diff --git a/docs/favicon/apple-touch-icon.png b/docs/favicon/apple-touch-icon.png new file mode 100644 index 00000000..b2736c9f Binary files /dev/null and b/docs/favicon/apple-touch-icon.png differ diff --git a/docs/favicon/favicon-96x96.png b/docs/favicon/favicon-96x96.png new file mode 100644 index 00000000..dcbd2ffe Binary files /dev/null and b/docs/favicon/favicon-96x96.png differ diff --git a/docs/favicon/favicon.ico b/docs/favicon/favicon.ico new file mode 100644 index 00000000..1063ddd1 Binary files /dev/null and b/docs/favicon/favicon.ico differ diff --git a/docs/favicon/favicon.svg b/docs/favicon/favicon.svg new file mode 100644 index 00000000..87415e30 --- /dev/null +++ b/docs/favicon/favicon.svg @@ -0,0 +1 @@ +RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file diff --git a/docs/favicon/site.webmanifest b/docs/favicon/site.webmanifest new file mode 100644 index 00000000..5c26a5d7 --- /dev/null +++ b/docs/favicon/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "commons", + "short_name": "commons", + "icons": [ + { + "src": "web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/docs/favicon/web-app-manifest-192x192.png b/docs/favicon/web-app-manifest-192x192.png new file mode 100644 index 00000000..4e8fd63f Binary files /dev/null and b/docs/favicon/web-app-manifest-192x192.png differ diff --git a/docs/favicon/web-app-manifest-512x512.png b/docs/favicon/web-app-manifest-512x512.png new file mode 100644 index 00000000..e7d50212 Binary files /dev/null and b/docs/favicon/web-app-manifest-512x512.png differ diff --git a/docs/index.html b/docs/index.html index ce3af2a7..d0aeb53a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -10,7 +10,11 @@ - + + + + + diff --git a/favicon/README.md b/favicon/README.md new file mode 100644 index 00000000..4ced947f --- /dev/null +++ b/favicon/README.md @@ -0,0 +1,15 @@ +# Site icons + +The icons every page of the documentation tree serves: the landing page, the R +site, and the Python site. Generated from `pkg-r/man/figures/logo.png` by +`pkgdown::build_favicons()`, whose file names the R site expects, so keep them +as they are. + +`scripts/sync-shared.sh` copies this directory into `pkg-r/pkgdown/favicon/`, +`pkg-py/docs/favicon/`, and `docs/favicon/`, and CI fails when a copy is stale. +Edit the files here, never a copy. + +The icon paths in `site.webmanifest` are relative, because each site serves its +own copy from a different prefix. The `` tags live with each site: the +pkgdown template writes them for the R site, `pkg-py/docs/favicon.html` for the +Python site, and `docs/index.html` for the landing page. diff --git a/favicon/apple-touch-icon.png b/favicon/apple-touch-icon.png new file mode 100644 index 00000000..b2736c9f Binary files /dev/null and b/favicon/apple-touch-icon.png differ diff --git a/favicon/favicon-96x96.png b/favicon/favicon-96x96.png new file mode 100644 index 00000000..dcbd2ffe Binary files /dev/null and b/favicon/favicon-96x96.png differ diff --git a/favicon/favicon.ico b/favicon/favicon.ico new file mode 100644 index 00000000..1063ddd1 Binary files /dev/null and b/favicon/favicon.ico differ diff --git a/favicon/favicon.svg b/favicon/favicon.svg new file mode 100644 index 00000000..87415e30 --- /dev/null +++ b/favicon/favicon.svg @@ -0,0 +1 @@ +RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file diff --git a/favicon/site.webmanifest b/favicon/site.webmanifest new file mode 100644 index 00000000..5c26a5d7 --- /dev/null +++ b/favicon/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "commons", + "short_name": "commons", + "icons": [ + { + "src": "web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/favicon/web-app-manifest-192x192.png b/favicon/web-app-manifest-192x192.png new file mode 100644 index 00000000..4e8fd63f Binary files /dev/null and b/favicon/web-app-manifest-192x192.png differ diff --git a/favicon/web-app-manifest-512x512.png b/favicon/web-app-manifest-512x512.png new file mode 100644 index 00000000..e7d50212 Binary files /dev/null and b/favicon/web-app-manifest-512x512.png differ diff --git a/pkg-py/docs/_quarto.yml b/pkg-py/docs/_quarto.yml index 7928a6a2..1df5ef6d 100644 --- a/pkg-py/docs/_quarto.yml +++ b/pkg-py/docs/_quarto.yml @@ -10,6 +10,10 @@ project: - governance.qmd - feature-parity.qmd - reference/*.qmd + # Copied whole: Quarto follows the tags in favicon.html, but not the + # icons site.webmanifest names inside itself. + resources: + - favicon/** website: title: "commons" @@ -44,6 +48,7 @@ format: theme: cosmo toc: true css: assets/styles.css + include-in-header: favicon.html metadata-files: - reference/_sidebar.yml diff --git a/pkg-py/docs/favicon.html b/pkg-py/docs/favicon.html new file mode 100644 index 00000000..c3072b26 --- /dev/null +++ b/pkg-py/docs/favicon.html @@ -0,0 +1,7 @@ + + + + + + diff --git a/pkg-py/docs/favicon/apple-touch-icon.png b/pkg-py/docs/favicon/apple-touch-icon.png new file mode 100644 index 00000000..b2736c9f Binary files /dev/null and b/pkg-py/docs/favicon/apple-touch-icon.png differ diff --git a/pkg-py/docs/favicon/favicon-96x96.png b/pkg-py/docs/favicon/favicon-96x96.png new file mode 100644 index 00000000..dcbd2ffe Binary files /dev/null and b/pkg-py/docs/favicon/favicon-96x96.png differ diff --git a/pkg-py/docs/favicon/favicon.ico b/pkg-py/docs/favicon/favicon.ico new file mode 100644 index 00000000..1063ddd1 Binary files /dev/null and b/pkg-py/docs/favicon/favicon.ico differ diff --git a/pkg-py/docs/favicon/favicon.svg b/pkg-py/docs/favicon/favicon.svg new file mode 100644 index 00000000..87415e30 --- /dev/null +++ b/pkg-py/docs/favicon/favicon.svg @@ -0,0 +1 @@ +RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file diff --git a/pkg-py/docs/favicon/site.webmanifest b/pkg-py/docs/favicon/site.webmanifest new file mode 100644 index 00000000..5c26a5d7 --- /dev/null +++ b/pkg-py/docs/favicon/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "commons", + "short_name": "commons", + "icons": [ + { + "src": "web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/pkg-py/docs/favicon/web-app-manifest-192x192.png b/pkg-py/docs/favicon/web-app-manifest-192x192.png new file mode 100644 index 00000000..4e8fd63f Binary files /dev/null and b/pkg-py/docs/favicon/web-app-manifest-192x192.png differ diff --git a/pkg-py/docs/favicon/web-app-manifest-512x512.png b/pkg-py/docs/favicon/web-app-manifest-512x512.png new file mode 100644 index 00000000..e7d50212 Binary files /dev/null and b/pkg-py/docs/favicon/web-app-manifest-512x512.png differ diff --git a/pkg-r/pkgdown/favicon/apple-touch-icon.png b/pkg-r/pkgdown/favicon/apple-touch-icon.png new file mode 100644 index 00000000..b2736c9f Binary files /dev/null and b/pkg-r/pkgdown/favicon/apple-touch-icon.png differ diff --git a/pkg-r/pkgdown/favicon/favicon-96x96.png b/pkg-r/pkgdown/favicon/favicon-96x96.png new file mode 100644 index 00000000..dcbd2ffe Binary files /dev/null and b/pkg-r/pkgdown/favicon/favicon-96x96.png differ diff --git a/pkg-r/pkgdown/favicon/favicon.ico b/pkg-r/pkgdown/favicon/favicon.ico new file mode 100644 index 00000000..1063ddd1 Binary files /dev/null and b/pkg-r/pkgdown/favicon/favicon.ico differ diff --git a/pkg-r/pkgdown/favicon/favicon.svg b/pkg-r/pkgdown/favicon/favicon.svg new file mode 100644 index 00000000..87415e30 --- /dev/null +++ b/pkg-r/pkgdown/favicon/favicon.svg @@ -0,0 +1 @@ +RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file diff --git a/pkg-r/pkgdown/favicon/site.webmanifest b/pkg-r/pkgdown/favicon/site.webmanifest new file mode 100644 index 00000000..5c26a5d7 --- /dev/null +++ b/pkg-r/pkgdown/favicon/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "commons", + "short_name": "commons", + "icons": [ + { + "src": "web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/pkg-r/pkgdown/favicon/web-app-manifest-192x192.png b/pkg-r/pkgdown/favicon/web-app-manifest-192x192.png new file mode 100644 index 00000000..4e8fd63f Binary files /dev/null and b/pkg-r/pkgdown/favicon/web-app-manifest-192x192.png differ diff --git a/pkg-r/pkgdown/favicon/web-app-manifest-512x512.png b/pkg-r/pkgdown/favicon/web-app-manifest-512x512.png new file mode 100644 index 00000000..e7d50212 Binary files /dev/null and b/pkg-r/pkgdown/favicon/web-app-manifest-512x512.png differ diff --git a/scripts/preview-docs.sh b/scripts/preview-docs.sh new file mode 100755 index 00000000..3e2749a4 --- /dev/null +++ b/scripts/preview-docs.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# Build the documentation sites and serve them the way they are deployed. +# +# The sites are published as one tree: the landing page at the root, the R +# site under r/, and the Python site under py/, which mirrors the deployment +# arrangement. This script assembles the documentation page in docs/ and +# serves it in the same layout a pull request preview publishes, so it +# should match what a PR reviewer will see. +# +# Usage: +# scripts/preview-docs.sh build both sites and serve +# scripts/preview-docs.sh r build only the R site and serve +# scripts/preview-docs.sh py build only the Python site and serve +# scripts/preview-docs.sh --no-serve build, then stop +# scripts/preview-docs.sh --port 8000 +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +sites="" +serve=true +port=4444 + +while [ $# -gt 0 ]; do + case "$1" in + r|py) sites="$sites $1" ;; + --no-serve) serve=false ;; + --port) shift; port="${1:?--port needs a number}" ;; + # Prints the header block, stopping at the first line that is not a + # comment, so the two cannot drift apart. + -h|--help) sed -n '2,${/^#/!q; s/^# \{0,1\}//p;}' "${BASH_SOURCE[0]}"; exit 0 ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac + shift +done +[ -n "$sites" ] || sites="r py" + +# Collected rather than printed as they happen, so the reason a site is +# missing is still on screen once a build has scrolled past. Each entry is a +# reason followed by a command to paste: naming a package leaves the reader to +# work out where to type it, and the answer differs between the shell and the +# R console. +skipped=() + +# skip [command...] +# +# Commands are printed one per line, indented and with nothing in front of +# them, so a block can be selected and pasted as-is. No box drawing, because +# the border characters come along with the copy. +skip() { + local what="$1" reason="$2" + shift 2 + local block="Skipped $what: $reason" + if [ $# -gt 0 ]; then + block="$block + + To fix it, run: +" + local command + for command in "$@"; do + block="$block + $command" + done + fi + skipped+=("$block") +} + +build_r() { + if ! command -v Rscript >/dev/null 2>&1; then + skip "the R site" "Rscript is not on PATH. Install R from https://cloud.r-project.org/." + return + fi + if ! Rscript -e 'quit(status = !requireNamespace("pkgdown", quietly = TRUE))' 2>/dev/null; then + skip "the R site" "pkgdown is not installed." \ + "Rscript -e 'install.packages(\"pkgdown\", repos = \"https://cloud.r-project.org\")'" + return + fi + # pkgdown runs the examples, so it needs commons itself installed, exactly + # as the workflow does through setup-r-dependencies. Checking here turns + # what is otherwise a long build ending in an R stack trace into one line. + # + # The snippet installs pak first if it has to, and runs from $root, so it + # works whatever directory the reader pasted it into. + if ! Rscript -e 'quit(status = !requireNamespace("commons", quietly = TRUE))' 2>/dev/null; then + # Absolute paths, so no cd is needed and pasting these does not move the + # reader's shell. The pak line appears only when pak is actually absent, + # rather than as a conditional the reader has to evaluate. + local install=() + if ! Rscript -e 'quit(status = !requireNamespace("pak", quietly = TRUE))' 2>/dev/null; then + install+=("Rscript -e 'install.packages(\"pak\", repos = \"https://cloud.r-project.org\")'") + fi + install+=("Rscript -e 'pak::local_install_deps(\"$root/pkg-r\")'") + install+=("Rscript -e 'pak::local_install(\"$root/pkg-r\")'") + skip "the R site" "commons is not installed, and pkgdown runs its examples." \ + "${install[@]}" + return + fi + echo "==> Building the R site into docs/r" + # Matches .github/workflows/pkgdown.yaml. The destination comes from + # pkg-r/_pkgdown.yml, so it lands in docs/r without being named here. + # + # pkgdown and rmarkdown ask pandoc for math with the --mathml and --mathjax + # spellings pandoc 3.11 deprecated, once per topic and per vignette, and + # neither flag is ours to set. Drop those lines from stderr and leave + # everything else, including the exit status, alone. + { + (cd "$root/pkg-r" && Rscript -e 'pkgdown::build_site(new_process = FALSE, install = FALSE)') \ + 2>&1 1>&3 | sed '/^\[WARNING\] Deprecated: --math/d' >&2 + } 3>&1 + # So the landing page's link to r/ works here. The published site keeps the + # released documentation there, which is why this is not part of the build. + "$root/.github/scripts/link-r-dev.sh" "$root/docs" +} + +build_py() { + if ! command -v uv >/dev/null 2>&1; then + skip "the Python site" "uv is not on PATH." \ + "curl -LsSf https://astral.sh/uv/install.sh | sh" + return + fi + if ! command -v quarto >/dev/null 2>&1; then + skip "the Python site" "quarto is not on PATH. Install it from https://quarto.org/docs/get-started/." + return + fi + echo "==> Building the Python site into docs/py" + # Matches .github/workflows/quartodoc.yaml, including the copy: quarto + # warns and refuses to clean an output directory outside its project, so + # the site renders in place and is copied into position. + (cd "$root/pkg-py" && uv sync --extra shiny --group docs --quiet) + (cd "$root/pkg-py/docs" && uv run quartodoc build && uv run quarto render) + rm -rf "$root/docs/py" + cp -r "$root/pkg-py/docs/_site" "$root/docs/py" +} + +for site in $sites; do + "build_$site" +done + +for note in "${skipped[@]+"${skipped[@]}"}"; do + printf '\n%s\n' "$note" >&2 +done +[ ${#skipped[@]} -eq 0 ] || echo >&2 + +# pkgdown's automatic development mode decides where a site lands from the +# version in DESCRIPTION: a released version goes to the root of destination, +# a development version to dev/ below it. So the R entry point moves, and +# pointing at r/ on a development version serves a directory listing. The +# landing page links to r/, which is right for the deployed site and wrong +# here, so the addresses below are worth reading rather than guessing. +entry_point() { + if [ -f "$root/docs/$1/index.html" ]; then + echo "$1/" + elif [ -f "$root/docs/$1/dev/index.html" ]; then + echo "$1/dev/" + fi +} + +echo +echo "Pages:" +printf ' %-14s http://localhost:%s/ \n' "landing page" "$port" +for site in r py; do + case "$site" in + r) label="R site" ;; + py) label="Python site" ;; + esac + path="$(entry_point "$site")" + if [ -n "$path" ]; then + printf ' %-14s http://localhost:%s/%s \n' "$label" "$port" "$path" + else + # Named rather than omitted: a missing row reads as a broken build. + printf ' %-14s not built, so links to it will 404\n' "$label" + fi +done +echo + +if [ "$serve" = true ]; then + echo "==> Serving $root/docs (Ctrl-C to stop)" + cd "$root/docs" && python3 -m http.server "$port" +fi diff --git a/scripts/sync-shared.sh b/scripts/sync-shared.sh index 8bd04e2c..6cd32c1f 100755 --- a/scripts/sync-shared.sh +++ b/scripts/sync-shared.sh @@ -15,13 +15,15 @@ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # The Python suite reads tests/shared/ in place, so only the R package needs # that copy; both packages ship the prompts and the browser assets. Nothing # in pkg-py reads www/ until its UI layer lands, but the destination belongs -# with the move rather than with the first reader. The logos go to the two -# documentation sites, and the provenance-marker figs to the Python one, which -# serve them as they stand. +# with the move rather than with the first reader. The icons go to three +# places, because the landing page is a site of its own. The logos go to the +# two documentation sites, and the provenance-marker figs to the Python one, +# which serve them as they stand. sources=( "tests/shared pkg-r/tests/testthat/fixtures/shared" "prompts pkg-r/inst/prompts pkg-py/src/commons/prompts" "www pkg-r/inst/www pkg-py/src/commons/www" + "favicon pkg-r/pkgdown/favicon:bare pkg-py/docs/favicon:bare docs/favicon:bare" "logos pkg-py/docs/assets/logos:bare pkg-r/pkgdown/assets/logos:bare" "www/commons-chat/figs pkg-py/docs/assets/figs:bare" )