You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/09/27 18:23:18 UTC

(magpie-site) branch main updated: feat(preview): support Jekyll sites, including file-and-line lookup (#205)

This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie-site.git


The following commit(s) were added to refs/heads/main by this push:
     new ce07e883 feat(preview): support Jekyll sites, including file-and-line lookup (#205)
ce07e883 is described below

commit ce07e8831731eb6b53dc35e815dc70ce65919f5d
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Sun Sep 27 20:23:13 2026 +0200

    feat(preview): support Jekyll sites, including file-and-line lookup (#205)
    
    Add a Jekyll adapter that stamps data-magpie-src on a preview build:
    Markdown blocks from kramdown's source lines, and opening tags in
    layouts and includes before Liquid runs. prepare.sh installs it into
    _plugins/ for the preview build only.
    
    Generator-specific code now lives under scripts/preview/adapters/; the
    Astro Babel plugin moves there unchanged. The build contract is written
    down in scripts/preview/README.md, and PREVIEW_SITE_NAME and
    PREVIEW_BUILD_WORKFLOW replace the hardcoded staging label and build
    workflow, defaulting to this site's values.
    
    A jekyll-adapter CI job builds a fixture site with the plugin in a
    digest-pinned ruby:3.3 container, from a checksummed Gemfile.lock.
    
    Generated-by: Claude Opus 5
---
 .github/workflows/build.yml                        |  28 +++
 astro.config.mjs                                   |   2 +-
 .../2026-09-27-preview-jekyll-adapter-design.md    |  90 ++++++++
 scripts/preview/README.md                          |  52 +++++
 .../astro}/babel-plugin-magpie-src.mjs             |   0
 .../astro}/babel-plugin-magpie-src.test.mjs        |   0
 scripts/preview/adapters/jekyll/README.md          |  86 ++++++++
 scripts/preview/adapters/jekyll/magpie_src.rb      | 235 +++++++++++++++++++++
 scripts/preview/adapters/jekyll/prepare.sh         |  36 ++++
 scripts/preview/adapters/jekyll/test/Gemfile       |   6 +
 scripts/preview/adapters/jekyll/test/Gemfile.lock  | 133 ++++++++++++
 .../adapters/jekyll/test/fixture/_config.yml       |   2 +
 .../jekyll/test/fixture/_includes/nav.html         |   3 +
 .../jekyll/test/fixture/_layouts/default.html      |  14 ++
 .../preview/adapters/jekyll/test/fixture/index.md  |  12 ++
 .../adapters/jekyll/test/magpie_src_test.rb        | 119 +++++++++++
 scripts/preview/files.mjs                          |  20 +-
 scripts/preview/files.test.mjs                     |  13 ++
 scripts/preview/github.mjs                         |  19 +-
 scripts/preview/github.test.mjs                    |  23 +-
 20 files changed, 888 insertions(+), 5 deletions(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 55275fd6..e4a58361 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -51,6 +51,34 @@ jobs:
       - name: Run unit tests
         run: npm test
 
+  # The Jekyll source-annotation plugin for PR previews. This site is Astro, so
+  # nothing else here builds Jekyll; the test builds a fixture site with the
+  # plugin loaded. It runs in a pinned Ruby image rather than through a
+  # setup-ruby action, so no third-party action is added.
+  jekyll-adapter:
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    permissions:
+      contents: read
+    container:
+      image: ruby:3.3@sha256:21e6a5125535e13999143c49b6471f68c92a74d8faad0a3541c51b11de0266ad
+    env:
+      # Install exactly what the lockfile names, checksums included, and fail
+      # rather than re-resolve if the Gemfile and lockfile disagree.
+      BUNDLE_GEMFILE: scripts/preview/adapters/jekyll/test/Gemfile
+      BUNDLE_FROZEN: "true"
+    steps:
+      - name: Checkout
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+        with:
+          persist-credentials: false
+
+      - name: Install the locked test dependencies
+        run: bundle install
+
+      - name: Test the Jekyll adapter
+        run: bundle exec ruby scripts/preview/adapters/jekyll/test/magpie_src_test.rb
+
   build:
     needs: lint
     runs-on: ubuntu-slim
diff --git a/astro.config.mjs b/astro.config.mjs
index 1e8bf6b6..105dc7fa 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -45,7 +45,7 @@ export default defineConfig({
               plugins: [
                 // Preview builds only: stamps data-magpie-src so the review
                 // overlay can map a marked region back to a diff line.
-                ["./scripts/preview/babel-plugin-magpie-src.mjs", { root: process.cwd() }],
+                ["./scripts/preview/adapters/astro/babel-plugin-magpie-src.mjs", { root: process.cwd() }],
               ],
             },
           }
diff --git a/docs/designs/2026-09-27-preview-jekyll-adapter-design.md b/docs/designs/2026-09-27-preview-jekyll-adapter-design.md
new file mode 100644
index 00000000..541a2104
--- /dev/null
+++ b/docs/designs/2026-09-27-preview-jekyll-adapter-design.md
@@ -0,0 +1,90 @@
+# PR previews for Jekyll sites
+
+**Status:** implemented · **Date:** 2026-09-27
+
+## Goal
+
+The PR-preview system (`scripts/preview/`) was built for this Astro site, but
+most ASF project sites are Jekyll. Make previews — including the overlay's
+file-and-line lookup — work for a Jekyll site, without changing how this site
+behaves.
+
+The preview system is due to move to its own repository, so it stays here for
+now, and the generator-specific parts are kept in separable directories so that
+move is a file move.
+
+## What was already generic
+
+Almost all of it. The publisher consumes a built directory plus
+`preview-meta.json` and never builds anything; validation, reaping, diff
+anchors and the overlay do not care which generator produced the HTML. Two
+things were Astro-specific, and two were hardcoded to this site:
+
+- **Source annotation** — `data-magpie-src` came from a Babel plugin, so only
+  `.tsx`/`.jsx` was covered.
+- **The build commands** in `build.yml`.
+- **The staging hostname label** `magpie`, and **the build workflow name**
+  `build.yml` the publisher looks up artifacts from.
+
+## Design
+
+**Adapters.** `scripts/preview/adapters/<generator>/` holds the one piece
+that differs per generator. The Babel plugin moved to `adapters/astro/`
+unchanged. `adapters/jekyll/` is new.
+
+**The contract** between a site's build and the publisher is now written down
+in `scripts/preview/README.md`: a `preview-site` artifact, `preview-meta.json`,
+and optional `data-magpie-src` attributes. Optional matters — an element
+without one falls back to the Conversation tab, which is already how `.astro`
+templates behave here.
+
+**Settings.** `PREVIEW_SITE_NAME` and `PREVIEW_BUILD_WORKFLOW` replace the two
+hardcoded values, defaulting to them. Both reach a hostname, YAML or an API
+path, so both are validated to a strict character set, for the same reason the
+PR number is.
+
+**The Jekyll plugin** (`magpie_src.rb`) stamps from two sources:
+
+- *Markdown.* kramdown records a source line on every block element. The plugin
+  adds the front-matter length and stamps blocks while Jekyll converts a
+  document's own content — tracked around `Jekyll::Renderer#convert`, so
+  `markdownify` inside a layout is not mis-stamped with the page's path.
+  kramdown calls `convert` only on the root element and dispatches children
+  directly, so the tree is stamped once, from the root.
+- *Layouts and includes.* Opening HTML tags in the raw template are stamped
+  before Liquid parses it: layouts after the site is read, includes as they
+  are loaded (`Jekyll::Inclusion#content` for `include`, which Jekyll 4 renders
+  through `OptimizedIncludeTag`; `IncludeTag#read_file` for
+  `include_relative`). Liquid spans, raw/comment/highlight blocks, scripts,
+  styles and HTML comments pass through untouched.
+
+It is installed by `prepare.sh`, which copies it into `_plugins/` for the
+preview build only — no Gemfile change, and the production build never loads
+it. `prepare.sh` refuses `safe: true` (the `github-pages` gem), where
+`_plugins` is ignored and the build would silently stamp nothing.
+
+## Known limits
+
+- A Liquid tag in a Markdown page that expands to several lines shifts the
+  line numbers of later blocks on that page. The path stays correct and the
+  screenshot caption names it. Fixing it means mapping lines through Liquid
+  rendering, which Jekyll does not expose.
+- Sites that must build in safe mode cannot use the adapter.
+
+## Alternatives considered
+
+- **Extract the preview system into `apache/magpie`** as a reusable workflow
+  plus a packaging action. Rejected for now: the system is moving to a
+  different repository.
+- **Inject kramdown IAL markers into the Markdown source** before rendering.
+  Exact through Liquid, but it changes Markdown semantics around lists, code
+  fences and tables — too easy to break the page being reviewed.
+
+## Testing
+
+`adapters/jekyll/test/` builds a fixture site with the plugin loaded and
+asserts stamped `path:line` values for Markdown, a layout and an include, plus
+what must stay untouched. CI runs it in the `jekyll-adapter` job, in a
+digest-pinned `ruby:3.3` container, so no new third-party action is needed,
+installing from a checksummed `Gemfile.lock` with `BUNDLE_FROZEN`.
+Verified locally against Jekyll 4.4.1 and 4.3.4.
diff --git a/scripts/preview/README.md b/scripts/preview/README.md
new file mode 100644
index 00000000..e784c622
--- /dev/null
+++ b/scripts/preview/README.md
@@ -0,0 +1,52 @@
+# PR previews
+
+Publishes an armed pull request's built site to
+`https://<site>-pr<N>.staged.apache.org/`, with a review overlay that turns a
+marked region into a screenshot and a link to the matching diff line. The
+design is in
+[`docs/designs/2026-09-16-pr-preview-deployments-design.md`](../../docs/designs/2026-09-16-pr-preview-deployments-design.md)
+and
+[`docs/designs/2026-09-17-preview-review-overlay-design.md`](../../docs/designs/2026-09-17-preview-review-overlay-design.md).
+
+Everything in this directory except `adapters/` is independent of the site
+generator. An adapter is the one generator-specific piece: it makes a preview
+build say which source line each element came from.
+
+| Path | What it is |
+|---|---|
+| `publish.mjs` and its modules | The privileged publisher run by `preview-publish.yml` |
+| `overlay/` | The review overlay injected into every published page |
+| `adapters/astro/` | Babel plugin stamping JSX, used by this site |
+| `adapters/jekyll/` | Jekyll plugin stamping Markdown, layouts and includes |
+
+## The build contract
+
+The publisher never builds anything. It consumes an artifact that the
+unprivileged pull-request build produced, and this is all it expects of it:
+
+1. **An artifact named `preview-site`**, uploaded by a successful run of the
+   build workflow for the pull request's head commit, holding the built static
+   site. Upload with `include-hidden-files: true` if the site has dotfiles.
+2. **`preview-meta.json` at its root**:
+   `{"pr": <number>, "headSha": "<40-hex head commit>"}`. The publisher checks
+   both against the pull request and refuses a mismatch.
+3. **Optionally, `data-magpie-src="<repo-relative path>:<line>"`** on rendered
+   elements. The overlay walks up from the marked region to the nearest stamped
+   element and, when that line is in the diff, opens the Files tab on it. An
+   element without one falls back to the Conversation tab, so an adapter that
+   covers only some sources is still useful.
+
+The attribute is for preview builds only. Build production without the
+adapter and fail the build if `data-magpie-src` appears in it — see the
+assertion step in `.github/workflows/build.yml`.
+
+## Settings for another site
+
+Both default to this site's values.
+
+| Variable | Default | Meaning |
+|---|---|---|
+| `PREVIEW_SITE_NAME` | `magpie` | First label of the staging hostname. ASF staging derives it from the repository, so it must match what infra serves. |
+| `PREVIEW_BUILD_WORKFLOW` | `build.yml` | File name of the workflow whose runs carry the `preview-site` artifact |
+
+Set them in the `env:` of the publish step in `preview-publish.yml`.
diff --git a/scripts/preview/babel-plugin-magpie-src.mjs b/scripts/preview/adapters/astro/babel-plugin-magpie-src.mjs
similarity index 100%
rename from scripts/preview/babel-plugin-magpie-src.mjs
rename to scripts/preview/adapters/astro/babel-plugin-magpie-src.mjs
diff --git a/scripts/preview/babel-plugin-magpie-src.test.mjs b/scripts/preview/adapters/astro/babel-plugin-magpie-src.test.mjs
similarity index 100%
rename from scripts/preview/babel-plugin-magpie-src.test.mjs
rename to scripts/preview/adapters/astro/babel-plugin-magpie-src.test.mjs
diff --git a/scripts/preview/adapters/jekyll/README.md b/scripts/preview/adapters/jekyll/README.md
new file mode 100644
index 00000000..adc7f7c1
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/README.md
@@ -0,0 +1,86 @@
+# Jekyll adapter
+
+Makes a Jekyll site's preview build stamp `data-magpie-src="<path>:<line>"` 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
+[build contract](../../README.md#the-build-contract) 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 (`{{ "<b>" }}`), `{% raw %}`, `{% comment %}` and
+`{% highlight %}` blocks, `<script>`, `<style>` and HTML comments, Markdown
+rendered by `markdownify` inside a layout, and files from a theme gem, which a
+pull request cannot change.
+
+Paths are relative to the repository root: the nearest ancestor of the site
+source holding `.git`, or `MAGPIE_SRC_ROOT` when set.
+
+**Known limit.** A Liquid tag inside a Markdown page that expands to several
+lines shifts the line of every block after it on that page. The file is still
+right, and the screenshot's caption names it.
+
+## Wiring it into a Jekyll site's CI
+
+In the pull-request build, after the production build and its check:
+
+```yaml
+      - name: Assert the production build carries no preview annotation
+        run: |
+          if grep -rq "data-magpie-src" _site/; then
+            echo "::error::data-magpie-src found in a production build"
+            exit 1
+          fi
+
+      - name: Build the preview site with source annotation
+        if: github.event_name == 'pull_request'
+        run: |
+          sh scripts/preview/adapters/jekyll/prepare.sh .
+          bundle exec jekyll build
+
+      - name: Write preview metadata
+        if: github.event_name == 'pull_request'
+        env:
+          PR_NUMBER: ${{ github.event.pull_request.number }}
+          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+        run: |
+          printf '{"pr":%s,"headSha":"%s"}\n' "$PR_NUMBER" "$HEAD_SHA" > _site/preview-meta.json
+
+      - name: Upload preview artifact
+        if: github.event_name == 'pull_request'
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: preview-site
+          path: _site
+          include-hidden-files: true
+```
+
+`prepare.sh <site source>` copies `magpie_src.rb` into the site's plugins
+directory (`plugins_dir`, default `_plugins`). Run it only in CI, or delete the
+copied file afterwards: anything built while it is there is annotated. It
+refuses a site with `safe: true` — which the `github-pages` gem forces —
+because safe mode ignores `_plugins` and the build would silently stamp
+nothing.
+
+For publishing, carry over `scripts/preview/`, `vendor/html2canvas-pro/` and
+`.github/workflows/preview-publish.yml`, and set `PREVIEW_SITE_NAME` and
+`PREVIEW_BUILD_WORKFLOW` as described in the
+[settings](../../README.md#settings-for-another-site).
+
+## Tests
+
+```sh
+export BUNDLE_GEMFILE=scripts/preview/adapters/jekyll/test/Gemfile
+bundle install
+bundle exec ruby scripts/preview/adapters/jekyll/test/magpie_src_test.rb
+```
+
+The test builds `test/fixture/` with the plugin loaded and checks the stamped
+lines. CI runs it in the `jekyll-adapter` job of `build.yml`. Verified against
+Jekyll 4.4.1 and 4.3.4.
diff --git a/scripts/preview/adapters/jekyll/magpie_src.rb b/scripts/preview/adapters/jekyll/magpie_src.rb
new file mode 100644
index 00000000..8455767a
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/magpie_src.rb
@@ -0,0 +1,235 @@
+# 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.
+
+# Stamps rendered elements with the source file and line they came from, as
+# data-magpie-src="<repo-relative path>:<line>", so a preview's review overlay
+# can resolve a marked region back to a diff line. The Jekyll counterpart of
+# adapters/astro/babel-plugin-magpie-src.mjs.
+#
+# Preview builds only: prepare.sh copies this file into the site's _plugins/
+# for the preview build, and nothing else loads it. A production build must
+# carry none of these attributes.
+#
+# Two sources are stamped:
+#   - Markdown pages and collection documents, from the line kramdown records
+#     for each block, shifted past the front matter.
+#   - Layouts and includes, by stamping each opening HTML tag of the raw
+#     template before Liquid runs.
+#
+# Known limit: a Liquid tag inside Markdown that expands to several lines
+# shifts the line of every block after it. The file is still right.
+
+require "jekyll"
+require "kramdown"
+require "pathname"
+
+module MagpieSrc
+  ATTR = "data-magpie-src"
+
+  # Blocks stamped in Markdown. Inline elements are left alone: the overlay
+  # walks up to the nearest stamped ancestor, and a block is the unit a
+  # reviewer comments on.
+  MARKDOWN_BLOCKS = %i[
+    p header ul ol li blockquote table tr codeblock dl dt dd hr html_element math
+  ].freeze
+
+  # Never stamped in templates: they render nothing a reviewer can mark, or
+  # sit above <body>, where the overlay's walk stops.
+  SKIP_TAGS = %w[html head body meta link script style title base noscript template].freeze
+
+  # Template regions whose contents are not markup to stamp.
+  OPAQUE = [
+    [/\{%-?\s*raw\s*-?%\}/, /\{%-?\s*endraw\s*-?%\}/],
+    [/\{%-?\s*comment\s*-?%\}/, /\{%-?\s*endcomment\s*-?%\}/],
+    [/\{%-?\s*highlight\b/, /\{%-?\s*endhighlight\s*-?%\}/],
+    [/<script\b/i, %r{</script\s*>}i],
+    [/<style\b/i, %r{</style\s*>}i],
+    [/<!--/, /-->/],
+  ].freeze
+
+  LIQUID = /\{\{.*?\}\}|\{%.*?%\}/m
+  OPEN_TAG = /<([a-zA-Z][a-zA-Z0-9-]*)(?=[\s>\/])/
+
+  module_function
+
+  # The directory paths are reported relative to: MAGPIE_SRC_ROOT when set,
+  # else the nearest ancestor of the site source holding .git (a directory, or
+  # a file in a worktree), else the site source itself.
+  def repo_root(source)
+    return Pathname.new(File.expand_path(ENV["MAGPIE_SRC_ROOT"])) if ENV["MAGPIE_SRC_ROOT"].to_s != ""
+
+    dir = Pathname.new(File.expand_path(source))
+    dir.ascend { |d| return d if (d + ".git").exist? }
+    Pathname.new(File.expand_path(source))
+  end
+
+  # The repo-relative path, or nil for a file outside the repository — a
+  # theme gem's layout or include, which no pull request can change.
+  def relative(root, path)
+    rel = Pathname.new(File.expand_path(path)).relative_path_from(root).to_s
+    rel.start_with?("..") ? nil : rel
+  rescue ArgumentError
+    nil
+  end
+
+  # The 1-based line of the front matter's closing delimiter, or 0 when the
+  # file has none, so body line N is file line N + this. Mirrors the pattern
+  # Jekyll itself uses to split front matter from content.
+  def front_matter_lines(path)
+    raw = File.read(path, encoding: "UTF-8")
+    match = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m.match(raw)
+    match ? match[0].count("\n") : 0
+  rescue SystemCallError
+    0
+  end
+
+  # Stamp every opening tag of a raw template with its file line. Liquid spans
+  # and opaque regions are copied through untouched.
+  def stamp_template(content, rel, first_line)
+    closer = nil
+    content.each_line.with_index.map do |line, i|
+      where = "#{rel}:#{first_line + i}"
+      out = +""
+      rest = line
+      until rest.empty?
+        if closer
+          at = closer.match(rest)
+          if at.nil?
+            out << rest
+            break
+          end
+          out << rest[0...at.end(0)]
+          rest = rest[at.end(0)..]
+          closer = nil
+          next
+        end
+
+        opener, close = OPAQUE.map { |o, c| [o.match(rest), c] }
+                              .reject { |m, _| m.nil? }
+                              .min_by { |m, _| m.begin(0) }
+        stop = opener ? opener.begin(0) : rest.length
+        out << stamp_segment(rest[0...stop], where)
+        break unless opener
+
+        out << rest[stop...opener.end(0)]
+        rest = rest[opener.end(0)..]
+        closer = close
+      end
+      out
+    end.join
+  end
+
+  # Stamp the tags in a run of template text that lies outside Liquid spans.
+  def stamp_segment(text, where)
+    pieces = text.split(/(#{LIQUID})/)
+    pieces.each_with_index.map do |piece, i|
+      next piece if i.odd?
+
+      piece.gsub(OPEN_TAG) do |tag|
+        name = Regexp.last_match(1)
+        SKIP_TAGS.include?(name.downcase) ? tag : %(#{tag} #{ATTR}="#{where}")
+      end
+    end.join
+  end
+
+  # Set while Jekyll converts one document's own content, and read by the
+  # kramdown converter below. Unset for markdownify in a layout, which would
+  # otherwise be stamped with the page's path.
+  def current
+    Thread.current[:magpie_src]
+  end
+
+  def with_document(document)
+    site = document.site
+    # Page#path is source-relative and Document#path absolute; relative_path
+    # is source-relative on both.
+    path = site.in_source_dir(document.relative_path.to_s)
+    rel = File.file?(path) ? relative(repo_root(site.source), path) : nil
+    return yield unless rel
+
+    Thread.current[:magpie_src] = { rel: rel, offset: front_matter_lines(path) }
+    yield
+  ensure
+    Thread.current[:magpie_src] = nil
+  end
+
+  module Renderer
+    def convert(content)
+      MagpieSrc.with_document(document) { super }
+    end
+  end
+
+  def stamp_tree(el, ctx)
+    line = el.options[:location]
+    if line && MARKDOWN_BLOCKS.include?(el.type) && !el.attr.key?(ATTR)
+      el.attr[ATTR] = "#{ctx[:rel]}:#{line + ctx[:offset]}"
+    end
+    el.children.each { |child| stamp_tree(child, ctx) }
+  end
+
+  def stamp_include(site, path, content)
+    rel = relative(repo_root(site.source), path)
+    rel ? stamp_template(content, rel, 1) : content
+  end
+
+  # kramdown calls #convert on the root only; every child is dispatched
+  # straight to its convert_<type>. So the whole tree is stamped from the root.
+  module KramdownHtml
+    def convert(el, *args)
+      ctx = MagpieSrc.current
+      MagpieSrc.stamp_tree(el, ctx) if ctx && el.type == :root
+      super
+    end
+  end
+
+  # `include` is OptimizedIncludeTag, which renders a Jekyll::Inclusion and
+  # reads the file through Inclusion#content; `include_relative` still goes
+  # through IncludeTag#read_file. Both are read once per build and cached.
+  module Inclusion
+    def content
+      @content ||= MagpieSrc.stamp_include(site, path, super)
+    end
+  end
+
+  module IncludeTag
+    def read_file(file, context)
+      MagpieSrc.stamp_include(context.registers[:site], file, super)
+    end
+  end
+end
+
+Jekyll::Renderer.prepend(MagpieSrc::Renderer)
+Kramdown::Converter::Html.prepend(MagpieSrc::KramdownHtml)
+Jekyll::Inclusion.prepend(MagpieSrc::Inclusion)
+Jekyll::Tags::IncludeTag.prepend(MagpieSrc::IncludeTag)
+
+# Layouts are read once per build, after which their content is only parsed.
+Jekyll::Hooks.register :site, :post_read do |site|
+  root = MagpieSrc.repo_root(site.source)
+  site.layouts.each_value do |layout|
+    next unless layout.path && File.file?(layout.path)
+
+    rel = MagpieSrc.relative(root, layout.path)
+    next unless rel
+
+    layout.content = MagpieSrc.stamp_template(
+      layout.content, rel, MagpieSrc.front_matter_lines(layout.path) + 1,
+    )
+  end
+end
diff --git a/scripts/preview/adapters/jekyll/prepare.sh b/scripts/preview/adapters/jekyll/prepare.sh
new file mode 100755
index 00000000..11824763
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/prepare.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements; and to You under the Apache License,
+# Version 2.0. See http://www.apache.org/licenses/LICENSE-2.0
+#
+# Install the source-annotation plugin into a Jekyll site for a PREVIEW build.
+# Run it in the preview build only, never before the production build: the
+# plugin stamps every page with data-magpie-src, which must not ship.
+#
+# Usage: prepare.sh <jekyll site source directory>
+set -eu
+
+site="${1:?usage: prepare.sh <jekyll site source directory>}"
+
+config=""
+for c in "$site/_config.yml" "$site/_config.yaml"; do
+  if [ -f "$c" ]; then config="$c"; break; fi
+done
+if [ -z "$config" ]; then
+  echo "prepare.sh: no _config.yml in $site" >&2
+  exit 1
+fi
+
+# Safe mode, which the github-pages gem forces, ignores _plugins entirely. The
+# build would succeed with nothing stamped, so refuse rather than pretend.
+if grep -Eq '^[[:space:]]*safe:[[:space:]]*true' "$config"; then
+  echo "prepare.sh: $config sets safe: true, which ignores _plugins" >&2
+  exit 1
+fi
+
+plugins_dir="$(sed -n 's/^plugins_dir:[[:space:]]*["'\'']*\([^"'\'' ]*\).*/\1/p' "$config" | head -n 1)"
+plugins_dir="${plugins_dir:-_plugins}"
+
+mkdir -p "$site/$plugins_dir"
+cp "$(dirname "$0")/magpie_src.rb" "$site/$plugins_dir/magpie_src.rb"
+echo "prepare.sh: installed magpie_src.rb into $site/$plugins_dir"
diff --git a/scripts/preview/adapters/jekyll/test/Gemfile b/scripts/preview/adapters/jekyll/test/Gemfile
new file mode 100644
index 00000000..90c2d537
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/Gemfile
@@ -0,0 +1,6 @@
+# Test dependencies for the Jekyll adapter; locked so CI installs exactly
+# these versions. The adapter itself needs only a Jekyll 4 site.
+source "https://rubygems.org"
+
+gem "jekyll", "4.4.1"
+gem "minitest", "~> 5.25"
diff --git a/scripts/preview/adapters/jekyll/test/Gemfile.lock b/scripts/preview/adapters/jekyll/test/Gemfile.lock
new file mode 100644
index 00000000..2ffb68a0
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/Gemfile.lock
@@ -0,0 +1,133 @@
+GEM
+  remote: https://rubygems.org/
+  specs:
+    addressable (2.9.0)
+      public_suffix (>= 2.0.2, < 8.0)
+    base64 (0.3.0)
+    bigdecimal (4.1.3)
+    colorator (1.1.0)
+    concurrent-ruby (1.3.8)
+    csv (3.3.6)
+    em-websocket (0.5.3)
+      eventmachine (>= 0.12.9)
+      http_parser.rb (~> 0)
+    eventmachine (1.2.7)
+    ffi (1.17.4-aarch64-linux-gnu)
+    ffi (1.17.4-x86_64-linux-gnu)
+    forwardable-extended (2.6.0)
+    google-protobuf (4.36.2-aarch64-linux-gnu)
+      bigdecimal
+      rake (~> 13.3)
+    google-protobuf (4.36.2-x86_64-linux-gnu)
+      bigdecimal
+      rake (~> 13.3)
+    http_parser.rb (0.8.1)
+    i18n (1.15.2)
+      concurrent-ruby (~> 1.0)
+    jekyll (4.4.1)
+      addressable (~> 2.4)
+      base64 (~> 0.2)
+      colorator (~> 1.0)
+      csv (~> 3.0)
+      em-websocket (~> 0.5)
+      i18n (~> 1.0)
+      jekyll-sass-converter (>= 2.0, < 4.0)
+      jekyll-watch (~> 2.0)
+      json (~> 2.6)
+      kramdown (~> 2.3, >= 2.3.1)
+      kramdown-parser-gfm (~> 1.0)
+      liquid (~> 4.0)
+      mercenary (~> 0.3, >= 0.3.6)
+      pathutil (~> 0.9)
+      rouge (>= 3.0, < 5.0)
+      safe_yaml (~> 1.0)
+      terminal-table (>= 1.8, < 4.0)
+      webrick (~> 1.7)
+    jekyll-sass-converter (3.1.0)
+      sass-embedded (~> 1.75)
+    jekyll-watch (2.2.1)
+      listen (~> 3.0)
+    json (2.21.2)
+    kramdown (2.5.2)
+      rexml (>= 3.4.4)
+    kramdown-parser-gfm (1.1.0)
+      kramdown (~> 2.0)
+    liquid (4.0.4)
+    listen (3.10.0)
+      logger
+      rb-fsevent (~> 0.10, >= 0.10.3)
+      rb-inotify (~> 0.9, >= 0.9.10)
+    logger (1.7.0)
+    mercenary (0.4.0)
+    minitest (5.27.0)
+    pathutil (0.16.2)
+      forwardable-extended (~> 2.6)
+    public_suffix (7.0.5)
+    rake (13.4.2)
+    rb-fsevent (0.11.2)
+    rb-inotify (0.11.1)
+      ffi (~> 1.0)
+    rexml (3.4.4)
+    rouge (4.7.0)
+    safe_yaml (1.0.5)
+    sass-embedded (1.105.0-aarch64-linux-gnu)
+      google-protobuf (~> 4.31)
+    sass-embedded (1.105.0-x86_64-linux-gnu)
+      google-protobuf (~> 4.31)
+    terminal-table (3.0.2)
+      unicode-display_width (>= 1.1.1, < 3)
+    unicode-display_width (2.6.0)
+    webrick (1.9.2)
+
+PLATFORMS
+  aarch64-linux-gnu
+  x86_64-linux-gnu
+
+DEPENDENCIES
+  jekyll (= 4.4.1)
+  minitest (~> 5.25)
+
+CHECKSUMS
+  addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
+  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+  bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
+  bundler (4.0.21) sha256=7cbf5499b426076aa16c8d28a59e4c2c3aad5a438402abcd961f0119f29f1dd0
+  colorator (1.1.0) sha256=e2f85daf57af47d740db2a32191d1bdfb0f6503a0dfbc8327d0c9154d5ddfc38
+  concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
+  csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
+  em-websocket (0.5.3) sha256=f56a92bde4e6cb879256d58ee31f124181f68f8887bd14d53d5d9a292758c6a8
+  eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972
+  ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
+  ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
+  forwardable-extended (2.6.0) sha256=1bec948c469bbddfadeb3bd90eb8c85f6e627a412a3e852acfd7eaedbac3ec97
+  google-protobuf (4.36.2-aarch64-linux-gnu) sha256=c3279ec6eaa59f6ed6276eda2a33cffb4306471183ab79a332e698b8564f5169
+  google-protobuf (4.36.2-x86_64-linux-gnu) sha256=d25a820873e423f1d209ae8c672a4a28b019e2429debd0901d26670a0b4e14db
+  http_parser.rb (0.8.1) sha256=9ae8df145b39aa5398b2f90090d651c67bd8e2ebfe4507c966579f641e11097a
+  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
+  jekyll (4.4.1) sha256=4c1144d857a5b2b80d45b8cf5138289579a9f8136aadfa6dd684b31fe2bc18c1
+  jekyll-sass-converter (3.1.0) sha256=83925d84f1d134410c11d0c6643b0093e82e3a3cf127e90757a85294a3862443
+  jekyll-watch (2.2.1) sha256=bc44ed43f5e0a552836245a54dbff3ea7421ecc2856707e8a1ee203a8387a7e1
+  json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
+  kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa
+  kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729
+  liquid (4.0.4) sha256=4fcfebb1a045e47918388dbb7a0925e7c3893e58d2bd6c3b3c73ec17a2d8fdb3
+  listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2
+  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+  mercenary (0.4.0) sha256=b25a1e4a59adca88665e08e24acf0af30da5b5d859f7d8f38fba52c28f405138
+  minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
+  pathutil (0.16.2) sha256=e43b74365631cab4f6d5e4228f812927efc9cb2c71e62976edcb252ee948d589
+  public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
+  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+  rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
+  rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
+  rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
+  rouge (4.7.0) sha256=dba5896715c0325c362e895460a6d350803dbf6427454f49a47500f3193ea739
+  safe_yaml (1.0.5) sha256=a6ac2d64b7eb027bdeeca1851fe7e7af0d668e133e8a88066a0c6f7087d9f848
+  sass-embedded (1.105.0-aarch64-linux-gnu) sha256=26dc2b0f8063f99ad4df226ebecac361c52ce06c63d8acc20ebc615d1450ea09
+  sass-embedded (1.105.0-x86_64-linux-gnu) sha256=c7cb4884c6d1ddad41d52f5950167d5cf77e242d7900ca0ec13170ba43878977
+  terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
+  unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
+  webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131
+
+BUNDLED WITH
+  4.0.21
diff --git a/scripts/preview/adapters/jekyll/test/fixture/_config.yml b/scripts/preview/adapters/jekyll/test/fixture/_config.yml
new file mode 100644
index 00000000..227742f3
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/fixture/_config.yml
@@ -0,0 +1,2 @@
+title: Fixture
+markdown: kramdown
diff --git a/scripts/preview/adapters/jekyll/test/fixture/_includes/nav.html b/scripts/preview/adapters/jekyll/test/fixture/_includes/nav.html
new file mode 100644
index 00000000..4d762527
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/fixture/_includes/nav.html
@@ -0,0 +1,3 @@
+<nav>
+  <a href="/">{{ "<b>" }}Home</a>
+</nav>
diff --git a/scripts/preview/adapters/jekyll/test/fixture/_layouts/default.html b/scripts/preview/adapters/jekyll/test/fixture/_layouts/default.html
new file mode 100644
index 00000000..a73e7a2c
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/fixture/_layouts/default.html
@@ -0,0 +1,14 @@
+---
+---
+<!doctype html>
+<html>
+<head><title>{{ page.title }}</title></head>
+<body>
+<header class="site">{% include nav.html %}</header>
+<main>{{ content }}</main>
+<aside>{{ "**from markdownify**" | markdownify }}</aside>
+<script>var x = "<div>";</script>
+{% raw %}<p>literal</p>{% endraw %}
+<!-- <section>commented</section> -->
+</body>
+</html>
diff --git a/scripts/preview/adapters/jekyll/test/fixture/index.md b/scripts/preview/adapters/jekyll/test/fixture/index.md
new file mode 100644
index 00000000..86058319
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/fixture/index.md
@@ -0,0 +1,12 @@
+---
+layout: default
+title: Home
+---
+# Hello
+
+A paragraph.
+
+- one
+- two
+
+> quoted
diff --git a/scripts/preview/adapters/jekyll/test/magpie_src_test.rb b/scripts/preview/adapters/jekyll/test/magpie_src_test.rb
new file mode 100644
index 00000000..8b75d4a7
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/magpie_src_test.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements; and to You under the Apache License,
+# Version 2.0. See http://www.apache.org/licenses/LICENSE-2.0
+#
+# Run: ruby scripts/preview/adapters/jekyll/test/magpie_src_test.rb
+# Needs the jekyll gem (4.x) and minitest.
+
+require "minitest/autorun"
+require "tmpdir"
+require "fileutils"
+require_relative "../magpie_src"
+
+FIXTURE = File.expand_path("fixture", __dir__)
+PREPARE = File.expand_path("../prepare.sh", __dir__)
+
+class MagpieSrcBuildTest < Minitest::Test
+  def self.built
+    @built ||= begin
+      dest = Dir.mktmpdir("magpie-src-site")
+      ENV["MAGPIE_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("MAGPIE_SRC_ROOT")
+    end
+  end
+
+  def html = self.class.built
+
+  def test_markdown_blocks_carry_their_file_line
+    assert_match(/<h1[^>]*data-magpie-src="index\.md:5"/, html)
+    assert_match(/<p data-magpie-src="index\.md:7">A paragraph\.<\/p>/, html)
+    assert_match(/<li data-magpie-src="index\.md:9">one/, html)
+    assert_match(/<li data-magpie-src="index\.md:10">two/, html)
+    assert_match(/<blockquote data-magpie-src="index\.md:12">/, html)
+  end
+
+  def test_layout_tags_carry_their_file_line
+    assert_includes html, %(<header data-magpie-src="_layouts/default.html:7" class="site">)
+    assert_includes html, %(<main data-magpie-src="_layouts/default.html:8">)
+  end
+
+  def test_include_tags_carry_their_file_line
+    assert_includes html, %(<nav data-magpie-src="_includes/nav.html:1">)
+    assert_includes html, %(<a data-magpie-src="_includes/nav.html:2" href="/">)
+  end
+
+  def test_liquid_output_is_not_stamped
+    assert_includes html, "<b>Home"
+  end
+
+  def test_markdownify_in_a_layout_is_not_given_the_page_path
+    assert_includes html, "<p><strong>from markdownify</strong></p>"
+  end
+
+  def test_opaque_regions_and_document_chrome_are_untouched
+    assert_includes html, %(var x = "<div>";)
+    assert_includes html, "<p>literal</p>"
+    assert_includes html, "<!-- <section>commented</section> -->"
+    assert_includes html, "<body>\n"
+    assert_includes html, "<html>\n"
+    assert_includes html, "<head><title>"
+  end
+end
+
+class MagpieSrcUnitTest < Minitest::Test
+  def test_stamp_template_tracks_lines_across_an_opaque_region
+    src = "<div>\n{% comment %}\n<p>x</p>\n{% endcomment %}\n<span>y</span>\n"
+    out = MagpieSrc.stamp_template(src, "f.html", 10)
+    assert_includes out, %(<div data-magpie-src="f.html:10">)
+    assert_includes out, "<p>x</p>"
+    assert_includes out, %(<span data-magpie-src="f.html:14">)
+  end
+
+  def test_stamp_template_leaves_liquid_spans_alone
+    out = MagpieSrc.stamp_template(%(<a href="{{ '<i>' }}">{% if x %}<em>{% endif %}), "f.html", 1)
+    assert_includes out, %(<a data-magpie-src="f.html:1" href="{{ '<i>' }}">)
+    assert_includes out, %({% if x %}<em data-magpie-src="f.html:1">{% endif %})
+  end
+
+  def test_front_matter_lines
+    Dir.mktmpdir do |dir|
+      path = File.join(dir, "p.md")
+      File.write(path, "---\na: 1\n---\nbody\n")
+      assert_equal 3, MagpieSrc.front_matter_lines(path)
+      File.write(path, "no front matter\n")
+      assert_equal 0, MagpieSrc.front_matter_lines(path)
+    end
+  end
+
+  def test_repo_root_walks_up_to_git_and_skips_files_outside_it
+    Dir.mktmpdir do |dir|
+      FileUtils.mkdir_p(File.join(dir, ".git"))
+      site = File.join(dir, "site", "src")
+      FileUtils.mkdir_p(site)
+      root = MagpieSrc.repo_root(site)
+      assert_equal File.realpath(dir), File.realpath(root.to_s)
+      assert_equal "site/src/a.md", MagpieSrc.relative(root, File.join(site, "a.md"))
+      assert_nil MagpieSrc.relative(root, "/usr/lib/gems/theme/_layouts/x.html")
+    end
+  end
+
+  def test_prepare_installs_the_plugin_and_refuses_safe_mode
+    Dir.mktmpdir do |dir|
+      File.write(File.join(dir, "_config.yml"), "plugins_dir: my_plugins\n")
+      assert system(PREPARE, dir, out: File::NULL)
+      assert File.file?(File.join(dir, "my_plugins", "magpie_src.rb"))
+
+      File.write(File.join(dir, "_config.yml"), "safe: true\n")
+      refute system(PREPARE, dir, out: File::NULL, err: File::NULL)
+    end
+  end
+end
diff --git a/scripts/preview/files.mjs b/scripts/preview/files.mjs
index cad52ffe..dd7a3633 100644
--- a/scripts/preview/files.mjs
+++ b/scripts/preview/files.mjs
@@ -14,8 +14,26 @@ function assertPr(pr) {
   return String(pr);
 }
 
+/**
+ * The first label of the staging hostname, which ASF staging derives from the
+ * repository: apache/magpie-site stages at magpie-pr<N>.staged.apache.org.
+ * Another site running this publisher sets PREVIEW_SITE_NAME. Read on every
+ * call, and held to hostname characters for the same injection reason as the
+ * PR number.
+ */
+export function siteName(env = process.env) {
+  const name = env.PREVIEW_SITE_NAME || "magpie";
+  if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) {
+    throw new TypeError(
+      `PREVIEW_SITE_NAME must be a lowercase hostname label, got ${JSON.stringify(name)}`,
+    );
+  }
+  return name;
+}
+
 export const previewBranch = (pr) => `preview/pr${assertPr(pr)}-staging`;
-export const previewUrl = (pr) => `https://magpie-pr${assertPr(pr)}.staged.apache.org/`;
+export const previewUrl = (pr) =>
+  `https://${siteName()}-pr${assertPr(pr)}.staged.apache.org/`;
 
 /**
  * Generated by the publisher, never taken from the artifact: an .asf.yaml
diff --git a/scripts/preview/files.test.mjs b/scripts/preview/files.test.mjs
index 2c4be27d..dfb7787e 100644
--- a/scripts/preview/files.test.mjs
+++ b/scripts/preview/files.test.mjs
@@ -6,6 +6,7 @@ import {
   renderTombstone,
   previewBranch,
   previewUrl,
+  siteName,
 } from "./files.mjs";
 
 test("branch and url agree on the profile", () => {
@@ -52,3 +53,15 @@ test("refuses a missing or non-numeric pr", () => {
 test("still accepts a numeric string", () => {
   assert.equal(previewBranch("176"), "preview/pr176-staging");
 });
+
+test("siteName defaults to magpie and accepts another site's label", () => {
+  assert.equal(siteName({}), "magpie");
+  assert.equal(siteName({ PREVIEW_SITE_NAME: "" }), "magpie");
+  assert.equal(siteName({ PREVIEW_SITE_NAME: "tooling-site" }), "tooling-site");
+});
+
+test("siteName refuses anything that is not a hostname label", () => {
+  for (const bad of ["Magpie", "a.b", "-x", "x-", "a\nstaging:", "a b"]) {
+    assert.throws(() => siteName({ PREVIEW_SITE_NAME: bad }), /hostname label/);
+  }
+});
diff --git a/scripts/preview/github.mjs b/scripts/preview/github.mjs
index 42a3c2c8..d23fe1ba 100644
--- a/scripts/preview/github.mjs
+++ b/scripts/preview/github.mjs
@@ -1,6 +1,21 @@
 const API = "https://api.github.com";
 
-export function createClient({ repo, token, fetchImpl = fetch }) {
+/**
+ * The workflow whose successful runs carry the preview-site artifact. Another
+ * site sets PREVIEW_BUILD_WORKFLOW; it is interpolated into an API path, so it
+ * is held to a bare workflow file name.
+ */
+export function buildWorkflow(env = process.env) {
+  const name = env.PREVIEW_BUILD_WORKFLOW || "build.yml";
+  if (!/^[A-Za-z0-9_.-]+\.ya?ml$/.test(name) || name.startsWith(".")) {
+    throw new TypeError(
+      `PREVIEW_BUILD_WORKFLOW must be a workflow file name, got ${JSON.stringify(name)}`,
+    );
+  }
+  return name;
+}
+
+export function createClient({ repo, token, fetchImpl = fetch, workflow = buildWorkflow() }) {
   async function request(path, { method = "GET", body } = {}) {
     const res = await fetchImpl(`${API}${path}`, {
       method,
@@ -103,7 +118,7 @@ export function createClient({ repo, token, fetchImpl = fetch }) {
 
     async latestSuccessfulBuild(headSha) {
       const runs = await request(
-        `/repos/${repo}/actions/workflows/build.yml/runs?head_sha=${headSha}&status=success&per_page=1`,
+        `/repos/${repo}/actions/workflows/${workflow}/runs?head_sha=${headSha}&status=success&per_page=1`,
       );
       return runs.workflow_runs?.[0] ?? null;
     },
diff --git a/scripts/preview/github.test.mjs b/scripts/preview/github.test.mjs
index 2530e857..12ac530e 100644
--- a/scripts/preview/github.test.mjs
+++ b/scripts/preview/github.test.mjs
@@ -1,6 +1,6 @@
 import { test } from "node:test";
 import assert from "node:assert/strict";
-import { createClient } from "./github.mjs";
+import { createClient, buildWorkflow } from "./github.mjs";
 
 function fakeFetch(routes) {
   const calls = [];
@@ -142,3 +142,24 @@ test("listPullsForHead asks for every state, qualified by the repository owner",
   assert.deepEqual(await gh.listPullsForHead("fix/x"), []);
   assert.equal(calls.length, 1);
 });
+
+test("buildWorkflow defaults to build.yml and accepts another file name", () => {
+  assert.equal(buildWorkflow({}), "build.yml");
+  assert.equal(buildWorkflow({ PREVIEW_BUILD_WORKFLOW: "jekyll.yaml" }), "jekyll.yaml");
+});
+
+test("buildWorkflow refuses paths and non-workflow names", () => {
+  for (const bad of ["../x.yml", "a/b.yml", "build", ".yml", "x.yml?y=1"]) {
+    assert.throws(() => buildWorkflow({ PREVIEW_BUILD_WORKFLOW: bad }), /workflow file name/);
+  }
+});
+
+test("latestSuccessfulBuild queries the configured workflow", async () => {
+  const { impl, calls } = fakeFetch({
+    "GET /repos/apache/x-site/actions/workflows/jekyll.yml/runs?head_sha=abc&status=success&per_page=1":
+      { workflow_runs: [{ id: 7 }] },
+  });
+  const gh = createClient({ repo: "apache/x-site", token: "t", fetchImpl: impl, workflow: "jekyll.yml" });
+  assert.deepEqual(await gh.latestSuccessfulBuild("abc"), { id: 7 });
+  assert.equal(calls.length, 1);
+});