kaloko 0.20.0 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Your AI agents build; Kaloko walks every screen, puts it on one canvas and records who approved what.",
5
5
  "keywords": [
6
6
  "qa",
@@ -998,7 +998,7 @@
998
998
  },
999
999
  "full_page": {
1000
1000
  "type": "boolean",
1001
- "description": "Screenshot of the whole page (default true; false for stack pages: the first screen only)"
1001
+ "description": "Screenshot of the whole page (default true, stack pages too); false keeps the first screen only"
1002
1002
  },
1003
1003
  "screenshot": {
1004
1004
  "enum": ["full", "thumb"],
@@ -59,7 +59,7 @@ step script only to prepare each page, for example to dismiss a dialog
59
59
  - { id: author, label: Author, from: .author }
60
60
  - { id: cover, label: Cover, from: "article img@src" }
61
61
  - { id: links, label: Links, from: "count:main a" }
62
- # capture: { full_page: true } # default for stacks is the first screen only
62
+ # capture: { screenshot: thumb, full_page: false } # whole pages by default; this is the smallest
63
63
  criteria:
64
64
  - { id: AC1, text: Has a cover image, check: { type: deterministic, assert: exists, selector: "article img" } }
65
65
  - { id: AC2, text: Reads as an article, check: { type: semantic, question: Does the page read as a complete magazine article? } }
@@ -80,6 +80,12 @@ Item sources:
80
80
  `sitemap-de.xml`; `en_US` finds `en-us`, `en` does not), only that
81
81
  locale's sitemaps are read; otherwise all of them. Use it when a site
82
82
  lists one sitemap per language, so the scenario does not hard-code paths.
83
+ - `kaloko walk` reads robots.txt and sitemaps with the environment's gate
84
+ (headers, HTTP Basic, client certificates) and the walk's cookies, on the
85
+ environment's own origins only, so a staging behind Basic auth or mTLS
86
+ needs no public sitemap. A sitemap on another host (a CDN, an index
87
+ pointing elsewhere) or a redirect to another host gets none of them.
88
+ Redirects are followed by hand (at most 5); 60 s and 60 MB per file.
83
89
  - `crawl: { from: /clanky, links: "a.card", next: "a.next", pages: 20 }`
84
90
  collects links from a listing and follows its pagination.
85
91
  - `script: qa/steps/items.mjs` uses the default export
@@ -229,9 +235,12 @@ stack, set:
229
235
  locale).
230
236
  - A script that lists pages per locale instead: give each page the same
231
237
  `key` in every locale (for example the source path), so they are one item.
232
- - Hosted runs (`kaloko schedule add`) do not group variants yet: there each
233
- locale reads its own list and HC1005 is not reported (`kaloko validate`
234
- warns). Use `kaloko walk` or the scheduled GitHub workflow.
238
+ - Hosted runs (`kaloko schedule add`) group variants the same way: same
239
+ ids, window, `missing` and HC1005. They read each source page's head for
240
+ its hreflang on the environment's own addresses (with the schedule's
241
+ password or cookies), and each read counts as one hosted page of the
242
+ monthly budget. Budget a stack of N source pages in L locales as
243
+ N reads + N × L pages at most.
235
244
 
236
245
  ## Cost and size: choose deliberately
237
246
 
@@ -244,20 +253,32 @@ stack, set:
244
253
  caps the calls of a whole evaluation (evaluation.md). Raise the sample only when the question
245
254
  really differs between pages, and tell the person what it costs
246
255
  (one call per page and viewport).
247
- - Stacks capture the first screen of each page (fast, small). Set
248
- `capture: { full_page: true }` when the criteria are about the whole page.
249
- - Walking takes about 1–2 s per page. For a first look, use
256
+ - Stacks capture the whole page, like every step (since 0.21.0; before, the
257
+ first screen only). Vision checks see what is below the fold, and so does a
258
+ reviewer. A typical long page then stores a screenshot of about 600 KB
259
+ (about 70 KB for the first screen) and a thumbnail of about 180 KB (about
260
+ 25 KB), because the thumbnail is as tall as the page.
261
+ - To keep a stack small, set on the step:
262
+ - `capture: { full_page: false }`: a sharp first screen of each page;
263
+ - `capture: { screenshot: thumb, full_page: false }`: the smallest, a
264
+ 640 px thumbnail of the first screen. Recommend it for big monitors
265
+ (SEO crawls, catalogues of thousands of pages) whose checks read the
266
+ page and do not need the picture below the fold.
267
+ - `capture: { screenshot: thumb }` alone keeps a thumbnail of the whole
268
+ page, still several times the first screen.
269
+ - Walking takes about 1–2 s per page, a little more for long pages captured
270
+ whole. For a first look, use
250
271
  `kaloko walk --limit 50`. Re-walk chosen pages with
251
272
  `--items clanek-7,clanek-12` (matches the id or the path; picked from
252
273
  every matching page, also outside this week's rotation, and such a
253
274
  partial walk leaves the rotation state alone).
254
275
  - A shared run holds at most 20000 files and 300 MB as stored (HTML and JSON
255
276
  travel gzip-compressed, about a tenth of their size on disk). Each page
256
- stores about four files, so a stack of a few thousand first-screen pages
257
- fits. For SEO crawls where the checks read the page, not the picture, set
258
- `capture: { screenshot: thumb }`: only the 640 px thumbnail is kept. Split
259
- larger catalogues by section (`match`) or walk a rotating sample
260
- (`rotate: week`).
277
+ stores about four files. Whole pages fill 300 MB at roughly 400 pages;
278
+ `screenshot: thumb, full_page: false` lets the 20000 files be the limit
279
+ (about 5000 pages). Split larger catalogues by section (`match`) or walk a
280
+ rotating sample (`rotate: week`). When a whole-page stack fills most of a
281
+ run near the limit, `kaloko share` names it and suggests these settings.
261
282
 
262
283
  ## Reading the results
263
284
 
package/src/cli.mjs CHANGED
@@ -33,7 +33,7 @@ import { detectProject, initScenario, skeletonScenario, scenarioHeader, formatDe
33
33
  import { checkSlug, checkName, setConfigKeys, createProject, renameProject, syncProject, shareProjectNote } from './lib/projects-cli.mjs'
34
34
  import { recheckCaptures } from './lib/recheck.mjs'
35
35
  import { runFindings, findingsText } from './lib/findings.mjs'
36
- import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage, sourceLocaleOf, variantReader } from './lib/stack.mjs'
36
+ import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage, sourceLocaleOf, stackReader, readsOutside } from './lib/stack.mjs'
37
37
  import { walkSiteChecks, siteChecksOn } from './lib/site.mjs'
38
38
  import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
39
39
  import { expandPacks } from './lib/packs.mjs'
@@ -48,6 +48,7 @@ import { login } from './lib/login.mjs'
48
48
  import { initNextSteps, loginNextSteps } from './lib/onboarding.mjs'
49
49
  import { specCmd, driftCmd } from './lib/spec-cli.mjs'
50
50
  import { hashFiles, missingHashes, uploadRunFiles, PROGRESS_FILE, fmtBytes } from './lib/upload.mjs'
51
+ import { biggestSteps, stackSizeAdvice } from './lib/run-size.mjs'
51
52
  import { affectedFor, carryCaptures } from './lib/affected.mjs'
52
53
  import { formatAffected, selectedIds } from './lib/affected-core.mjs'
53
54
  import { healStep, formatHeal } from './lib/heal.mjs'
@@ -818,8 +819,9 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
818
819
  const sourceLocale = node.items?.variants ? sourceLocaleOf(node, scenario, locale) : locale
819
820
  const counter = state && node.items.variants && node.items.rotate === 'run' ? await rotationState(statePath('variants'), run.meta.id) : null
820
821
  const round = counter ? counter.run : state?.run || 0
821
- // locale variants read the source pages for their hreflang with the environment's access (variantReader)
822
- const reader = node.items?.variants ? await variantReader({ page, gate, origins: envOrigins(env, scenario, locale) }) : null
822
+ // sitemaps, robots.txt and the hreflang of source pages are read with the environment's access (stackReader): its
823
+ // gate and the walk's cookies on its own origins only
824
+ const reader = readsOutside(node.items) ? await stackReader({ page, gate, origins: envOrigins(env, scenario, locale) }) : null
823
825
  let resolved
824
826
  try { resolved = await resolveItems({ node, base: baseUrl(env, locale), sourceBase: baseUrl(env, sourceLocale), page, scenarioDir, env, locale, scenario, limit: Number(args.limit) || undefined, rotation: { run: round }, checked: state?.checked || null, only: list(args.items), cache, readPage: reader?.read || null }) } finally { await reader?.dispose() }
825
827
  const { items, total, truncated, all, rotation, partial, variants, resolve_failed: unresolved, resolve_error: unresolvedWhy } = resolved
@@ -1177,14 +1179,6 @@ async function projectsWrite (sub, args) {
1177
1179
  if (sub !== 'sync' && slug === config.project && name && config.project_name && name !== config.project_name) say(`Note: kaloko.config.yml still says project_name: ${config.project_name}; change it there too, or kaloko projects sync would rename the project back.`)
1178
1180
  }
1179
1181
 
1180
- /** The steps that take most of a run, as stored: " Largest steps: products 212 MB (1 390 files), …" or ''. */
1181
- function biggestSteps (entries, n = 3) {
1182
- const by = new Map()
1183
- for (const e of entries) { const k = e.path.match(/^steps\/([^/]+)\//)?.[1]; if (!k) continue; const x = by.get(k) || { bytes: 0, files: 0 }; x.bytes += e.stored ?? e.bytes; x.files++; by.set(k, x) }
1184
- const top = [...by].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, n)
1185
- return top.length ? ` Largest steps: ${top.map(([k, x]) => `${k} ${fmtBytes(x.bytes)} (${x.files} files)`).join(', ')}.` : ''
1186
- }
1187
-
1188
1182
  const commands = {
1189
1183
  async init (args) {
1190
1184
  const cwd = process.cwd()
@@ -1610,7 +1604,7 @@ const commands = {
1610
1604
  const pick = list(args.steps)
1611
1605
  // stacks with locale variants read their source list and each page's hreflang once per walk, for every locale
1612
1606
  const stackCache = new Map()
1613
- const stackGate = await gateForRequests(env, { root }) // the gate for requests outside the browser: link checks, hreflang of stack pages
1607
+ const stackGate = await gateForRequests(env, { root }) // the gate for requests outside the browser: link checks, stack sitemaps and hreflang
1614
1608
  const locales = list(args.locales) || run.meta.scope.locales
1615
1609
  const scope = run.meta.scope
1616
1610
  // browser engines and device profiles (batch E): one pass each, capturing only its own keys; a run without
@@ -2244,7 +2238,10 @@ const commands = {
2244
2238
  const resumed = (await readJson(join(run.dir, PROGRESS_FILE), null).catch(() => null))?.run === manifest.id
2245
2239
  try {
2246
2240
  if (!u) throw new Error('usage unavailable')
2247
- if (total > u.limits.max_run_bytes) throw new UserError(`This run is ${fmtBytes(total)}; the limit per run is ${fmtBytes(u.limits.max_run_bytes)}.${biggestSteps(entries)} Capture fewer viewports or locales; for a stack, lower items.limit, walk a rotating sample (items.rotate: week) or keep thumbnails only (capture: { screenshot: thumb }). An admin can also ask for a higher limit.`)
2241
+ if (total > u.limits.max_run_bytes) throw new UserError(`This run is ${fmtBytes(total)}; the limit per run is ${fmtBytes(u.limits.max_run_bytes)}.${biggestSteps(entries)}${stackSizeAdvice({ entries, scenario, limit: u.limits.max_run_bytes, over: true })} Capture fewer viewports or locales; for a stack, lower items.limit, walk a rotating sample (items.rotate: week), keep the first screen only (capture: { full_page: false }) or, smallest, keep thumbnails of it only (capture: { screenshot: thumb, full_page: false }). An admin can also ask for a higher limit.`)
2242
+ // a stack captured whole (the default) that fills most of a run close to the limit: say so before the next walk grows it
2243
+ const near = stackSizeAdvice({ entries, scenario, limit: u.limits.max_run_bytes })
2244
+ if (near) say(near)
2248
2245
  if (u.usage.bytes + fresh > u.limits.quota_bytes) throw new UserError(`Storage quota would be exceeded: ${fmtBytes(u.usage.bytes)} used + ${fmtBytes(fresh)} > ${fmtBytes(u.limits.quota_bytes)}. Delete old runs in Settings or ask for a higher quota.`)
2249
2246
  const recBytes = entries.filter((e) => /\.rec\./.test(e.path)).reduce((n, e) => n + e.bytes, 0)
2250
2247
  say(`${resumed ? 'Resuming the interrupted share: ' : ''}${files.size} files, ${fmtBytes(total)}${recBytes ? ` (recordings ${fmtBytes(recBytes)})` : ''}${missing ? `; ${fmtBytes(fresh)} new, the rest already stored` : ''} (organization uses ${fmtBytes(u.usage.bytes)} of ${fmtBytes(u.limits.quota_bytes)})`)
@@ -88,8 +88,9 @@ export function captureOptions (scenario, node) {
88
88
  mask: [...new Set(masks.flatMap((m) => m.selectors))],
89
89
  maskAuto: masks.some((m) => m.auto),
90
90
  annotate: o.annotate === true,
91
- // stack pages default to the first screen: hundreds of full-page shots would swamp storage
92
- fullPage: o.full_page != null ? o.full_page !== false : node?.kind !== 'stack',
91
+ // the whole page by default, stack pages too: a review needs what is below the fold (full_page: false for the
92
+ // first screen only; screenshot: thumb when a crawl of thousands of pages needs no picture)
93
+ fullPage: o.full_page !== false,
93
94
  // screenshot: thumb (#150): only the card thumbnail is stored, as the step's image too (SEO crawls of thousands of
94
95
  // pages need the page facts, not a sharp picture of each)
95
96
  thumbOnly: o.screenshot === 'thumb'
@@ -138,9 +139,45 @@ export async function settle (page) {
138
139
  await page.evaluate(async (overlay) => {
139
140
  const open = [...document.querySelectorAll(overlay)].some((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 })
140
141
  if (open) return
141
- const step = innerHeight * 0.8
142
- for (let y = 0; y < document.documentElement.scrollHeight; y += step) { scrollTo(0, y); await new Promise((r) => setTimeout(r, 60)) }
143
- scrollTo(0, 0)
142
+ // what scrolling changes on the fixed and sticky parts (a header that floats, a search panel that fades in):
143
+ // the capture waits until they are back as they were at the top, or until they stopped changing for 1 s (a
144
+ // widget revealed on scroll for good), at most 3 s
145
+ // fixed, sticky or inside such a part; asked once per element (the observer sees every class change of the page)
146
+ const floats = new WeakMap()
147
+ const floating = (el) => {
148
+ if (!el || el === document.body || el === document.documentElement) return false
149
+ if (!floats.has(el)) { const p = getComputedStyle(el).position; floats.set(el, p === 'fixed' || p === 'sticky' || floating(el.parentElement)) }
150
+ return floats.get(el)
151
+ }
152
+ const before = new Map()
153
+ let changed = performance.now()
154
+ const mo = new MutationObserver((list) => {
155
+ for (const m of list) {
156
+ const el = m.target
157
+ if (!(el === document.documentElement || el === document.body || floating(el))) continue
158
+ changed = performance.now()
159
+ const key = `${m.attributeName}`
160
+ const seen = before.get(el) || before.set(el, {}).get(el)
161
+ if (!(key in seen)) seen[key] = m.oldValue
162
+ }
163
+ })
164
+ // an empty class or style attribute looks the same as none
165
+ const same = (name, a, b) => name === 'class' || name === 'style' ? (a || '') === (b || '') : a === b
166
+ try {
167
+ mo.observe(document.documentElement, { subtree: true, attributes: true, attributeOldValue: true, attributeFilter: ['class', 'style', 'hidden', 'aria-expanded'] })
168
+ const step = innerHeight * 0.8
169
+ for (let y = 0; y < document.documentElement.scrollHeight; y += step) { scrollTo(0, y); await new Promise((r) => setTimeout(r, 60)) }
170
+ scrollTo(0, 0)
171
+ const start = performance.now()
172
+ // the quiet second counts from the return to the top: a header that changed once on the first scroll and goes
173
+ // back a moment after the page is at the top again is not quiet yet
174
+ changed = Math.max(changed, start)
175
+ const back = () => [...before].every(([el, attrs]) => Object.entries(attrs).every(([name, value]) => same(name, el.getAttribute(name), value)))
176
+ // only what moves on the fixed and sticky parts: a carousel or spinner elsewhere does not hold the capture
177
+ const moving = () => (document.getAnimations?.() || []).some((a) => a.playState === 'running' && a.effect?.getComputedTiming?.().iterations !== Infinity && floating(a.effect?.target))
178
+ const quiet = () => performance.now() - changed >= 1000
179
+ while (performance.now() - start < 3000 && (moving() || !(back() || quiet()))) await new Promise((r) => setTimeout(r, 50))
180
+ } finally { mo.disconnect() }
144
181
  }, OVERLAY)
145
182
  } catch {}
146
183
  await page.waitForTimeout(250)
@@ -229,18 +266,21 @@ export async function captureStep ({ page, node, locale, viewports, run, console
229
266
  // (or 2× for documentation), so the canvas, boxes and diffs work the same for every device
230
267
  const dpr = await page.evaluate(() => devicePixelRatio).catch(() => 1)
231
268
  let shotScale = opts.scale
269
+ // a full-page capture resizes the page; resize handlers restart CSS transitions (a panel fading out again), and
270
+ // the image would catch them halfway. Transitions end at once and finite animations finish for the capture
232
271
  if (opts.scale === 2 && engine === 'chromium') {
233
272
  const cdp = await page.context().newCDPSession(page)
273
+ const still = await page.addStyleTag({ content: '*, *::before, *::after { transition: none !important }' }).catch(() => null)
234
274
  try {
235
275
  const doc = await page.evaluate(() => ({ w: Math.ceil(document.documentElement.scrollWidth), h: Math.ceil(document.documentElement.scrollHeight) }))
236
276
  const r = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: opts.fullPage, clip: { x: 0, y: 0, width: opts.fullPage ? doc.w : width, height: opts.fullPage ? doc.h : height, scale: 2 / (dpr || 1) } })
237
277
  shot = Buffer.from(r.data, 'base64')
238
- } finally { await cdp.detach().catch(() => {}) }
278
+ } finally { await cdp.detach().catch(() => {}); await still?.evaluate((el) => el.remove()).catch(() => {}) }
239
279
  } else {
240
280
  // WebKit and Firefox have no 2× capture of their own: 2× when the context renders at 2× or more, else 1×
241
281
  // (exactly 2×: a 3× or 2.625× device would give an image the masks and marks are not drawn for)
242
282
  if (opts.scale === 2 && dpr !== 2) shotScale = 1
243
- shot = await page.screenshot({ fullPage: opts.fullPage, scale: shotScale === 2 ? 'device' : 'css' })
283
+ shot = await page.screenshot({ fullPage: opts.fullPage, scale: shotScale === 2 ? 'device' : 'css', animations: 'disabled' })
244
284
  }
245
285
  // secrets the CLI used (gate, accounts) never land in a capture, even if the page echoes them
246
286
  await writeFile(html, redact(maskHtml(await page.content(), masking?.attrs)))
@@ -0,0 +1,45 @@
1
+ // What takes the room in a run before `kaloko share` sends it: the largest steps, and the advice for a stack whose
2
+ // pages are captured whole (the default since 0.21.0) when it fills most of the per-run limit.
3
+ import { fmtBytes } from './upload.mjs'
4
+ import { captureOptions } from './capture.mjs'
5
+
6
+ /** Bytes and files per step, as stored (entries from hashFiles: { path, bytes, stored? }), largest first. */
7
+ export function stepSizes (entries) {
8
+ const by = new Map()
9
+ for (const e of entries) {
10
+ const k = e.path.match(/^steps\/([^/]+)\//)?.[1]
11
+ if (!k) continue
12
+ const x = by.get(k) || { bytes: 0, files: 0 }
13
+ x.bytes += e.stored ?? e.bytes; x.files++
14
+ by.set(k, x)
15
+ }
16
+ return [...by].sort((a, b) => b[1].bytes - a[1].bytes)
17
+ }
18
+
19
+ /** The steps that take most of a run: " Largest steps: products 212 MB (1 390 files), …" or ''. */
20
+ export function biggestSteps (entries, n = 3) {
21
+ const top = stepSizes(entries).slice(0, n)
22
+ return top.length ? ` Largest steps: ${top.map(([k, x]) => `${k} ${fmtBytes(x.bytes)} (${x.files} files)`).join(', ')}.` : ''
23
+ }
24
+
25
+ /** Stack steps of the scenario whose pages are captured whole and kept sharp (no full_page: false, no thumb). */
26
+ export function wholePageStacks (scenario) {
27
+ return new Set((scenario?.nodes || []).filter((n) => n.kind === 'stack').filter((n) => { const o = captureOptions(scenario, n); return o.fullPage && !o.thumbOnly }).map((n) => n.id))
28
+ }
29
+
30
+ const SMALL = 'capture: { full_page: false } keeps the first screen of each page, capture: { screenshot: thumb, full_page: false } is the smallest'
31
+
32
+ /**
33
+ * Advice when a whole-page stack makes up most of a run that is near or over the limit, or ''.
34
+ * `over` words it for the refusal (appended to it); otherwise it is a warning of its own.
35
+ */
36
+ export function stackSizeAdvice ({ entries, scenario, limit, over = false }) {
37
+ const total = entries.reduce((n, e) => n + (e.stored ?? e.bytes), 0)
38
+ if (!limit || total < limit * 0.8) return ''
39
+ const whole = wholePageStacks(scenario)
40
+ const hit = stepSizes(entries).find(([k, x]) => whole.has(k) && x.bytes > total / 2)
41
+ if (!hit) return ''
42
+ const [id, x] = hit
43
+ if (over) return ` The stack ${id} is ${fmtBytes(x.bytes)} of it because each page is captured whole.`
44
+ return `This run is ${fmtBytes(total)} of the ${fmtBytes(limit)} allowed per run, and the stack ${id} is ${fmtBytes(x.bytes)} of it because each page is captured whole. If its pictures below the fold are not needed, ${SMALL}.`
45
+ }
@@ -120,7 +120,6 @@ export function scenarioWarnings (data, { projectRequired = null } = {}) {
120
120
  const out = []
121
121
  if (!data || !Array.isArray(data.nodes)) return out
122
122
  for (const node of data.nodes) {
123
- if (node.kind === 'stack' && node.items?.variants) out.push(`node "${node.id}": hosted runs (kaloko schedule add) do not group locale variants yet: there each language reads its own list and missing translations (HC1005) are not reported; kaloko walk and the scheduled GitHub workflow do`)
124
123
  if (node.kind === 'stack' && node.items?.variants && (data.locales || []).length < 2) out.push(`node "${node.id}": items.variants: hreflang groups the language versions of a page, but the scenario walks ${(data.locales || []).length ? `one locale (${data.locales[0]})` : 'no locales'}; list the other locales under locales:`)
125
124
  if (node.kind === 'stack' && (node.packs || []).includes('errors')) out.push(`node "${node.id}": the errors pack is meant for error pages (404, 500, maintenance); on a stack of normal pages HC601 and HC605 stay not applicable. Remove it, or give the step expect_status when its pages are error pages`)
126
125
  }
package/src/lib/stack.mjs CHANGED
@@ -8,7 +8,7 @@ import { pathToFileURL } from 'node:url'
8
8
  import { gunzipSync } from 'node:zlib'
9
9
  import { UserError, localized, readJson, writeJson } from './util.mjs'
10
10
  import { STACK_DEFAULT_FIELDS, STACK_SEMANTIC_SAMPLE, stackFieldsOf } from './manifest-core.mjs'
11
- import { variantItems, htmlAlternates, headerAlternates, sitemapAlternates, stripInert } from './variants-core.mjs'
11
+ import { variantItems, sitemapAlternates, pageAlternates, readHeadFrom } from './variants-core.mjs'
12
12
  import { DEFAULT_LIMIT, SITEMAP_MAX_BYTES, SITEMAP_MAX_CHILDREN, ROTATE_MODES, itemId, sitemapLocs, robotsSitemaps, sitemapsForLocale, handoffOf, isoWeek, rotateWindow, patternFor as corePatternFor, withLocale, sitemapPages, selectItems } from './stack-core.mjs'
13
13
 
14
14
  /** How long items.resolve may take for one page (ms) before the page is walked without its edit link. */
@@ -94,7 +94,8 @@ export function rotationCoverage (all, checked) {
94
94
  * source page; `checked` and `only` use it). `variants` in the result: { mode, source_locale, locale, pages,
95
95
  * missing: [{ id, path, url, reason }], shared: [{ url, ids, paths }] } (`source: true` in the source locale). `cache`
96
96
  * (a Map the walk keeps for all its locales) reads the source list and each page's alternates once; `readPage` (a
97
- * pageReader with the environment's gate and the walk's cookies) reads the pages, else a plain one over `fetchImpl`.
97
+ * stackReader's read: the environment's gate and the walk's cookies) reads the sitemaps, robots.txt and the pages,
98
+ * else a plain pageReader over `fetchImpl`.
98
99
  */
99
100
  export async function resolveItems ({ node, base, page, scenarioDir, env, locale, limit, fetchImpl = fetch, rotation = {}, checked = null, scenario = null, only = null, resolveTimeout = RESOLVE_TIMEOUT, sourceBase = null, cache = null, readPage = null }) {
100
101
  const memo = cache || new Map()
@@ -111,7 +112,7 @@ export async function resolveItems ({ node, base, page, scenarioDir, env, locale
111
112
  const listLocale = mapped ? sourceLocale : locale
112
113
  const listBase = mapped ? (sourceBase || base) : base
113
114
  const listSrc = mapped ? withLocale(node.items || {}, listLocale) : src
114
- const read = () => sourceEntries({ src: listSrc, locale: listLocale, base: listBase, page, scenarioDir, env, info: infoFor(listLocale), fetchImpl, withAlternates: variants })
115
+ const read = () => sourceEntries({ src: listSrc, locale: listLocale, base: listBase, page, scenarioDir, env, info: infoFor(listLocale), fetchImpl, readSource: readPage, withAlternates: variants })
115
116
  const { raw, alternates } = variants ? await cached(memo, `list|${scenario?.id || ''}|${node.id}|${listLocale}|${listBase}`, read) : await read()
116
117
  // 1. and 2.: match, exclude, deduplication, the window (stack-core.mjs, shared with hosted runs); with variants in
117
118
  // the source locale, so every locale walks the same window with the same ids
@@ -146,7 +147,7 @@ const urlKey = (u) => { try { return new URL(u).href } catch { return String(u)
146
147
  * { raw: [{ path, title?, key?, purpose?, edit_url?, content_ref? }], alternates: Map(url → the sitemap's xhtml:link
147
148
  * alternates of that page, read when `withAlternates`) }.
148
149
  */
149
- async function sourceEntries ({ src, locale, base, page, scenarioDir, env, info, fetchImpl, withAlternates = false }) {
150
+ async function sourceEntries ({ src, locale, base, page, scenarioDir, env, info, fetchImpl, readSource = null, withAlternates = false }) {
150
151
  const origin = new URL(base).origin
151
152
  const abs = (p) => new URL(p, base.endsWith('/') ? base : base + '/').href.replace(/^(https?:\/\/[^/]+)\/\//, '$1/')
152
153
  const toUrl = (p) => (/^https?:\/\//.test(p) ? p : new URL(p, origin).href)
@@ -154,11 +155,15 @@ async function sourceEntries ({ src, locale, base, page, scenarioDir, env, info,
154
155
  if (src.urls) raw = src.urls.map((p) => ({ path: p }))
155
156
  else if (src.file) raw = await listFile(resolve(scenarioDir, src.file))
156
157
  else if (src.sitemap || src.robots) {
158
+ // through the environment's gate: its headers, Basic, client certificates and the walk's cookies on its own
159
+ // origins only; a sitemap on another host, or a redirect there, gets none of them
160
+ const reader = readSource || pageReader({ fetchImpl })
157
161
  const get = async (u, what = 'items.sitemap') => {
158
- const r = await fetchImpl(u, { headers: { 'User-Agent': 'Kaloko', 'Accept-Encoding': 'gzip' } })
159
- if (!r.ok) throw new UserError(`${what} ${u}: HTTP ${r.status}`)
160
- if (what !== 'items.sitemap') return r.text()
161
- const text = sitemapText(Buffer.from(await r.arrayBuffer()), u)
162
+ let r
163
+ try { r = await reader(u, { bytes: true }) } catch (e) { throw new UserError(`${what} ${u}: ${String(e?.message || e).split('\n')[0]}`) }
164
+ if (r.status < 200 || r.status >= 300) throw new UserError(`${what} ${u}: HTTP ${r.status}${r.url && r.url !== u ? ` (after a redirect to ${r.url})` : ''}`)
165
+ if (what !== 'items.sitemap') return r.body.toString('utf8')
166
+ const text = sitemapText(r.body, u)
162
167
  // with locale variants the xhtml:link alternates of each <url> are kept for pages that name none themselves
163
168
  if (withAlternates) for (const [k, v] of sitemapAlternates(text)) alternates.set(urlKey(k), v)
164
169
  return text
@@ -206,57 +211,76 @@ export const ALTERNATES_MAX = 1024 * 1024
206
211
 
207
212
  /**
208
213
  * A pageReader over a Playwright request client (APIRequestContext), for gates with client certificates, which fetch
209
- * cannot present: the client brings the certificates and the cookies; the gate's headers and HTTP Basic go only to
210
- * `origins`, and redirects are followed by hand (at most 5).
214
+ * cannot present: the client brings the certificates (Playwright presents each only to its origin); the gate's headers,
215
+ * HTTP Basic and the walk's cookies (`cookiesFor`) go only to `origins`, and redirects are followed by hand (at most 5).
216
+ * `want` as in pageReader (a sitemap: `{ bytes: true }`).
211
217
  */
212
- export function contextReader (request, { gate = null, origins = [], timeout = ALTERNATES_TIMEOUT, max = ALTERNATES_MAX } = {}) {
213
- return async (url) => {
218
+ export function contextReader (request, { gate = null, origins = [], cookiesFor = null, timeout = ALTERNATES_TIMEOUT, max = ALTERNATES_MAX } = {}) {
219
+ return async (url, want = {}) => {
220
+ const limit = want.max || (want.bytes ? SITEMAP_MAX_BYTES : max)
221
+ // one time limit for a sitemap with all its redirects, as with fetch; a page head has it per request
222
+ const deadline = want.bytes ? Date.now() + (want.timeout || SITEMAP_TIMEOUT) : null
214
223
  let at = url
215
224
  for (let hop = 0; hop <= 5; hop++) {
216
- const gated = gateHeadersFor(gate, at, origins)
217
- const r = await request.get(at, { headers: { 'User-Agent': 'Kaloko', Accept: 'text/html,application/xhtml+xml', ...gated }, timeout, maxRedirects: 0 })
225
+ const left = deadline ? deadline - Date.now() : (want.timeout || timeout)
226
+ if (left <= 0) throw new Error(`no answer in ${Math.round((want.timeout || SITEMAP_TIMEOUT) / 1000)} s (timeout)`)
227
+ const headers = { ...readHeaders(want), ...gateHeadersFor(gate, at, origins) }
228
+ if (cookiesFor && origins.includes(new URL(at).origin)) { const c = await cookiesFor(at); if (c) headers.Cookie = c }
229
+ const r = await request.get(at, { headers, timeout: Math.max(1, left), maxRedirects: 0 })
218
230
  const h = r.headers()
219
- if (r.status() >= 300 && r.status() < 400 && h.location) { await r.dispose().catch(() => {}); at = new URL(h.location, at).href; continue }
220
- const html = r.ok() ? (await r.body()).subarray(0, max).toString('utf8') : ''
221
- await r.dispose().catch(() => {})
222
- return { status: r.status(), url: at, link: h.link || null, html }
231
+ if (r.status() >= 300 && r.status() < 400 && h.location) { await r.dispose().catch(() => {}); at = nextHop(h.location, at); continue }
232
+ try {
233
+ if (!want.bytes) return { status: r.status(), url: at, link: h.link || null, html: r.ok() ? (await r.body()).subarray(0, limit).toString('utf8') : '' }
234
+ // the client reads the whole body before it answers: the limit is checked on what came
235
+ const body = r.ok() ? await r.body() : Buffer.alloc(0)
236
+ if (body.length > limit) throw new Error(`larger than ${Math.round(limit / 1048576)} MB`)
237
+ return { status: r.status(), url: at, link: h.link || null, body }
238
+ } finally { await r.dispose().catch(() => {}) }
223
239
  }
224
240
  throw new Error('more than 5 redirects')
225
241
  }
226
242
  }
227
243
 
228
- /** Options of the request client variantReader makes for client certificates: the gate's and the walk's cookies. */
229
- export const variantRequestOptions = (gate, cookies = []) => ({ clientCertificates: gate.clientCertificates, ...(cookies.length ? { storageState: { cookies, origins: [] } } : {}) })
244
+ /**
245
+ * Options of the request client stackReader makes for client certificates: the certificates only. The walk's cookies
246
+ * go as a header on the environment's own origins (contextReader), not into the client's jar, which would send a
247
+ * domain cookie to a sibling host after a redirect.
248
+ */
249
+ export const variantRequestOptions = (gate) => ({ clientCertificates: gate.clientCertificates })
250
+
251
+ /** The next address of a redirect: http(s) only. */
252
+ const nextHop = (location, from) => {
253
+ const u = new URL(location, from)
254
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error(`a redirect to ${u.protocol} is not followed`)
255
+ return u.href
256
+ }
230
257
 
231
258
  /**
232
- * How a stack with locale variants reads its source pages for their hreflang: { read, dispose }. With the gate's
233
- * headers and HTTP Basic, and the walk's cookies, only on `origins`. A gate with client certificates gets a request
234
- * client of its own (`factory`: Playwright's `request`), made with the certificates and a copy of the page's cookies:
235
- * the browser context of a shared browser (connected over CDP) has neither, they live where the browser was started.
259
+ * How a stack reads its sitemaps, robots.txt and (with locale variants) its source pages for their hreflang:
260
+ * { read, dispose }. With the gate's headers and HTTP Basic, and the walk's cookies, only on `origins`. A gate with
261
+ * client certificates gets a request client of its own (`factory`: Playwright's `request`), made with the certificates:
262
+ * the browser context of a shared browser (connected over CDP) has none, they live where the browser was started.
236
263
  * `dispose` closes it after the reads.
237
264
  */
238
- export async function variantReader ({ page = null, gate = null, origins = [], factory = null, fetchImpl = fetch }) {
265
+ export async function stackReader ({ page = null, gate = null, origins = [], factory = null, fetchImpl = fetch }) {
266
+ const cookiesFor = page ? async (url) => (await page.context().cookies(url).catch(() => [])).map((c) => `${c.name}=${c.value}`).join('; ') : null
239
267
  if (gate?.clientCertificates?.length) {
240
- const cookies = page ? await page.context().cookies().catch(() => []) : []
241
268
  const pw = factory || (await import('playwright')).request
242
- const request = await pw.newContext(variantRequestOptions(gate, cookies))
243
- return { kind: 'certificates', read: contextReader(request, { gate, origins }), dispose: () => request.dispose().catch(() => {}) }
269
+ const request = await pw.newContext(variantRequestOptions(gate))
270
+ return { kind: 'certificates', read: contextReader(request, { gate, origins, cookiesFor }), dispose: () => request.dispose().catch(() => {}) }
244
271
  }
245
- const cookiesFor = page ? async (url) => (await page.context().cookies(url).catch(() => [])).map((c) => `${c.name}=${c.value}`).join('; ') : null
246
272
  return { kind: 'fetch', read: pageReader({ fetchImpl, gate, origins, cookiesFor }), dispose: async () => {} }
247
273
  }
274
+ /** The name of before, when only locale variants read through the gate. */
275
+ export const variantReader = stackReader
276
+
277
+ /** Whether a stack reads anything outside the browser: a sitemap, robots.txt or the hreflang of its pages. */
278
+ export const readsOutside = (items = {}) => !!(items.sitemap || items.robots || items.variants)
248
279
 
249
280
  /** The head of a page as text: read until </head> (outside comments and scripts) or `max` bytes, then stopped. */
250
281
  export async function readHead (r, max = ALTERNATES_MAX) {
251
282
  if (!r.body?.getReader) return String(await r.text()).slice(0, max)
252
- const reader = r.body.getReader(); const dec = new TextDecoder(); let text = ''; let bytes = 0
253
- for (;;) {
254
- const { done, value } = await reader.read()
255
- if (done) break
256
- bytes += value.length; text += dec.decode(value, { stream: true })
257
- if (bytes >= max || /<\/head\s*>|<body[\s>]/i.test(stripInert(text))) { try { await reader.cancel() } catch {} break }
258
- }
259
- return text.slice(0, max)
283
+ return readHeadFrom(r.body.getReader(), max)
260
284
  }
261
285
 
262
286
  /**
@@ -272,21 +296,49 @@ export function gateHeadersFor (gate, url, origins = []) {
272
296
  return h
273
297
  }
274
298
 
299
+ /** How long reading one sitemap or robots.txt may take (ms); its size is capped by SITEMAP_MAX_BYTES. */
300
+ export const SITEMAP_TIMEOUT = 60000
301
+
302
+ const readHeaders = (want) => want.bytes
303
+ ? { 'User-Agent': 'Kaloko', Accept: 'application/xml,text/xml,text/plain;q=0.9,*/*;q=0.8', 'Accept-Encoding': 'gzip' }
304
+ : { 'User-Agent': 'Kaloko', Accept: 'text/html,application/xhtml+xml' }
305
+
306
+ /** The whole body as bytes, at most `max` (else an error, the rest is not read). */
307
+ export async function readBytes (r, max = SITEMAP_MAX_BYTES) {
308
+ const big = () => new Error(`larger than ${Math.round(max / 1048576)} MB`)
309
+ if (!r.body?.getReader) { const b = Buffer.from(await r.arrayBuffer()); if (b.length > max) throw big(); return b }
310
+ const reader = r.body.getReader(); const parts = []; let bytes = 0
311
+ for (;;) {
312
+ const { done, value } = await reader.read()
313
+ if (done) break
314
+ bytes += value.length
315
+ if (bytes > max) { try { await reader.cancel() } catch {} throw big() }
316
+ parts.push(value)
317
+ }
318
+ return Buffer.concat(parts)
319
+ }
320
+
275
321
  /**
276
322
  * Reads a page for its hreflang: { status, url (after redirects), link (the Link header), html (the head) }. The
277
323
  * gate (headers, Basic) and the walk's cookies (`cookiesFor(url)` → Cookie header) go only to `origins`; redirects
278
- * are followed by hand (at most 5) so they never carry them elsewhere; each request has `timeout` ms.
324
+ * are followed by hand (at most 5) so they never carry them elsewhere; each request has `timeout` ms. `want.bytes`
325
+ * reads a sitemap or robots.txt instead: the whole body as `body` (bytes, at most SITEMAP_MAX_BYTES, all hops within
326
+ * SITEMAP_TIMEOUT ms; `want.max` and `want.timeout` change them).
279
327
  */
280
328
  export function pageReader ({ fetchImpl = fetch, gate = null, origins = [], cookiesFor = null, timeout = ALTERNATES_TIMEOUT, max = ALTERNATES_MAX } = {}) {
281
- return async (url) => {
329
+ return async (url, want = {}) => {
282
330
  let at = url
331
+ const whole = want.bytes ? AbortSignal.timeout(want.timeout || SITEMAP_TIMEOUT) : null
283
332
  for (let hop = 0; hop <= 5; hop++) {
284
- const headers = { 'User-Agent': 'Kaloko', Accept: 'text/html,application/xhtml+xml', ...gateHeadersFor(gate, at, origins) }
333
+ const headers = { ...readHeaders(want), ...gateHeadersFor(gate, at, origins) }
285
334
  if (cookiesFor && origins.includes(new URL(at).origin)) { const c = await cookiesFor(at); if (c) headers.Cookie = c }
286
- const r = await fetchImpl(at, { redirect: 'manual', headers, signal: AbortSignal.timeout(timeout) })
335
+ const r = await fetchImpl(at, { redirect: 'manual', headers, signal: whole || AbortSignal.timeout(timeout) })
287
336
  const loc = r.headers?.get?.('location')
288
- if (r.status >= 300 && r.status < 400 && loc) { try { await r.body?.cancel() } catch {} at = new URL(loc, at).href; continue }
289
- return { status: r.status, url: at, link: r.headers?.get?.('link') || null, html: r.ok ? await readHead(r, max) : '' }
337
+ if (r.status >= 300 && r.status < 400 && loc) { try { await r.body?.cancel() } catch {} at = nextHop(loc, at); continue }
338
+ const link = r.headers?.get?.('link') || null
339
+ if (!want.bytes) return { status: r.status, url: at, link, html: r.ok ? await readHead(r, max) : '' }
340
+ if (!r.ok) { try { await r.body?.cancel() } catch {} return { status: r.status, url: at, link, body: Buffer.alloc(0) } }
341
+ return { status: r.status, url: at, link, body: await readBytes(r, want.max || SITEMAP_MAX_BYTES) }
290
342
  }
291
343
  throw new Error('more than 5 redirects')
292
344
  }
@@ -298,22 +350,7 @@ export function pageReader ({ fetchImpl = fetch, gate = null, origins = [], cook
298
350
  * a timeout or a 5xx is tried once more; a result with an error is not kept, so another locale asks again.
299
351
  */
300
352
  export async function readAlternates (read, url, fallback = null, cache = new Map()) {
301
- const got = await cached(cache, `alt|${url}`, async () => {
302
- let alternates = []; let error = null; let at = url
303
- for (let attempt = 0; attempt < 2; attempt++) {
304
- try {
305
- const r = await read(url)
306
- at = r.url || url
307
- if (r.status < 200 || r.status >= 300) { error = `HTTP ${r.status}`; if (r.status >= 500) continue; break }
308
- error = null
309
- alternates = htmlAlternates(r.html, at)
310
- if (!alternates.length) alternates = headerAlternates(r.link, at)
311
- break
312
- } catch (e) { error = String(e?.message || e).split('\n')[0] }
313
- }
314
- if (!alternates.length && fallback?.length) return { alternates: fallback, error: null, url: at, from: 'sitemap' }
315
- return { alternates, error, url: at }
316
- })
353
+ const got = await cached(cache, `alt|${url}`, () => pageAlternates(read, url, fallback))
317
354
  if (got.error) cache.delete(`alt|${url}`)
318
355
  return got
319
356
  }
@@ -24,6 +24,53 @@ const unique = (list) => { const seen = new Set(); return list.filter((a) => a &
24
24
  */
25
25
  export const stripInert = (html) => String(html || '').replace(/<!--[\s\S]*?(?:-->|$)/g, '').replace(/<script\b[\s\S]*?(?:<\/script\s*>|$)/gi, '').replace(/<style\b[\s\S]*?(?:<\/style\s*>|$)/gi, '')
26
26
 
27
+ /**
28
+ * Where the head of a page ends, found as the text arrives: `feed(chunk)` is true once `</head>` or `<body>` shows up
29
+ * outside comments, scripts and styles (the same rule as stripInert, then the search in htmlAlternates). Each call
30
+ * scans only the new text and a few characters before it, so a long head read in small chunks stays linear.
31
+ */
32
+ export function headEnd () {
33
+ let text = ''; let pos = 0; let mode = null
34
+ const OPEN = /<!--|<script\b|<style\b|<\/head\s*>|<body[\s>]/gi
35
+ const CLOSE = { comment: /-->/g, script: /<\/script\s*>/gi, style: /<\/style\s*>/gi }
36
+ return (chunk) => {
37
+ text += chunk
38
+ for (;;) {
39
+ const re = mode ? CLOSE[mode] : OPEN
40
+ re.lastIndex = pos
41
+ const m = re.exec(text)
42
+ // nothing yet: keep the last characters, a token may be split between two chunks
43
+ if (!m) { pos = Math.max(pos, text.length - 16); return false }
44
+ // `<script` at the very end may still turn into `<scripts`: wait for the next character
45
+ if (!mode && m.index + m[0].length >= text.length && /^<(script|style)/i.test(m[0])) { pos = m.index; return false }
46
+ pos = m.index + m[0].length
47
+ if (mode) { mode = null; continue }
48
+ const t = m[0].toLowerCase()
49
+ if (t === '<!--') mode = 'comment'
50
+ else if (t.startsWith('<script')) mode = 'script'
51
+ else if (t.startsWith('<style')) mode = 'style'
52
+ else return true
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The head of a page from a stream reader (`body.getReader()`): read until the head ends (headEnd) or `max` bytes,
59
+ * then the stream is cancelled. Shared by the CLI (stack.mjs readHead) and hosted runs (hosted-stacks.js).
60
+ */
61
+ export async function readHeadFrom (reader, max) {
62
+ const dec = new TextDecoder(); const ended = headEnd(); let text = ''; let bytes = 0
63
+ for (;;) {
64
+ const { done, value } = await reader.read()
65
+ if (done) break
66
+ bytes += value.byteLength
67
+ const chunk = dec.decode(value, { stream: true })
68
+ text += chunk
69
+ if (bytes >= max || ended(chunk)) { try { await reader.cancel() } catch {} break }
70
+ }
71
+ return text.slice(0, max)
72
+ }
73
+
27
74
  /** hreflang alternates in the head of an HTML page: [{ lang (lowercase, en-us), href (absolute) }]. */
28
75
  export function htmlAlternates (html, url) {
29
76
  const text = stripInert(html)
@@ -49,18 +96,30 @@ export function headerAlternates (link, url) {
49
96
  return unique(out)
50
97
  }
51
98
 
52
- /** The `<url>` entries of a sitemap with their `xhtml:link` alternates: Map(loc → [{ lang, href }]). */
53
- export function sitemapAlternates (xml) {
99
+ /**
100
+ * The `<url>` entries of a sitemap that name alternates, unparsed: Map(loc → the entry's markup). The service keeps
101
+ * these for one sitemap at a time and parses only the pages it takes (urlAlternates).
102
+ */
103
+ export function sitemapUrlEntries (xml) {
54
104
  const out = new Map()
55
105
  for (const m of String(xml || '').matchAll(/<url(?:\s[^>]*)?>([\s\S]*?)<\/url>/gi)) {
106
+ if (!/<(?:xhtml:)?link\b/i.test(m[1])) continue
56
107
  const loc = m[1].match(LOC)?.[1]
57
- if (!loc) continue
58
- const alts = unique([...m[1].matchAll(/<(?:xhtml:)?link\b[^>]*>/gi)].map((l) => { const a = attrsOf(l[0]); return /(^|\s)alternate(\s|$)/i.test(a.rel || '') ? alternate(a.hreflang, a.href) : null }))
59
- if (alts.length) out.set(decode(loc), alts)
108
+ if (loc) out.set(decode(loc), m[1])
60
109
  }
61
110
  return out
62
111
  }
63
112
 
113
+ /** The `xhtml:link` alternates of one `<url>` entry of a sitemap: [{ lang, href }]. */
114
+ export const urlAlternates = (entry) => unique([...String(entry || '').matchAll(/<(?:xhtml:)?link\b[^>]*>/gi)].map((l) => { const a = attrsOf(l[0]); return /(^|\s)alternate(\s|$)/i.test(a.rel || '') ? alternate(a.hreflang, a.href) : null }))
115
+
116
+ /** The `<url>` entries of a sitemap with their `xhtml:link` alternates: Map(loc → [{ lang, href }]). */
117
+ export function sitemapAlternates (xml) {
118
+ const out = new Map()
119
+ for (const [loc, entry] of sitemapUrlEntries(xml)) { const alts = urlAlternates(entry); if (alts.length) out.set(loc, alts) }
120
+ return out
121
+ }
122
+
64
123
  /**
65
124
  * The alternate that stands for `locale`: the same tag (en-us for en_US), else the bare language (en) for a locale
66
125
  * with a region, else the first regional tag of the language (sk-sk) for a locale without one. A locale with a script
@@ -84,6 +143,30 @@ export function alternateFor (alts, locale) {
84
143
  return parts.find(([, q]) => q?.lang === p.lang && !q.script)?.[0] || parts.find(([, q]) => q?.lang === p.lang)?.[0] || null
85
144
  }
86
145
 
146
+ /**
147
+ * The hreflang alternates of one page as `read` (url → { status, url (after redirects), link, html }) gives it: from
148
+ * its HTML head, else its Link header, else `fallback` (the sitemap's xhtml:link entries for it):
149
+ * { alternates: [{ lang, href }], error, url, from? }. A network error, a timeout or a 5xx is tried once more, except
150
+ * an error `noRetry(e)` names (a refused address); an error `rethrow(e)` names (the service's spent round) is not
151
+ * caught. Shared by the CLI (stack.mjs readAlternates) and hosted runs (worker/src/hosted-stacks.js).
152
+ */
153
+ export async function pageAlternates (read, url, fallback = null, { rethrow = () => false, noRetry = () => false } = {}) {
154
+ let alternates = []; let error = null; let at = url
155
+ for (let attempt = 0; attempt < 2; attempt++) {
156
+ try {
157
+ const r = await read(url)
158
+ at = r.url || url
159
+ if (r.status < 200 || r.status >= 300) { error = `HTTP ${r.status}`; if (r.status >= 500) continue; break }
160
+ error = null
161
+ alternates = htmlAlternates(r.html, at)
162
+ if (!alternates.length) alternates = headerAlternates(r.link, at)
163
+ break
164
+ } catch (e) { if (rethrow(e)) throw e; error = String(e?.message || e).split('\n')[0]; if (noRetry(e)) break }
165
+ }
166
+ if (!alternates.length && fallback?.length) return { alternates: fallback, error: null, url: at, from: 'sitemap' }
167
+ return { alternates, error, url: at }
168
+ }
169
+
87
170
  const hostOf = (u) => { try { return new URL(u).host } catch { return null } }
88
171
  const sameLanguage = (a, b) => { const x = localeParts(a); const y = localeParts(b); return !!x && !!y && x.lang === y.lang }
89
172
 
package/viewer/index.html CHANGED
@@ -2136,7 +2136,7 @@ function hlLocal () {
2136
2136
  return { checks, counts, findings, legacy: !!RUN.health_legacy };
2137
2137
  }
2138
2138
  /** The step's health checks that apply here, and how many of them are findings in this capture. */
2139
- function hlStepList (n) { return n.item || n.kind === 'decision' ? [] : (n.health || []).filter((c) => !c.not_applicable); }
2139
+ function hlStepList (n) { return n.kind === 'decision' ? [] : (n.health || []).filter((c) => !c.not_applicable); }
2140
2140
  const hlCellSev = (c, v) => hlSev((c.by_locale?.[S.loc]?.[v.name] || (c.counts ? c : {})).verdict, c);
2141
2141
  const hlStepBad = (n, v) => hlStepList(n).filter((c) => hlCellSev(c, v)).length;
2142
2142
  // the service's answer adds what changed since the previous run, the adoptions and whether this person may adopt
@@ -2203,11 +2203,11 @@ function hlRow (n, v, cap, c) {
2203
2203
  const st = cellSt(r);
2204
2204
  const sev = hlSev(r.verdict, c);
2205
2205
  const hasBox = (cap?.boxes?.[c.id] || []).some((b) => b.w > 0 && b.h > 0);
2206
- const ad = hlAdoption(n.id, c.id);
2206
+ const ad = hlAdoption(n.stack || n.id, c.id);
2207
2207
  // a cross-page check of a stack (#151) compares pages with each other: no single page can adopt it
2208
2208
  const adopt = c.cross_page ? '' : ad ? `<p class="note">${esc(T(ad.applied_at ? 'hlApplied' : 'hlAdopted')(ad.criterion?.id || hlAcId(c.id)))}</p>`
2209
2209
  : hlCanAdopt() ? `<div data-hl-adopt-box="${esc(c.id)}"><button type="button" class="btn" data-hl-adopt="${esc(c.id)}">${T('hlAdopt')}</button></div>`
2210
- : !APP() ? `<details class="fixhelp"><summary>${T('hlAdopt')}</summary><p>${esc(T('hlAdoptLocal')(hlAcId(c.id)))}</p><code class="ev hl-cmd">kaloko health adopt ${esc(n.id)}:${esc(c.id)} --text "${esc(c.text)}"</code></details>` : '';
2210
+ : !APP() ? `<details class="fixhelp"><summary>${T('hlAdopt')}</summary><p>${esc(T('hlAdoptLocal')(hlAcId(c.id)))}</p><code class="ev hl-cmd">kaloko health adopt ${esc(n.stack || n.id)}:${esc(c.id)} --text "${esc(c.text)}"</code></details>` : '';
2211
2211
  return `<li class="st-${st}" data-hl="${esc(c.id)}" data-where="${esc(hasBox ? 'page' : c.target?.kind || '')}" tabindex="0"><span class="mark">${MARK[st]}</span><span><span class="id">${esc(c.id)}</span>${esc(critText(c))}${sev ? `<span class="hl-sev hl-sev--${sev}">${T('hls_' + sev)}</span>` : ''}${c.pack ? `<span class="acb st-skip" style="margin-left:6px">${esc(c.pack)}</span>` : ''}</span><div class="acbody">${r.evidence ? (humanEvidence(r.evidence) ? `<span class="ev ev--text">${esc(humanEvidence(r.evidence))}</span>` : `<code class="ev">${esc(r.evidence)}</code>`) : r.reason ? `<span class="ev ev--text">${esc(r.reason)}</span>` : ''}${c.urls?.length && sev ? `<p class="note hl-urls">${c.urls.map(esc).join(', ')}</p>` : ''}${isFocusCheck(c) ? focusStrip(n, v, cap) : ''}${helpBlock(c, st)}${adopt}</div></li>`;
2212
2212
  }
2213
2213
  /** The step's Health tab: its health checks, findings first, each with its evidence, help and Adopt. */
@@ -2257,13 +2257,15 @@ document.addEventListener('submit', async (e) => {
2257
2257
  const text = f.querySelector('textarea').value.replace(/\s+/g, ' ').trim();
2258
2258
  if (!text) { f.querySelector('textarea').focus(); return; }
2259
2259
  const btn = f.querySelector('[type=submit]'); btn.disabled = true;
2260
+ // a page of a stack adopts for its stack step
2261
+ const step = n.stack || n.id;
2260
2262
  try {
2261
- const res = await fetch(`${APP().api}/orgs/${APP().org}/runs/${APP().run}/health/adopt`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ step: n.id, check: c.id, ...(text !== c.text ? { text } : {}) }) });
2263
+ const res = await fetch(`${APP().api}/orgs/${APP().org}/runs/${APP().run}/health/adopt`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ step, check: c.id, ...(text !== c.text ? { text } : {}) }) });
2262
2264
  if (res.status === 401) throw sessionLost();
2263
2265
  const data = await res.json().catch(() => ({}));
2264
2266
  if (!res.ok) { toast(data.error || `HTTP ${res.status}`); btn.disabled = false; return; }
2265
2267
  const ad = data.adoption || data;
2266
- S.hl ||= { adoptions: [] }; S.hl.adoptions = [...(S.hl.adoptions || []).filter((a) => !(a.step === n.id && hlId(a.check) === c.id)), { id: ad.id, step: n.id, check: c.id, text: ad.text || text, criterion: ad.criterion || null, applied_at: ad.applied_at || null }];
2268
+ S.hl ||= { adoptions: [] }; S.hl.adoptions = [...(S.hl.adoptions || []).filter((a) => !(a.step === step && hlId(a.check) === c.id)), { id: ad.id, step, check: c.id, text: ad.text || text, criterion: ad.criterion || null, applied_at: ad.applied_at || null }];
2267
2269
  toast(T('hlAdopted')(ad.criterion?.id || hlAcId(c.id)));
2268
2270
  openLb(S.open, S.lbVp, { keepFrame: true });
2269
2271
  } catch (err) { toast(err.message); btn.disabled = false; }
@@ -2512,9 +2514,20 @@ function ensureItemNode (n, data, it) {
2512
2514
  const crit = (data.criteria || n.criteria).map((c) => {
2513
2515
  const by_locale = {};
2514
2516
  for (const [key, res] of Object.entries(it.results || {})) { const [l, vp] = key.split('|'); const r = res[c.id]; if (r) ((by_locale[l] ||= {})[vp] = { verdict: r[0], evidence: r[1], score: r[2] }); }
2515
- return { id: c.id, text: c.text, type: c.type, pack: c.pack || null, by_locale, evidence: null };
2517
+ return { id: c.id, text: c.text, type: c.type, pack: c.pack || null, ...(c.level ? { level: c.level } : {}), ...(c.acceptance ? { acceptance: true } : {}), ...(c.health ? { health: true } : {}), by_locale, evidence: null };
2516
2518
  }).filter((c) => Object.keys(c.by_locale).length);
2517
- const node = { id, item: true, itemId: it.id, stack: n.id, kind: 'screen', title: it.title || it.path, path: it.path, edit_url: it.edit_url || null, content_ref: it.content_ref || null, purpose: n.purpose, captures: it.captures || {}, criteria: crit, by_locale: it.by_locale || {}, status: it.status, attachments: it.attachments || [], semantic: {}, metadata: {}, execution: n.execution, session: n.session };
2519
+ // the page's health checks get the Health tab, as on a step: kaloko marks them (health: true);
2520
+ // runs from before the mark tell them by the pack outside acceptance or by the HC id
2521
+ const marked = (data.criteria || n.criteria).some((c) => 'health' in c);
2522
+ const isHealth = (c) => marked ? !!c.health : hlLegacyCrit(c) || /^HC[0-9]/.test(c.id);
2523
+ const captures = it.captures || {};
2524
+ const health = crit.filter(isHealth).map((c) => {
2525
+ const hid = hlId(c.id); if (hid === c.id) return c;
2526
+ // a run from before the split names the check AC…: its highlight boxes answer to the HC id too
2527
+ for (const vps of Object.values(captures)) for (const cap of Object.values(vps || {})) if (cap?.boxes?.[c.id] && !cap.boxes[hid]) cap.boxes[hid] = cap.boxes[c.id];
2528
+ return { ...c, sid: c.id, id: hid };
2529
+ });
2530
+ const node = { id, item: true, itemId: it.id, stack: n.id, kind: 'screen', title: it.title || it.path, path: it.path, edit_url: it.edit_url || null, content_ref: it.content_ref || null, purpose: n.purpose, captures, criteria: crit.filter((c) => !isHealth(c)), health, by_locale: it.by_locale || {}, status: it.status, attachments: it.attachments || [], semantic: {}, metadata: {}, execution: n.execution, session: n.session };
2518
2531
  ITEM_NODES.set(id, node);
2519
2532
  return node;
2520
2533
  }