kaloko 0.20.0 → 0.20.1

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.20.1",
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",
@@ -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
 
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'
@@ -818,8 +818,9 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
818
818
  const sourceLocale = node.items?.variants ? sourceLocaleOf(node, scenario, locale) : locale
819
819
  const counter = state && node.items.variants && node.items.rotate === 'run' ? await rotationState(statePath('variants'), run.meta.id) : null
820
820
  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
821
+ // sitemaps, robots.txt and the hreflang of source pages are read with the environment's access (stackReader): its
822
+ // gate and the walk's cookies on its own origins only
823
+ const reader = readsOutside(node.items) ? await stackReader({ page, gate, origins: envOrigins(env, scenario, locale) }) : null
823
824
  let resolved
824
825
  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
826
  const { items, total, truncated, all, rotation, partial, variants, resolve_failed: unresolved, resolve_error: unresolvedWhy } = resolved
@@ -1610,7 +1611,7 @@ const commands = {
1610
1611
  const pick = list(args.steps)
1611
1612
  // stacks with locale variants read their source list and each page's hreflang once per walk, for every locale
1612
1613
  const stackCache = new Map()
1613
- const stackGate = await gateForRequests(env, { root }) // the gate for requests outside the browser: link checks, hreflang of stack pages
1614
+ const stackGate = await gateForRequests(env, { root }) // the gate for requests outside the browser: link checks, stack sitemaps and hreflang
1614
1615
  const locales = list(args.locales) || run.meta.scope.locales
1615
1616
  const scope = run.meta.scope
1616
1617
  // browser engines and device profiles (batch E): one pass each, capturing only its own keys; a run without
@@ -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