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/17 13:37:07 UTC

(magpie-site) branch main updated (65c1e2a -> 529e5af)

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

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


    from 65c1e2a  chore: add LICENSE and NOTICE
     new f7fa327  feat(preview): compute GitHub diff anchors and added-line ranges
     new 6766fbf  fix(preview): removals do not advance new-file line numbers in diff parsing
     new a25fc25  chore: vendor html2canvas 1.4.1 and add LICENSE entry
     new ae805b1  feat(preview): stamp JSX host elements with their source location
     new 08d163c  feat(preview): region clamping, caption text and diff-line URL resolution
     new fdc22d7  fix(preview): proper viewport clamping and inclusive range boundaries
     new 25a1b54  feat(preview): the review overlay
     new f550420  fix(preview): hide button and toast before capture
     new d8e9a92  feat(preview): publish the review overlay into every preview
     new c422810  feat(preview): build the preview artifact with source annotation
     new 444578c  fix(preview): resolve source before hiding the overlay, not after
     new 599b8d9  fix(preview): guard overlay injection on a marker, inject at the last </body>
     new e89299c  ci(preview): assert the production build carries no preview annotation
     new 90c59ab  docs(preview): correct the design to describe what was built
     new 33a503e  docs(preview): stop describing two behaviours that were not built
     new 529e5af  ci: install dependencies before running the unit tests

The 16 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .github/workflows/build.yml                        |  24 ++
 .pre-commit-config.yaml                            |   3 +
 LICENSE                                            |   6 +
 astro.config.mjs                                   |  17 +-
 .../2026-09-17-preview-review-overlay-design.md    |  45 ++--
 package-lock.json                                  |  67 +++---
 package.json                                       |   1 +
 scripts/preview/anchors.mjs                        |  55 +++++
 scripts/preview/anchors.test.mjs                   |  61 ++++++
 scripts/preview/babel-plugin-magpie-src.mjs        |  45 ++++
 scripts/preview/babel-plugin-magpie-src.test.mjs   |  37 ++++
 scripts/preview/git.mjs                            |   8 +-
 scripts/preview/git.test.mjs                       |   7 +
 scripts/preview/github.mjs                         |   1 +
 scripts/preview/overlay/logic.mjs                  |  51 +++++
 scripts/preview/overlay/logic.test.mjs             |  85 +++++++
 scripts/preview/overlay/review.js                  | 243 +++++++++++++++++++++
 scripts/preview/publish.mjs                        |  66 +++++-
 scripts/preview/publish.test.mjs                   |  34 +++
 vendor/html2canvas/LICENSE                         |  22 ++
 vendor/html2canvas/html2canvas.min.js              |  20 ++
 21 files changed, 838 insertions(+), 60 deletions(-)
 create mode 100644 scripts/preview/anchors.mjs
 create mode 100644 scripts/preview/anchors.test.mjs
 create mode 100644 scripts/preview/babel-plugin-magpie-src.mjs
 create mode 100644 scripts/preview/babel-plugin-magpie-src.test.mjs
 create mode 100644 scripts/preview/overlay/logic.mjs
 create mode 100644 scripts/preview/overlay/logic.test.mjs
 create mode 100644 scripts/preview/overlay/review.js
 create mode 100644 vendor/html2canvas/LICENSE
 create mode 100644 vendor/html2canvas/html2canvas.min.js


(magpie-site) 13/16: ci(preview): assert the production build carries no preview annotation

Posted by po...@apache.org.
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

commit e89299c032998ee09d0d4a72fd072f443a16d1b3
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 13:55:48 2026 +0200

    ci(preview): assert the production build carries no preview annotation
    
    The design's Constraints and Vendoring sections both claim a build check
    asserts this, but no such step existed — data-magpie-src could leak into
    a production build with nothing catching it. Add the assertion between
    the production Build step and the annotated preview rebuild.
    
    Generated-by: Claude Opus 5
---
 .github/workflows/build.yml | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 41f509f..97dc245 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -76,6 +76,15 @@ jobs:
       - name: Build
         run: npm run build
 
+      - name: Assert the production build carries no preview annotation
+        run: |
+          if grep -rq "data-magpie-src" dist/; then
+            echo "::error::data-magpie-src found in a production build — it must never ship to magpie.apache.org"
+            grep -rl "data-magpie-src" dist/ | head -20
+            exit 1
+          fi
+          echo "production build is clean of preview annotation"
+
       - name: Build the preview site with source annotation
         if: github.event_name == 'pull_request'
         env:


(magpie-site) 03/16: chore: vendor html2canvas 1.4.1 and add LICENSE entry

Posted by po...@apache.org.
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

commit a25fc25487c2a9f9f2fa9c73641061b20df53cda
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 12:55:26 2026 +0200

    chore: vendor html2canvas 1.4.1 and add LICENSE entry
    
    Vendors html2canvas 1.4.1 (dist/html2canvas.min.js + upstream LICENSE)
    under vendor/html2canvas/ for the preview publisher to copy into
    previews at publish time. It is not a package.json dependency.
    
    Excludes vendor/ from the three pre-commit hooks that mutate file
    bytes (end-of-file-fixer, mixed-line-ending, trailing-whitespace) so
    the vendored file stays byte-identical to what npm distributes and
    its checksum stays verifiable. detect-private-key and
    check-added-large-files still run over vendor/.
    
    Provenance:
      npm registry integrity (html2canvas@1.4.1 tarball):
        sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==
      sha256 of vendor/html2canvas/html2canvas.min.js:
        e87e550794322e574a1fda0c1549a3c70dae5a93d9113417a429016838eab8cb
    
    Generated-by: Claude Opus 5
---
 .pre-commit-config.yaml               |  3 +++
 LICENSE                               |  6 ++++++
 vendor/html2canvas/LICENSE            | 22 ++++++++++++++++++++++
 vendor/html2canvas/html2canvas.min.js | 20 ++++++++++++++++++++
 4 files changed, 51 insertions(+)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c21e8c5..0910124 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -46,9 +46,12 @@ repos:
       - id: check-yaml
       - id: detect-private-key
       - id: end-of-file-fixer
+        exclude: ^vendor/
       - id: mixed-line-ending
+        exclude: ^vendor/
       - id: trailing-whitespace
         args: [--markdown-linebreak-ext=md]
+        exclude: ^vendor/
   - repo: local
     hooks:
       # Guard against the Astro/JSX whitespace-collapse bug (#10, #29): a prose
diff --git a/LICENSE b/LICENSE
index d645695..46781af 100644
--- a/LICENSE
+++ b/LICENSE
@@ -200,3 +200,9 @@
    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.
+
+=======================================================================
+This product bundles html2canvas 1.4.1, which is available under the MIT
+licence. See vendor/html2canvas/LICENSE for the full text.
+  https://github.com/niklasvh/html2canvas
+=======================================================================
diff --git a/vendor/html2canvas/LICENSE b/vendor/html2canvas/LICENSE
new file mode 100644
index 0000000..a73ffc9
--- /dev/null
+++ b/vendor/html2canvas/LICENSE
@@ -0,0 +1,22 @@
+Copyright (c) 2012 Niklas von Hertzen
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/vendor/html2canvas/html2canvas.min.js b/vendor/html2canvas/html2canvas.min.js
new file mode 100644
index 0000000..aed6bfd
--- /dev/null
+++ b/vendor/html2canvas/html2canvas.min.js
@@ -0,0 +1,20 @@
+/*!
+ * html2canvas 1.4.1 <https://html2canvas.hertzen.com>
+ * Copyright (c) 2022 Niklas von Hertzen <https://hertzen.com>
+ * Released under MIT License
+ */
+!function(A,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(A="undefined"!=typeof globalThis?globalThis:A||self).html2canvas=e()}(this,function(){"use strict";
+/*! *****************************************************************************
+    Copyright (c) Microsoft Corporation.
+
+    Permission to use, copy, modify, and/or distribute this software for any
+    purpose with or without fee is hereby granted.
+
+    THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+    REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+    AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+    INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+    LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+    OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+    PERFORMANCE OF THIS SOFTWARE.
+    ***************************************************************************** */var r=function(A,e){return(r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(A,e){A.__proto__=e}||function(A,e){for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&(A[t]=e[t])})(A,e)};function A(A,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function t(){this.constructor=A}r(A,e),A.prototype=null===e?Object [...]
\ No newline at end of file


(magpie-site) 11/16: fix(preview): resolve source before hiding the overlay, not after

Posted by po...@apache.org.
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

commit 444578ca5036b90733a2ff3eedc90170bca89b8d
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 13:55:34 2026 +0200

    fix(preview): resolve source before hiding the overlay, not after
    
    submit() called sourceUnder() while root — the full-viewport,
    pointer-events-accepting overlay — was still displayed, so
    elementFromPoint always hit root itself and the walk up the tree ended
    at <body>. The source was always null, and every capture silently fell
    back to the Conversation tab with "source not resolved". hideChrome()
    now runs first.
    
    Also: clipboard.write() is reached synchronously via a ClipboardItem
    holding a promise, so transient activation survives the capture (Safari
    otherwise refuses); window.open() is called without "noopener" so a
    successful popup isn't mistaken for a blocked one; toBlob's SecurityError
    on a tainted canvas is now reported instead of leaving the overlay stuck;
    the c shortcut ignores Cmd/Ctrl/Alt so copying selected text no longer
    arms the overlay; disarm() clears a stale marking box, and window
    mouseup/blur listeners recover drag state from a release the overlay
    never saw.
    
    Generated-by: Claude Opus 5
---
 scripts/preview/overlay/review.js | 159 ++++++++++++++++++++++++++------------
 1 file changed, 110 insertions(+), 49 deletions(-)

diff --git a/scripts/preview/overlay/review.js b/scripts/preview/overlay/review.js
index 3e172a2..a19f809 100644
--- a/scripts/preview/overlay/review.js
+++ b/scripts/preview/overlay/review.js
@@ -48,10 +48,18 @@
     armed = false;
     drag = null;
     root.style.display = "none";
+    box.style.display = "none";
     button.textContent = "Comment on this preview";
   }
 
   async function submit(region) {
+    // Hide the overlay BEFORE resolving the source. root is
+    // position:fixed;inset:0 and must accept pointer events to receive the
+    // drag, so with it displayed elementFromPoint returns root itself, the walk
+    // ends at <body>, and the source is never resolved — which silently turns
+    // every capture into a Conversation-tab fallback.
+    hideChrome();
+
     var source = sourceUnder(region.x + region.w / 2, region.y + region.h / 2);
     var url = targetUrl({ repo: cfg.repo, pr: cfg.pr, source: source, anchors: cfg.anchors });
     var caption = captionFor({
@@ -61,67 +69,101 @@
       sha: cfg.sha,
     });
 
-    hideChrome();
-    var shot;
-    try {
-      shot = await window.html2canvas(document.body, {
+    // Built as a promise and handed straight to ClipboardItem, so
+    // clipboard.write() is reached while the click's transient activation is
+    // still valid. Awaiting the capture first loses it, and Safari then refuses
+    // the write on every large page.
+    var blobPromise = (async function () {
+      var shot = await window.html2canvas(document.body, {
         x: window.scrollX, y: window.scrollY,
         width: window.innerWidth, height: window.innerHeight,
         scale: Math.min(window.devicePixelRatio || 1, 2),
         useCORS: true, logging: false,
       });
+
+      var scale = shot.width / window.innerWidth;
+      var out = document.createElement("canvas");
+      out.width = shot.width;
+      out.height = shot.height + 28 * scale;
+      var ctx = out.getContext("2d");
+
+      ctx.drawImage(shot, 0, 0);
+      ctx.fillStyle = "rgba(15,23,42,0.55)";
+      ctx.fillRect(0, 0, out.width, region.y * scale);
+      ctx.fillRect(0, (region.y + region.h) * scale, out.width, shot.height);
+      ctx.fillRect(0, region.y * scale, region.x * scale, region.h * scale);
+      ctx.fillRect((region.x + region.w) * scale, region.y * scale, out.width, region.h * scale);
+      ctx.strokeStyle = "#e11d48";
+      ctx.lineWidth = 2 * scale;
+      ctx.strokeRect(region.x * scale, region.y * scale, region.w * scale, region.h * scale);
+
+      ctx.fillStyle = "#0f172a";
+      ctx.fillRect(0, shot.height, out.width, 28 * scale);
+      ctx.fillStyle = "#e2e8f0";
+      ctx.font = (13 * scale) + "px ui-monospace, monospace";
+
+      var text = caption;
+      while (text.length > 12 && ctx.measureText(text).width > out.width - 16 * scale) {
+        text = text.slice(0, -4) + "…";
+      }
+      ctx.fillText(text, 8 * scale, shot.height + 19 * scale);
+
+      // toBlob throws SecurityError on a canvas tainted by a cross-origin
+      // image. Inside this promise it surfaces as a rejection and is reported,
+      // rather than escaping a callback and leaving the overlay stuck.
+      return await new Promise(function (resolve, reject) {
+        try {
+          out.toBlob(function (blob) {
+            if (blob) resolve(blob);
+            else reject(new Error("the canvas produced no image"));
+          }, "image/png");
+        } catch (err) {
+          reject(err);
+        }
+      });
+    })();
+
+    var copied = false;
+    try {
+      await navigator.clipboard.write([new ClipboardItem({ "image/png": blobPromise })]);
+      copied = true;
     } catch (err) {
-      showChrome();
-      root.style.display = "block";
-      say("Could not capture the page: " + err.message);
-      return;
+      copied = false;
     }
 
     showChrome();
 
-    var scale = shot.width / window.innerWidth;
-    var out = document.createElement("canvas");
-    out.width = shot.width;
-    out.height = shot.height + 28 * scale;
-    var ctx = out.getContext("2d");
-
-    ctx.drawImage(shot, 0, 0);
-    ctx.fillStyle = "rgba(15,23,42,0.55)";
-    ctx.fillRect(0, 0, out.width, region.y * scale);
-    ctx.fillRect(0, (region.y + region.h) * scale, out.width, shot.height);
-    ctx.fillRect(0, region.y * scale, region.x * scale, region.h * scale);
-    ctx.fillRect((region.x + region.w) * scale, region.y * scale, out.width, region.h * scale);
-    ctx.strokeStyle = "#e11d48";
-    ctx.lineWidth = 2 * scale;
-    ctx.strokeRect(region.x * scale, region.y * scale, region.w * scale, region.h * scale);
-
-    ctx.fillStyle = "#0f172a";
-    ctx.fillRect(0, shot.height, out.width, 28 * scale);
-    ctx.fillStyle = "#e2e8f0";
-    ctx.font = (13 * scale) + "px ui-monospace, monospace";
-    // The caption can carry a long URL and a long source path. Measure and
-    // truncate rather than letting it run off the edge of the image.
-    var text = caption;
-    while (text.length > 12 && ctx.measureText(text).width > out.width - 16 * scale) {
-      text = text.slice(0, -4) + "…";
-    }
-    ctx.fillText(text, 8 * scale, shot.height + 19 * scale);
-
-    out.toBlob(async function (blob) {
+    if (!copied) {
+      // Either the clipboard refused, or the capture itself failed. Awaiting
+      // the promise tells us which, and reports the real reason either way.
+      var blob = null;
       try {
-        await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
-        say("Screenshot copied — paste it into the comment box");
+        blob = await blobPromise;
       } catch (err) {
-        var a = document.createElement("a");
-        a.href = URL.createObjectURL(blob);
-        a.download = "preview-pr" + cfg.pr + ".png";
-        a.click();
-        say("Clipboard refused — the screenshot was downloaded; drag it into the comment box");
+        root.style.display = "block";
+        say("Could not capture the page: " + err.message);
+        return;
       }
-      var opened = window.open(url, "_blank", "noopener");
-      if (!opened) say("Popup blocked — open the pull request manually: " + url, 15000);
-      disarm();
-    }, "image/png");
+
+      var a = document.createElement("a");
+      a.href = URL.createObjectURL(blob);
+      a.download = "preview-pr" + cfg.pr + ".png";
+      a.click();
+      say("Clipboard refused — the screenshot was downloaded; drag it into the comment box");
+    } else {
+      say("Screenshot copied — paste it into the comment box");
+    }
+
+    // window.open returns null whenever "noopener" is passed, blocked or not,
+    // so the opener is cleared manually instead and a null result genuinely
+    // means the popup was blocked.
+    var opened = window.open(url, "_blank");
+    if (opened) {
+      try { opened.opener = null; } catch (err) { /* cross-origin, already safe */ }
+    } else {
+      say("Popup blocked — open the pull request manually: " + url, 15000);
+    }
+    disarm();
   }
 
   function build() {
@@ -166,7 +208,26 @@
 
     document.addEventListener("keydown", function (e) {
       if (e.key === "Escape" && armed) disarm();
-      if (e.key === "c" && !armed && e.target === document.body) button.click();
+      if (
+        e.key === "c" &&
+        !armed &&
+        !e.metaKey && !e.ctrlKey && !e.altKey &&
+        e.target === document.body
+      ) {
+        button.click();
+      }
+    });
+
+    // A mouseup outside the window never reaches root, which would leave drag
+    // set and make the next mousemove resize a box the user never started.
+    window.addEventListener("mouseup", function () {
+      if (!drag) return;
+      drag = null;
+      box.style.display = "none";
+    });
+    window.addEventListener("blur", function () {
+      drag = null;
+      box.style.display = "none";
     });
 
     document.body.appendChild(root);


(magpie-site) 06/16: fix(preview): proper viewport clamping and inclusive range boundaries

Posted by po...@apache.org.
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

commit fdc22d7b24a5af5a8b2bb0201c2e3791599f2ee5
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 13:37:33 2026 +0200

    fix(preview): proper viewport clamping and inclusive range boundaries
    
    - Fix clampRegion to clamp all four corners, preventing negative width/height
    - Add boundary tests to prevent regression in targetUrl range check
    
    Generated-by: Claude Opus 5
---
 scripts/preview/overlay/logic.mjs      | 16 ++++++++++++----
 scripts/preview/overlay/logic.test.mjs | 16 ++++++++++++++++
 2 files changed, 28 insertions(+), 4 deletions(-)

diff --git a/scripts/preview/overlay/logic.mjs b/scripts/preview/overlay/logic.mjs
index 809c72f..25c363d 100644
--- a/scripts/preview/overlay/logic.mjs
+++ b/scripts/preview/overlay/logic.mjs
@@ -2,10 +2,18 @@ const MIN_SIDE = 12;
 
 /** Normalise a drag into a viewport-clipped rectangle, or null if it is a stray click. */
 export function clampRegion({ x1, y1, x2, y2 }, viewport) {
-  const x = Math.max(0, Math.min(x1, x2));
-  const y = Math.max(0, Math.min(y1, y2));
-  const w = Math.min(viewport.w, Math.max(x1, x2)) - x;
-  const h = Math.min(viewport.h, Math.max(y1, y2)) - y;
+  // Clamp both corners into the viewport before measuring, so an off-screen
+  // drag yields a zero-size region rather than a negative one. Relying on the
+  // minimum-size check to reject negatives works, but only by coincidence.
+  const left = Math.min(Math.max(0, Math.min(x1, x2)), viewport.w);
+  const top = Math.min(Math.max(0, Math.min(y1, y2)), viewport.h);
+  const right = Math.min(Math.max(0, Math.max(x1, x2)), viewport.w);
+  const bottom = Math.min(Math.max(0, Math.max(y1, y2)), viewport.h);
+
+  const x = left;
+  const y = top;
+  const w = right - left;
+  const h = bottom - top;
 
   if (w < MIN_SIDE || h < MIN_SIDE) return null;
   return { x, y, w, h };
diff --git a/scripts/preview/overlay/logic.test.mjs b/scripts/preview/overlay/logic.test.mjs
index 5201d59..34a6268 100644
--- a/scripts/preview/overlay/logic.test.mjs
+++ b/scripts/preview/overlay/logic.test.mjs
@@ -67,3 +67,19 @@ test("targetUrl falls back when the file, the manifest or the source is missing"
   assert.equal(targetUrl({ ...base, source: "a.tsx:1", anchors: null }), conversation);
   assert.equal(targetUrl({ ...base, source: null, anchors: {} }), conversation);
 });
+
+test("targetUrl treats the diff range as inclusive at both ends", () => {
+  const anchors = { "a.tsx": { anchor: "diff-deadbeef", ranges: [[10, 20]] } };
+  const base = { repo: "apache/magpie-site", pr: 180, anchors };
+  const files = (line) => targetUrl({ ...base, source: `a.tsx:${line}` });
+
+  assert.match(files(10), /files#diff-deadbeefR10$/, "the first line of a range is in the diff");
+  assert.match(files(20), /files#diff-deadbeefR20$/, "the last line of a range is in the diff");
+  assert.equal(files(9), "https://github.com/apache/magpie-site/pull/180", "one before is not");
+  assert.equal(files(21), "https://github.com/apache/magpie-site/pull/180", "one after is not");
+});
+
+test("clampRegion yields no region for a drag entirely off-screen", () => {
+  assert.equal(clampRegion({ x1: 1200, y1: 100, x2: 1300, y2: 200 }, viewport), null);
+  assert.equal(clampRegion({ x1: 100, y1: -300, x2: 200, y2: -200 }, viewport), null);
+});


(magpie-site) 15/16: docs(preview): stop describing two behaviours that were not built

Posted by po...@apache.org.
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

commit 33a503e2b7bcbfaec09ad0839320fd39dc96e685
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 15:22:57 2026 +0200

    docs(preview): stop describing two behaviours that were not built
    
    The failure table promised the blocked-popup toast would carry "the PR link as
    a plain anchor to click". say() sets textContent, so it is unclickable text;
    the table now says what actually happens.
    
    The testing and vendoring sections both claimed the CI check asserts a
    production build contains "neither a preview-only asset nor any
    data-magpie-src". It greps for the attribute only. There is no preview-asset
    assertion, and there is nothing to assert: those files are written by the
    publisher into a preview branch and never enter a production build.
    
    Found by the re-review of the final fix wave. These two are the last of the
    design's claims that outran the code.
    
    Generated-by: Claude Opus 5
---
 docs/designs/2026-09-17-preview-review-overlay-design.md | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/docs/designs/2026-09-17-preview-review-overlay-design.md b/docs/designs/2026-09-17-preview-review-overlay-design.md
index b37940c..a08af7f 100644
--- a/docs/designs/2026-09-17-preview-review-overlay-design.md
+++ b/docs/designs/2026-09-17-preview-review-overlay-design.md
@@ -139,7 +139,7 @@ supplies this.
 |---|---|
 | Clipboard write refused (Safari/Firefox permissions) | The PNG downloads instead, and the toast says to drag the file into the comment box |
 | `html2canvas` throws | Toast names the failure; the overlay stays open so the region is not lost |
-| Popup blocked when opening the PR | Toast carries the PR link as a plain anchor to click |
+| Popup blocked when opening the PR | Toast shows the pull request's URL as text, for fifteen seconds, to copy by hand |
 | `window.__MAGPIE_PREVIEW__` missing | The button never appears — this is not a preview |
 | No annotated ancestor above the marked region | Conversation tab instead of a diff line; the caption says the source was not resolved |
 | Source line is outside the diff | Conversation tab; the caption still names the `file:line` |
@@ -184,9 +184,11 @@ are verified by hand against a real preview: mark a region, confirm the PNG
 lands on the clipboard, paste it into a comment, confirm the caption is legible
 and the dimming shows the right area.
 
-A build check asserts that a production build contains neither a preview-only
-asset nor any `data-magpie-src` attribute — the one regression that would leak
-preview-only markup to `magpie.apache.org`.
+A build check asserts that a production build contains no `data-magpie-src`
+attribute — the one regression that would leak preview-only markup to
+`magpie.apache.org`. The preview's own assets are written by the publisher into
+a preview branch and never enter a production build, so there is nothing to
+assert about them there.
 
 ## Open questions
 


(magpie-site) 16/16: ci: install dependencies before running the unit tests

Posted by po...@apache.org.
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

commit 529e5aff03786651c4b22276d64a7a01a4f4d831
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 15:31:44 2026 +0200

    ci: install dependencies before running the unit tests
    
    The `lint` job ran `npm test` with no install step. That worked for as long as
    every test imported Node built-ins alone, which was true until this branch: the
    first test to import a real dependency failed with
    
        Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@babel/core'
        imported from scripts/preview/babel-plugin-magpie-src.test.mjs
    
    which reads as a missing dependency and is not one. `@babel/core` is declared in
    devDependencies and present in package-lock.json; nothing had ever installed it.
    
    Adds `npm ci` and `cache: npm`, matching what the `build` job below has always
    done. Locally the suite is 110 passing with dependencies installed, against
    CI's 107-with-1-failure — the three missing are the babel test's subtests, which
    never registered because the import threw first.
    
    Generated-by: Claude Opus 5
---
 .github/workflows/build.yml | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 97dc245..8d0b4a4 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -38,6 +38,15 @@ jobs:
         uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
         with:
           node-version: "22"
+          cache: npm
+
+      # The tests were previously run without installing anything, which
+      # worked only for as long as every test imported Node built-ins alone.
+      # The first test to import a real dependency failed with
+      # ERR_MODULE_NOT_FOUND, which reads as a missing dependency rather than
+      # a missing install step. `build` below has always done this.
+      - name: Install dependencies
+        run: npm ci
 
       - name: Run unit tests
         run: npm test


(magpie-site) 10/16: feat(preview): build the preview artifact with source annotation

Posted by po...@apache.org.
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

commit c422810fde2e04ed735a348b32b1200d35b2b6dd
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 13:47:36 2026 +0200

    feat(preview): build the preview artifact with source annotation
    
    Generated-by: Claude Opus 5
---
 .github/workflows/build.yml | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 7721da9..41f509f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -76,6 +76,12 @@ jobs:
       - name: Build
         run: npm run build
 
+      - name: Build the preview site with source annotation
+        if: github.event_name == 'pull_request'
+        env:
+          MAGPIE_PREVIEW_ANNOTATE: "1"
+        run: npm run build
+
       - name: Write preview metadata
         if: github.event_name == 'pull_request'
         env:


(magpie-site) 14/16: docs(preview): correct the design to describe what was built

Posted by po...@apache.org.
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

commit 90c59abe722525d569877ef33ccfe49bf1eb391a
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 13:55:56 2026 +0200

    docs(preview): correct the design to describe what was built
    
    - Anchors are embedded in the window.__MAGPIE_PREVIEW__ payload, not
      written to a separate _preview/anchors.json file. Corrected every
      mention (the design had more than the two the review flagged: Landing
      it on the diff line, the failure-handling table, Testing, and Open
      questions).
    - Annotation only covers .tsx/.jsx (wired through @astrojs/react's babel
      option); .astro templates and synced markdown docs resolve no source
      and always fall back to the Conversation tab. Documented in the design
      and in the babel plugin's own docblock, which read broader than the
      truth.
    - sessionStorage-backed armed state and an Enter/Submit-button
      confirmation were never built (a mouseup confirms directly); removed
      those sentences rather than leaving the design describe behaviour that
      does not exist.
    
    Generated-by: Claude Opus 5
---
 .../2026-09-17-preview-review-overlay-design.md    | 35 ++++++++++++----------
 scripts/preview/babel-plugin-magpie-src.mjs        |  4 +++
 2 files changed, 24 insertions(+), 15 deletions(-)

diff --git a/docs/designs/2026-09-17-preview-review-overlay-design.md b/docs/designs/2026-09-17-preview-review-overlay-design.md
index f3931ca..b37940c 100644
--- a/docs/designs/2026-09-17-preview-review-overlay-design.md
+++ b/docs/designs/2026-09-17-preview-review-overlay-design.md
@@ -49,14 +49,12 @@ none of it.
 
 **Activation.** A floating button sits bottom-right — *Comment on this
 preview* — with `c` as a shortcut. It is off until asked for: a review tool
-that overlays the thing being reviewed is worse than useless. The armed state
-lives in `sessionStorage`, so it does not follow the reviewer to the next page.
+that overlays the thing being reviewed is worse than useless.
 
 **Marking.** Click and drag a rectangle. While dragging, the area outside the
 rectangle dims and its edge is outlined, so what will be captured is visible
-before committing to it. `Escape` cancels. `Enter`, or the Submit button,
-confirms. A minimum size is enforced so a stray click cannot submit a
-zero-pixel region.
+before committing to it. `Escape` cancels. Releasing the drag confirms. A
+minimum size is enforced so a stray click cannot submit a zero-pixel region.
 
 **Capture.** `html2canvas` rasterizes the viewport at `devicePixelRatio`,
 capped at 2 so a retina screen does not produce an eight-megabyte PNG. The
@@ -92,11 +90,18 @@ around a button usually starts outside it. If nothing under the centre is
 annotated, the walk continues up the tree; if the walk reaches `<body>` with no
 annotation, there is no source line and the fallback below applies.
 
+Only `.tsx` and `.jsx` are annotated. `.astro` templates and the synced
+markdown docs are not: annotating them needs the Astro compiler rather than
+Babel. Content originating there resolves no source and falls back to the
+Conversation tab — which means a documentation-only pull request never gets
+diff-line landing. On this repository that is 25 `.tsx` against 13 `.astro`
+and 159 markdown files, so the landing page is covered and the docs are not.
+
 ## Landing it on the diff line
 
 The publisher knows the diff — it has the PR number and an API token — so it
-writes `_preview/anchors.json` beside the site: for each changed file, its diff
-anchor and the line ranges the diff actually touches.
+embeds an anchors payload in `window.__MAGPIE_PREVIEW__`: for each changed
+file, its diff anchor and the line ranges the diff actually touches.
 
 - **Source line inside the diff** → the overlay opens the Files tab anchored at
   that line (`/pull/<N>/files#diff-<anchor>R<line>`). The reviewer clicks the
@@ -113,8 +118,8 @@ screenshot already on the clipboard. That is one click and one paste from a
 comment attached to the exact source line — which is the thing this feature
 exists to make cheap.
 
-The anchor format is not contractual and has changed before, so `anchors.json`
-is generated in one place: if GitHub changes it, one function changes.
+The anchor format is not contractual and has changed before, so the anchors
+payload is generated in one place: if GitHub changes it, one function changes.
 
 ## How the page knows which PR it is
 
@@ -138,7 +143,7 @@ supplies this.
 | `window.__MAGPIE_PREVIEW__` missing | The button never appears — this is not a preview |
 | No annotated ancestor above the marked region | Conversation tab instead of a diff line; the caption says the source was not resolved |
 | Source line is outside the diff | Conversation tab; the caption still names the `file:line` |
-| `_preview/anchors.json` missing or stale | Conversation tab — never a wrong line |
+| anchors missing from the payload | Conversation tab — never a wrong line |
 
 Nothing degrades into doing nothing silently. Two defects in the publishing
 pipeline were exactly that shape, and both were expensive to find.
@@ -169,8 +174,8 @@ of every single screenshot. That kills the ergonomics the feature exists for.
 
 The parts worth testing are pure and are separated for that reason: composing
 the caption text, clamping a dragged rectangle to the viewport and rejecting
-undersized ones, resolving a `file:line` against `anchors.json` to a URL —
-including the outside-the-diff and missing-manifest fallbacks — and building
+undersized ones, resolving a `file:line` against the anchors payload to a URL
+— including the outside-the-diff and missing-payload fallbacks — and building
 the PR URL. Those get unit tests in the existing
 `node --test` suite.
 
@@ -188,9 +193,9 @@ preview-only markup to `magpie.apache.org`.
 **What is GitHub's current diff-anchor format?** The Files-tab anchor
 (`#diff-<anchor>R<line>`) is not a documented contract and has changed before —
 it has been both an MD5 and a SHA-256 of the file path. This must be verified
-against a real pull request on this repository before implementation, and
-`anchors.json` exists so that a future change is one function rather than a
-hunt.
+against a real pull request on this repository before implementation, and the
+anchors payload is generated in one place so that a future change is one
+function rather than a hunt.
 
 **Does GitHub's comment box accept a programmatically-written clipboard image
 on every browser we care about?** Chrome and Edge are certain. Firefox
diff --git a/scripts/preview/babel-plugin-magpie-src.mjs b/scripts/preview/babel-plugin-magpie-src.mjs
index 0282b3a..8423b65 100644
--- a/scripts/preview/babel-plugin-magpie-src.mjs
+++ b/scripts/preview/babel-plugin-magpie-src.mjs
@@ -8,6 +8,10 @@ import { relative } from "node:path";
  * host elements of its own, and those carry the location the reviewer can act
  * on. Active only when the build sets MAGPIE_PREVIEW_ANNOTATE=1 — the
  * production build must emit none of this.
+ *
+ * This is a Babel plugin wired through @astrojs/react, so it only sees JSX:
+ * `.tsx` and `.jsx` files. `.astro` templates and the synced markdown docs are
+ * never annotated — that would need the Astro compiler, not Babel.
  */
 export default function magpieSrc({ types: t }) {
   return {


(magpie-site) 05/16: feat(preview): region clamping, caption text and diff-line URL resolution

Posted by po...@apache.org.
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

commit 08d163ca2498e070c4d1246caa1bbf512a8db12b
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 13:34:38 2026 +0200

    feat(preview): region clamping, caption text and diff-line URL resolution
    
    Generated-by: Claude Opus 5
---
 scripts/preview/overlay/logic.mjs      | 43 +++++++++++++++++++++
 scripts/preview/overlay/logic.test.mjs | 69 ++++++++++++++++++++++++++++++++++
 2 files changed, 112 insertions(+)

diff --git a/scripts/preview/overlay/logic.mjs b/scripts/preview/overlay/logic.mjs
new file mode 100644
index 0000000..809c72f
--- /dev/null
+++ b/scripts/preview/overlay/logic.mjs
@@ -0,0 +1,43 @@
+const MIN_SIDE = 12;
+
+/** Normalise a drag into a viewport-clipped rectangle, or null if it is a stray click. */
+export function clampRegion({ x1, y1, x2, y2 }, viewport) {
+  const x = Math.max(0, Math.min(x1, x2));
+  const y = Math.max(0, Math.min(y1, y2));
+  const w = Math.min(viewport.w, Math.max(x1, x2)) - x;
+  const h = Math.min(viewport.h, Math.max(y1, y2)) - y;
+
+  if (w < MIN_SIDE || h < MIN_SIDE) return null;
+  return { x, y, w, h };
+}
+
+/**
+ * Burned into the image rather than written beside it: a caption survives being
+ * dragged into a comment, quoted or downloaded, where a separate line would not.
+ */
+export function captionFor({ url, source, region, sha }) {
+  const where = source ?? "source not resolved";
+  return `${url} — ${where} — ${region.w}×${region.h} at (${region.x},${region.y}) — built from ${sha}`;
+}
+
+/**
+ * The Files tab anchored at the marked line when that line is part of the diff,
+ * and the Conversation tab otherwise. It never guesses a line.
+ */
+export function targetUrl({ repo, pr, source, anchors }) {
+  const conversation = `https://github.com/${repo}/pull/${pr}`;
+  if (!source || !anchors) return conversation;
+
+  const match = /^(.*):(\d+)$/.exec(source);
+  if (!match) return conversation;
+
+  const [, file, lineText] = match;
+  const line = Number(lineText);
+  const entry = anchors[file];
+  if (!entry?.anchor) return conversation;
+
+  const inDiff = (entry.ranges ?? []).some(([from, to]) => line >= from && line <= to);
+  if (!inDiff) return conversation;
+
+  return `https://github.com/${repo}/pull/${pr}/files#${entry.anchor}R${line}`;
+}
diff --git a/scripts/preview/overlay/logic.test.mjs b/scripts/preview/overlay/logic.test.mjs
new file mode 100644
index 0000000..5201d59
--- /dev/null
+++ b/scripts/preview/overlay/logic.test.mjs
@@ -0,0 +1,69 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { clampRegion, captionFor, targetUrl } from "./logic.mjs";
+
+const viewport = { w: 1000, h: 800 };
+
+test("clampRegion normalises a drag in any direction", () => {
+  assert.deepEqual(clampRegion({ x1: 300, y1: 200, x2: 100, y2: 50 }, viewport), {
+    x: 100, y: 50, w: 200, h: 150,
+  });
+});
+
+test("clampRegion clips to the viewport", () => {
+  assert.deepEqual(clampRegion({ x1: -50, y1: -20, x2: 1200, y2: 900 }, viewport), {
+    x: 0, y: 0, w: 1000, h: 800,
+  });
+});
+
+test("clampRegion rejects a region too small to be deliberate", () => {
+  assert.equal(clampRegion({ x1: 10, y1: 10, x2: 14, y2: 14 }, viewport), null);
+});
+
+test("captionFor names the page, the source and the build", () => {
+  const caption = captionFor({
+    url: "https://magpie-pr180.staged.apache.org/tools",
+    source: "src/components/landing/SiteFooter.tsx:72",
+    region: { x: 240, y: 1150, w: 420, h: 180 },
+    sha: "14fdc13",
+  });
+
+  assert.match(caption, /magpie-pr180\.staged\.apache\.org\/tools/);
+  assert.match(caption, /SiteFooter\.tsx:72/);
+  assert.match(caption, /420×180/);
+  assert.match(caption, /14fdc13/);
+});
+
+test("captionFor says so when no source was resolved", () => {
+  const caption = captionFor({
+    url: "https://x/",
+    source: null,
+    region: { x: 0, y: 0, w: 10, h: 10 },
+    sha: "abc1234",
+  });
+  assert.match(caption, /source not resolved/i);
+});
+
+test("targetUrl anchors on the diff line when the line is in the diff", () => {
+  const anchors = { "a.tsx": { anchor: "diff-deadbeef", ranges: [[10, 20]] } };
+  assert.equal(
+    targetUrl({ repo: "apache/magpie-site", pr: 180, source: "a.tsx:12", anchors }),
+    "https://github.com/apache/magpie-site/pull/180/files#diff-deadbeefR12",
+  );
+});
+
+test("targetUrl falls back to the conversation for a line outside the diff", () => {
+  const anchors = { "a.tsx": { anchor: "diff-deadbeef", ranges: [[10, 20]] } };
+  assert.equal(
+    targetUrl({ repo: "apache/magpie-site", pr: 180, source: "a.tsx:99", anchors }),
+    "https://github.com/apache/magpie-site/pull/180",
+  );
+});
+
+test("targetUrl falls back when the file, the manifest or the source is missing", () => {
+  const base = { repo: "apache/magpie-site", pr: 180 };
+  const conversation = "https://github.com/apache/magpie-site/pull/180";
+  assert.equal(targetUrl({ ...base, source: "other.tsx:1", anchors: {} }), conversation);
+  assert.equal(targetUrl({ ...base, source: "a.tsx:1", anchors: null }), conversation);
+  assert.equal(targetUrl({ ...base, source: null, anchors: {} }), conversation);
+});


(magpie-site) 02/16: fix(preview): removals do not advance new-file line numbers in diff parsing

Posted by po...@apache.org.
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

commit 6766fbf4c9d0e8d5e885c9116cf8d75a3a50293e
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 12:47:49 2026 +0200

    fix(preview): removals do not advance new-file line numbers in diff parsing
    
    Deletions in a unified diff exist only in the old file, so the new-file
    line cursor must not advance past them. This ensures comments anchored to
    changed lines link to the correct location—off-by-one errors cause reviews
    to point to the wrong line with no warning.
    
    Generated-by: Claude Opus 5
---
 scripts/preview/anchors.mjs      |  4 +++-
 scripts/preview/anchors.test.mjs | 20 +++++++++++++++++++-
 2 files changed, 22 insertions(+), 2 deletions(-)

diff --git a/scripts/preview/anchors.mjs b/scripts/preview/anchors.mjs
index 76a0e39..ccc8066 100644
--- a/scripts/preview/anchors.mjs
+++ b/scripts/preview/anchors.mjs
@@ -27,9 +27,11 @@ export function addedLineRanges(patch) {
       run = run ? [run[0], line] : [line, line];
       line += 1;
     } else if (text.startsWith("-")) {
+      // A removed line exists only in the old file, so the new-file cursor
+      // stays put. Advancing here shifts every following line number and
+      // anchors comments to the wrong line.
       if (run) ranges.push(run);
       run = null;
-      line += 1;
     } else {
       if (run) ranges.push(run);
       run = null;
diff --git a/scripts/preview/anchors.test.mjs b/scripts/preview/anchors.test.mjs
index 8964337..d722ca2 100644
--- a/scripts/preview/anchors.test.mjs
+++ b/scripts/preview/anchors.test.mjs
@@ -22,7 +22,7 @@ test("addedLineRanges reads the new-file line numbers a hunk touches", () => {
     "+changed",
   ].join("\n");
 
-  assert.deepEqual(addedLineRanges(patch), [[2, 3], [22, 22]]);
+  assert.deepEqual(addedLineRanges(patch), [[2, 3], [21, 21]]);
 });
 
 test("addedLineRanges is empty for a patch with no additions", () => {
@@ -41,3 +41,21 @@ test("buildAnchors keys by path and carries anchor plus ranges", () => {
   assert.deepEqual(built["a.tsx"].ranges, [[2, 2]]);
   assert.deepEqual(built["b.png"].ranges, [], "a binary file has no line ranges");
 });
+
+test("deletions never advance the new-file line number", () => {
+  const patch = [
+    "@@ -10,6 +10,4 @@",
+    " context",       // new line 10
+    "-gone one",
+    "-gone two",
+    "-gone three",
+    "+replacement",   // new line 11
+    " context",       // new line 12
+  ].join("\n");
+
+  assert.deepEqual(
+    addedLineRanges(patch),
+    [[11, 11]],
+    "three deletions must not push the replacement to line 14",
+  );
+});


(magpie-site) 07/16: feat(preview): the review overlay

Posted by po...@apache.org.
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

commit 25a1b549262df43ceb6f70f031dd0e2647e0e587
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 13:41:35 2026 +0200

    feat(preview): the review overlay
    
    Generated-by: Claude Opus 5
---
 scripts/preview/overlay/review.js | 167 ++++++++++++++++++++++++++++++++++++++
 1 file changed, 167 insertions(+)

diff --git a/scripts/preview/overlay/review.js b/scripts/preview/overlay/review.js
new file mode 100644
index 0000000..d199bb0
--- /dev/null
+++ b/scripts/preview/overlay/review.js
@@ -0,0 +1,167 @@
+(function () {
+  "use strict";
+  var cfg = window.__MAGPIE_PREVIEW__;
+  if (!cfg || !cfg.repo || !cfg.pr) return; // not a preview
+
+  var armed = false;
+  var drag = null;
+  var root, box, button, toast;
+
+  function el(tag, style, text) {
+    var n = document.createElement(tag);
+    n.style.cssText = style;
+    if (text) n.textContent = text;
+    return n;
+  }
+
+  function say(message, ms) {
+    toast.textContent = message;
+    toast.style.display = "block";
+    clearTimeout(say._t);
+    say._t = setTimeout(function () { toast.style.display = "none"; }, ms || 6000);
+  }
+
+  function sourceUnder(x, y) {
+    var node = document.elementFromPoint(x, y);
+    while (node && node !== document.body) {
+      if (node.getAttribute && node.getAttribute("data-magpie-src")) {
+        return node.getAttribute("data-magpie-src");
+      }
+      node = node.parentElement;
+    }
+    return null;
+  }
+
+  function disarm() {
+    armed = false;
+    drag = null;
+    root.style.display = "none";
+    button.textContent = "Comment on this preview";
+  }
+
+  async function submit(region) {
+    var source = sourceUnder(region.x + region.w / 2, region.y + region.h / 2);
+    var url = targetUrl({ repo: cfg.repo, pr: cfg.pr, source: source, anchors: cfg.anchors });
+    var caption = captionFor({
+      url: location.href,
+      source: source,
+      region: region,
+      sha: cfg.sha,
+    });
+
+    root.style.display = "none"; // keep the overlay out of the capture
+    var shot;
+    try {
+      shot = await window.html2canvas(document.body, {
+        x: window.scrollX, y: window.scrollY,
+        width: window.innerWidth, height: window.innerHeight,
+        scale: Math.min(window.devicePixelRatio || 1, 2),
+        useCORS: true, logging: false,
+      });
+    } catch (err) {
+      root.style.display = "block";
+      say("Could not capture the page: " + err.message);
+      return;
+    }
+
+    var scale = shot.width / window.innerWidth;
+    var out = document.createElement("canvas");
+    out.width = shot.width;
+    out.height = shot.height + 28 * scale;
+    var ctx = out.getContext("2d");
+
+    ctx.drawImage(shot, 0, 0);
+    ctx.fillStyle = "rgba(15,23,42,0.55)";
+    ctx.fillRect(0, 0, out.width, region.y * scale);
+    ctx.fillRect(0, (region.y + region.h) * scale, out.width, shot.height);
+    ctx.fillRect(0, region.y * scale, region.x * scale, region.h * scale);
+    ctx.fillRect((region.x + region.w) * scale, region.y * scale, out.width, region.h * scale);
+    ctx.strokeStyle = "#e11d48";
+    ctx.lineWidth = 2 * scale;
+    ctx.strokeRect(region.x * scale, region.y * scale, region.w * scale, region.h * scale);
+
+    ctx.fillStyle = "#0f172a";
+    ctx.fillRect(0, shot.height, out.width, 28 * scale);
+    ctx.fillStyle = "#e2e8f0";
+    ctx.font = (13 * scale) + "px ui-monospace, monospace";
+    // The caption can carry a long URL and a long source path. Measure and
+    // truncate rather than letting it run off the edge of the image.
+    var text = caption;
+    while (text.length > 12 && ctx.measureText(text).width > out.width - 16 * scale) {
+      text = text.slice(0, -4) + "…";
+    }
+    ctx.fillText(text, 8 * scale, shot.height + 19 * scale);
+
+    out.toBlob(async function (blob) {
+      try {
+        await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
+        say("Screenshot copied — paste it into the comment box");
+      } catch (err) {
+        var a = document.createElement("a");
+        a.href = URL.createObjectURL(blob);
+        a.download = "preview-pr" + cfg.pr + ".png";
+        a.click();
+        say("Clipboard refused — the screenshot was downloaded; drag it into the comment box");
+      }
+      var opened = window.open(url, "_blank", "noopener");
+      if (!opened) say("Popup blocked — open the pull request manually: " + url, 15000);
+      disarm();
+    }, "image/png");
+  }
+
+  function build() {
+    button = el("button",
+      "position:fixed;right:16px;bottom:16px;z-index:2147483646;padding:8px 12px;" +
+      "border-radius:8px;border:1px solid #334155;background:#0f172a;color:#e2e8f0;" +
+      "font:13px system-ui;cursor:pointer", "Comment on this preview");
+    button.addEventListener("click", function () {
+      armed = !armed;
+      root.style.display = armed ? "block" : "none";
+      button.textContent = armed ? "Cancel (Esc)" : "Comment on this preview";
+    });
+
+    root = el("div", "position:fixed;inset:0;z-index:2147483645;display:none;cursor:crosshair");
+    box = el("div", "position:absolute;border:2px solid #e11d48;background:rgba(225,29,72,0.08);display:none");
+    root.appendChild(box);
+
+    toast = el("div",
+      "position:fixed;left:16px;bottom:16px;z-index:2147483647;display:none;max-width:60vw;" +
+      "padding:8px 12px;border-radius:8px;background:#0f172a;color:#e2e8f0;font:13px system-ui");
+
+    root.addEventListener("mousedown", function (e) {
+      drag = { x1: e.clientX, y1: e.clientY, x2: e.clientX, y2: e.clientY };
+      box.style.display = "block";
+    });
+    root.addEventListener("mousemove", function (e) {
+      if (!drag) return;
+      drag.x2 = e.clientX; drag.y2 = e.clientY;
+      var r = clampRegion(drag, { w: window.innerWidth, h: window.innerHeight }) ||
+              { x: Math.min(drag.x1, drag.x2), y: Math.min(drag.y1, drag.y2), w: 0, h: 0 };
+      box.style.left = r.x + "px"; box.style.top = r.y + "px";
+      box.style.width = r.w + "px"; box.style.height = r.h + "px";
+    });
+    root.addEventListener("mouseup", function () {
+      if (!drag) return;
+      var region = clampRegion(drag, { w: window.innerWidth, h: window.innerHeight });
+      drag = null;
+      box.style.display = "none";
+      if (!region) { say("That region is too small — drag a box around what you mean"); return; }
+      submit(region);
+    });
+
+    document.addEventListener("keydown", function (e) {
+      if (e.key === "Escape" && armed) disarm();
+      if (e.key === "c" && !armed && e.target === document.body) button.click();
+    });
+
+    document.body.appendChild(root);
+    document.body.appendChild(button);
+    document.body.appendChild(toast);
+  }
+
+  if (document.readyState === "loading") {
+    document.addEventListener("DOMContentLoaded", build);
+  } else {
+    build();
+  }
+})();


(magpie-site) 01/16: feat(preview): compute GitHub diff anchors and added-line ranges

Posted by po...@apache.org.
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

commit f7fa3277b3451bff5a56c70fd44a662920268a57
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 12:45:48 2026 +0200

    feat(preview): compute GitHub diff anchors and added-line ranges
    
    Generated-by: Claude Opus 5
---
 scripts/preview/anchors.mjs      | 53 ++++++++++++++++++++++++++++++++++++++++
 scripts/preview/anchors.test.mjs | 43 ++++++++++++++++++++++++++++++++
 2 files changed, 96 insertions(+)

diff --git a/scripts/preview/anchors.mjs b/scripts/preview/anchors.mjs
new file mode 100644
index 0000000..76a0e39
--- /dev/null
+++ b/scripts/preview/anchors.mjs
@@ -0,0 +1,53 @@
+import { createHash } from "node:crypto";
+
+/**
+ * GitHub's Files-tab anchor for a file: "diff-" followed by the sha256 of its
+ * repo-relative path. Not a documented contract — it has been an MD5 before —
+ * so it is computed in this one place. Verified 2026-09-17 against PR 186.
+ */
+export function diffAnchor(path) {
+  return `diff-${createHash("sha256").update(String(path)).digest("hex")}`;
+}
+
+/** The new-file line ranges a unified diff adds or changes. */
+export function addedLineRanges(patch) {
+  const ranges = [];
+  let line = 0;
+  let run = null;
+
+  for (const text of String(patch ?? "").split("\n")) {
+    const header = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(text);
+    if (header) {
+      if (run) ranges.push(run);
+      run = null;
+      line = Number(header[1]);
+      continue;
+    }
+    if (text.startsWith("+")) {
+      run = run ? [run[0], line] : [line, line];
+      line += 1;
+    } else if (text.startsWith("-")) {
+      if (run) ranges.push(run);
+      run = null;
+      line += 1;
+    } else {
+      if (run) ranges.push(run);
+      run = null;
+      line += 1;
+    }
+  }
+  if (run) ranges.push(run);
+  return ranges;
+}
+
+/** The manifest the overlay reads: path -> {anchor, ranges}. */
+export function buildAnchors(files) {
+  const out = {};
+  for (const file of files ?? []) {
+    out[file.filename] = {
+      anchor: diffAnchor(file.filename),
+      ranges: addedLineRanges(file.patch),
+    };
+  }
+  return out;
+}
diff --git a/scripts/preview/anchors.test.mjs b/scripts/preview/anchors.test.mjs
new file mode 100644
index 0000000..8964337
--- /dev/null
+++ b/scripts/preview/anchors.test.mjs
@@ -0,0 +1,43 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { diffAnchor, addedLineRanges, buildAnchors } from "./anchors.mjs";
+
+test("diffAnchor is sha256 of the path, as GitHub computes it", () => {
+  // Verified against https://github.com/apache/magpie-site/pull/186/files
+  assert.equal(
+    diffAnchor("scripts/preview/publish.mjs"),
+    "diff-ce7de5b1b8a1930e34a20d50991cf9ec8592b07a535ea8389690a4eeeff5ac64",
+  );
+});
+
+test("addedLineRanges reads the new-file line numbers a hunk touches", () => {
+  const patch = [
+    "@@ -1,3 +1,4 @@",
+    " context",
+    "+added one",
+    "+added two",
+    " context",
+    "@@ -20,2 +21,2 @@",
+    "-removed",
+    "+changed",
+  ].join("\n");
+
+  assert.deepEqual(addedLineRanges(patch), [[2, 3], [22, 22]]);
+});
+
+test("addedLineRanges is empty for a patch with no additions", () => {
+  assert.deepEqual(addedLineRanges("@@ -1,2 +1,1 @@\n context\n-gone"), []);
+  assert.deepEqual(addedLineRanges(""), []);
+  assert.deepEqual(addedLineRanges(undefined), []);
+});
+
+test("buildAnchors keys by path and carries anchor plus ranges", () => {
+  const built = buildAnchors([
+    { filename: "a.tsx", patch: "@@ -1,1 +1,2 @@\n context\n+new" },
+    { filename: "b.png" },
+  ]);
+
+  assert.equal(built["a.tsx"].anchor, diffAnchor("a.tsx"));
+  assert.deepEqual(built["a.tsx"].ranges, [[2, 2]]);
+  assert.deepEqual(built["b.png"].ranges, [], "a binary file has no line ranges");
+});


(magpie-site) 08/16: fix(preview): hide button and toast before capture

Posted by po...@apache.org.
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

commit f550420f86fc31120dfe81fb83caf288b8d6f774
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 13:42:53 2026 +0200

    fix(preview): hide button and toast before capture
    
    Generated-by: Claude Opus 5
---
 scripts/preview/overlay/review.js | 17 ++++++++++++++++-
 1 file changed, 16 insertions(+), 1 deletion(-)

diff --git a/scripts/preview/overlay/review.js b/scripts/preview/overlay/review.js
index d199bb0..3e172a2 100644
--- a/scripts/preview/overlay/review.js
+++ b/scripts/preview/overlay/review.js
@@ -21,6 +21,18 @@
     say._t = setTimeout(function () { toast.style.display = "none"; }, ms || 6000);
   }
 
+  // root holds the dimming and the marking box; button and toast are siblings
+  // on document.body, so hiding root alone leaves them in the captured image.
+  function hideChrome() {
+    root.style.display = "none";
+    button.style.display = "none";
+    toast.style.display = "none";
+  }
+
+  function showChrome() {
+    button.style.display = "";
+  }
+
   function sourceUnder(x, y) {
     var node = document.elementFromPoint(x, y);
     while (node && node !== document.body) {
@@ -49,7 +61,7 @@
       sha: cfg.sha,
     });
 
-    root.style.display = "none"; // keep the overlay out of the capture
+    hideChrome();
     var shot;
     try {
       shot = await window.html2canvas(document.body, {
@@ -59,11 +71,14 @@
         useCORS: true, logging: false,
       });
     } catch (err) {
+      showChrome();
       root.style.display = "block";
       say("Could not capture the page: " + err.message);
       return;
     }
 
+    showChrome();
+
     var scale = shot.width / window.innerWidth;
     var out = document.createElement("canvas");
     out.width = shot.width;


(magpie-site) 09/16: feat(preview): publish the review overlay into every preview

Posted by po...@apache.org.
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

commit d8e9a92dd77040d0c4042260ddc133bc5ce354c3
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 13:46:04 2026 +0200

    feat(preview): publish the review overlay into every preview
    
    Generated-by: Claude Opus 5
---
 scripts/preview/git.mjs          |  8 +++---
 scripts/preview/git.test.mjs     |  7 ++++++
 scripts/preview/github.mjs       |  1 +
 scripts/preview/publish.mjs      | 53 +++++++++++++++++++++++++++++++++++++---
 scripts/preview/publish.test.mjs | 17 +++++++++++++
 5 files changed, 79 insertions(+), 7 deletions(-)

diff --git a/scripts/preview/git.mjs b/scripts/preview/git.mjs
index f2d9202..a40680f 100644
--- a/scripts/preview/git.mjs
+++ b/scripts/preview/git.mjs
@@ -1,8 +1,8 @@
 import { execFile } from "node:child_process";
 import { promisify } from "node:util";
-import { mkdtemp, writeFile, rm, cp, readdir } from "node:fs/promises";
+import { mkdtemp, mkdir, writeFile, rm, cp, readdir } from "node:fs/promises";
 import { tmpdir } from "node:os";
-import { join } from "node:path";
+import { dirname, join } from "node:path";
 
 const run = promisify(execFile);
 
@@ -76,7 +76,9 @@ export async function prepareTree({ dir, files, contentDir = null }) {
   // Generated files are written AFTER the copy, so ours always win over any
   // file of the same name shipped inside the artifact.
   for (const [name, body] of Object.entries(files)) {
-    await writeFile(join(dir, name), body);
+    const target = join(dir, name);
+    await mkdir(dirname(target), { recursive: true });
+    await writeFile(target, body);
   }
 
   // Never publish the metadata the publisher validated against.
diff --git a/scripts/preview/git.test.mjs b/scripts/preview/git.test.mjs
index 802d2e8..60e8e2b 100644
--- a/scripts/preview/git.test.mjs
+++ b/scripts/preview/git.test.mjs
@@ -108,3 +108,10 @@ test("redactError scrubs the token from every field that can carry argv", () =>
   );
   assert.deepEqual(err.spawnargs.length, 4, "redaction must not drop arguments");
 });
+
+test("prepareTree writes generated files into nested directories", async () => {
+  const dir = await mkdtemp(join(tmpdir(), "preview-tree-"));
+  await prepareTree({ dir, files: { "_preview/review.js": "// hi\n" } });
+
+  assert.equal(await readFile(join(dir, "_preview", "review.js"), "utf8"), "// hi\n");
+});
diff --git a/scripts/preview/github.mjs b/scripts/preview/github.mjs
index 4576e59..0b9172f 100644
--- a/scripts/preview/github.mjs
+++ b/scripts/preview/github.mjs
@@ -43,6 +43,7 @@ export function createClient({ repo, token, fetchImpl = fetch }) {
     listOpenPulls: () => paginate(`/repos/${repo}/pulls?state=open`),
     getPull: (n) => request(`/repos/${repo}/pulls/${n}`),
     listComments: (n) => paginate(`/repos/${repo}/issues/${n}/comments`),
+    listPullFiles: (n) => paginate(`/repos/${repo}/pulls/${n}/files`),
 
     async hasWriteAccess(login) {
       try {
diff --git a/scripts/preview/publish.mjs b/scripts/preview/publish.mjs
index a146e13..9c70021 100644
--- a/scripts/preview/publish.mjs
+++ b/scripts/preview/publish.mjs
@@ -1,6 +1,9 @@
+import { readFile, writeFile, readdir } from "node:fs/promises";
+import { join } from "node:path";
 import { resolveArmed } from "./armed.mjs";
 import { planActions } from "./plan.mjs";
 import { validateMeta, findUnsafeEntries } from "./validate.mjs";
+import { buildAnchors } from "./anchors.mjs";
 import {
   renderAsfYaml,
   renderRobots,
@@ -245,12 +248,33 @@ async function publishOne({
     return false;
   }
 
+  const anchors = buildAnchors(await gh.listPullFiles(pr));
+  const logic = await readFile(new URL("./overlay/logic.mjs", import.meta.url), "utf8");
+  const overlay = await readFile(new URL("./overlay/review.js", import.meta.url), "utf8");
+  const vendor = await readFile(
+    new URL("../../vendor/html2canvas/html2canvas.min.js", import.meta.url), "utf8",
+  );
+
+  const generated = {
+    ".asf.yaml": renderAsfYaml(pr),
+    "robots.txt": renderRobots(),
+    "_preview/html2canvas.min.js": vendor,
+    // The overlay's pure logic is unit-tested as a module and inlined here; the
+    // browser file has no build step and no imports.
+    "_preview/review.js":
+      logic.replace(/^export /gm, "") +
+      `\nwindow.__MAGPIE_PREVIEW__ = ${JSON.stringify({
+        repo, pr, sha: headSha.slice(0, 7), anchors,
+      })};\n` + overlay,
+  };
+
+  for (const page of await findHtmlFiles(artifact.dir)) {
+    await writeFile(page, injectOverlay(await readFile(page, "utf8")));
+  }
+
   await git.pushTree(
     previewBranch(pr),
-    {
-      ".asf.yaml": renderAsfYaml(pr),
-      "robots.txt": renderRobots(),
-    },
+    generated,
     `Publish preview for #${pr} (${headSha.slice(0, 7)})`,
     artifact.dir,
   );
@@ -259,6 +283,27 @@ async function publishOne({
   return true;
 }
 
+const OVERLAY_TAGS =
+  '<script src="/_preview/html2canvas.min.js"></script>\n' +
+  '<script src="/_preview/review.js"></script>\n';
+
+/** Add the overlay's script tags to a page, exactly once. */
+export function injectOverlay(html) {
+  if (typeof html !== "string" || !html.includes("</body>")) return html;
+  if (html.includes("/_preview/review.js")) return html;
+  return html.replace("</body>", OVERLAY_TAGS + "</body>");
+}
+
+async function findHtmlFiles(root) {
+  const out = [];
+  for (const entry of await readdir(root, { withFileTypes: true })) {
+    const full = join(root, entry.name);
+    if (entry.isDirectory() && !entry.isSymbolicLink()) out.push(...(await findHtmlFiles(full)));
+    else if (entry.isFile() && entry.name.endsWith(".html")) out.push(full);
+  }
+  return out;
+}
+
 const publishedBody = (pr, sha) =>
   `### Preview published\n\n${previewUrl(pr)}\n\nBuilt from \`${sha.slice(0, 7)}\`. ` +
   `Staging takes a few minutes to pick up a new push.`;
diff --git a/scripts/preview/publish.test.mjs b/scripts/preview/publish.test.mjs
index 32583ac..d1469e1 100644
--- a/scripts/preview/publish.test.mjs
+++ b/scripts/preview/publish.test.mjs
@@ -42,6 +42,7 @@ function fakes({
       posted.push({ n, marker, body });
     },
     listPreviewBranches: async () => branches,
+    listPullFiles: async () => [],
     deleteBranch: async (name) => {
       deleted.push(name);
     },
@@ -351,3 +352,19 @@ test("a run with a persistently failing operation reports failure via the exit c
   assert.equal(process.exitCode, 1, "a run with a failed operation must set a nonzero exit code");
   process.exitCode = 0;
 });
+
+test("injectOverlay adds the scripts once, before </body>", async () => {
+  const { injectOverlay } = await import("./publish.mjs");
+  const html = "<html><body><h1>x</h1></body></html>";
+  const once = injectOverlay(html);
+
+  assert.match(once, /_preview\/review\.js/);
+  assert.match(once, /_preview\/html2canvas\.min\.js/);
+  assert.ok(once.indexOf("</body>") > once.indexOf("review.js"), "scripts come before </body>");
+  assert.equal(injectOverlay(once), once, "injecting twice changes nothing");
+});
+
+test("injectOverlay leaves a document with no body alone", async () => {
+  const { injectOverlay } = await import("./publish.mjs");
+  assert.equal(injectOverlay("no body here"), "no body here");
+});


(magpie-site) 04/16: feat(preview): stamp JSX host elements with their source location

Posted by po...@apache.org.
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

commit ae805b1f958c2ce156807f1771a85e0c5185052e
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 13:30:20 2026 +0200

    feat(preview): stamp JSX host elements with their source location
    
    Generated-by: Claude Opus 5
---
 astro.config.mjs                                 | 17 +++++-
 package-lock.json                                | 67 ++++++++++++------------
 package.json                                     |  1 +
 scripts/preview/babel-plugin-magpie-src.mjs      | 41 +++++++++++++++
 scripts/preview/babel-plugin-magpie-src.test.mjs | 37 +++++++++++++
 5 files changed, 129 insertions(+), 34 deletions(-)

diff --git a/astro.config.mjs b/astro.config.mjs
index 5e16635..1e8bf6b 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -37,7 +37,22 @@ export default defineConfig({
   redirects,
   // Markdown twins of docs pages (/docs/<page>.md) are alternates of the HTML
   // page, not pages, so they stay out of the sitemap.
-  integrations: [react(), sitemap({ filter: (page) => !page.endsWith('.md') })],
+  integrations: [
+    react(
+      process.env.MAGPIE_PREVIEW_ANNOTATE === "1"
+        ? {
+            babel: {
+              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() }],
+              ],
+            },
+          }
+        : {},
+    ),
+    sitemap({ filter: (page) => !page.endsWith('.md') }),
+  ],
   vite: {
     plugins: [tailwindcss()],
   },
diff --git a/package-lock.json b/package-lock.json
index 83e1cc6..8679c59 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
         "@astrojs/check": "^0.9.10",
         "@astrojs/react": "^6.0.5",
         "@astrojs/sitemap": "^3.7.4",
+        "@babel/core": "^7.29.7",
         "@tailwindcss/vite": "^4.3.3",
         "@types/node": "^26.5.1",
         "@types/react": "^19.3.0",
@@ -465,13 +466,13 @@
       }
     },
     "node_modules/@babel/generator": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
-      "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+      "version": "7.29.8",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+      "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
       "license": "MIT",
       "dependencies": {
-        "@babel/parser": "^7.29.7",
-        "@babel/types": "^7.29.7",
+        "@babel/parser": "^7.29.8",
+        "@babel/types": "^7.29.8",
         "@jridgewell/gen-mapping": "^0.3.12",
         "@jridgewell/trace-mapping": "^0.3.28",
         "jsesc": "^3.0.2"
@@ -508,6 +509,15 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
     "node_modules/@babel/helper-create-class-features-plugin": {
       "version": "7.29.7",
       "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz",
@@ -673,12 +683,12 @@
       }
     },
     "node_modules/@babel/parser": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
-      "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+      "version": "7.29.8",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+      "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
       "license": "MIT",
       "dependencies": {
-        "@babel/types": "^7.29.7"
+        "@babel/types": "^7.29.8"
       },
       "bin": {
         "parser": "bin/babel-parser.js"
@@ -818,17 +828,17 @@
       }
     },
     "node_modules/@babel/traverse": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
-      "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+      "version": "7.29.8",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+      "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
       "license": "MIT",
       "dependencies": {
         "@babel/code-frame": "^7.29.7",
-        "@babel/generator": "^7.29.7",
+        "@babel/generator": "^7.29.8",
         "@babel/helper-globals": "^7.29.7",
-        "@babel/parser": "^7.29.7",
+        "@babel/parser": "^7.29.8",
         "@babel/template": "^7.29.7",
-        "@babel/types": "^7.29.7",
+        "@babel/types": "^7.29.8",
         "debug": "^4.3.1"
       },
       "engines": {
@@ -836,9 +846,9 @@
       }
     },
     "node_modules/@babel/types": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
-      "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+      "version": "7.29.8",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+      "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
       "license": "MIT",
       "dependencies": {
         "@babel/helper-string-parser": "^7.29.7",
@@ -7847,12 +7857,12 @@
       }
     },
     "node_modules/lru-cache": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
-      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
-      "license": "ISC",
-      "dependencies": {
-        "yallist": "^3.0.2"
+      "version": "11.5.2",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+      "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": "20 || >=22"
       }
     },
     "node_modules/lucide-react": {
@@ -10508,15 +10518,6 @@
         }
       }
     },
-    "node_modules/unstorage/node_modules/lru-cache": {
-      "version": "11.5.1",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
-      "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
-      "license": "BlueOak-1.0.0",
-      "engines": {
-        "node": "20 || >=22"
-      }
-    },
     "node_modules/update-browserslist-db": {
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
diff --git a/package.json b/package.json
index 020473e..a377ce1 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
     "@astrojs/check": "^0.9.10",
     "@astrojs/react": "^6.0.5",
     "@astrojs/sitemap": "^3.7.4",
+    "@babel/core": "^7.29.7",
     "@tailwindcss/vite": "^4.3.3",
     "@types/node": "^26.5.1",
     "@types/react": "^19.3.0",
diff --git a/scripts/preview/babel-plugin-magpie-src.mjs b/scripts/preview/babel-plugin-magpie-src.mjs
new file mode 100644
index 0000000..0282b3a
--- /dev/null
+++ b/scripts/preview/babel-plugin-magpie-src.mjs
@@ -0,0 +1,41 @@
+import { relative } from "node:path";
+
+/**
+ * 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 MAGPIE_PREVIEW_ANNOTATE=1 — the
+ * production build must emit none of this.
+ */
+export default function magpieSrc({ types: t }) {
+  return {
+    name: "magpie-src",
+    visitor: {
+      JSXOpeningElement(path, state) {
+        const name = path.node.name;
+        if (name.type !== "JSXIdentifier") return;
+        if (!/^[a-z]/.test(name.name)) return;
+
+        const already = path.node.attributes.some(
+          (a) => a.type === "JSXAttribute" && a.name?.name === "data-magpie-src",
+        );
+        if (already) return;
+
+        const line = path.node.loc?.start?.line;
+        if (!line) return;
+
+        const root = state.opts?.root ?? state.file.opts.root ?? process.cwd();
+        const file = relative(root, state.filename ?? state.file.opts.filename ?? "");
+
+        path.node.attributes.push(
+          t.jsxAttribute(
+            t.jsxIdentifier("data-magpie-src"),
+            t.stringLiteral(`${file}:${line}`),
+          ),
+        );
+      },
+    },
+  };
+}
diff --git a/scripts/preview/babel-plugin-magpie-src.test.mjs b/scripts/preview/babel-plugin-magpie-src.test.mjs
new file mode 100644
index 0000000..cc52b8f
--- /dev/null
+++ b/scripts/preview/babel-plugin-magpie-src.test.mjs
@@ -0,0 +1,37 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { transformSync } from "@babel/core";
+import plugin from "./babel-plugin-magpie-src.mjs";
+
+const run = (code, filename = "/repo/src/components/Thing.tsx") =>
+  transformSync(code, {
+    filename,
+    root: "/repo",
+    plugins: [[plugin, { root: "/repo" }]],
+    parserOpts: { plugins: ["jsx", "typescript"] },
+    configFile: false,
+    babelrc: false,
+  }).code;
+
+test("stamps a host element with its repo-relative path and line", () => {
+  const out = run("const a = <div>hi</div>;");
+  assert.match(out, /data-magpie-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 = <Thing prop={1} />;");
+  assert.doesNotMatch(out, /data-magpie-src/);
+});
+
+test("does not overwrite an existing attribute", () => {
+  const out = run('const a = <div data-magpie-src="kept" />;');
+  assert.match(out, /data-magpie-src="kept"/);
+  assert.equal(out.match(/data-magpie-src/g).length, 1);
+});
+
+test("records the line each element starts on", () => {
+  const out = run("const a = (\n  <div>\n    <span>x</span>\n  </div>\n);");
+  assert.match(out, /data-magpie-src="src\/components\/Thing\.tsx:2"/);
+  assert.match(out, /data-magpie-src="src\/components\/Thing\.tsx:3"/);
+});


(magpie-site) 12/16: fix(preview): guard overlay injection on a marker, inject at the last

Posted by po...@apache.org.
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

commit 599b8d95d332444caf445462942eee240b51c08e
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Sep 17 13:55:42 2026 +0200

    fix(preview): guard overlay injection on a marker, inject at the last </body>
    
    injectOverlay guarded on the literal string "/_preview/review.js", so a
    page whose own content mentioned that path — this feature's design
    document, once published — would be mistaken for already-injected and
    silently get no overlay. It also replaced the first </body>, which can
    appear inside an inline script or a serialised island prop and corrupt
    the page.
    
    Guard on a dedicated marker comment instead, and inject at the LAST
    </body>.
    
    Generated-by: Claude Opus 5
---
 scripts/preview/publish.mjs      | 21 +++++++++++++++++----
 scripts/preview/publish.test.mjs | 17 +++++++++++++++++
 2 files changed, 34 insertions(+), 4 deletions(-)

diff --git a/scripts/preview/publish.mjs b/scripts/preview/publish.mjs
index 9c70021..40f9ed0 100644
--- a/scripts/preview/publish.mjs
+++ b/scripts/preview/publish.mjs
@@ -283,15 +283,28 @@ async function publishOne({
   return true;
 }
 
+const OVERLAY_MARKER = "<!-- magpie-preview-overlay -->";
 const OVERLAY_TAGS =
+  OVERLAY_MARKER + "\n" +
   '<script src="/_preview/html2canvas.min.js"></script>\n' +
   '<script src="/_preview/review.js"></script>\n';
 
-/** Add the overlay's script tags to a page, exactly once. */
+/**
+ * Add the overlay's script tags to a page, exactly once.
+ *
+ * Guarded on a marker comment rather than on the script path: a page whose
+ * CONTENT mentions "/_preview/review.js" — this feature's own design document,
+ * once published — would otherwise silently get no overlay. Injected at the
+ * LAST </body>, because an earlier one can appear inside an inline script or a
+ * serialised island prop, and injecting there corrupts the page.
+ */
 export function injectOverlay(html) {
-  if (typeof html !== "string" || !html.includes("</body>")) return html;
-  if (html.includes("/_preview/review.js")) return html;
-  return html.replace("</body>", OVERLAY_TAGS + "</body>");
+  if (typeof html !== "string") return html;
+  if (html.includes(OVERLAY_MARKER)) return html;
+
+  const at = html.lastIndexOf("</body>");
+  if (at === -1) return html;
+  return html.slice(0, at) + OVERLAY_TAGS + html.slice(at);
 }
 
 async function findHtmlFiles(root) {
diff --git a/scripts/preview/publish.test.mjs b/scripts/preview/publish.test.mjs
index d1469e1..0a96230 100644
--- a/scripts/preview/publish.test.mjs
+++ b/scripts/preview/publish.test.mjs
@@ -368,3 +368,20 @@ test("injectOverlay leaves a document with no body alone", async () => {
   const { injectOverlay } = await import("./publish.mjs");
   assert.equal(injectOverlay("no body here"), "no body here");
 });
+
+test("injectOverlay injects at the last </body> and only once", async () => {
+  const { injectOverlay } = await import("./publish.mjs");
+
+  // An inline script containing </body> must not be the injection point.
+  const html = '<html><body><script>var s = "</body>";</script></body></html>';
+  const once = injectOverlay(html);
+  assert.ok(
+    once.lastIndexOf("_preview/review.js") > once.indexOf('var s = "</body>"'),
+    "must inject after the string literal, not inside the script",
+  );
+  assert.equal(injectOverlay(once), once, "injecting twice changes nothing");
+
+  // A page that merely mentions the script path still gets the overlay.
+  const mentions = "<html><body>see /_preview/review.js for details</body></html>";
+  assert.match(injectOverlay(mentions), /magpie-preview-overlay/);
+});