@orbytes/astrolab 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +37 -0
- package/README.md +410 -0
- package/bin/lab-cull.mjs +401 -0
- package/bin/pin-gallery.mjs +121 -0
- package/defaults.mjs +120 -0
- package/dist/core/astro-integration.js +130 -0
- package/dist/core/index.js +6 -0
- package/dist/core/lib-paths.js +29 -0
- package/dist/core/options.js +173 -0
- package/dist/core/utils/get-exports.js +52 -0
- package/dist/core/utils/invariant.js +12 -0
- package/dist/core/utils/kebab-case.js +15 -0
- package/dist/core/utils/path-builder.js +22 -0
- package/dist/core/utils/path.js +53 -0
- package/dist/core/virtual-module/get-story-modules.js +60 -0
- package/dist/core/virtual-module/story-modules.js +8 -0
- package/dist/core/virtual-module/virtual-module-ids.js +22 -0
- package/dist/core/virtual-module/virtual-routes.js +83 -0
- package/dist/core/virtual-module/vite-plugin.js +98 -0
- package/docs/PIN-CONTRACT.md +263 -0
- package/docs/PIN.md +429 -0
- package/index.d.ts +279 -0
- package/index.mjs +347 -0
- package/package.json +93 -0
- package/src/Empty.astro +4 -0
- package/src/Home.astro +298 -0
- package/src/LabHead.astro +1102 -0
- package/src/core/LICENSE-astrobook +166 -0
- package/src/core/astro-integration.ts +166 -0
- package/src/core/client.ts +89 -0
- package/src/core/index.ts +7 -0
- package/src/core/lib/components/empty.astro +1 -0
- package/src/core/lib/components/head.astro +1 -0
- package/src/core/lib/components/home.astro +8 -0
- package/src/core/lib/components/with-decorators.astro +22 -0
- package/src/core/lib/pages/app.astro +19 -0
- package/src/core/lib/pages/preview.astro +17 -0
- package/src/core/lib/pages/story.astro +16 -0
- package/src/core/lib-paths.ts +72 -0
- package/src/core/options.ts +262 -0
- package/src/core/utils/get-exports.ts +59 -0
- package/src/core/utils/invariant.ts +13 -0
- package/src/core/utils/kebab-case.ts +30 -0
- package/src/core/utils/path-builder.ts +45 -0
- package/src/core/utils/path.ts +80 -0
- package/src/core/virtual-module/get-story-modules.ts +110 -0
- package/src/core/virtual-module/story-modules.ts +9 -0
- package/src/core/virtual-module/virtual-module-ids.ts +17 -0
- package/src/core/virtual-module/virtual-routes.ts +130 -0
- package/src/core/virtual-module/vite-plugin.ts +125 -0
- package/src/pin/board.mjs +1521 -0
- package/src/pin/index.mjs +666 -0
- package/src/pin/shot.mjs +427 -0
- package/src/pin/source-stamp.mjs +159 -0
- package/src/pin/tickets.mjs +697 -0
- package/src/pin/toolbar.js +3181 -0
- package/src/shell/Browse.astro +371 -0
- package/src/shell/CardGrid.astro +297 -0
- package/src/shell/Viewport.astro +1330 -0
- package/src/shell/index.json.ts +12 -0
- package/src/shell/lab-index.ts +344 -0
- package/src/shell/lab-params.ts +245 -0
- package/src/shell/live-files.mjs +164 -0
- package/src/shell/marks.mjs +136 -0
- package/src/types/index.ts +6 -0
- package/src/types/types.ts +239 -0
- package/src/types/virtual.d.ts +29 -0
- package/src/ui/components/app.astro +13 -0
- package/src/ui/components/build-path.ts +13 -0
- package/src/ui/components/build-tree.ts +108 -0
- package/src/ui/components/collapse-duration.ts +28 -0
- package/src/ui/components/compress-terms.ts +10 -0
- package/src/ui/components/dashboard-layout.astro +39 -0
- package/src/ui/components/home.astro +65 -0
- package/src/ui/components/layout.astro +110 -0
- package/src/ui/components/preview-layout.astro +109 -0
- package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
- package/src/ui/components/sidebar-button-search.astro +23 -0
- package/src/ui/components/sidebar-button-theme.astro +9 -0
- package/src/ui/components/sidebar-button.astro +24 -0
- package/src/ui/components/sidebar-resize-handle.astro +74 -0
- package/src/ui/components/sidebar-search-panel.astro +41 -0
- package/src/ui/components/sidebar-search-script.ts +103 -0
- package/src/ui/components/sidebar-title.astro +17 -0
- package/src/ui/components/sidebar-tree-node.astro +143 -0
- package/src/ui/components/sidebar-tree.astro +84 -0
- package/src/ui/components/sidebar.astro +29 -0
- package/src/ui/components/theme-message.ts +26 -0
- package/src/ui/components/theme-script.astro +71 -0
- package/src/ui/components/theme-toggle.astro +63 -0
- package/src/ui/components/theme.ts +32 -0
- package/src/ui/index.ts +4 -0
- package/src/ui/lab.css +549 -0
- package/virtual.d.ts +42 -0
package/src/pin/shot.mjs
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// orbytes-pin — the screenshot.
|
|
2
|
+
//
|
|
3
|
+
// ONE chromium for the whole dev session, launched lazily on the first pin and kept warm
|
|
4
|
+
// (../../docs/PIN-CONTRACT.md § Screenshots). A cold launch costs over a second; a warm one is a page
|
|
5
|
+
// open. You never wait on either, because index.mjs replies to the browser before this runs.
|
|
6
|
+
//
|
|
7
|
+
// None of the four capture settings below is a default. Each was worked out against a real site,
|
|
8
|
+
// and each is there because its absence produced a wrong picture:
|
|
9
|
+
// · freeze motion BOTH ways — `reducedMotion: "reduce"` only reaches a section that authored a
|
|
10
|
+
// prefers-reduced-motion block, and an injected `animation:none` cannot touch motion driven
|
|
11
|
+
// by JS or a canvas. The site it was written against runs lenis and motion, so on a real
|
|
12
|
+
// orbytes build neither half is optional.
|
|
13
|
+
// · settle every image before the shutter — a lazy image below the fold shoots as a white hole
|
|
14
|
+
// that is indistinguishable from a real missing image.
|
|
15
|
+
// · hide the dev toolbar.
|
|
16
|
+
// · delete the destination FIRST, so a timeout leaves no stale PNG that reads like a success.
|
|
17
|
+
//
|
|
18
|
+
// Two decisions are this file's own and are argued here rather than inherited:
|
|
19
|
+
// · The page is scrolled to the payload's `scroll` position before the shutter. The contract's
|
|
20
|
+
// rect is already in DOCUMENT coordinates and Playwright's `clip` is document-relative when
|
|
21
|
+
// `fullPage: true` is set (see the screenshot call — the contract omits that flag, and the
|
|
22
|
+
// claim is false without it), so
|
|
23
|
+
// this is not clip maths (the contract forbids that) — it is reproducing what was on screen
|
|
24
|
+
// when the pin was placed, which matters because a JS scroll-reveal leaves an out-of-view section at
|
|
25
|
+
// opacity 0 and an `animation:none` stylesheet cannot undo an inline style.
|
|
26
|
+
// · The unpainted-image refusal is scoped to images that OVERLAP the clip. Refusing on any
|
|
27
|
+
// unpainted image anywhere is right for a whole-page render; here it would throw away a good
|
|
28
|
+
// screenshot over a hole in a different section.
|
|
29
|
+
//
|
|
30
|
+
// `url` is the document the picker measured `rect` and `selector` IN, which since 2026-09-21 is
|
|
31
|
+
// not always the page that was on screen. The component lab renders every story inside a
|
|
32
|
+
// same-origin <iframe> of `/lab/stories/<id>`, and the picker now descends into it, so a lab pin
|
|
33
|
+
// arrives here carrying the STORY's url, the story window's viewport and scroll, and a rect in
|
|
34
|
+
// the story document's own coordinates. That is the only combination this file can act on: it
|
|
35
|
+
// re-loads `url` and clips `rect` against whatever comes back, so a url naming the shell and a
|
|
36
|
+
// rect measured inside the frame would photograph the wrong document and say nothing about it.
|
|
37
|
+
// The four travel together or not at all — and `verifyClip` below refuses the shot when the page
|
|
38
|
+
// that loaded cannot contain the rect it was handed (› verifyClip).
|
|
39
|
+
//
|
|
40
|
+
// And one job that is not a screenshot at all, here because this is the only place that has what
|
|
41
|
+
// it needs (› countSelector). This file already loads the page FRESH in a real browser — a second
|
|
42
|
+
// process, a second DOM, every framework re-initialised from scratch. That is precisely the
|
|
43
|
+
// negative control a selector needs: the pick-time page cannot tell you whether a selector
|
|
44
|
+
// survives a reload, because it IS the load the selector was written against. So before the
|
|
45
|
+
// shutter we ask the fresh page how many nodes the ticket's selector actually finds, and write
|
|
46
|
+
// the answer back into the ticket. It costs one `querySelectorAll` on a page that is already
|
|
47
|
+
// open. A ticket whose selector died is still workable by hand — source file, screenshot and
|
|
48
|
+
// outer_html are all correct — so it is labelled, never discarded. The defect was the silence.
|
|
49
|
+
//
|
|
50
|
+
// `playwright` is loaded by DYNAMIC import inside getBrowser(), never at module scope, and that is
|
|
51
|
+
// load-bearing rather than tidy. It is an OPTIONAL peer (package.json
|
|
52
|
+
// `peerDependenciesMeta.playwright.optional`) and the integration has a `shots: false` option that
|
|
53
|
+
// says screenshots are not wanted — but a static import here is reached from the package root
|
|
54
|
+
// entry, so a site without playwright installed could not even LOAD its astro.config, let alone
|
|
55
|
+
// run with shots off. The promise and the code disagreed; the code is now the one that changed.
|
|
56
|
+
// Found on the move into astrolab, 2026-09-22.
|
|
57
|
+
import { linkSync, mkdirSync, copyFileSync, readdirSync, rmSync } from "node:fs";
|
|
58
|
+
import { basename, dirname, join } from "node:path";
|
|
59
|
+
import { PIN_FENCE, recordSelectorMatches } from "./tickets.mjs";
|
|
60
|
+
import { readFileSync } from "node:fs";
|
|
61
|
+
|
|
62
|
+
const FREEZE_CSS =
|
|
63
|
+
"*,*::before,*::after{animation:none!important;transition:none!important;" +
|
|
64
|
+
"animation-duration:0s!important;transition-duration:0s!important;" +
|
|
65
|
+
"animation-delay:0s!important;transition-delay:0s!important;" +
|
|
66
|
+
"animation-play-state:paused!important;caret-color:transparent!important;scroll-behavior:auto!important}" +
|
|
67
|
+
"html{scroll-behavior:auto!important}video,marquee{animation-play-state:paused!important}";
|
|
68
|
+
|
|
69
|
+
const TOOLBAR_CSS = "astro-dev-toolbar{display:none!important}";
|
|
70
|
+
|
|
71
|
+
let browser = null;
|
|
72
|
+
let launching = null; // single-flight, so two pins in the same tick cannot launch two chromiums
|
|
73
|
+
let launchMs = null;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* playwright, loaded on demand. One sentence when it is not installed, not a resolution stack:
|
|
77
|
+
* this is an optional peer, and the honest answer is "install it, or turn shots off".
|
|
78
|
+
*/
|
|
79
|
+
async function loadChromium() {
|
|
80
|
+
try {
|
|
81
|
+
const { chromium } = await import("playwright");
|
|
82
|
+
return chromium;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
"orbytes-pin cannot take screenshots: playwright is not installed. " +
|
|
86
|
+
"Run `npm i -D playwright && npx playwright install chromium`, or pass " +
|
|
87
|
+
"`pin: { shots: false }` to orbytesLab() to write tickets without pictures. " +
|
|
88
|
+
`(${String(e?.message ?? e).split("\n")[0]})`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The warm browser, launched on first use. */
|
|
94
|
+
export async function getBrowser() {
|
|
95
|
+
if (browser?.isConnected()) return browser;
|
|
96
|
+
if (!launching) {
|
|
97
|
+
const started = Date.now();
|
|
98
|
+
launching = loadChromium()
|
|
99
|
+
.then((chromium) => chromium.launch())
|
|
100
|
+
.then((b) => {
|
|
101
|
+
browser = b;
|
|
102
|
+
launchMs = Date.now() - started;
|
|
103
|
+
// A crash must not leave a dead handle that every later pin reuses.
|
|
104
|
+
b.once("disconnected", () => { if (browser === b) browser = null; });
|
|
105
|
+
return b;
|
|
106
|
+
})
|
|
107
|
+
.finally(() => { launching = null; });
|
|
108
|
+
}
|
|
109
|
+
return launching;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Milliseconds the cold launch took, or null if the browser has not been launched yet. */
|
|
113
|
+
export const coldLaunchMs = () => launchMs;
|
|
114
|
+
|
|
115
|
+
/** Close the warm browser. Idempotent; safe to call on a server that never took a shot. */
|
|
116
|
+
export async function close() {
|
|
117
|
+
const b = browser;
|
|
118
|
+
browser = null;
|
|
119
|
+
if (b) await b.close().catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The ticket file this shot belongs to.
|
|
124
|
+
*
|
|
125
|
+
* `ticketFile` is the caller's job and the documented route. The derivation from the PNG path is
|
|
126
|
+
* a deliberate fallback, not a shortcut: the screenshot's basename IS the ticket number
|
|
127
|
+
* (`pin-007.png` ↔ `pin-007-<slug>.md`) and `link` is `<repo>/<backlogDir>/assets/pin-007.png`,
|
|
128
|
+
* so `../tasks` is one directory hop away. It exists so that the truth still reaches the ticket
|
|
129
|
+
* if a caller is written that forgets to pass the path — a fix that silently stops running is
|
|
130
|
+
* the same class of defect as the one it fixes.
|
|
131
|
+
*
|
|
132
|
+
* @returns {string|null} absolute path, or null when nothing on disk answers to that number
|
|
133
|
+
*/
|
|
134
|
+
function ticketFor({ ticketFile, link, dest }) {
|
|
135
|
+
if (ticketFile) return ticketFile;
|
|
136
|
+
const png = link || dest;
|
|
137
|
+
if (!png) return null;
|
|
138
|
+
const num = /^pin-(\d+)\.png$/.exec(basename(png));
|
|
139
|
+
if (!num) return null;
|
|
140
|
+
const tasks = join(dirname(dirname(png)), "tasks");
|
|
141
|
+
let names;
|
|
142
|
+
try {
|
|
143
|
+
names = readdirSync(tasks);
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const hit = names.find((n) => n.startsWith(`pin-${num[1]}-`) && n.endsWith(".md"));
|
|
148
|
+
return hit ? join(tasks, hit) : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The `selector:` line out of a ticket's pin block. Used only when the caller did not pass one. */
|
|
152
|
+
function selectorFromTicket(absolute) {
|
|
153
|
+
let text;
|
|
154
|
+
try {
|
|
155
|
+
text = readFileSync(absolute, "utf8");
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const fence = text.indexOf(PIN_FENCE);
|
|
160
|
+
if (fence === -1) return null;
|
|
161
|
+
const end = text.indexOf("\n```", fence + PIN_FENCE.length);
|
|
162
|
+
const m = /^selector:[ \t]*(.*)$/m.exec(text.slice(fence, end === -1 ? undefined : end));
|
|
163
|
+
if (!m) return null;
|
|
164
|
+
const raw = m[1].trim();
|
|
165
|
+
if (raw.startsWith("'") && raw.endsWith("'") && raw.length > 1) return raw.slice(1, -1).replace(/''/g, "'");
|
|
166
|
+
if (raw.startsWith('"') && raw.endsWith('"') && raw.length > 1) return raw.slice(1, -1);
|
|
167
|
+
return raw || null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* How many nodes `selector` matches on the fresh page. `null` when the question could not be
|
|
172
|
+
* asked at all — no selector to test, or a selector the browser refuses to parse.
|
|
173
|
+
*
|
|
174
|
+
* Note the two truths this does NOT conflate. Zero is an answer: the selector is dead, and the
|
|
175
|
+
* ticket says so. `null` is the absence of an answer, and it leaves the ticket reading `pending`
|
|
176
|
+
* — which is also true, because nothing was tested. Neither is ever rounded to "fine".
|
|
177
|
+
*/
|
|
178
|
+
async function countSelector(page, selector) {
|
|
179
|
+
if (typeof selector !== "string" || selector.trim() === "") return null;
|
|
180
|
+
return page.evaluate((sel) => {
|
|
181
|
+
try {
|
|
182
|
+
return document.querySelectorAll(sel).length;
|
|
183
|
+
} catch {
|
|
184
|
+
return null; // not a selector this browser will parse — an answer nobody can give
|
|
185
|
+
}
|
|
186
|
+
}, selector);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Do two boxes share any area at all? */
|
|
190
|
+
const overlaps = (a, b) =>
|
|
191
|
+
a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
192
|
+
|
|
193
|
+
const box = (r) => `${Math.round(r.width)}x${Math.round(r.height)} at (${Math.round(r.x)}, ${Math.round(r.y)})`;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Refuse a clip the loaded page cannot be describing.
|
|
197
|
+
*
|
|
198
|
+
* The failure this exists for is silent by construction. `rect` is in document coordinates of
|
|
199
|
+
* whichever document the picker measured it in; if `url` names a different one — the lab shell
|
|
200
|
+
* instead of the story inside its iframe — the clip still lands somewhere, the PNG is still
|
|
201
|
+
* written, `selector_matches` is still counted, and every trail behind it reads clean. The
|
|
202
|
+
* picture is simply of the wrong thing, and nothing on the ticket says so.
|
|
203
|
+
*
|
|
204
|
+
* So this REFUSES rather than warns (a guard that warns is not a guard). Two checks, both of
|
|
205
|
+
* which are the same question — can this page contain that rect? — and both scoped to
|
|
206
|
+
* disagreements that cannot be a layout shift:
|
|
207
|
+
*
|
|
208
|
+
* 1. the clip must share some area with the loaded document's own scrollable box. A rect from
|
|
209
|
+
* a taller page hangs off the bottom of a short one entirely;
|
|
210
|
+
* 2. when the selector resolves to EXACTLY ONE node on this page, that node's document rect
|
|
211
|
+
* must share some area with the clip. One node is the only case where there is an
|
|
212
|
+
* unambiguous answer to compare against; 0 and 2+ are already reported as such.
|
|
213
|
+
*
|
|
214
|
+
* Zero overlap, not "close enough": a lazy image settling or a font swapping can move an element
|
|
215
|
+
* by hundreds of pixels and that is a healthy shot, so the line is drawn where the picture
|
|
216
|
+
* provably cannot contain the element rather than where it is merely off.
|
|
217
|
+
*
|
|
218
|
+
* @throws with a plain sentence naming both boxes and the url.
|
|
219
|
+
*/
|
|
220
|
+
async function verifyClip(page, { url, clip, selector, matches }) {
|
|
221
|
+
const seen = await page.evaluate(
|
|
222
|
+
({ sel, one }) => {
|
|
223
|
+
const doc = document.documentElement;
|
|
224
|
+
const out = {
|
|
225
|
+
page: { x: 0, y: 0, width: doc.scrollWidth, height: doc.scrollHeight },
|
|
226
|
+
node: null,
|
|
227
|
+
};
|
|
228
|
+
if (one && sel) {
|
|
229
|
+
const el = document.querySelector(sel);
|
|
230
|
+
if (el) {
|
|
231
|
+
const r = el.getBoundingClientRect();
|
|
232
|
+
out.node = { x: r.left + scrollX, y: r.top + scrollY, width: r.width, height: r.height };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
},
|
|
237
|
+
{ sel: selector, one: matches === 1 },
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
if (!overlaps(clip, seen.page)) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`the clip ${box(clip)} falls entirely outside ${url}, whose document is ${box(seen.page)} — ` +
|
|
243
|
+
`that rect was measured in a different document, so this would photograph the wrong page`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (seen.node && !overlaps(clip, seen.node)) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`on ${url} the ticket's selector finds one element at ${box(seen.node)}, which does not ` +
|
|
249
|
+
`overlap the clip ${box(clip)} at all — the rect and the selector describe different ` +
|
|
250
|
+
`documents, so this would photograph the wrong thing`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Capture one pin.
|
|
257
|
+
*
|
|
258
|
+
* @param {object} o
|
|
259
|
+
* @param {string} o.origin the dev server's own origin — read from Vite, never hardcoded
|
|
260
|
+
* @param {string} o.url site path, e.g. "/" or "/about?x=1"
|
|
261
|
+
* @param {{width:number,height:number,dpr:number}} o.viewport
|
|
262
|
+
* @param {{x:number,y:number}} o.scroll
|
|
263
|
+
* @param {{x:number,y:number,width:number,height:number}} o.rect DOCUMENT coordinates
|
|
264
|
+
* @param {string} o.dest absolute path of the canonical copy (the archive)
|
|
265
|
+
* @param {string} [o.link] absolute path to hardlink the canonical copy to (the board's assets)
|
|
266
|
+
* @param {string} [o.selector] the ticket's CSS selector, re-counted on the fresh page
|
|
267
|
+
* @param {string} [o.ticketFile] absolute path of the ticket to record the count in
|
|
268
|
+
* @param {(msg:string)=>void} [o.log]
|
|
269
|
+
* @returns {Promise<{ ms:number, cold:boolean, linked:"hardlink"|"copy"|"none", selectorMatches:number|null }>}
|
|
270
|
+
* `selectorMatches` is the node count on the fresh load: 1 is healthy, 0 means the selector is
|
|
271
|
+
* dead, more than 1 means it is ambiguous, and null means the question could not be asked.
|
|
272
|
+
*/
|
|
273
|
+
export async function shoot({ origin, url, viewport, scroll, rect, dest, link, selector, ticketFile, log = () => {} }) {
|
|
274
|
+
const started = Date.now();
|
|
275
|
+
const cold = !browser?.isConnected();
|
|
276
|
+
const b = await getBrowser();
|
|
277
|
+
|
|
278
|
+
// Before anything can fail: nothing stale may survive this call under either name.
|
|
279
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
280
|
+
rmSync(dest, { force: true });
|
|
281
|
+
if (link) { mkdirSync(dirname(link), { recursive: true }); rmSync(link, { force: true }); }
|
|
282
|
+
|
|
283
|
+
const width = Math.max(320, Math.round(Number(viewport.width) || 1440));
|
|
284
|
+
const height = Math.max(240, Math.round(Number(viewport.height) || 960));
|
|
285
|
+
const page = await b.newPage({
|
|
286
|
+
viewport: { width, height },
|
|
287
|
+
deviceScaleFactor: Math.min(3, Math.max(1, Number(viewport.dpr) || 1)),
|
|
288
|
+
reducedMotion: "reduce",
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const target = new URL(url, origin).href;
|
|
293
|
+
const response = await page.goto(target, { waitUntil: "networkidle" });
|
|
294
|
+
if (!response) throw new Error(`${url} returned no response`);
|
|
295
|
+
if (response.status() >= 400) throw new Error(`${url} answered ${response.status()}`);
|
|
296
|
+
|
|
297
|
+
await page.addStyleTag({ content: TOOLBAR_CSS });
|
|
298
|
+
await page.addStyleTag({ content: FREEZE_CSS });
|
|
299
|
+
await page.evaluate(() => {
|
|
300
|
+
for (const v of document.querySelectorAll("video")) { try { v.pause(); v.currentTime = 0; } catch {} }
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Settle: force every lazy image to load, decode them, wait for fonts, then take up the
|
|
304
|
+
// position the pin was placed at so a scroll-reveal has fired.
|
|
305
|
+
// The sweep stops one viewport past the bottom of the clip rather than walking the whole
|
|
306
|
+
// document: an image below the clip cannot appear in this shot, and on a long page the
|
|
307
|
+
// difference is seconds of 50 ms steps for nothing.
|
|
308
|
+
await page.evaluate(async ({ to, until }) => {
|
|
309
|
+
for (const img of document.querySelectorAll("img")) {
|
|
310
|
+
img.loading = "eager";
|
|
311
|
+
img.setAttribute("fetchpriority", "high");
|
|
312
|
+
if (img.dataset.src && !img.src) img.src = img.dataset.src;
|
|
313
|
+
}
|
|
314
|
+
const step = Math.max(200, Math.floor(innerHeight / 2));
|
|
315
|
+
const last = Math.min(document.documentElement.scrollHeight, until);
|
|
316
|
+
for (let y = 0; y <= last; y += step) {
|
|
317
|
+
scrollTo(0, y);
|
|
318
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
319
|
+
}
|
|
320
|
+
scrollTo(to.x, to.y);
|
|
321
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
322
|
+
await Promise.all([...document.querySelectorAll("img")].map((i) => (i.decode ? i.decode().catch(() => {}) : null)));
|
|
323
|
+
if (document.fonts) await document.fonts.ready;
|
|
324
|
+
}, {
|
|
325
|
+
to: { x: Math.round(Number(scroll?.x) || 0), y: Math.round(Number(scroll?.y) || 0) },
|
|
326
|
+
until: Math.round(Number(rect.y) + Number(rect.height)) + height,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// A smooth-scroll library (lenis, here) animates towards the target after the settle pass.
|
|
330
|
+
// Shooting mid-flight moves every pixel in frame, so hold until the position stops changing.
|
|
331
|
+
const want = { x: Math.round(Number(scroll?.x) || 0), y: Math.round(Number(scroll?.y) || 0) };
|
|
332
|
+
for (let i = 0; i < 20; i++) {
|
|
333
|
+
const stable = await page.evaluate(async (to) => {
|
|
334
|
+
scrollTo(to.x, to.y);
|
|
335
|
+
const a = scrollY;
|
|
336
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
337
|
+
return Math.abs(a - scrollY) < 1;
|
|
338
|
+
}, want);
|
|
339
|
+
if (stable) break;
|
|
340
|
+
}
|
|
341
|
+
await page.waitForTimeout(150);
|
|
342
|
+
|
|
343
|
+
const clip = {
|
|
344
|
+
x: Math.max(0, Math.round(Number(rect.x))),
|
|
345
|
+
y: Math.max(0, Math.round(Number(rect.y))),
|
|
346
|
+
width: Math.max(1, Math.round(Number(rect.width))),
|
|
347
|
+
height: Math.max(1, Math.round(Number(rect.height))),
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Scoped refusal: an image that OVERLAPS the clip and is still unpainted would shoot as a
|
|
351
|
+
// hole, and a hole is indistinguishable from a real missing image. Images elsewhere on the
|
|
352
|
+
// page are none of this shot's business.
|
|
353
|
+
const holes = await page.evaluate((c) => {
|
|
354
|
+
return [...document.querySelectorAll("img")]
|
|
355
|
+
.filter((i) => {
|
|
356
|
+
const r = i.getBoundingClientRect();
|
|
357
|
+
const x = r.left + scrollX, y = r.top + scrollY;
|
|
358
|
+
return x < c.x + c.width && x + r.width > c.x && y < c.y + c.height && y + r.height > c.y;
|
|
359
|
+
})
|
|
360
|
+
.filter((i) => !i.complete || i.naturalWidth === 0)
|
|
361
|
+
.map((i) => i.currentSrc || i.src);
|
|
362
|
+
}, clip);
|
|
363
|
+
if (holes.length) {
|
|
364
|
+
throw new Error(`${holes.length} image(s) inside the clip are still unpainted — refusing to shoot a hole and call it a screenshot`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// `fullPage: true` is what MAKES the contract's "clip is document-relative" true, and it is
|
|
368
|
+
// not optional. Measured 2026-09-21 on playwright 1.63.0: without it, `clip` is relative to
|
|
369
|
+
// the VIEWPORT and a clip below the fold throws "Clipped area is either empty or outside the
|
|
370
|
+
// resulting image" — the contract's rect, which is in document coordinates, would never
|
|
371
|
+
// shoot. With it, Chromium rasterises the clip region alone, not the whole page: a clip at
|
|
372
|
+
// y=25000 on a 30,200px page at dpr 2 took 78 ms and produced a 2.8 KB file. So the contract
|
|
373
|
+
// still holds — no scroll maths is added to the rect — but the flag has to be there.
|
|
374
|
+
// The fresh-load selector count, taken HERE: after the settle pass, on the same DOM the
|
|
375
|
+
// shutter is about to photograph, so the number and the picture describe one moment. Any
|
|
376
|
+
// earlier and a scroll-revealed or carousel-driven element would be counted in a state the
|
|
377
|
+
// screenshot does not show.
|
|
378
|
+
const ticket = ticketFor({ ticketFile, link, dest });
|
|
379
|
+
const probe = typeof selector === "string" && selector.trim() !== ""
|
|
380
|
+
? selector
|
|
381
|
+
: ticket
|
|
382
|
+
? selectorFromTicket(ticket)
|
|
383
|
+
: null;
|
|
384
|
+
const selectorMatches = await countSelector(page, probe);
|
|
385
|
+
|
|
386
|
+
// Last thing before the shutter, and a refusal rather than a warning: a picture of the wrong
|
|
387
|
+
// document is worse than no picture, because a picture reads as the truth.
|
|
388
|
+
await verifyClip(page, { url, clip, selector: probe, matches: selectorMatches });
|
|
389
|
+
|
|
390
|
+
await page.screenshot({ path: dest, fullPage: true, clip });
|
|
391
|
+
|
|
392
|
+
// Recorded AFTER the shutter, so a shot that fails leaves the ticket honestly saying
|
|
393
|
+
// `pending` rather than claiming a check that produced no picture. A count of null is not
|
|
394
|
+
// written at all — there is nothing true to write.
|
|
395
|
+
if (ticket && Number.isInteger(selectorMatches)) {
|
|
396
|
+
try {
|
|
397
|
+
recordSelectorMatches(ticket, selectorMatches);
|
|
398
|
+
} catch (e) {
|
|
399
|
+
// Loud, because a count computed and then dropped is exactly the silence this fixes.
|
|
400
|
+
log(`could not record selector_matches=${selectorMatches} in ${basename(ticket)}: ${e.message}`);
|
|
401
|
+
}
|
|
402
|
+
} else if (probe && !ticket) {
|
|
403
|
+
log(`selector counted ${selectorMatches} times on a fresh load but no ticket file was found to record it in`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let linked = "none";
|
|
407
|
+
if (link) {
|
|
408
|
+
try {
|
|
409
|
+
linkSync(dest, link);
|
|
410
|
+
linked = "hardlink";
|
|
411
|
+
} catch (e) {
|
|
412
|
+
// Different filesystem (the archive is under $HOME, the repo may not be) — say so.
|
|
413
|
+
copyFileSync(dest, link);
|
|
414
|
+
linked = "copy";
|
|
415
|
+
log(`hardlink failed (${e.code ?? e.message}); copied instead — two copies of this PNG now exist on disk`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return { ms: Date.now() - started, cold, linked, selectorMatches };
|
|
419
|
+
} catch (e) {
|
|
420
|
+
// A failed shot leaves NO file behind under either name.
|
|
421
|
+
rmSync(dest, { force: true });
|
|
422
|
+
if (link) rmSync(link, { force: true });
|
|
423
|
+
throw e;
|
|
424
|
+
} finally {
|
|
425
|
+
await page.close().catch(() => {});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// orbytes-pin — the source attribute.
|
|
2
|
+
//
|
|
3
|
+
// Astro 7 does NOT emit `data-astro-source-file` (../../docs/PIN-CONTRACT.md § Source attribution), so
|
|
4
|
+
// this package injects its own at SECTION granularity: the first top-level element of every
|
|
5
|
+
// `.astro` file under the configured directories gets `data-orbytes-src="<repo-relative path>"`,
|
|
6
|
+
// and the picker walks up from the clicked node to the nearest ancestor carrying it.
|
|
7
|
+
//
|
|
8
|
+
// Two decisions make this safe to run over real files:
|
|
9
|
+
//
|
|
10
|
+
// 1. It is a `load` hook, not a `transform`. Astro's own `astro:build` plugin is `enforce: "pre"`
|
|
11
|
+
// and its `load` filter matches only `?astro` sub-requests, never the bare `.astro` id — so a
|
|
12
|
+
// `pre` load here always wins the bare id whatever the plugin order, and Astro's compiler
|
|
13
|
+
// receives the stamped source. A `transform` would race Astro's, which is also `pre`.
|
|
14
|
+
// 2. The attribute is inserted immediately after the TAG NAME, not at the end of the opening tag.
|
|
15
|
+
// `<section` becomes `<section data-orbytes-src="…"`. Nothing else in the tag is parsed, so no
|
|
16
|
+
// attribute value, expression, spread or self-closing slash can be mangled, and because the
|
|
17
|
+
// insert adds no newline every line number in the file stays where it was.
|
|
18
|
+
//
|
|
19
|
+
// A matched file with no element to stamp THROWS naming the file (contract non-negotiable 3:
|
|
20
|
+
// guards refuse, they do not warn). Two cases reach that throw — a template with no tag at all,
|
|
21
|
+
// and a template whose first tag is a component (`<About01Hero />`): Astro passes an attribute on
|
|
22
|
+
// a component as a prop, and a component that does not spread its props drops it silently, which
|
|
23
|
+
// is precisely the wrong-result-with-a-clean-trail this refuses to produce. Exempt a file with
|
|
24
|
+
// the `stampSkip` option, never with a warning.
|
|
25
|
+
//
|
|
26
|
+
// Error text is repo-relative on purpose: a thrown load error is rendered into Vite's browser
|
|
27
|
+
// overlay, and an absolute /Users/... path there is the leak contract non-negotiable 2 forbids.
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
29
|
+
import { relative, resolve, sep } from "node:path";
|
|
30
|
+
|
|
31
|
+
const posix = (p) => p.split(sep).join("/");
|
|
32
|
+
|
|
33
|
+
// The two comment forms that legally precede a template's root: an HTML comment, and the
|
|
34
|
+
// JSX-style brace comment Astro also accepts.
|
|
35
|
+
const COMMENTS = [
|
|
36
|
+
{ open: "<!--", close: "-->" },
|
|
37
|
+
{ open: "{/*", close: "*/}" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Where the template begins: after the frontmatter fence, or 0 when there is none.
|
|
42
|
+
* The opening fence must be the first thing in the file (after an optional BOM); a `---` further
|
|
43
|
+
* down is a horizontal rule in the template and must not be mistaken for one.
|
|
44
|
+
*/
|
|
45
|
+
export function templateStart(code) {
|
|
46
|
+
const bom = code.charCodeAt(0) === 0xfeff ? 1 : 0;
|
|
47
|
+
if (!/^-{3}[ \t]*\r?\n/.test(code.slice(bom))) return bom;
|
|
48
|
+
const close = /\r?\n-{3}[ \t]*(\r?\n|$)/.exec(code.slice(bom));
|
|
49
|
+
if (!close) throw new Error("the frontmatter fence is opened and never closed");
|
|
50
|
+
return bom + close.index + close[0].length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The offset just past the name of the first top-level HTML element, or a `reason` when there is
|
|
55
|
+
* none. Skips whitespace and both comment forms; refuses everything else.
|
|
56
|
+
* @returns {{ at: number, tag: string } | { reason: string }}
|
|
57
|
+
*/
|
|
58
|
+
export function findFirstElement(code, from) {
|
|
59
|
+
let i = from;
|
|
60
|
+
for (;;) {
|
|
61
|
+
while (i < code.length && /\s/.test(code[i])) i++;
|
|
62
|
+
const comment = COMMENTS.find((c) => code.startsWith(c.open, i));
|
|
63
|
+
if (!comment) break;
|
|
64
|
+
const end = code.indexOf(comment.close, i + comment.open.length);
|
|
65
|
+
if (end === -1) return { reason: `an unterminated ${comment.open} comment precedes the template's first element` };
|
|
66
|
+
i = end + comment.close.length;
|
|
67
|
+
}
|
|
68
|
+
if (i >= code.length) return { reason: "the template is empty" };
|
|
69
|
+
if (code[i] !== "<") {
|
|
70
|
+
return { reason: `the template opens with ${JSON.stringify(code.slice(i, i + 24))}, not an element` };
|
|
71
|
+
}
|
|
72
|
+
if (code.startsWith("<>", i)) return { reason: "the template's root is a fragment (<>), which renders no element to stamp" };
|
|
73
|
+
const name = /^<([A-Za-z][A-Za-z0-9._:-]*)/.exec(code.slice(i, i + 80));
|
|
74
|
+
if (!name) return { reason: `the template opens with ${JSON.stringify(code.slice(i, i + 24))}, not an element` };
|
|
75
|
+
const tag = name[1];
|
|
76
|
+
if (/^[A-Z]/.test(tag) || tag.includes(".")) {
|
|
77
|
+
return {
|
|
78
|
+
reason:
|
|
79
|
+
`the template's first top-level tag is the component <${tag}>, not an element. Astro passes an ` +
|
|
80
|
+
`attribute on a component as a prop, so the stamp would be dropped silently. Wrap it in an ` +
|
|
81
|
+
`element, or list this file in the integration's stampSkip option`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { at: i + name[0].length, tag };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const attrSafe = (p) => p.replace(/&/g, "&").replace(/"/g, """);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Stamp one `.astro` source. `relPath` is already repo-relative, posix-separated.
|
|
91
|
+
* Idempotent: a file that already carries the attribute on its first tag is returned unchanged.
|
|
92
|
+
* @throws when there is no element to stamp — with the repo-relative path in the message.
|
|
93
|
+
*/
|
|
94
|
+
export function stampSource(code, relPath) {
|
|
95
|
+
let start;
|
|
96
|
+
try {
|
|
97
|
+
start = templateStart(code);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
throw new Error(`orbytes-pin cannot stamp ${relPath}: ${e.message}`);
|
|
100
|
+
}
|
|
101
|
+
const found = findFirstElement(code, start);
|
|
102
|
+
if ("reason" in found) throw new Error(`orbytes-pin cannot stamp ${relPath}: ${found.reason}.`);
|
|
103
|
+
|
|
104
|
+
// Idempotence is checked inside the opening tag only, so a stamp on a nested element (or the
|
|
105
|
+
// string appearing in a comment further down) never makes this a no-op.
|
|
106
|
+
const tagEnd = code.indexOf(">", found.at);
|
|
107
|
+
if (tagEnd !== -1 && code.slice(found.at, tagEnd).includes("data-orbytes-src")) return code;
|
|
108
|
+
|
|
109
|
+
return `${code.slice(0, found.at)} data-orbytes-src="${attrSafe(relPath)}"${code.slice(found.at)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The dev-only Vite plugin.
|
|
114
|
+
*
|
|
115
|
+
* `dirs` and `skip` are both SITE-relative, and they are resolved the same way on purpose.
|
|
116
|
+
*
|
|
117
|
+
* Until the move into astrolab (2026-09-22) they were not: `dirs` resolved against `siteRoot`
|
|
118
|
+
* while `skip` was compared to a path made relative to `repoRoot`, so a live value had to carry a
|
|
119
|
+
* `site/` prefix that the option it sits beside must not have. The two agreed only in a repo whose
|
|
120
|
+
* app sits in `site/`, and they failed SILENTLY everywhere else — a root-shaped repo's exemption
|
|
121
|
+
* matched nothing, the file it named stopped being exempt, and the stamp's hard throw came back on
|
|
122
|
+
* a file somebody had already decided to let through. Nothing says so; the entry just stops
|
|
123
|
+
* working. A repo shape is exactly the thing that changes when a package moves, which is why it
|
|
124
|
+
* was fixed on the way in rather than found later.
|
|
125
|
+
*
|
|
126
|
+
* Both spellings are accepted — the site-relative one this option now documents, and the
|
|
127
|
+
* repo-relative one a config written before today carries — because an exemption that quietly
|
|
128
|
+
* lapses is the defect, and refusing the old spelling would recreate it once.
|
|
129
|
+
*
|
|
130
|
+
* @param {{ repoRoot: string, siteRoot: string, dirs: string[], skip?: string[] }} options
|
|
131
|
+
* `dirs` are site-relative directories; `skip` are site-relative `.astro` paths exempt from the
|
|
132
|
+
* throw (a repo-relative path is accepted too).
|
|
133
|
+
*/
|
|
134
|
+
export function sourceStampPlugin({ repoRoot, siteRoot, dirs, skip = [] }) {
|
|
135
|
+
const roots = dirs.map((d) => posix(resolve(siteRoot, d)) + "/");
|
|
136
|
+
// Absolute, so the comparison is against the id Vite hands `load` rather than against one of two
|
|
137
|
+
// possible relative spellings of it.
|
|
138
|
+
const exempt = new Set(
|
|
139
|
+
skip.flatMap((entry) => {
|
|
140
|
+
const raw = posix(String(entry)).replace(/^\.\//, "");
|
|
141
|
+
return [posix(resolve(siteRoot, raw)), posix(resolve(repoRoot, raw))];
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
return {
|
|
145
|
+
name: "orbytes-pin/source-stamp",
|
|
146
|
+
// `pre` puts this ahead of every transform, and `serve` is the third assertion that nothing
|
|
147
|
+
// in this package can run in `astro build` (contract non-negotiable 1).
|
|
148
|
+
enforce: "pre",
|
|
149
|
+
apply: "serve",
|
|
150
|
+
load(id) {
|
|
151
|
+
const file = posix(id.split("?")[0]);
|
|
152
|
+
if (!file.endsWith(".astro")) return null;
|
|
153
|
+
if (!roots.some((r) => file.startsWith(r))) return null;
|
|
154
|
+
if (exempt.has(file)) return null;
|
|
155
|
+
const relPath = posix(relative(repoRoot, file));
|
|
156
|
+
return { code: stampSource(readFileSync(file, "utf8"), relPath), map: null };
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|