@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.
Files changed (94) hide show
  1. package/LICENSE +37 -0
  2. package/README.md +410 -0
  3. package/bin/lab-cull.mjs +401 -0
  4. package/bin/pin-gallery.mjs +121 -0
  5. package/defaults.mjs +120 -0
  6. package/dist/core/astro-integration.js +130 -0
  7. package/dist/core/index.js +6 -0
  8. package/dist/core/lib-paths.js +29 -0
  9. package/dist/core/options.js +173 -0
  10. package/dist/core/utils/get-exports.js +52 -0
  11. package/dist/core/utils/invariant.js +12 -0
  12. package/dist/core/utils/kebab-case.js +15 -0
  13. package/dist/core/utils/path-builder.js +22 -0
  14. package/dist/core/utils/path.js +53 -0
  15. package/dist/core/virtual-module/get-story-modules.js +60 -0
  16. package/dist/core/virtual-module/story-modules.js +8 -0
  17. package/dist/core/virtual-module/virtual-module-ids.js +22 -0
  18. package/dist/core/virtual-module/virtual-routes.js +83 -0
  19. package/dist/core/virtual-module/vite-plugin.js +98 -0
  20. package/docs/PIN-CONTRACT.md +263 -0
  21. package/docs/PIN.md +429 -0
  22. package/index.d.ts +279 -0
  23. package/index.mjs +347 -0
  24. package/package.json +93 -0
  25. package/src/Empty.astro +4 -0
  26. package/src/Home.astro +298 -0
  27. package/src/LabHead.astro +1102 -0
  28. package/src/core/LICENSE-astrobook +166 -0
  29. package/src/core/astro-integration.ts +166 -0
  30. package/src/core/client.ts +89 -0
  31. package/src/core/index.ts +7 -0
  32. package/src/core/lib/components/empty.astro +1 -0
  33. package/src/core/lib/components/head.astro +1 -0
  34. package/src/core/lib/components/home.astro +8 -0
  35. package/src/core/lib/components/with-decorators.astro +22 -0
  36. package/src/core/lib/pages/app.astro +19 -0
  37. package/src/core/lib/pages/preview.astro +17 -0
  38. package/src/core/lib/pages/story.astro +16 -0
  39. package/src/core/lib-paths.ts +72 -0
  40. package/src/core/options.ts +262 -0
  41. package/src/core/utils/get-exports.ts +59 -0
  42. package/src/core/utils/invariant.ts +13 -0
  43. package/src/core/utils/kebab-case.ts +30 -0
  44. package/src/core/utils/path-builder.ts +45 -0
  45. package/src/core/utils/path.ts +80 -0
  46. package/src/core/virtual-module/get-story-modules.ts +110 -0
  47. package/src/core/virtual-module/story-modules.ts +9 -0
  48. package/src/core/virtual-module/virtual-module-ids.ts +17 -0
  49. package/src/core/virtual-module/virtual-routes.ts +130 -0
  50. package/src/core/virtual-module/vite-plugin.ts +125 -0
  51. package/src/pin/board.mjs +1521 -0
  52. package/src/pin/index.mjs +666 -0
  53. package/src/pin/shot.mjs +427 -0
  54. package/src/pin/source-stamp.mjs +159 -0
  55. package/src/pin/tickets.mjs +697 -0
  56. package/src/pin/toolbar.js +3181 -0
  57. package/src/shell/Browse.astro +371 -0
  58. package/src/shell/CardGrid.astro +297 -0
  59. package/src/shell/Viewport.astro +1330 -0
  60. package/src/shell/index.json.ts +12 -0
  61. package/src/shell/lab-index.ts +344 -0
  62. package/src/shell/lab-params.ts +245 -0
  63. package/src/shell/live-files.mjs +164 -0
  64. package/src/shell/marks.mjs +136 -0
  65. package/src/types/index.ts +6 -0
  66. package/src/types/types.ts +239 -0
  67. package/src/types/virtual.d.ts +29 -0
  68. package/src/ui/components/app.astro +13 -0
  69. package/src/ui/components/build-path.ts +13 -0
  70. package/src/ui/components/build-tree.ts +108 -0
  71. package/src/ui/components/collapse-duration.ts +28 -0
  72. package/src/ui/components/compress-terms.ts +10 -0
  73. package/src/ui/components/dashboard-layout.astro +39 -0
  74. package/src/ui/components/home.astro +65 -0
  75. package/src/ui/components/layout.astro +110 -0
  76. package/src/ui/components/preview-layout.astro +109 -0
  77. package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
  78. package/src/ui/components/sidebar-button-search.astro +23 -0
  79. package/src/ui/components/sidebar-button-theme.astro +9 -0
  80. package/src/ui/components/sidebar-button.astro +24 -0
  81. package/src/ui/components/sidebar-resize-handle.astro +74 -0
  82. package/src/ui/components/sidebar-search-panel.astro +41 -0
  83. package/src/ui/components/sidebar-search-script.ts +103 -0
  84. package/src/ui/components/sidebar-title.astro +17 -0
  85. package/src/ui/components/sidebar-tree-node.astro +143 -0
  86. package/src/ui/components/sidebar-tree.astro +84 -0
  87. package/src/ui/components/sidebar.astro +29 -0
  88. package/src/ui/components/theme-message.ts +26 -0
  89. package/src/ui/components/theme-script.astro +71 -0
  90. package/src/ui/components/theme-toggle.astro +63 -0
  91. package/src/ui/components/theme.ts +32 -0
  92. package/src/ui/index.ts +4 -0
  93. package/src/ui/lab.css +549 -0
  94. package/virtual.d.ts +42 -0
@@ -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, "&amp;").replace(/"/g, "&quot;");
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
+ }