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