@getvitops/utils 2.0.0 → 2.1.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.
@@ -0,0 +1,547 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { createSign, randomBytes } from "node:crypto";
4
+
5
+ //#region src/indexing/plan.ts
6
+ const INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
7
+ /**
8
+ * IndexNow's own cap per submission. Batches larger than this are split rather
9
+ * than truncated — a silently dropped tail is the failure mode this command is
10
+ * meant to eliminate, not reproduce.
11
+ */
12
+ const INDEXNOW_BATCH = 1e4;
13
+ const trimSlash = (s) => s.replace(/\/+$/, "");
14
+ function hostOf(url) {
15
+ try {
16
+ return new URL(url).host;
17
+ } catch {
18
+ return;
19
+ }
20
+ }
21
+ function chunk(items, size) {
22
+ if (items.length === 0) return [];
23
+ const out = [];
24
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
25
+ return out;
26
+ }
27
+ /**
28
+ * Resolve the sitemap URL.
29
+ *
30
+ * `sitemap-index.xml` is @astrojs/sitemap's default filename and is what
31
+ * `vitops({ sitemap })` registers, so it is the right default here. It resolves to
32
+ * a flat `<urlset>` on a small site, which `parseSitemap` handles by reading the
33
+ * root element rather than the name.
34
+ */
35
+ function resolveSitemapUrl(config) {
36
+ if (config.sitemapUrl) return config.sitemapUrl;
37
+ if (!config.canonical) return void 0;
38
+ return `${trimSlash(config.canonical)}/sitemap-index.xml`;
39
+ }
40
+ /** Resolve the IndexNow key file URL. */
41
+ function resolveKeyLocation(config) {
42
+ const indexNow = config.indexNow;
43
+ if (!indexNow) return void 0;
44
+ if (indexNow.keyLocation) return indexNow.keyLocation;
45
+ if (!config.canonical) return void 0;
46
+ return `${trimSlash(config.canonical)}/${indexNow.key}.txt`;
47
+ }
48
+ /** Which URLs differ from the snapshot. */
49
+ function diff(current, previous) {
50
+ const changed = [];
51
+ for (const e of current) {
52
+ const before = previous.entries[e.loc];
53
+ if (before === void 0 || before !== (e.lastmod ?? "")) changed.push(e.loc);
54
+ }
55
+ return changed;
56
+ }
57
+ /**
58
+ * Decide what this run should do.
59
+ *
60
+ * Ordering of the URL-selection rules matters and is not arbitrary: an explicit
61
+ * `--urls` is an operator overriding the tool, `--all` is an operator overriding
62
+ * the diff, a cold snapshot is the tool having no basis to narrow, and the diff is
63
+ * the normal path. Each is reported in `reason` so `--dry` explains itself.
64
+ */
65
+ function plan(input) {
66
+ const { config, current, previous, explicitUrls, all } = input;
67
+ const notes = [];
68
+ const sitemapUrl = resolveSitemapUrl(config);
69
+ const check = config.priorityUrls ?? [];
70
+ if (config.robots && /\bnoindex\b/i.test(config.robots)) return {
71
+ blocked: `environment robots policy is "${config.robots}" — refusing to ask search engines to index an environment marked noindex`,
72
+ sitemapUrl,
73
+ urls: [],
74
+ reason: "diff",
75
+ cold: false,
76
+ indexNow: {
77
+ enabled: false,
78
+ skip: "blocked",
79
+ endpoint: INDEXNOW_ENDPOINT,
80
+ batches: []
81
+ },
82
+ searchConsole: {
83
+ enabled: false,
84
+ skip: "blocked"
85
+ },
86
+ check: [],
87
+ notes
88
+ };
89
+ const cold = !explicitUrls && !all && !previous;
90
+ let urls;
91
+ let reason;
92
+ if (explicitUrls?.length) {
93
+ urls = explicitUrls;
94
+ reason = "explicit";
95
+ } else if (all) {
96
+ urls = current.map((e) => e.loc);
97
+ reason = "all";
98
+ } else if (!previous) {
99
+ urls = current.map((e) => e.loc);
100
+ reason = "cold";
101
+ notes.push(`no previous snapshot — treating all ${urls.length} URLs as changed. Persist \`.vitops/\` between runs (a CI cache) so later runs submit only what moved.`);
102
+ } else {
103
+ urls = diff(current, previous);
104
+ reason = "diff";
105
+ }
106
+ if (reason === "diff" || reason === "cold") {
107
+ const withLastmod = current.filter((e) => e.lastmod).length;
108
+ if (current.length > 0 && withLastmod === 0) notes.push("no <lastmod> in the sitemap — only added/removed pages can be detected, never an edit. Wire `gitLastmod()` into your sitemap options, or pass --all.");
109
+ else if (withLastmod < current.length) notes.push(`${current.length - withLastmod} of ${current.length} sitemap entries have no <lastmod> — edits to those pages cannot be detected.`);
110
+ }
111
+ const keyLocation = resolveKeyLocation(config);
112
+ const keyHost = keyLocation ? hostOf(keyLocation) : void 0;
113
+ let indexNow;
114
+ if (!config.indexNow) indexNow = {
115
+ enabled: false,
116
+ skip: "not configured (seo.indexing.indexNow)",
117
+ endpoint: INDEXNOW_ENDPOINT,
118
+ batches: []
119
+ };
120
+ else if (!keyLocation) indexNow = {
121
+ enabled: false,
122
+ skip: "no keyLocation and no domains.canonical to derive one",
123
+ endpoint: config.indexNow.endpoint ?? "https://api.indexnow.org/indexnow",
124
+ key: config.indexNow.key,
125
+ batches: []
126
+ };
127
+ else {
128
+ const onHost = urls.filter((u) => hostOf(u) === keyHost);
129
+ const offHost = urls.length - onHost.length;
130
+ if (offHost > 0) notes.push(`IndexNow: dropped ${offHost} URL(s) not on ${keyHost} — a submission must match its key file's host.`);
131
+ indexNow = {
132
+ enabled: onHost.length > 0,
133
+ ...onHost.length === 0 ? { skip: "no URLs to submit" } : {},
134
+ endpoint: config.indexNow.endpoint ?? "https://api.indexnow.org/indexnow",
135
+ keyLocation,
136
+ key: config.indexNow.key,
137
+ ...keyHost ? { host: keyHost } : {},
138
+ batches: chunk(onHost, INDEXNOW_BATCH)
139
+ };
140
+ }
141
+ let searchConsole;
142
+ if (!config.searchConsole) searchConsole = {
143
+ enabled: false,
144
+ skip: "not configured (seo.indexing.searchConsole)"
145
+ };
146
+ else if (config.searchConsole.resubmitSitemap === false) searchConsole = {
147
+ enabled: false,
148
+ skip: "resubmitSitemap is false",
149
+ siteUrl: config.searchConsole.siteUrl
150
+ };
151
+ else if (!sitemapUrl) searchConsole = {
152
+ enabled: false,
153
+ skip: "no sitemapUrl and no domains.canonical to derive one",
154
+ siteUrl: config.searchConsole.siteUrl
155
+ };
156
+ else searchConsole = {
157
+ enabled: true,
158
+ siteUrl: config.searchConsole.siteUrl
159
+ };
160
+ return {
161
+ sitemapUrl,
162
+ urls,
163
+ reason,
164
+ cold,
165
+ indexNow,
166
+ searchConsole,
167
+ check,
168
+ notes
169
+ };
170
+ }
171
+ /** Render a plan as the human-readable block `--dry` prints. */
172
+ function formatPlan(p) {
173
+ const lines = [];
174
+ if (p.blocked) {
175
+ lines.push(`✗ blocked — ${p.blocked}`);
176
+ return lines.join("\n");
177
+ }
178
+ lines.push(`sitemap ${p.sitemapUrl ?? "(none)"}`);
179
+ lines.push(`urls ${p.urls.length} (${p.reason})`);
180
+ lines.push(p.indexNow.enabled ? `IndexNow ${p.indexNow.batches.reduce((n, b) => n + b.length, 0)} URL(s) → ${p.indexNow.endpoint}` : `IndexNow skipped — ${p.indexNow.skip}`);
181
+ lines.push(p.searchConsole.enabled ? `Search Console resubmit sitemap → ${p.searchConsole.siteUrl}` : `Search Console skipped — ${p.searchConsole.skip}`);
182
+ for (const n of p.notes) lines.push(` ! ${n}`);
183
+ return lines.join("\n");
184
+ }
185
+
186
+ //#endregion
187
+ //#region src/indexing/sitemap.ts
188
+ /**
189
+ * The five predefined XML entities, plus numeric character references.
190
+ *
191
+ * Not cosmetic: a sitemap URL carrying a query string is written `?a=1&amp;b=2`,
192
+ * and submitting the un-decoded form means submitting a URL the site does not
193
+ * serve — which IndexNow accepts and then silently fails to crawl.
194
+ */
195
+ function decodeXml(s) {
196
+ return s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(Number.parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number.parseInt(d, 10))).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
197
+ }
198
+ const tag = (block, name) => {
199
+ const m = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, "i").exec(block);
200
+ return m ? decodeXml(m[1].trim()) : void 0;
201
+ };
202
+ function blocks(xml, name) {
203
+ const out = [];
204
+ const re = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, "gi");
205
+ let m;
206
+ while (m = re.exec(xml)) out.push(m[1]);
207
+ return out;
208
+ }
209
+ /**
210
+ * Parse a sitemap or sitemap index.
211
+ *
212
+ * Which one it is comes from the root element, not from the filename — an
213
+ * `sitemap-index.xml` that is actually a flat `<urlset>` is common enough
214
+ * (@astrojs/sitemap emits an index only past `entryLimit`) that keying on the name
215
+ * would misread the single-file case as "no URLs".
216
+ */
217
+ function parseSitemap(xml) {
218
+ if (/<sitemapindex[\s>]/i.test(xml)) return {
219
+ entries: [],
220
+ sitemaps: blocks(xml, "sitemap").map((b) => tag(b, "loc")).filter((l) => !!l)
221
+ };
222
+ const entries = [];
223
+ for (const b of blocks(xml, "url")) {
224
+ const loc = tag(b, "loc");
225
+ if (!loc) continue;
226
+ const lastmod = tag(b, "lastmod");
227
+ entries.push(lastmod ? {
228
+ loc,
229
+ lastmod
230
+ } : { loc });
231
+ }
232
+ return {
233
+ entries,
234
+ sitemaps: []
235
+ };
236
+ }
237
+ /**
238
+ * Resolve a sitemap to its full entry list, following an index one level down.
239
+ *
240
+ * One level is the whole spec — the sitemaps protocol forbids an index pointing at
241
+ * another index — so recursing further would only chase a malformed document.
242
+ * Child fetches run sequentially: a large site's index can list dozens of files,
243
+ * and hammering the consumer's own origin to save a second is a poor trade.
244
+ */
245
+ async function collectEntries(source, read) {
246
+ const root = parseSitemap(await read(source));
247
+ if (root.sitemaps.length === 0) return {
248
+ entries: root.entries,
249
+ sources: [source]
250
+ };
251
+ const entries = [];
252
+ const sources = [];
253
+ for (const child of root.sitemaps) {
254
+ const parsed = parseSitemap(await read(child));
255
+ entries.push(...parsed.entries);
256
+ sources.push(child);
257
+ }
258
+ return {
259
+ entries,
260
+ sources
261
+ };
262
+ }
263
+
264
+ //#endregion
265
+ //#region src/indexing/snapshot.ts
266
+ /**
267
+ * The previous run's sitemap state — what makes "only submit what changed"
268
+ * possible at all.
269
+ *
270
+ * A plain JSON file rather than anything cleverer, because the thing reading it is
271
+ * usually a CI job restoring a cache directory. It must survive being absent
272
+ * (first run, cold cache) without pretending nothing changed — `readSnapshot`
273
+ * returns `undefined` there and `plan()` treats that as "submit everything, and
274
+ * say so".
275
+ */
276
+ /** Default location, relative to the consumer's project root. */
277
+ const SNAPSHOT_PATH = ".vitops/sitemap-snapshot.json";
278
+ /**
279
+ * Read a snapshot, or `undefined` when there isn't a usable one.
280
+ *
281
+ * A corrupt or wrong-version file reads as absent, not as empty. The distinction
282
+ * matters: "absent" submits everything, whereas an empty entry map would look like
283
+ * a site that legitimately has no URLs and submit nothing — the silent-success
284
+ * failure this whole command exists to remove.
285
+ */
286
+ function readSnapshot(path) {
287
+ if (!existsSync(path)) return void 0;
288
+ try {
289
+ const raw = JSON.parse(readFileSync(path, "utf8"));
290
+ if (raw?.version !== 1 || typeof raw.entries !== "object" || raw.entries === null) return void 0;
291
+ return raw;
292
+ } catch {
293
+ return;
294
+ }
295
+ }
296
+ /** Build a snapshot from the entries just read. */
297
+ function toSnapshot(sitemapUrl, entries, takenAt) {
298
+ const map = {};
299
+ for (const e of entries) map[e.loc] = e.lastmod ?? "";
300
+ return {
301
+ version: 1,
302
+ sitemapUrl,
303
+ takenAt,
304
+ entries: map
305
+ };
306
+ }
307
+ /**
308
+ * Write the snapshot, creating its directory.
309
+ *
310
+ * Call this only after a submission actually succeeded. Writing it eagerly would
311
+ * record URLs as notified that never were, and because the next run diffs against
312
+ * it, those URLs would never be submitted again — a single transient 503 would
313
+ * silently drop a page from every future run.
314
+ */
315
+ function writeSnapshot(path, snapshot) {
316
+ mkdirSync(dirname(path), { recursive: true });
317
+ writeFileSync(path, `${JSON.stringify(snapshot, null, 2)}\n`);
318
+ }
319
+
320
+ //#endregion
321
+ //#region src/indexing/indexnow.ts
322
+ /**
323
+ * IndexNow submission.
324
+ *
325
+ * Honoured by Bing, Yandex, Naver, Seznam and Yep — and, through Bing's index, by
326
+ * the AI crawlers built on it. **Not** by Google, which has never adopted the
327
+ * protocol despite testing it; nothing here should be described to a consumer as
328
+ * affecting Google results.
329
+ *
330
+ * Participating engines share submissions with each other, so one endpoint is
331
+ * normally the whole integration.
332
+ */
333
+ /** Generate a key. 32 hex characters, the conventional shape. */
334
+ function newKey() {
335
+ return randomBytes(16).toString("hex");
336
+ }
337
+ /** The body of the key file served at `keyLocation`: the key, and nothing else. */
338
+ const keyFileContents = (key) => `${key}\n`;
339
+ /**
340
+ * How the endpoint's status codes actually read.
341
+ *
342
+ * `403` and `422` are the two that matter and they are easy to misdiagnose: both
343
+ * mean the key file is wrong, not that the URLs are.
344
+ */
345
+ function explain(status) {
346
+ switch (status) {
347
+ case 200:
348
+ case 202: return;
349
+ case 400: return "bad request — malformed URL list";
350
+ case 403: return "key file not found or its contents do not match the key";
351
+ case 422: return "a submitted URL does not belong to the key file host, or the key does not match";
352
+ case 429: return "rate limited — too many submissions";
353
+ default: return `unexpected status ${status}`;
354
+ }
355
+ }
356
+ /**
357
+ * Verify the key file is live and correct before submitting.
358
+ *
359
+ * Worth a request of its own because of how IndexNow fails: a submission with an
360
+ * unreachable key file returns `403`, but one with a *reachable* key file whose
361
+ * contents are stale is accepted with `202` and then silently discarded. Checking
362
+ * first is the only way to tell "submitted" from "submitted and ignored".
363
+ */
364
+ async function verifyKeyFile(keyLocation, key, fetchImpl = fetch) {
365
+ let res;
366
+ try {
367
+ res = await fetchImpl(keyLocation, { method: "GET" });
368
+ } catch (e) {
369
+ return {
370
+ ok: false,
371
+ reason: `could not fetch ${keyLocation}: ${e.message}`
372
+ };
373
+ }
374
+ if (!res.ok) return {
375
+ ok: false,
376
+ reason: `${keyLocation} returned ${res.status}`
377
+ };
378
+ const body = (await res.text()).trim();
379
+ if (body !== key) return {
380
+ ok: false,
381
+ reason: `${keyLocation} does not contain the configured key (found ${body.length} chars)`
382
+ };
383
+ return { ok: true };
384
+ }
385
+ /**
386
+ * Submit one batch. Callers iterate `plan.batches` — the split is the planner's
387
+ * job, so that what gets sent is decided in the pure layer and visible in `--dry`.
388
+ */
389
+ async function submitBatch(plan, urls, fetchImpl = fetch) {
390
+ const body = {
391
+ host: plan.host,
392
+ key: plan.key,
393
+ keyLocation: plan.keyLocation,
394
+ urlList: urls
395
+ };
396
+ const res = await fetchImpl(plan.endpoint, {
397
+ method: "POST",
398
+ headers: { "content-type": "application/json; charset=utf-8" },
399
+ body: JSON.stringify(body)
400
+ });
401
+ const message = explain(res.status);
402
+ return {
403
+ ok: res.ok,
404
+ status: res.status,
405
+ urls: urls.length,
406
+ ...message ? { message } : {}
407
+ };
408
+ }
409
+
410
+ //#endregion
411
+ //#region src/indexing/gsc.ts
412
+ /**
413
+ * Google Search Console — the two things its API will actually do.
414
+ *
415
+ * Worth stating plainly, because the gap between what people expect and what
416
+ * exists is the whole reason this module is small: **there is no "request
417
+ * indexing" endpoint.** The button in the Search Console UI is not exposed in the
418
+ * API or anywhere else, and the sitemap ping endpoint was removed in 2023. What
419
+ * remains is `sitemaps.submit` (write) and `urlInspection` (read).
420
+ *
421
+ * The Indexing API is deliberately not wired here. It is scoped to `JobPosting`
422
+ * and `BroadcastEvent`; it accepts other URLs and discards them, and using it for
423
+ * general pages violates its terms with the consumer's own GCP project on the
424
+ * line. A toolchain should not ship that as a documented path.
425
+ *
426
+ * Auth is minted by hand with `node:crypto` rather than through `googleapis`,
427
+ * which would be an enormous dependency in a CLI that installs into every consumer
428
+ * project, for two endpoints.
429
+ */
430
+ const TOKEN_URL = "https://oauth2.googleapis.com/token";
431
+ const SCOPE = "https://www.googleapis.com/auth/webmasters";
432
+ const b64url = (input) => Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
433
+ /**
434
+ * Parse a service-account credential from its two supported sources.
435
+ *
436
+ * `VITOPS_GSC_SERVICE_ACCOUNT` holds the JSON inline (what a CI secret store
437
+ * gives you); `GOOGLE_APPLICATION_CREDENTIALS` holds a path (Google's own
438
+ * convention, and what a local `gcloud` setup already has). Supporting both means
439
+ * neither environment has to be reshaped to fit the tool.
440
+ */
441
+ function parseServiceAccount(raw) {
442
+ let parsed;
443
+ try {
444
+ parsed = JSON.parse(raw);
445
+ } catch {
446
+ throw new Error("service account is not valid JSON");
447
+ }
448
+ if (!parsed.client_email || !parsed.private_key) throw new Error("service account JSON is missing client_email or private_key");
449
+ return {
450
+ client_email: parsed.client_email,
451
+ private_key: parsed.private_key
452
+ };
453
+ }
454
+ /**
455
+ * Exchange a service-account key for an access token (RS256 JWT bearer flow).
456
+ *
457
+ * `now` is a parameter so this is testable without mocking the clock.
458
+ */
459
+ async function getAccessToken(account, fetchImpl = fetch, now = Date.now()) {
460
+ const iat = Math.floor(now / 1e3);
461
+ const header = b64url(JSON.stringify({
462
+ alg: "RS256",
463
+ typ: "JWT"
464
+ }));
465
+ const claims = b64url(JSON.stringify({
466
+ iss: account.client_email,
467
+ scope: SCOPE,
468
+ aud: TOKEN_URL,
469
+ iat,
470
+ exp: iat + 3600
471
+ }));
472
+ const signer = createSign("RSA-SHA256");
473
+ signer.update(`${header}.${claims}`);
474
+ const pem = account.private_key.replace(/\\n/g, "\n");
475
+ const assertion = `${header}.${claims}.${b64url(signer.sign(pem))}`;
476
+ const res = await fetchImpl(TOKEN_URL, {
477
+ method: "POST",
478
+ headers: { "content-type": "application/x-www-form-urlencoded" },
479
+ body: new URLSearchParams({
480
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
481
+ assertion
482
+ }).toString()
483
+ });
484
+ if (!res.ok) throw new Error(`token exchange failed (${res.status}): ${await res.text()}`);
485
+ const { access_token } = await res.json();
486
+ if (!access_token) throw new Error("token exchange returned no access_token");
487
+ return access_token;
488
+ }
489
+ const encodeSite = (siteUrl) => encodeURIComponent(siteUrl);
490
+ /**
491
+ * Re-submit a sitemap — the automated form of the manual resubmit in the UI.
492
+ *
493
+ * A `403` here almost always means `siteUrl` doesn't match the property exactly
494
+ * (`sc-domain:acme.ca` vs `https://acme.ca/`) or the service account isn't an
495
+ * owner of it, rather than that the sitemap is bad.
496
+ */
497
+ async function submitSitemap(token, siteUrl, sitemapUrl, fetchImpl = fetch) {
498
+ const res = await fetchImpl(`https://www.googleapis.com/webmasters/v3/sites/${encodeSite(siteUrl)}/sitemaps/${encodeURIComponent(sitemapUrl)}`, {
499
+ method: "PUT",
500
+ headers: { authorization: `Bearer ${token}` }
501
+ });
502
+ if (res.ok) return {
503
+ ok: true,
504
+ status: res.status
505
+ };
506
+ const detail = res.status === 403 ? `403 — check that siteUrl "${siteUrl}" matches the Search Console property exactly and that the service account is an owner of it` : `${res.status}: ${await res.text()}`;
507
+ return {
508
+ ok: false,
509
+ status: res.status,
510
+ message: detail
511
+ };
512
+ }
513
+ /**
514
+ * Inspect one URL. **Read-only** — this reports what Google did, it cannot ask for
515
+ * anything. Quota is 2000 queries/day per property, which is why the caller works
516
+ * from an explicit `priorityUrls` list rather than the whole sitemap.
517
+ */
518
+ async function inspectUrl(token, siteUrl, inspectionUrl, fetchImpl = fetch) {
519
+ const res = await fetchImpl("https://searchconsole.googleapis.com/v1/urlInspection/index:inspect", {
520
+ method: "POST",
521
+ headers: {
522
+ authorization: `Bearer ${token}`,
523
+ "content-type": "application/json"
524
+ },
525
+ body: JSON.stringify({
526
+ inspectionUrl,
527
+ siteUrl
528
+ })
529
+ });
530
+ if (!res.ok) return {
531
+ url: inspectionUrl,
532
+ indexed: false,
533
+ error: `${res.status}: ${await res.text()}`
534
+ };
535
+ const r = (await res.json()).inspectionResult?.indexStatusResult ?? {};
536
+ return {
537
+ url: inspectionUrl,
538
+ ...r.verdict ? { verdict: r.verdict } : {},
539
+ ...r.coverageState ? { coverageState: r.coverageState } : {},
540
+ ...r.lastCrawlTime ? { lastCrawlTime: r.lastCrawlTime } : {},
541
+ indexed: r.verdict === "PASS"
542
+ };
543
+ }
544
+
545
+ //#endregion
546
+ export { INDEXNOW_BATCH, INDEXNOW_ENDPOINT, SNAPSHOT_PATH, collectEntries, formatPlan, getAccessToken, inspectUrl, keyFileContents, newKey, parseServiceAccount, parseSitemap, plan, readSnapshot, resolveKeyLocation, resolveSitemapUrl, submitBatch, submitSitemap, toSnapshot, verifyKeyFile, writeSnapshot };
547
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/indexing/plan.ts","../../src/indexing/sitemap.ts","../../src/indexing/snapshot.ts","../../src/indexing/indexnow.ts","../../src/indexing/gsc.ts"],"sourcesContent":["/**\n * Every decision `vitops indexing` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock. Everything the\n * command could get wrong — submitting a staging URL, submitting nothing because a\n * cache was cold, submitting a URL IndexNow will reject for the wrong host — is\n * decided here and asserted in `plan.test.ts`. The I/O modules beside this one do\n * what they are told and nothing else.\n *\n * That split is the same one `@getvitops/core`'s consent store makes against its\n * DOM wiring, for the same reason: the consequential logic has to be testable\n * without the environment it runs in.\n */\nimport type { IndexingConfig, SitemapEntry, Snapshot } from './types.ts';\n\nexport const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow';\n\n/**\n * IndexNow's own cap per submission. Batches larger than this are split rather\n * than truncated — a silently dropped tail is the failure mode this command is\n * meant to eliminate, not reproduce.\n */\nexport const INDEXNOW_BATCH = 10_000;\n\n/** Why the URL set is what it is. */\nexport type UrlReason = 'explicit' | 'all' | 'cold' | 'diff';\n\n/** A channel's decision: on, or off with a stated reason. */\nexport interface ChannelPlan {\n enabled: boolean;\n /** Why it's off. Present iff `enabled` is false. */\n skip?: string;\n}\n\nexport interface IndexNowPlan extends ChannelPlan {\n endpoint: string;\n keyLocation?: string | undefined;\n key?: string | undefined;\n host?: string | undefined;\n /** URL batches, each within `INDEXNOW_BATCH`. */\n batches: string[][];\n}\n\nexport interface SearchConsolePlan extends ChannelPlan {\n siteUrl?: string | undefined;\n}\n\nexport interface IndexingPlan {\n /** Set when the whole run is refused. Nothing is submitted. */\n blocked?: string;\n /** Always computed; `undefined` when nothing in the config resolves to one. */\n sitemapUrl: string | undefined;\n /** URLs that changed and should be submitted. */\n urls: string[];\n reason: UrlReason;\n /** No usable previous snapshot — `urls` is everything. */\n cold: boolean;\n indexNow: IndexNowPlan;\n searchConsole: SearchConsolePlan;\n /** URLs `--check` will inspect. */\n check: string[];\n /** Things the operator needs to read. Printed by `--dry` and by a real run. */\n notes: string[];\n}\n\nexport interface PlanInput {\n config: IndexingConfig;\n /** The sitemap as it stands now. */\n current: SitemapEntry[];\n /** The previous run's state, if any. */\n previous?: Snapshot | undefined;\n /** `--urls`: submit exactly these, skipping the diff. */\n explicitUrls?: string[] | undefined;\n /** `--all`: submit every URL in the sitemap. */\n all?: boolean | undefined;\n}\n\nconst trimSlash = (s: string) => s.replace(/\\/+$/, '');\n\nfunction hostOf(url: string): string | undefined {\n try {\n return new URL(url).host;\n } catch {\n return undefined;\n }\n}\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (items.length === 0) return [];\n const out: T[][] = [];\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));\n return out;\n}\n\n/**\n * Resolve the sitemap URL.\n *\n * `sitemap-index.xml` is @astrojs/sitemap's default filename and is what\n * `vitops({ sitemap })` registers, so it is the right default here. It resolves to\n * a flat `<urlset>` on a small site, which `parseSitemap` handles by reading the\n * root element rather than the name.\n */\nexport function resolveSitemapUrl(config: IndexingConfig): string | undefined {\n if (config.sitemapUrl) return config.sitemapUrl;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/sitemap-index.xml`;\n}\n\n/** Resolve the IndexNow key file URL. */\nexport function resolveKeyLocation(config: IndexingConfig): string | undefined {\n const indexNow = config.indexNow;\n if (!indexNow) return undefined;\n if (indexNow.keyLocation) return indexNow.keyLocation;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/${indexNow.key}.txt`;\n}\n\n/** Which URLs differ from the snapshot. */\nfunction diff(current: SitemapEntry[], previous: Snapshot): string[] {\n const changed: string[] = [];\n for (const e of current) {\n const before = previous.entries[e.loc];\n // Absent from the snapshot → new. Present with a different lastmod → updated.\n // A removed URL is deliberately not reported: there is nothing to submit for a\n // page that no longer exists, and IndexNow has no deletion semantics.\n if (before === undefined || before !== (e.lastmod ?? '')) changed.push(e.loc);\n }\n return changed;\n}\n\n/**\n * Decide what this run should do.\n *\n * Ordering of the URL-selection rules matters and is not arbitrary: an explicit\n * `--urls` is an operator overriding the tool, `--all` is an operator overriding\n * the diff, a cold snapshot is the tool having no basis to narrow, and the diff is\n * the normal path. Each is reported in `reason` so `--dry` explains itself.\n */\nexport function plan(input: PlanInput): IndexingPlan {\n const { config, current, previous, explicitUrls, all } = input;\n const notes: string[] = [];\n\n const sitemapUrl = resolveSitemapUrl(config);\n const check = config.priorityUrls ?? [];\n\n /*\n * The robots gate, before anything else.\n *\n * `environments.<env>.robots` already states whether a deployment is meant to be\n * indexed. Submitting a `noindex` environment's URLs to IndexNow is not a\n * no-op that the directive later undoes — it publishes the staging host to\n * several search engines and their AI crawlers, and asks them to come look. The\n * config knows the answer, so the tool must not need to be told twice.\n */\n if (config.robots && /\\bnoindex\\b/i.test(config.robots)) {\n return {\n blocked: `environment robots policy is \"${config.robots}\" — refusing to ask search engines to index an environment marked noindex`,\n sitemapUrl,\n urls: [],\n reason: 'diff',\n cold: false,\n indexNow: { enabled: false, skip: 'blocked', endpoint: INDEXNOW_ENDPOINT, batches: [] },\n searchConsole: { enabled: false, skip: 'blocked' },\n check: [],\n notes,\n };\n }\n\n const cold = !explicitUrls && !all && !previous;\n\n let urls: string[];\n let reason: UrlReason;\n if (explicitUrls?.length) {\n urls = explicitUrls;\n reason = 'explicit';\n } else if (all) {\n urls = current.map((e) => e.loc);\n reason = 'all';\n } else if (!previous) {\n urls = current.map((e) => e.loc);\n reason = 'cold';\n notes.push(\n `no previous snapshot — treating all ${urls.length} URLs as changed. Persist ${'`.vitops/`'} between runs (a CI cache) so later runs submit only what moved.`,\n );\n } else {\n urls = diff(current, previous);\n reason = 'diff';\n }\n\n /*\n * The lastmod warning.\n *\n * Without per-URL lastmod the diff can still see pages appear and disappear, but\n * it cannot see one change — so an edited page is never resubmitted and the\n * command quietly does less than it appears to. Worth a note every run, because\n * the output otherwise looks identical to the healthy case.\n */\n if (reason === 'diff' || reason === 'cold') {\n const withLastmod = current.filter((e) => e.lastmod).length;\n if (current.length > 0 && withLastmod === 0)\n notes.push(\n 'no <lastmod> in the sitemap — only added/removed pages can be detected, never an edit. Wire `gitLastmod()` into your sitemap options, or pass --all.',\n );\n else if (withLastmod < current.length)\n notes.push(\n `${current.length - withLastmod} of ${current.length} sitemap entries have no <lastmod> — edits to those pages cannot be detected.`,\n );\n }\n\n // ── IndexNow ────────────────────────────────────────────────────────────────\n const keyLocation = resolveKeyLocation(config);\n const keyHost = keyLocation ? hostOf(keyLocation) : undefined;\n let indexNow: IndexNowPlan;\n if (!config.indexNow) {\n indexNow = {\n enabled: false,\n skip: 'not configured (seo.indexing.indexNow)',\n endpoint: INDEXNOW_ENDPOINT,\n batches: [],\n };\n } else if (!keyLocation) {\n indexNow = {\n enabled: false,\n skip: 'no keyLocation and no domains.canonical to derive one',\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n key: config.indexNow.key,\n batches: [],\n };\n } else {\n /*\n * IndexNow requires every URL in a submission to share the key file's host.\n * A mixed batch is rejected whole, so the off-host URLs are dropped here and\n * named — silently sending them would fail the on-host ones too.\n */\n const onHost = urls.filter((u) => hostOf(u) === keyHost);\n const offHost = urls.length - onHost.length;\n if (offHost > 0)\n notes.push(\n `IndexNow: dropped ${offHost} URL(s) not on ${keyHost} — a submission must match its key file's host.`,\n );\n indexNow = {\n enabled: onHost.length > 0,\n ...(onHost.length === 0 ? { skip: 'no URLs to submit' } : {}),\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n keyLocation,\n key: config.indexNow.key,\n ...(keyHost ? { host: keyHost } : {}),\n batches: chunk(onHost, INDEXNOW_BATCH),\n };\n }\n\n // ── Search Console ──────────────────────────────────────────────────────────\n // Independent of `urls`: resubmitting the sitemap is what tells Google to\n // re-read it, and it is worth doing whenever the sitemap was rebuilt — there is\n // no per-URL submission to gate on, because Google exposes none.\n let searchConsole: SearchConsolePlan;\n if (!config.searchConsole) {\n searchConsole = { enabled: false, skip: 'not configured (seo.indexing.searchConsole)' };\n } else if (config.searchConsole.resubmitSitemap === false) {\n searchConsole = {\n enabled: false,\n skip: 'resubmitSitemap is false',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else if (!sitemapUrl) {\n searchConsole = {\n enabled: false,\n skip: 'no sitemapUrl and no domains.canonical to derive one',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else {\n searchConsole = { enabled: true, siteUrl: config.searchConsole.siteUrl };\n }\n\n return {\n sitemapUrl,\n urls,\n reason,\n cold,\n indexNow,\n searchConsole,\n check,\n notes,\n };\n}\n\n/** Render a plan as the human-readable block `--dry` prints. */\nexport function formatPlan(p: IndexingPlan): string {\n const lines: string[] = [];\n if (p.blocked) {\n lines.push(`✗ blocked — ${p.blocked}`);\n return lines.join('\\n');\n }\n lines.push(`sitemap ${p.sitemapUrl ?? '(none)'}`);\n lines.push(`urls ${p.urls.length} (${p.reason})`);\n\n lines.push(\n p.indexNow.enabled\n ? `IndexNow ${p.indexNow.batches.reduce((n, b) => n + b.length, 0)} URL(s) → ${p.indexNow.endpoint}`\n : `IndexNow skipped — ${p.indexNow.skip}`,\n );\n lines.push(\n p.searchConsole.enabled\n ? `Search Console resubmit sitemap → ${p.searchConsole.siteUrl}`\n : `Search Console skipped — ${p.searchConsole.skip}`,\n );\n for (const n of p.notes) lines.push(` ! ${n}`);\n return lines.join('\\n');\n}\n","/**\n * Sitemap parsing — enough of it, and no more.\n *\n * Hand-rolled rather than pulled from an XML library. A sitemap is a closed,\n * trivially-shaped document (`<urlset>` of `<url>`, or `<sitemapindex>` of\n * `<sitemap>`, each carrying `<loc>` and optionally `<lastmod>`), and this package\n * installs into every consumer project — a parser dependency for two element names\n * is not a trade worth making. The same reasoning the legal renderer applies to its\n * markdown subset: exactly as capable as the documents it has to read.\n */\nimport type { SitemapEntry } from './types.ts';\n\n/** What a sitemap document turned out to be. */\nexport interface ParsedSitemap {\n /** `<url>` entries, when this was a `<urlset>`. */\n entries: SitemapEntry[];\n /** Child sitemap URLs, when this was a `<sitemapindex>`. */\n sitemaps: string[];\n}\n\n/**\n * The five predefined XML entities, plus numeric character references.\n *\n * Not cosmetic: a sitemap URL carrying a query string is written `?a=1&amp;b=2`,\n * and submitting the un-decoded form means submitting a URL the site does not\n * serve — which IndexNow accepts and then silently fails to crawl.\n */\nfunction decodeXml(s: string): string {\n return (\n s\n .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(Number.parseInt(h, 16)))\n .replace(/&#(\\d+);/g, (_, d) => String.fromCodePoint(Number.parseInt(d, 10)))\n .replace(/&lt;/g, '<')\n .replace(/&gt;/g, '>')\n .replace(/&quot;/g, '\"')\n .replace(/&apos;/g, \"'\")\n // `&amp;` last: decoding it first would let `&amp;lt;` collapse to `<`.\n .replace(/&amp;/g, '&')\n );\n}\n\nconst tag = (block: string, name: string): string | undefined => {\n const m = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'i').exec(block);\n return m ? decodeXml(m[1]!.trim()) : undefined;\n};\n\nfunction blocks(xml: string, name: string): string[] {\n const out: string[] = [];\n const re = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'gi');\n let m: RegExpExecArray | null;\n while ((m = re.exec(xml))) out.push(m[1]!);\n return out;\n}\n\n/**\n * Parse a sitemap or sitemap index.\n *\n * Which one it is comes from the root element, not from the filename — an\n * `sitemap-index.xml` that is actually a flat `<urlset>` is common enough\n * (@astrojs/sitemap emits an index only past `entryLimit`) that keying on the name\n * would misread the single-file case as \"no URLs\".\n */\nexport function parseSitemap(xml: string): ParsedSitemap {\n if (/<sitemapindex[\\s>]/i.test(xml))\n return {\n entries: [],\n sitemaps: blocks(xml, 'sitemap')\n .map((b) => tag(b, 'loc'))\n .filter((l): l is string => !!l),\n };\n\n const entries: SitemapEntry[] = [];\n for (const b of blocks(xml, 'url')) {\n const loc = tag(b, 'loc');\n if (!loc) continue; // `<loc>` is required; an entry without one is unusable\n const lastmod = tag(b, 'lastmod');\n entries.push(lastmod ? { loc, lastmod } : { loc });\n }\n return { entries, sitemaps: [] };\n}\n\n/** Reads a sitemap from a URL or a local path. */\nexport type SitemapReader = (source: string) => Promise<string>;\n\n/**\n * Resolve a sitemap to its full entry list, following an index one level down.\n *\n * One level is the whole spec — the sitemaps protocol forbids an index pointing at\n * another index — so recursing further would only chase a malformed document.\n * Child fetches run sequentially: a large site's index can list dozens of files,\n * and hammering the consumer's own origin to save a second is a poor trade.\n */\nexport async function collectEntries(\n source: string,\n read: SitemapReader,\n): Promise<{ entries: SitemapEntry[]; sources: string[] }> {\n const root = parseSitemap(await read(source));\n if (root.sitemaps.length === 0) return { entries: root.entries, sources: [source] };\n\n const entries: SitemapEntry[] = [];\n const sources: string[] = [];\n for (const child of root.sitemaps) {\n const parsed = parseSitemap(await read(child));\n entries.push(...parsed.entries);\n sources.push(child);\n }\n return { entries, sources };\n}\n","/**\n * The previous run's sitemap state — what makes \"only submit what changed\"\n * possible at all.\n *\n * A plain JSON file rather than anything cleverer, because the thing reading it is\n * usually a CI job restoring a cache directory. It must survive being absent\n * (first run, cold cache) without pretending nothing changed — `readSnapshot`\n * returns `undefined` there and `plan()` treats that as \"submit everything, and\n * say so\".\n */\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname } from 'node:path';\nimport type { SitemapEntry, Snapshot } from './types.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const SNAPSHOT_PATH = '.vitops/sitemap-snapshot.json';\n\n/**\n * Read a snapshot, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, not as empty. The distinction\n * matters: \"absent\" submits everything, whereas an empty entry map would look like\n * a site that legitimately has no URLs and submit nothing — the silent-success\n * failure this whole command exists to remove.\n */\nexport function readSnapshot(path: string): Snapshot | undefined {\n if (!existsSync(path)) return undefined;\n try {\n const raw = JSON.parse(readFileSync(path, 'utf8')) as Snapshot;\n if (raw?.version !== 1 || typeof raw.entries !== 'object' || raw.entries === null)\n return undefined;\n return raw;\n } catch {\n return undefined;\n }\n}\n\n/** Build a snapshot from the entries just read. */\nexport function toSnapshot(sitemapUrl: string, entries: SitemapEntry[], takenAt: string): Snapshot {\n const map: Record<string, string> = {};\n for (const e of entries) map[e.loc] = e.lastmod ?? '';\n return { version: 1, sitemapUrl, takenAt, entries: map };\n}\n\n/**\n * Write the snapshot, creating its directory.\n *\n * Call this only after a submission actually succeeded. Writing it eagerly would\n * record URLs as notified that never were, and because the next run diffs against\n * it, those URLs would never be submitted again — a single transient 503 would\n * silently drop a page from every future run.\n */\nexport function writeSnapshot(path: string, snapshot: Snapshot): void {\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, `${JSON.stringify(snapshot, null, 2)}\\n`);\n}\n","/**\n * IndexNow submission.\n *\n * Honoured by Bing, Yandex, Naver, Seznam and Yep — and, through Bing's index, by\n * the AI crawlers built on it. **Not** by Google, which has never adopted the\n * protocol despite testing it; nothing here should be described to a consumer as\n * affecting Google results.\n *\n * Participating engines share submissions with each other, so one endpoint is\n * normally the whole integration.\n */\nimport { randomBytes } from 'node:crypto';\nimport type { IndexNowPlan } from './plan.ts';\n\n/** Generate a key. 32 hex characters, the conventional shape. */\nexport function newKey(): string {\n return randomBytes(16).toString('hex');\n}\n\n/** The body of the key file served at `keyLocation`: the key, and nothing else. */\nexport const keyFileContents = (key: string): string => `${key}\\n`;\n\nexport interface SubmitResult {\n ok: boolean;\n status: number;\n urls: number;\n /** The endpoint's own explanation, when it gave one. */\n message?: string;\n}\n\n/**\n * How the endpoint's status codes actually read.\n *\n * `403` and `422` are the two that matter and they are easy to misdiagnose: both\n * mean the key file is wrong, not that the URLs are.\n */\nfunction explain(status: number): string | undefined {\n switch (status) {\n case 200:\n case 202:\n return undefined;\n case 400:\n return 'bad request — malformed URL list';\n case 403:\n return 'key file not found or its contents do not match the key';\n case 422:\n return 'a submitted URL does not belong to the key file host, or the key does not match';\n case 429:\n return 'rate limited — too many submissions';\n default:\n return `unexpected status ${status}`;\n }\n}\n\n/**\n * Verify the key file is live and correct before submitting.\n *\n * Worth a request of its own because of how IndexNow fails: a submission with an\n * unreachable key file returns `403`, but one with a *reachable* key file whose\n * contents are stale is accepted with `202` and then silently discarded. Checking\n * first is the only way to tell \"submitted\" from \"submitted and ignored\".\n */\nexport async function verifyKeyFile(\n keyLocation: string,\n key: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; reason?: string }> {\n let res: Response;\n try {\n res = await fetchImpl(keyLocation, { method: 'GET' });\n } catch (e) {\n return { ok: false, reason: `could not fetch ${keyLocation}: ${(e as Error).message}` };\n }\n if (!res.ok) return { ok: false, reason: `${keyLocation} returned ${res.status}` };\n const body = (await res.text()).trim();\n if (body !== key)\n return {\n ok: false,\n reason: `${keyLocation} does not contain the configured key (found ${body.length} chars)`,\n };\n return { ok: true };\n}\n\n/**\n * Submit one batch. Callers iterate `plan.batches` — the split is the planner's\n * job, so that what gets sent is decided in the pure layer and visible in `--dry`.\n */\nexport async function submitBatch(\n plan: IndexNowPlan,\n urls: string[],\n fetchImpl: typeof fetch = fetch,\n): Promise<SubmitResult> {\n const body = {\n host: plan.host,\n key: plan.key,\n keyLocation: plan.keyLocation,\n urlList: urls,\n };\n const res = await fetchImpl(plan.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json; charset=utf-8' },\n body: JSON.stringify(body),\n });\n const message = explain(res.status);\n return { ok: res.ok, status: res.status, urls: urls.length, ...(message ? { message } : {}) };\n}\n","/**\n * Google Search Console — the two things its API will actually do.\n *\n * Worth stating plainly, because the gap between what people expect and what\n * exists is the whole reason this module is small: **there is no \"request\n * indexing\" endpoint.** The button in the Search Console UI is not exposed in the\n * API or anywhere else, and the sitemap ping endpoint was removed in 2023. What\n * remains is `sitemaps.submit` (write) and `urlInspection` (read).\n *\n * The Indexing API is deliberately not wired here. It is scoped to `JobPosting`\n * and `BroadcastEvent`; it accepts other URLs and discards them, and using it for\n * general pages violates its terms with the consumer's own GCP project on the\n * line. A toolchain should not ship that as a documented path.\n *\n * Auth is minted by hand with `node:crypto` rather than through `googleapis`,\n * which would be an enormous dependency in a CLI that installs into every consumer\n * project, for two endpoints.\n */\nimport { createSign } from 'node:crypto';\n\nconst TOKEN_URL = 'https://oauth2.googleapis.com/token';\nconst SCOPE = 'https://www.googleapis.com/auth/webmasters';\n\n/** The fields this module needs from a service-account JSON key. */\nexport interface ServiceAccount {\n client_email: string;\n private_key: string;\n}\n\nconst b64url = (input: Buffer | string): string =>\n Buffer.from(input).toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n\n/**\n * Parse a service-account credential from its two supported sources.\n *\n * `VITOPS_GSC_SERVICE_ACCOUNT` holds the JSON inline (what a CI secret store\n * gives you); `GOOGLE_APPLICATION_CREDENTIALS` holds a path (Google's own\n * convention, and what a local `gcloud` setup already has). Supporting both means\n * neither environment has to be reshaped to fit the tool.\n */\nexport function parseServiceAccount(raw: string): ServiceAccount {\n let parsed: Partial<ServiceAccount>;\n try {\n parsed = JSON.parse(raw) as Partial<ServiceAccount>;\n } catch {\n throw new Error('service account is not valid JSON');\n }\n if (!parsed.client_email || !parsed.private_key)\n throw new Error('service account JSON is missing client_email or private_key');\n return { client_email: parsed.client_email, private_key: parsed.private_key };\n}\n\n/**\n * Exchange a service-account key for an access token (RS256 JWT bearer flow).\n *\n * `now` is a parameter so this is testable without mocking the clock.\n */\nexport async function getAccessToken(\n account: ServiceAccount,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n const iat = Math.floor(now / 1000);\n const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));\n const claims = b64url(\n JSON.stringify({\n iss: account.client_email,\n scope: SCOPE,\n aud: TOKEN_URL,\n iat,\n exp: iat + 3600,\n }),\n );\n const signer = createSign('RSA-SHA256');\n signer.update(`${header}.${claims}`);\n // `\\n` survives most secret stores as a literal; normalise so a pasted key works.\n const pem = account.private_key.replace(/\\\\n/g, '\\n');\n const assertion = `${header}.${claims}.${b64url(signer.sign(pem))}`;\n\n const res = await fetchImpl(TOKEN_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion,\n }).toString(),\n });\n if (!res.ok) {\n // The body names the cause (clock skew, wrong audience, revoked key) and\n // contains no secret — the assertion is in the request, not the response.\n throw new Error(`token exchange failed (${res.status}): ${await res.text()}`);\n }\n const { access_token } = (await res.json()) as { access_token?: string };\n if (!access_token) throw new Error('token exchange returned no access_token');\n return access_token;\n}\n\nconst encodeSite = (siteUrl: string) => encodeURIComponent(siteUrl);\n\n/**\n * Re-submit a sitemap — the automated form of the manual resubmit in the UI.\n *\n * A `403` here almost always means `siteUrl` doesn't match the property exactly\n * (`sc-domain:acme.ca` vs `https://acme.ca/`) or the service account isn't an\n * owner of it, rather than that the sitemap is bad.\n */\nexport async function submitSitemap(\n token: string,\n siteUrl: string,\n sitemapUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const url = `https://www.googleapis.com/webmasters/v3/sites/${encodeSite(siteUrl)}/sitemaps/${encodeURIComponent(sitemapUrl)}`;\n const res = await fetchImpl(url, {\n method: 'PUT',\n headers: { authorization: `Bearer ${token}` },\n });\n if (res.ok) return { ok: true, status: res.status };\n const detail =\n res.status === 403\n ? `403 — check that siteUrl \"${siteUrl}\" matches the Search Console property exactly and that the service account is an owner of it`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, status: res.status, message: detail };\n}\n\n/** What the URL Inspection API said about one page. */\nexport interface InspectionResult {\n url: string;\n /** Google's verdict, e.g. `PASS`, `NEUTRAL`, `FAIL`. */\n verdict?: string;\n /** e.g. `INDEXING_ALLOWED`, `BLOCKED_BY_ROBOTS_TXT`. */\n coverageState?: string;\n lastCrawlTime?: string;\n /** True when Google reports the page as indexed. */\n indexed: boolean;\n error?: string;\n}\n\n/**\n * Inspect one URL. **Read-only** — this reports what Google did, it cannot ask for\n * anything. Quota is 2000 queries/day per property, which is why the caller works\n * from an explicit `priorityUrls` list rather than the whole sitemap.\n */\nexport async function inspectUrl(\n token: string,\n siteUrl: string,\n inspectionUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<InspectionResult> {\n const res = await fetchImpl(\n 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect',\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${token}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({ inspectionUrl, siteUrl }),\n },\n );\n if (!res.ok)\n return { url: inspectionUrl, indexed: false, error: `${res.status}: ${await res.text()}` };\n\n const body = (await res.json()) as {\n inspectionResult?: {\n indexStatusResult?: { verdict?: string; coverageState?: string; lastCrawlTime?: string };\n };\n };\n const r = body.inspectionResult?.indexStatusResult ?? {};\n return {\n url: inspectionUrl,\n ...(r.verdict ? { verdict: r.verdict } : {}),\n ...(r.coverageState ? { coverageState: r.coverageState } : {}),\n ...(r.lastCrawlTime ? { lastCrawlTime: r.lastCrawlTime } : {}),\n // `PASS` is the only verdict that means indexed. `NEUTRAL` covers \"discovered,\n // not indexed\" and \"excluded by canonical\" — both are *not indexed*, and\n // reading them as success would make --check pass on the exact pages it exists\n // to catch.\n indexed: r.verdict === 'PASS',\n };\n}\n"],"mappings":"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"}