diff --git a/.github/workflows/release-actions.yml b/.github/workflows/release-actions.yml index 8dce942f1..011a4c1ac 100644 --- a/.github/workflows/release-actions.yml +++ b/.github/workflows/release-actions.yml @@ -34,10 +34,11 @@ on: - "allowlist-check/**" - "pelican/**" - "stash/**" + - "website-comment/**" workflow_dispatch: inputs: action: - description: "Tag prefix to release (allowlist-check, pelican, save, restore). Leave empty to auto-detect from the latest commit." + description: "Tag prefix to release (allowlist-check, pelican, save, restore, preview-annotate, preview-artifact, preview-main-overlay, preview-publish). Leave empty to auto-detect from the latest commit." required: false default: "" bump: diff --git a/.github/workflows/website-comment-test.yml b/.github/workflows/website-comment-test.yml new file mode 100644 index 000000000..86b9796df --- /dev/null +++ b/.github/workflows/website-comment-test.yml @@ -0,0 +1,224 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +name: Test the PR preview actions +on: + pull_request: + paths: + - "website-comment/**" + - ".github/workflows/website-comment-test.yml" + push: + # Branches only, so release tag pushes do not re-run it. + branches: + - main + paths: + - "website-comment/**" + - ".github/workflows/website-comment-test.yml" + +permissions: + contents: read + +jobs: + node: + name: "publisher, overlay and Astro adapter" + runs-on: ubuntu-26.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + + - name: Install the locked test dependencies + working-directory: website-comment + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Unit tests + working-directory: website-comment + run: npm test + + - name: preview-annotate outputs the Astro plugin + id: astro + uses: ./website-comment/preview-annotate + with: + generator: astro + + - name: The plugin path resolves + env: + PLUGIN: ${{ steps.astro.outputs.vite-plugin }} + run: test -f "$PLUGIN" + + - name: A built site + run: | + mkdir -p "$RUNNER_TEMP/main-site" + echo '

x

' > "$RUNNER_TEMP/main-site/index.html" + + - name: preview-main-overlay injects the overlay in main mode + uses: ./website-comment/preview-main-overlay + with: + path: ${{ runner.temp }}/main-site + branch: main + + - name: The page loads the overlay, configured for the published site + run: | + grep -q '_preview/review.js' "$RUNNER_TEMP/main-site/index.html" + grep -q '"mode":"main"' "$RUNNER_TEMP/main-site/_preview/review.js" + + - name: preview-publish does nothing outside its events + id: gate + continue-on-error: true + uses: ./website-comment/preview-publish + with: + site-name: example + + - name: It did not fail + if: steps.gate.outcome != 'failure' + env: + EVENT_NAME: ${{ github.event_name }} + run: | + echo "::error::preview-publish accepted a $EVENT_NAME event" + exit 1 + + - name: A production site that documents the attribute + run: | + mkdir -p "$RUNNER_TEMP/docs-site" + echo '
<h1 data-preview-src="index.md:1">

Stamp data-preview-src in preview builds.

' > "$RUNNER_TEMP/docs-site/index.html" + + - name: preview-annotate accepts escaped and prose mentions + uses: ./website-comment/preview-annotate + with: + generator: none + production-output: ${{ runner.temp }}/docs-site + + - name: A production page with the attribute on a tag split across lines + run: | + mkdir -p "$RUNNER_TEMP/split-site" + printf 'x\n' > "$RUNNER_TEMP/split-site/index.html" + + - name: preview-annotate rejects it + id: split + continue-on-error: true + uses: ./website-comment/preview-annotate + with: + generator: none + production-output: ${{ runner.temp }}/split-site + + - name: It did not fail + if: steps.split.outcome != 'failure' + run: | + echo "::error::preview-annotate accepted a split-line annotated tag" + exit 1 + + - name: preview-annotate rejects an unknown generator + id: unknown + continue-on-error: true + uses: ./website-comment/preview-annotate + with: + generator: gatsby + + - name: It did not fail + if: steps.unknown.outcome != 'failure' + run: | + echo "::error::preview-annotate accepted an unknown generator" + exit 1 + + # ubuntu-slim cannot start a job container, so Ruby comes from apt rather + # than a Ruby image, and no third-party setup-ruby action is added. + jekyll: + name: "Jekyll adapter, end to end" + runs-on: ubuntu-slim + permissions: + contents: read + pull-requests: read # preview-artifact lists the PR's changed files for anchors + timeout-minutes: 10 + env: + # Install exactly what the lockfile names, checksums included. + BUNDLE_GEMFILE: website-comment/adapters/jekyll/test/Gemfile + BUNDLE_FROZEN: "true" + FIXTURE: website-comment/adapters/jekyll/test/fixture + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # eventmachine and friends build native extensions, hence the toolchain. + - name: Install Ruby, Bundler and a C toolchain + run: | + sudo apt-get update -q + sudo apt-get install -y -q --no-install-recommends ruby-full ruby-bundler build-essential + ruby --version + + # A writable gem home, so Bundler can switch itself to the version the + # lockfile's BUNDLED WITH names, instead of an unpinned `gem install`. + - name: Use a user gem home + run: | + echo "GEM_HOME=$RUNNER_TEMP/gems" >> "$GITHUB_ENV" + echo "$RUNNER_TEMP/gems/bin" >> "$GITHUB_PATH" + + - name: Install the locked test dependencies + run: bundle install + + - name: Plugin tests + run: bundle exec ruby website-comment/adapters/jekyll/test/preview_src_test.rb + + - name: Production build + run: bundle exec jekyll build -s "$FIXTURE" -d "$RUNNER_TEMP/production" + + - name: preview-annotate installs the plugin + uses: ./website-comment/preview-annotate + with: + generator: jekyll + source: website-comment/adapters/jekyll/test/fixture + production-output: ${{ runner.temp }}/production + + - name: Preview build is annotated with repository paths + run: | + bundle exec jekyll build -s "$FIXTURE" -d "$RUNNER_TEMP/preview" + grep -q 'data-preview-src="website-comment/adapters/jekyll/test/fixture/index.md:7"' "$RUNNER_TEMP/preview/index.html" + + - name: preview-annotate rejects an annotated production build + id: dirty + continue-on-error: true + uses: ./website-comment/preview-annotate + with: + generator: none + production-output: ${{ runner.temp }}/preview + + - name: It did not fail + if: steps.dirty.outcome != 'failure' + run: | + echo "::error::preview-annotate accepted an annotated production build" + exit 1 + + - name: preview-artifact packages the preview + if: github.event_name == 'pull_request' + uses: ./website-comment/preview-artifact + with: + path: ${{ runner.temp }}/preview + retention-days: 1 + + - name: The metadata names this pull request + if: github.event_name == 'pull_request' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + grep -q "\"pr\":$PR_NUMBER," "$RUNNER_TEMP/preview/preview-meta.json" + grep -q '"anchors":{' "$RUNNER_TEMP/preview/preview-meta.json" diff --git a/.gitignore b/.gitignore index d4a37e763..5b33358d6 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,5 @@ approved_patterns.yml # RAT work dir utils/.rat/** + +node_modules/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ca72b1343..921332bd4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -113,7 +113,8 @@ repos: (?x) ^(?:.*/)?AGENTS\.md$| ^(?:.*/)?CLAUDE\.md$| - ^stash/.*\.md$ + ^stash/.*\.md$| + ^website-comment/adapters/jekyll/test/fixture/ args: - --comment-style - "" diff --git a/.rat-excludes b/.rat-excludes index a08089e32..2515cae08 100644 --- a/.rat-excludes +++ b/.rat-excludes @@ -14,3 +14,11 @@ # limitations under the License. **/uv.lock +# website-comment: vendored MIT library (credited in LICENSE), JSON and lock files +# that cannot carry a header, and a Jekyll fixture site whose line numbers the +# adapter's tests assert. +website-comment/vendor/** +website-comment/package.json +website-comment/package-lock.json +website-comment/adapters/jekyll/test/Gemfile.lock +website-comment/adapters/jekyll/test/fixture/** diff --git a/LICENSE b/LICENSE index e3fe95dc3..a227b0559 100644 --- a/LICENSE +++ b/LICENSE @@ -207,3 +207,12 @@ This product includes code from cache GitHub action in the `stash` directory. Copyright (c) 2018 GitHub, Inc. and contributors Homepage: https://github.com/actions/cache License: The MIT License (MIT): https://raw.githubusercontent.com/actions/cache/refs/heads/main/LICENSE + +-------------------------------------------------------------------------------- + +This product bundles html2canvas-pro 2.4.3 in the `website-comment/vendor/html2canvas-pro` +directory. + +Copyright (c) 2024-present yorickshan and html2canvas-pro contributors +Homepage: https://github.com/yorickshan/html2canvas-pro +License: The MIT License (MIT): see website-comment/vendor/html2canvas-pro/LICENSE diff --git a/README.md b/README.md index b8b76e568..f608b912c 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ verification from `verify-action-build` directly. - [ASF Infrastructure Pelican Action](/pelican/README.md): Generate and publish project websites with GitHub Actions - [Stash Action](/stash/README.md): Manage large build caches - [ASF Allowlist Check](/allowlist-check/README.md): Verify workflow action refs are on the ASF allowlist + - [Website Comment](/website-comment/README.md): Publish per-pull-request website previews on ASF staging, with a review overlay that links a marked region to its diff line ## Versioning and Pinning Actions @@ -135,6 +136,10 @@ which you repeat after the `@`: | `pelican` | `apache/infrastructure-actions/pelican@ # pelican/v1.2.3` | | `stash/save` | `apache/infrastructure-actions/stash/save@ # save/v1.2.3` | | `stash/restore` | `apache/infrastructure-actions/stash/restore@ # restore/v1.2.3` | +| `website-comment/preview-annotate` | `apache/infrastructure-actions/website-comment/preview-annotate@ # preview-annotate/v1.0.0` | +| `website-comment/preview-artifact` | `apache/infrastructure-actions/website-comment/preview-artifact@ # preview-artifact/v1.0.0` | +| `website-comment/preview-publish` | `apache/infrastructure-actions/website-comment/preview-publish@ # preview-publish/v1.0.0` | +| `website-comment/preview-main-overlay` | `apache/infrastructure-actions/website-comment/preview-main-overlay@ # preview-main-overlay/v1.0.0` | Pinning to a commit SHA with the version in a trailing comment is the recommended, [Zizmor](https://zizmor.sh/)-friendly form: the SHA is immutable, diff --git a/RELEASING.md b/RELEASING.md index 1ecc46c56..dc571f1ca 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -35,6 +35,10 @@ prefix a consumer repeats in the `@ref`: | `pelican` | `pelican` | `pelican/v1.2.3` | | `stash/save` | `save` | `save/v1.2.3` | | `stash/restore` | `restore` | `restore/v1.2.3` | +| `website-comment/preview-annotate` | `preview-annotate` | `preview-annotate/v1.2.3` | +| `website-comment/preview-artifact` | `preview-artifact` | `preview-artifact/v1.2.3` | +| `website-comment/preview-publish` | `preview-publish` | `preview-publish/v1.2.3` | +| `website-comment/preview-main-overlay` | `preview-main-overlay` | `preview-main-overlay/v1.2.3` | For every `X.Y.Z` release we also move a **major** tag (`/vN`) to the same commit, so consumers can track a major line if they prefer. @@ -62,7 +66,10 @@ on every push to `main` that touches an action's files. The workflow runs 1. Diffs the pushed range and maps changed files to the affected action(s). A change under `stash/shared/` releases **both** stash actions, since they - import that shared code at runtime. + import that shared code at runtime. Likewise `website-comment/adapters/` + releases `preview-annotate`; `website-comment/lib/` releases `preview-artifact`, + `preview-publish` and `preview-main-overlay`; and `overlay/` and `vendor/` + release `preview-publish` and `preview-main-overlay`. 2. Picks the bump type (see below). 3. For each affected action, computes the next version from the newest existing `/vX.Y.Z` tag (the first release seeds `v1.0.0`), creates diff --git a/scripts/release_actions.py b/scripts/release_actions.py index 4d30eab6e..a4524083c 100644 --- a/scripts/release_actions.py +++ b/scripts/release_actions.py @@ -72,12 +72,46 @@ class Action: # The actions this repo publishes. ``stash/save`` and ``stash/restore`` both # import ``stash/shared/mung.py`` at runtime (PYTHONPATH -> ../shared), so a -# change there releases both. +# change there releases both. The ``website-comment/*`` actions run code from +# sibling directories through ``github.action_path/..``: the annotator the +# generator adapters, the artifact step the library's metadata writer, and the +# publisher and main-site overlay the library, the overlay they inject and the +# vendored screenshot library. ACTIONS: tuple[Action, ...] = ( Action("allowlist-check", "allowlist-check", ("allowlist-check/",)), Action("pelican", "pelican", ("pelican/",)), Action("save", "stash/save", ("stash/save/", "stash/shared/")), Action("restore", "stash/restore", ("stash/restore/", "stash/shared/")), + Action( + "preview-annotate", + "website-comment/preview-annotate", + ("website-comment/preview-annotate/", "website-comment/adapters/"), + ), + Action( + "preview-artifact", + "website-comment/preview-artifact", + ("website-comment/preview-artifact/", "website-comment/lib/"), + ), + Action( + "preview-main-overlay", + "website-comment/preview-main-overlay", + ( + "website-comment/preview-main-overlay/", + "website-comment/lib/", + "website-comment/overlay/", + "website-comment/vendor/", + ), + ), + Action( + "preview-publish", + "website-comment/preview-publish", + ( + "website-comment/preview-publish/", + "website-comment/lib/", + "website-comment/overlay/", + "website-comment/vendor/", + ), + ), ) VALID_BUMPS = ("major", "minor", "patch") diff --git a/scripts/test_release_actions.py b/scripts/test_release_actions.py index 37252be93..9f9f18f5e 100644 --- a/scripts/test_release_actions.py +++ b/scripts/test_release_actions.py @@ -42,6 +42,28 @@ def _prefixes(actions): return sorted(a.tag_prefix for a in actions) +def _push_path_globs(): + """The `paths:` filter of the release workflow's push trigger.""" + workflow = Path(__file__).parents[1] / ".github/workflows/release-actions.yml" + lines = workflow.read_text().splitlines() + start = lines.index(" paths:") + 1 + globs = [] + for line in lines[start:]: + if not line.startswith(" - "): + break + globs.append(line.split("- ", 1)[1].strip().strip('"')) + return globs + + +def test_release_workflow_triggers_on_every_watched_path(): + # A watched path outside the push filter never triggers the workflow, so + # the action it belongs to would never be released automatically. + roots = {g.removesuffix("/**") + "/" for g in _push_path_globs()} + for action in ra.ACTIONS: + for path in action.watch_paths: + assert any(path.startswith(root) for root in roots), (action.tag_prefix, path) + + def test_affected_single_action(): changed = ["allowlist-check/check_asf_allowlist.py"] assert _prefixes(ra.affected_actions(changed)) == ["allowlist-check"] @@ -62,6 +84,36 @@ def test_save_only_change_bumps_only_save(): assert _prefixes(ra.affected_actions(changed)) == ["save"] +def test_website_comment_adapter_change_releases_only_the_annotator(): + changed = ["website-comment/adapters/jekyll/preview_src.rb"] + assert _prefixes(ra.affected_actions(changed)) == ["preview-annotate"] + + +def test_website_comment_library_change_releases_every_action_that_runs_it(): + changed = ["website-comment/lib/anchors.mjs"] + assert _prefixes(ra.affected_actions(changed)) == [ + "preview-artifact", + "preview-main-overlay", + "preview-publish", + ] + + +def test_website_comment_overlay_change_releases_the_actions_that_inject_it(): + for path in ( + "website-comment/overlay/review.js", + "website-comment/vendor/html2canvas-pro/html2canvas-pro.min.js", + ): + assert _prefixes(ra.affected_actions([path])) == [ + "preview-main-overlay", + "preview-publish", + ], path + + +def test_website_comment_docs_release_nothing(): + changed = ["website-comment/README.md", "website-comment/package.json"] + assert ra.affected_actions(changed) == [] + + def test_multiple_actions_changed_at_once(): changed = ["pelican/entrypoint.sh", "allowlist-check/action.yml"] assert _prefixes(ra.affected_actions(changed)) == ["allowlist-check", "pelican"] diff --git a/website-comment/README.md b/website-comment/README.md new file mode 100644 index 000000000..59becb9b6 --- /dev/null +++ b/website-comment/README.md @@ -0,0 +1,511 @@ + +# PR previews on ASF staging + +Publish a live preview of a pull request's website at +`https://-pr.staged.apache.org/`, and let reviewers comment on it from +the page itself: drag a box over what you mean, and the preview copies a +captioned screenshot and opens the pull request — on the exact diff line that +produced that part of the page, when the site generator can tell us. + +- [How it works](#how-it-works) +- [Quick start](#quick-start) +- [Choose your path](#choose-your-path) — Jekyll, Astro, Pelican, anything else +- [Using a preview](#using-a-preview) +- [The overlay on the published site](#the-overlay-on-the-published-site) +- [Lifecycle and retention](#lifecycle-and-retention) +- [Security model](#security-model) +- [Reference](#reference) +- [Development](#development) + +## How it works + +Three actions, split along the one boundary that matters: **the job that +builds pull-request code never holds a write token, and the job that holds a +write token never runs pull-request code.** + +```mermaid +graph LR + pr["Pull request"] --> build + subgraph build["Your build workflow (pull_request, read-only)"] + a["build the site"] --> b["preview-annotate
(optional)"] --> c["build again"] --> d["preview-artifact"] + end + d -- "preview-site artifact" --> publish + subgraph publish["Your publish workflow (workflow_run, write token)"] + e["preview-publish"] + end + e -- "push preview/pr<N>-staging" --> staging["<site>-pr<N>.staged.apache.org"] + e -- "status comment" --> pr +``` + +| Action | Runs in | Does | +|---|---|---| +| [`preview-annotate`](preview-annotate/action.yml) | your PR build | Switches on source annotation for your generator, so elements carry `data-preview-src="path:line"`. Optional. | +| [`preview-artifact`](preview-artifact/action.yml) | your PR build | Writes `preview-meta.json`, with the diff-line anchors, and uploads the built site as the `preview-site` artifact. | +| [`preview-publish`](preview-publish/action.yml) | a workflow that runs when the build completes | Publishes armed pull requests, retires the rest, keeps one status comment per PR up to date. | + +A fourth, optional action, [`preview-main-overlay`](preview-main-overlay/action.yml), +puts the same review overlay on your published site; see +[below](#the-overlay-on-the-published-site). + +Nothing is published until a committer asks for it: previews are **opt-in per +pull request**, with the `preview` label or a `/show-preview` comment. + +## Quick start + +A Jekyll site in `apache/foo-site`, built with `bundle exec jekyll build` into +`_site`. Replace each `` with the commit of the tag in the trailing +comment (see [Versioning](../README.md#versioning-and-pinning-actions)). + +**1. Add the preview steps to your pull-request build** +(`.github/workflows/build.yml`): + +```yaml +name: Build +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read # the PR's changed files, for the diff-line anchors + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The PR's head, not the default merge commit: the diff anchors use + # the head's line numbers, and a merge would shift the stamped lines + # wherever the base branch changed the same file. + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + # ... set up Ruby the way you already do ... + - name: Production build + run: bundle exec jekyll build + + - name: Switch on source annotation + if: github.event_name == 'pull_request' + uses: apache/infrastructure-actions/website-comment/preview-annotate@ # preview-annotate/v1.0.0 + with: + generator: jekyll + production-output: _site # fails the build if the production output is annotated + + - name: Preview build + if: github.event_name == 'pull_request' + run: bundle exec jekyll build + + - name: Upload the preview + if: github.event_name == 'pull_request' + uses: apache/infrastructure-actions/website-comment/preview-artifact@ # preview-artifact/v1.0.0 + with: + path: _site +``` + +**2. Add a signal workflow** (`.github/workflows/preview-signal.yml`). Adding +or removing the label, or closing the PR, starts no build, but the publisher +still has to run. This workflow holds no permissions and runs nothing; its +completion is what triggers the publisher: + +```yaml +name: Preview signal +on: + pull_request: + types: [opened, reopened, labeled, unlabeled, closed] +permissions: {} +jobs: + signal: + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - run: echo "preview-publish.yml runs on this workflow's completion" +``` + +**3. Add the publisher** (`.github/workflows/preview-publish.yml`): + +```yaml +name: Publish PR previews +on: + # Runs this file from the default branch, never the PR's; see Security model. + workflow_run: # zizmor: ignore[dangerous-triggers] -- default-branch code, no PR input + workflows: ["Build", "Preview signal"] # the workflows' `name:`s + types: [completed] + issue_comment: # a /show-preview comment publishes at once + types: [created] + workflow_dispatch: + inputs: + pr: + description: "Arm and publish this PR number (blank: reconcile every PR)" + required: false + type: string +permissions: {} +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write # push and delete preview/* branches + pull-requests: write # comment on and label pull requests + issues: write # create the label, react to /show-preview + actions: read # find and download the preview-site artifact + concurrency: + group: preview-publish + cancel-in-progress: false + steps: + - uses: apache/infrastructure-actions/website-comment/preview-publish@ # preview-publish/v1.0.0 + with: + site-name: foo # apache/foo-site stages at foo-pr.staged.apache.org + build-workflow: build.yml + pr: ${{ inputs.pr }} +``` + +No checkout is needed: the publisher never looks at your repository's files. +It skips events it has nothing to do with (a comment on an issue, a +`workflow_run` from a push to `main`) and fails on any other trigger. + +**4. Try it.** Open a pull request and add the `preview` label, or comment +`/show-preview` on it (you need write access; the bot adds the label and +reacts 🚀). Once the build is green the bot posts the preview URL; staging +takes a few more minutes to serve it. + +## Choose your path + +The build side is the only part that depends on your site generator. Pick the +row that matches your site; the publisher is identical for all of them. + +| Your site | `preview-annotate` | What reviewers get | Notes | +|---|---|---|---| +| **Jekyll** | `generator: jekyll` | Diff line for Markdown blocks, layout and include tags | [Jekyll path](#jekyll) | +| **Astro** (React/JSX components) | `generator: astro` | Diff line for JSX elements | [Astro path](#astro) | +| **Pelican** (incl. the [ASF Pelican action](../pelican/README.md)) | `generator: none` or skip it | Screenshot + Conversation tab | [Pelican path](#pelican) | +| **Hugo, Sphinx, Docusaurus, MkDocs, plain HTML, anything static** | `generator: none` or skip it | Screenshot + Conversation tab | [Any other generator](#any-other-generator) | + +Without annotation everything else still works — the preview, the overlay, +the captioned screenshot. Only the jump to the exact diff line is missing, and +the screenshot caption still records the page URL and commit. + +### Jekyll + +The [Quick start](#quick-start) is the Jekyll path. `preview-annotate` +installs a plugin into your site's `_plugins/` for the preview build only; no +Gemfile change. It stamps: + +- **Markdown pages and collection documents** — headings, paragraphs, list + items, tables, code blocks, blockquotes — from kramdown's source lines. +- **Layouts and includes** — every opening HTML tag, before Liquid runs. + +Details, limits and the plugin's tests: [`adapters/jekyll/`](adapters/jekyll/README.md). + +- Set `source:` if your site is not at the repository root. +- **Safe mode is not supported.** The `github-pages` gem forces + `safe: true`, which ignores `_plugins`; the action refuses a site that sets + `safe: true` or uses that gem, rather than producing a preview with + nothing stamped. Use `generator: none` there. + +### Astro + +Astro builds through Vite, so annotation is a Vite plugin that runs before +the JSX transform. It parses with the site's own Vite (8 or later), which +`astro.config.mjs` hands it, so it needs no Babel and no extra dependency. +Make the config pick it up when the action says so: + +```js +import * as vite from "vite"; + +const previewSrc = + process.env.PREVIEW_ANNOTATE === "1" + ? (await import(process.env.PREVIEW_VITE_PLUGIN)).default + : null; + +export default defineConfig({ + vite: { + plugins: [previewSrc && previewSrc({ vite })], + }, +}); +``` + +Stamped paths are relative to the repository root, found as the nearest +directory above the build holding `.git`, so a site in a subdirectory works +too; set `PREVIEW_SRC_ROOT` to override it. + +Then in the build workflow, pass the plugin to the preview build step: + +```yaml + - run: npm run build + - if: github.event_name == 'pull_request' + id: annotate + uses: apache/infrastructure-actions/website-comment/preview-annotate@ # preview-annotate/v1.0.0 + with: + generator: astro + production-output: dist + - if: github.event_name == 'pull_request' + env: + PREVIEW_ANNOTATE: "1" + PREVIEW_VITE_PLUGIN: ${{ steps.annotate.outputs.vite-plugin }} + run: npm run build + - if: github.event_name == 'pull_request' + uses: apache/infrastructure-actions/website-comment/preview-artifact@ # preview-artifact/v1.0.0 + with: + path: dist +``` + +Only `.tsx`/`.jsx` is stamped; `.astro` templates and Markdown content fall +back to the Conversation tab. See [`adapters/astro/`](adapters/astro/README.md). + +### Pelican + +There is no Pelican adapter yet, so skip `preview-annotate`. With the +[ASF Pelican action](../pelican/README.md), build without publishing on pull +requests and upload the build. Without publishing, the Pelican action leaves +the site in its `tempdir` and never fills `output`, so name the `tempdir`: + +```yaml + - uses: apache/infrastructure-actions/pelican@ # pelican/vX.Y.Z + with: + publish: ${{ github.event_name != 'pull_request' }} + tempdir: ${{ runner.temp }}/site + - if: github.event_name == 'pull_request' + uses: apache/infrastructure-actions/website-comment/preview-artifact@ # preview-artifact/v1.0.0 + with: + path: ${{ runner.temp }}/site +``` + +### Any other generator + +Build as you normally do on `pull_request`, then call `preview-artifact` with +your output directory. That is the whole integration. + +If your generator can emit attributes itself, you can add annotation without +an adapter: stamp any element with `data-preview-src=":"` in the **preview build only**. The overlay uses the nearest +stamped ancestor of the marked region, so partial coverage is fine. + +## Using a preview + +- **Arm it.** The `preview` label arms a pull request, when whoever added it + last has write access. Three ways to put it there: add it directly; comment + `/show-preview` — the whole comment, on its own line — and the bot adds it + and reacts 🚀; or run the publish workflow by hand with `pr: `, which + adds it and publishes at once. The label is created on first use. +- **Follow it.** From then on every new head commit is republished the moment + its build goes green. The bot keeps **one** status comment per pull request + up to date (published / waiting on a build / refused / retired), and posts a + new *Preview updated* comment on each publish, since an edit notifies nobody. + Every preview comment leads with the preview URL. +- **Disarm it.** Remove the label. The preview is retired on the next run. +- **Review on it.** The page shows a banner naming the pull request, so a + forwarded link is never mistaken for the live site. Press `c`, or pick + *Comment on a region of this page* from the *Comment / Suggest a change* + button, drag a box, and the preview copies a captioned screenshot and offers + an **Open PR** button — to the Files tab on the source line when it was + resolved, otherwise the Conversation tab. Paste into the comment box. If the + page has its own `a.suggest-change` edit link, the overlay hides it and + offers it from the same menu, so there is one button, not two. + +## The overlay on the published site + +`preview-main-overlay` injects the same overlay into the site built from your +default branch. There it shows no preview banner, and a comment opens a **new +issue** prefilled with the page URL, the source line on the branch, and the +commit — the screenshot is on the clipboard, ready to paste. Build the site +annotated, then inject before you publish: + +```yaml + - if: github.ref == 'refs/heads/main' + uses: apache/infrastructure-actions/website-comment/preview-annotate@ # preview-annotate/v1.0.0 + with: + generator: jekyll + - if: github.ref == 'refs/heads/main' + run: bundle exec jekyll build + - if: github.ref == 'refs/heads/main' + uses: apache/infrastructure-actions/website-comment/preview-main-overlay@ # preview-main-overlay/v1.0.0 + with: + path: _site +``` + +This deliberately ships `data-preview-src` to the live site, so do not point +`preview-annotate`'s `production-output` check at this output. Pass +`generated:` for source trees that are not in this repository, such as pages +synced from elsewhere: their lines cannot be linked, so the issue carries only +the page. + +## Lifecycle and retention + +Everything this system creates is either replaced in place or removed by the +publisher; nothing accumulates per commit. + +| Thing | Kept for | Removed when | +|---|---|---| +| `preview-site` artifact | `retention-days` (default **30**) | GitHub expires it. The publisher only needs the artifact for the PR's **current** head commit; an older one is never used again. If it expires before a preview is armed, the status comment says *waiting on a build* — re-run the build. | +| `preview/pr-staging` branch | While the PR is open **and** armed | The PR closes or merges, or the label is removed. Each publish is a single force-pushed orphan commit, so the branch never grows history. | +| The staged site `-pr.staged.apache.org` | While the branch is live | **Never unstaged.** ASF staging keeps serving the last content after its branch is deleted. So retirement is two steps: first a **tombstone** — a one-page "this preview has been retired" notice with `robots.txt: Disallow: /` — is force-pushed over the preview, and the branch is deleted only by a later run once the tombstone is at least 30 minutes old, so staging has served it. The hostname keeps serving the tombstone, never the pull request's code. | +| Status and how-to comments | The life of the pull request | Never deleted; edited in place, so each PR has at most one of each. | +| *Preview updated* and dispatch comments | The life of the pull request | Never deleted. One per publish, and one per arming by dispatch. | +| Your repository's head branches of closed PRs | Until the PR closes | Only with `reap-head-branches: "true"`: deleted once every PR from the branch is closed, and only if nobody pushed to it after — checked atomically by the delete itself. Protected, `main`, `publish`, `preview/*` and `asf-*` branches are never touched. | + +Cost and noise: + +- The publisher runs on events — a build or signal run completing, a PR + comment, a dispatch — with no schedule, and exits quickly when nothing + changed. An unchanged head is not republished, and an artifact already + refused for the current head (or built by another PR with the same head) is + not downloaded again; a re-run's new artifact, a new commit or a dispatch is + screened. + Every run reconciles every + open PR, so a run that fails part-way is repaired by the next event, or by a + dispatch with no PR number. +- Every preview push is a branch update, which ASF mirrors to your + `commits@` list. Previews are opt-in per PR for exactly this reason. +- Staging content is `noindex`; `robots.txt` disallows everything. + +## Security model + +- **`preview-artifact` refuses anything but `pull_request`.** Under + `pull_request_target` the build would run pull-request code while holding a + privileged token. +- **`preview-publish` runs on `workflow_run`, `issue_comment` and + `workflow_dispatch` only, never `pull_request_target`.** `workflow_run` and + `issue_comment` run the workflow from the default branch, so pull-request + code never gets near the write token. The events are signals: nothing is + taken from them into the publisher, which re-derives everything from the + API. It never checks out or executes repository code; it only downloads the + artifact. +- **The publisher never reads pull-request content.** Every GitHub response + passes through a projection in [`lib/github.mjs`](lib/github.mjs) first, so + it learns only the PR number, label names, head SHA, author login and type, + and a comment's body only when that is exactly `/show-preview`, + bot-authored or the publisher's own — never code, diff, title, description, branch name or commit + messages. The diff-line anchors are computed by the unprivileged build and + sanitised by the publisher. [`lib/boundary.test.mjs`](lib/boundary.test.mjs) + enforces this with a full run against responses baited in every forbidden + field, plus a fixed endpoint allowlist. +- **The artifact is untrusted input.** Before publishing, the publisher checks + `preview-meta.json` against the pull request's real number and head commit, + refuses archive entries with absolute or `..` paths and symlinks, and + archives over 200 MiB, expanding past 500 MiB or holding over 100,000 files, + and strips + `.git`, `.gitignore` and `.gitmodules` from the tree. +- **The publisher writes `.asf.yaml` itself**, after copying the artifact, so + a pull request can never ship a `publish:` block or another staging profile. +- **Arming is checked against actual repository permission.** The label counts + only when whoever last added it has write access (triage can label without + being a committer); a label with no visible `labeled` event counts as + unarmed. A `/show-preview` counts only from a commenter with write access, + and only when the comment is exactly the command. A label from the + publisher's own login counts only when the publisher recorded that exact + label event as one it added for a committer: a hidden line naming the event + in its explainer comment (edited, so nobody is notified), its dispatch + comment, or, on a bot's PR, a short "Preview armed" comment. The default + `github-actions[bot]` is shared by every workflow in the repository, so an + auto-labeller adding the label, even right after a disarm, arms nothing. If other + workflows post comments or reactions as that login, give the publisher its + own GitHub App token and `publisher-login`. +- **Status comments are edited only when authored by the publisher's own + login and ending in the exact marker**, so nobody can plant a marker in + someone else's comment to have it overwritten. +- **Tokens are redacted** from every git error before it can reach a log. + +## Reference + +### `preview-annotate` + +| Input | Required | Default | Description | +|---|---|---|---| +| `generator` | yes | | `jekyll`, `astro` or `none` | +| `source` | no | `.` | Jekyll: the site source directory, holding `_config.yml` | +| `production-output` | no | | Fail if any page in this directory has an element carrying `data-preview-src`; escaped examples and prose mentions pass | + +| Output | Description | +|---|---| +| `vite-plugin` | `astro` only: the Vite plugin's path, to pass to the preview build as `PREVIEW_VITE_PLUGIN` | + +### `preview-artifact` + +| Input | Required | Default | Description | +|---|---|---|---| +| `path` | yes | | Directory holding the built preview site | +| `retention-days` | no | `30` | Days to keep the `preview-site` artifact | +| `token` | no | `github.token` | Reads the PR's changed files for the anchors; needs `pull-requests: read`. Without it the overlay links to the Conversation tab. | + +### `preview-publish` + +| Input | Required | Default | Description | +|---|---|---|---| +| `site-name` | yes | | First label of the staging hostname: `foo` for `foo-pr.staged.apache.org` | +| `build-workflow` | no | `build.yml` | File name of the workflow that runs `preview-artifact` | +| `label` | no | `preview` | The label that arms a pull request | +| `pr` | no | | `workflow_dispatch` only: arm and publish this PR now | +| `reap-head-branches` | no | `false` | Also delete head branches of closed PRs | +| `token` | no | `github.token` | Needs `contents`, `pull-requests`, `issues` write and `actions` read | +| `publisher-login` | no | `github-actions[bot]` | The login `token` acts as: `[bot]` for a GitHub App token, the user's login for a personal token | + +### `preview-main-overlay` + +| Input | Required | Default | Description | +|---|---|---|---| +| `path` | yes | | Directory holding the built, annotated site | +| `branch` | no | the default branch | Branch the site was built from; issue links point at its source lines | +| `generated` | no | | Source-path prefixes, one per line, whose files are not in this repository | + +### The build contract + +If you build the artifact without `preview-artifact`, this is what the +publisher expects: an artifact named `preview-site`, from a successful run of +`build-workflow` for the PR's head commit, holding the static site with +`preview-meta.json` at its root: + +```json +{"pr": 123, "headSha": "<40-hex head commit>", "anchors": {}} +``` + +`anchors` is optional: `lib/write-meta.mjs` derives it from the PR's changed +files. The publisher treats it as untrusted and rebuilds each entry from its +path. + +## Development + +```sh +cd website-comment +npm ci +npm test # publisher, overlay, Astro adapter + +export BUNDLE_GEMFILE=adapters/jekyll/test/Gemfile +bundle install +bundle exec ruby adapters/jekyll/test/preview_src_test.rb # Jekyll adapter +``` + +CI runs both in [`website-comment-test.yml`](../.github/workflows/website-comment-test.yml), +plus an end-to-end run of `preview-annotate` and `preview-artifact` against +the Jekyll fixture site, `preview-main-overlay` against a small site, and a +check that `preview-publish` refuses a `pull_request` event. + +| Path | What it is | +|---|---| +| `lib/` | The publisher (arming, planning, publishing, retiring, GitHub and git clients), the build's metadata writer and the main-site injector | +| `overlay/` | The review overlay injected into every published page | +| `adapters/` | Generator-specific source annotation | +| `vendor/html2canvas-pro/` | Screenshot library served with the overlay (MIT) | + +Originally developed for the [Apache Magpie website](https://github.com/apache/magpie-site), +whose design notes cover the reasoning in more depth: +[preview deployments](https://github.com/apache/magpie-site/blob/main/docs/designs/2026-09-16-pr-preview-deployments-design.md), +[review overlay](https://github.com/apache/magpie-site/blob/main/docs/designs/2026-09-17-preview-review-overlay-design.md), +[Jekyll adapter](https://github.com/apache/magpie-site/blob/main/docs/designs/2026-09-27-preview-jekyll-adapter-design.md). diff --git a/website-comment/adapters/astro/README.md b/website-comment/adapters/astro/README.md new file mode 100644 index 000000000..869da4bea --- /dev/null +++ b/website-comment/adapters/astro/README.md @@ -0,0 +1,48 @@ + +# Astro adapter + +A Vite plugin that stamps every JSX host element (lowercase tag) with +`data-preview-src=":"` in a preview build. It parses +each `.tsx`/`.jsx` file with the site's own Vite parser (`parseSync`, oxc) +before the JSX transform and inserts the attribute into the opening tag, so +line mappings still hold. + +Enable it with [`preview-annotate`](../../preview-annotate/action.yml) and +`generator: astro`, whose `vite-plugin` output is this plugin's path. Pass +it to the preview build as `PREVIEW_VITE_PLUGIN`, with `PREVIEW_ANNOTATE=1`, +for `astro.config.mjs` to load — see the [Astro path](../../README.md#astro). +The config passes the site's `vite` module in: this file is loaded from the +action's checkout, which has no `node_modules` of its own. It needs Vite 8 or +later, and neither Babel nor `@astrojs/react` options — since `@astrojs/react` +7 the React integration no longer runs Babel. + +Paths are relative to the repository root: `PREVIEW_SRC_ROOT` when set, +else the nearest directory above the build holding `.git`, as the Jekyll +adapter does. A site in a subdirectory gets the same paths as the pull +request's diff. + +Left alone: component elements (``), whose host elements are stamped +in their own file; member-expression tags (``); an element that +already carries `data-preview-src`; files under `node_modules`; and a file +that fails to parse, which passes through unchanged. `.astro` templates and +Markdown content are compiled by Astro, so a region there falls back to the +pull request's Conversation tab. + +Tests: `npm test` in `website-comment/`. diff --git a/website-comment/adapters/astro/vite-plugin-preview-src.mjs b/website-comment/adapters/astro/vite-plugin-preview-src.mjs new file mode 100644 index 000000000..869f3dc7b --- /dev/null +++ b/website-comment/adapters/astro/vite-plugin-preview-src.mjs @@ -0,0 +1,108 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { existsSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; + +/** + * The directory stamped paths are relative to, as the Jekyll adapter finds it: + * PREVIEW_SRC_ROOT when set, else the nearest ancestor of `start` holding .git + * (a directory, or a file in a worktree), else `start`. Paths must match the + * repository-relative ones the diff anchors use, also for a site in a + * subdirectory. + */ +export function repoRoot(start = process.cwd(), env = process.env) { + if (env.PREVIEW_SRC_ROOT) return resolve(env.PREVIEW_SRC_ROOT); + for (let dir = resolve(start); ; dir = dirname(dir)) { + if (existsSync(join(dir, ".git"))) return dir; + if (dirname(dir) === dir) return resolve(start); + } +} + +/** + * Stamps every JSX host element with the source file and line it came from, so + * a preview's overlay can resolve a marked region back to a diff line. + * + * Only host elements (lowercase names) are stamped: a component element renders + * host elements of its own, and those carry the location the reviewer can act + * on. Active only when the build sets PREVIEW_ANNOTATE=1 — the + * production build must emit none of this. + * + * This is a Vite plugin running before the JSX transform, so it only sees JSX: + * `.tsx` and `.jsx` files. `.astro` templates and Markdown content are + * never annotated — that would need the Astro compiler. + * + * `vite` is the site's own Vite module (`import * as vite from "vite"`). This + * file is loaded from the action's checkout, which has no node_modules, so it + * cannot import Vite itself; it uses the parser the site already builds with. + */ +export default function previewSrc({ vite, root = repoRoot() } = {}) { + if (typeof vite?.parseSync !== "function" || typeof vite?.Visitor !== "function") { + throw new TypeError("previewSrc needs { vite }: the site's `import * as vite from \"vite\"` (Vite 8 or later)"); + } + return { + name: "preview-src", + enforce: "pre", + transform: { + filter: { id: { include: /\.[jt]sx(?:$|\?)/, exclude: /[/\\]node_modules[/\\]/ } }, + handler(code, id) { + const stamped = stampSource(code, id.split("?")[0], root, vite); + // Attributes are inserted within a line, so line mappings still hold. + return stamped === code ? null : { code: stamped, map: null }; + }, + }, + }; +} + +export function stampSource(code, filename, root, { parseSync, Visitor }) { + const lang = filename.endsWith(".jsx") ? "jsx" : "tsx"; + const { program, errors } = parseSync(filename, code, { lang }); + if (errors.length) return code; + + const lineStarts = [0]; + for (let i = 0; i < code.length; i++) if (code[i] === "\n") lineStarts.push(i + 1); + const lineOf = (offset) => { + let lo = 0; + let hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (lineStarts[mid] <= offset) lo = mid; + else hi = mid - 1; + } + return lo + 1; + }; + + const file = relative(root, filename); + const inserts = []; + new Visitor({ + JSXOpeningElement(node) { + if (node.name.type !== "JSXIdentifier") return; + if (!/^[a-z]/.test(node.name.name)) return; + const already = node.attributes.some( + (a) => a.type === "JSXAttribute" && a.name.name === "data-preview-src", + ); + if (already) return; + inserts.push([node.name.end, ` data-preview-src="${file}:${lineOf(node.start)}"`]); + }, + }).visit(program); + + let out = code; + for (const [at, text] of inserts.sort((a, b) => b[0] - a[0])) { + out = out.slice(0, at) + text + out.slice(at); + } + return out; +} diff --git a/website-comment/adapters/astro/vite-plugin-preview-src.test.mjs b/website-comment/adapters/astro/vite-plugin-preview-src.test.mjs new file mode 100644 index 000000000..08d27637f --- /dev/null +++ b/website-comment/adapters/astro/vite-plugin-preview-src.test.mjs @@ -0,0 +1,78 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as vite from "vite"; +import previewSrc, { stampSource, repoRoot } from "./vite-plugin-preview-src.mjs"; + +const run = (code, filename = "/repo/src/components/Thing.tsx") => + stampSource(code, filename, "/repo", vite); + +test("stamps a host element with its repo-relative path and line", () => { + const out = run("const a =
hi
;"); + assert.match(out, /data-preview-src="src\/components\/Thing\.tsx:1"/); +}); + +test("leaves component elements alone", () => { + // A component renders host elements of its own, which get stamped there. + const out = run("const a = ;"); + assert.doesNotMatch(out, /data-preview-src/); +}); + +test("does not overwrite an existing attribute", () => { + const out = run('const a =
;'); + assert.match(out, /data-preview-src="kept"/); + assert.equal(out.match(/data-preview-src/g).length, 1); +}); + +test("records the line each element starts on", () => { + const out = run("const a = (\n
\n x\n
\n);"); + assert.match(out, /data-preview-src="src\/components\/Thing\.tsx:2"/); + assert.match(out, /data-preview-src="src\/components\/Thing\.tsx:3"/); +}); + +test("parses TypeScript and inserts at the right place after non-ASCII text", () => { + const out = run("const s: string = 'é—✓';\nconst a =

naïve

;"); + assert.ok(out.includes('

'), out); +}); + +test("leaves unparsable source untouched", () => { + const code = "const a =

;"; + assert.equal(run(code), code); +}); + +test("the plugin transforms through the site's Vite, and refuses to run without it", () => { + const plugin = previewSrc({ vite, root: "/repo" }); + const out = plugin.transform.handler("const a =
;", "/repo/src/A.jsx?v=1"); + assert.match(out.code, /data-preview-src="src\/A\.jsx:1"/); + assert.throws(() => previewSrc({ root: "/repo" }), /needs \{ vite \}/); +}); + +test("paths are relative to the repository, also for a site in a subdirectory", async () => { + const repo = await mkdtemp(join(tmpdir(), "astro-root-")); + await mkdir(join(repo, ".git")); + await mkdir(join(repo, "site", "src"), { recursive: true }); + assert.equal(repoRoot(join(repo, "site"), {}), repo); + assert.equal(repoRoot(join(repo, "site"), { PREVIEW_SRC_ROOT: "/elsewhere" }), "/elsewhere"); + + const out = stampSource("const a =
;", join(repo, "site", "src", "A.tsx"), repoRoot(join(repo, "site"), {}), vite); + assert.match(out, /data-preview-src="site\/src\/A\.tsx:1"/); +}); diff --git a/website-comment/adapters/jekyll/README.md b/website-comment/adapters/jekyll/README.md new file mode 100644 index 000000000..4b10b548b --- /dev/null +++ b/website-comment/adapters/jekyll/README.md @@ -0,0 +1,73 @@ + + +# Jekyll adapter + +Makes a Jekyll site's preview build stamp `data-preview-src=":"` on +rendered elements, so the review overlay can take a reviewer from a marked +region to the source line in the pull request's diff. See the +[overview](../../README.md) for how this fits the rest of the preview +system. + +## What gets stamped + +| Source | How | Granularity | +|---|---|---| +| Markdown pages and collection documents | From the source line kramdown records for each block, shifted past the front matter | Headings, paragraphs, list items, blockquotes, tables and rows, code blocks, definition lists, embedded HTML blocks | +| Layouts (`_layouts/`) | Each opening HTML tag of the raw template, before Liquid runs | Every tag outside `html`, `head`, `body`, `meta`, `link`, `script`, `style`, `title`, `base`, `noscript`, `template` | +| Includes (`include`, `include_relative`) | Same as layouts | Same as layouts | + +Left alone: Liquid output (`{{ "" }}`), `{% raw %}`, `{% comment %}` and +`{% highlight %}` blocks, ` +{% raw %}

literal

{% endraw %} + + + diff --git a/website-comment/adapters/jekyll/test/fixture/index.md b/website-comment/adapters/jekyll/test/fixture/index.md new file mode 100644 index 000000000..86058319c --- /dev/null +++ b/website-comment/adapters/jekyll/test/fixture/index.md @@ -0,0 +1,12 @@ +--- +layout: default +title: Home +--- +# Hello + +A paragraph. + +- one +- two + +> quoted diff --git a/website-comment/adapters/jekyll/test/preview_src_test.rb b/website-comment/adapters/jekyll/test/preview_src_test.rb new file mode 100644 index 000000000..d17cbf8d6 --- /dev/null +++ b/website-comment/adapters/jekyll/test/preview_src_test.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# Run: BUNDLE_GEMFILE=website-comment/adapters/jekyll/test/Gemfile bundle exec ruby website-comment/adapters/jekyll/test/preview_src_test.rb +# Needs the jekyll gem (4.x) and minitest. + +require "minitest/autorun" +require "tmpdir" +require "fileutils" +require_relative "../preview_src" + +FIXTURE = File.expand_path("fixture", __dir__) +PREPARE = File.expand_path("../prepare.sh", __dir__) + +class PreviewSrcBuildTest < Minitest::Test + def self.built + @built ||= begin + dest = Dir.mktmpdir("preview-src-site") + ENV["PREVIEW_SRC_ROOT"] = FIXTURE + config = Jekyll.configuration( + "source" => FIXTURE, "destination" => dest, "quiet" => true, + "disable_disk_cache" => true, + ) + Jekyll::Site.new(config).process + File.read(File.join(dest, "index.html")) + ensure + ENV.delete("PREVIEW_SRC_ROOT") + end + end + + def html = self.class.built + + def test_markdown_blocks_carry_their_file_line + assert_match(/]*data-preview-src="index\.md:5"/, html) + assert_match(/

A paragraph\.<\/p>/, html) + assert_match(/

  • one/, html) + assert_match(/
  • two/, html) + assert_match(/
    /, html) + end + + def test_layout_tags_carry_their_file_line + assert_includes html, %(
    ) + assert_includes html, %(
    ) + end + + def test_include_tags_carry_their_file_line + assert_includes html, %(