@getvitops/utils 2.1.0 → 4.0.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/CHANGELOG.md +148 -0
- package/README.md +1 -1
- package/dist/indexing/index.d.mts +5 -8
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/indexing/index.mjs +13 -37
- package/dist/indexing/index.mjs.map +1 -1
- package/dist/media/index.d.mts +1 -1
- package/dist/media/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +207 -0
- package/dist/notify/index.d.mts.map +1 -0
- package/dist/notify/index.mjs +320 -0
- package/dist/notify/index.mjs.map +1 -0
- package/dist/onboarding/index.d.mts +231 -0
- package/dist/onboarding/index.d.mts.map +1 -0
- package/dist/onboarding/index.mjs +448 -0
- package/dist/onboarding/index.mjs.map +1 -0
- package/dist/platform-CIdwwI_Z.mjs +88 -0
- package/dist/platform-CIdwwI_Z.mjs.map +1 -0
- package/dist/token--18wN9UR.d.mts +46 -0
- package/dist/token--18wN9UR.d.mts.map +1 -0
- package/dist/token-BV7tPHh2.mjs +96 -0
- package/dist/token-BV7tPHh2.mjs.map +1 -0
- package/dist/tracking/index.d.mts +88 -0
- package/dist/tracking/index.d.mts.map +1 -0
- package/dist/tracking/index.mjs +77 -0
- package/dist/tracking/index.mjs.map +1 -0
- package/dist/types-C3lHaSrU.d.mts +69 -0
- package/dist/types-C3lHaSrU.d.mts.map +1 -0
- package/package.json +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,153 @@
|
|
|
1
1
|
# @getvitops/utils
|
|
2
2
|
|
|
3
|
+
## 4.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- f7bc0a0: Add `vitops search setup` — onboard domains into Google Search Console as domain properties.
|
|
8
|
+
|
|
9
|
+
**Breaking:** the `vitops indexing` command has been renamed to **`vitops search notify`**. There is
|
|
10
|
+
no alias — update any scripts or CI that call `vitops indexing` to `vitops search notify` (same flags,
|
|
11
|
+
same behaviour).
|
|
12
|
+
|
|
13
|
+
**New: `vitops search setup`.** For each domain in a site config's new `site.searchConsole` block
|
|
14
|
+
(a record keyed by bare hostname), it:
|
|
15
|
+
- ensures the apex verification TXT record in Cloudflare,
|
|
16
|
+
- verifies ownership via the Site Verification API (DNS_TXT), retried with exponential backoff while
|
|
17
|
+
DNS propagates — a still-unverified domain is reported **PENDING**, not failed,
|
|
18
|
+
- adds the `sc-domain:` property via the Search Console API,
|
|
19
|
+
- adds any `delegatedOwners` to the verified Site Verification web resource.
|
|
20
|
+
|
|
21
|
+
It is idempotent (a re-run of an onboarded domain is a no-op), supports `--check` (report drift, exit
|
|
22
|
+
non-zero, mutate nothing) and `--dry` (print the plan, change nothing), and `--domain <name>` to
|
|
23
|
+
scope to one entry. DNS records are only ever created, never edited or deleted.
|
|
24
|
+
|
|
25
|
+
Credentials come from the environment, never the config: `CLOUDFLARE_API_TOKEN` (a `Zone:DNS:Edit`
|
|
26
|
+
token) and a Google **user OAuth refresh token** as `VITOPS_GOOGLE_CLIENT_ID` /
|
|
27
|
+
`VITOPS_GOOGLE_CLIENT_SECRET` / `VITOPS_GOOGLE_REFRESH_TOKEN` (scoped to `siteverification` +
|
|
28
|
+
`webmasters`). Granting a Google Group **Full-User** access has no Search Console API and is surfaced
|
|
29
|
+
as a reminder in the summary rather than automated.
|
|
30
|
+
|
|
31
|
+
- ceed51f: `vitops search notify` now accepts either Google credential, so most sites need only one.
|
|
32
|
+
|
|
33
|
+
The two halves of `vitops search` each demanded a different, unrelated Google setup — five environment variables between them. `search setup` requires a user OAuth credential (verifying a site makes the caller an **owner** of the property, and that should be a person, not a project robot), while `search notify` accepted only a service account. Search Console does not care which identity calls it, so a consumer who had already run `search setup` was being made to create a second credential for the other half of the same command.
|
|
34
|
+
|
|
35
|
+
`search notify` now takes whichever you have, preferring the service account when both are set — it runs on every deploy, in CI, and a service account does not expire, whereas a refresh token can be revoked and expires after 7 days for an OAuth client still in _Testing_ publishing status. `search setup` is unchanged and still requires user OAuth.
|
|
36
|
+
|
|
37
|
+
**Breaking — two renamed exports.** Both did the same job under the same name from different subpaths, which forced an alias at every call site:
|
|
38
|
+
- `@getvitops/utils/indexing`: `getAccessToken` → **`serviceAccountToken`**
|
|
39
|
+
- `@getvitops/utils/onboarding`: `getAccessToken` → **`refreshTokenGrant`**
|
|
40
|
+
|
|
41
|
+
New: `googleAccessToken(credential)` from `@getvitops/utils/indexing` takes a `GoogleCredential` union and is what both wrappers now call. If you were importing either `getAccessToken`, switch to the specific name, or to `googleAccessToken` if you want to accept either identity.
|
|
42
|
+
|
|
43
|
+
Internally the exchange is now one function. The two grants send different form bodies; everything after that — endpoint, content-type, error handling, `access_token` extraction — was duplicated verbatim in two modules. The service-account JWT signing had no test at all, because it was only reachable over the network; it now does, including the literal-`\n`-in-a-PEM case that secret stores produce.
|
|
44
|
+
|
|
45
|
+
### Minor Changes
|
|
46
|
+
|
|
47
|
+
- c6b99e7: Ad-click attribution and conversion notifications ship as part of the toolchain.
|
|
48
|
+
|
|
49
|
+
A visitor arriving on an ad carries a click ID in the URL; `<Tracking />` captures it into a first-party `_ac` cookie, and when that visitor later submits a form or taps a `tel:` link, the conversion handler reads the cookie back and notifies whoever the config says. Previously this was per-site code.
|
|
50
|
+
|
|
51
|
+
**Added — `@getvitops/utils/tracking`.** The attribution vocabulary and cookie, as pure functions over a cookie _string_ so the browser capture and the server-side handler share one implementation: `parseTrackingCookie`, `serializeTrackingCookie`, `mergeTracking`, `identifyPlatform`, `getPrimaryClickId`, plus the click-ID/UTM tables. No DOM, no network, no clock — every function that needs "now" takes it as an argument, so it runs unchanged in a Worker.
|
|
52
|
+
|
|
53
|
+
**Added — `@getvitops/utils/notify`.** Conversion notifications, split the way `indexing/` is: `plan.ts` decides everything purely (which channels fire, who they reach, and **why anything is skipped**), `render.ts` turns the event into prose, `email.ts` executes and decides nothing. A misconfigured site can therefore be told exactly why no notification will arrive — a silently unsent one is indistinguishable from no conversion.
|
|
54
|
+
|
|
55
|
+
The `ConversionEvent` is the abstraction: the event is the _fact_, and how it reads is the channel's business.
|
|
56
|
+
|
|
57
|
+
**Added — the `email` channel, via Cloudflare Email Sending.** Uses the current structured binding (`env.EMAIL.send({ to, from, subject, html, text })`), not the legacy `EmailMessage` + hand-built MIME path. The binding is **passed in, never imported**, so `@getvitops/utils` takes no Cloudflare dependency and the sender is testable with a stub. Retries only the transient error codes; a configuration fault like `E_SENDER_NOT_VERIFIED` is surfaced verbatim rather than collapsed into "send failed". With no binding it prints the notification — the dev path.
|
|
58
|
+
|
|
59
|
+
Remember to onboard the sending domain (`wrangler email sending enable <domain>`) and add `"send_email": [{ "name": "EMAIL" }]` to `wrangler.jsonc`, or every send fails.
|
|
60
|
+
|
|
61
|
+
**Added — `<Tracking />` and `createConversionRoute()`** in `@getvitops/astro` (`@getvitops/astro/Tracking.astro`, `@getvitops/astro/routes`). The route factory handles both the `tel:` beacon and a form POST; validation and business rules stay the consumer's, because every site's form differs. A failed notification never fails the request — the visitor has already submitted, and refusing them because our mail didn't go out turns a lost notification into a lost conversion.
|
|
62
|
+
|
|
63
|
+
**Added — `site.tracking` and a widened `site.notifications`.** `notifications.email` now accepts a full channel object as well as a bare address (which still means what it did). `tracking` gains `category`.
|
|
64
|
+
|
|
65
|
+
**Fixed — the capture script now asks for consent instead of only reading it.** It previously checked `granted('marketing')` passively, which under the demand-driven banner is a permanent no-op: nothing else on a page demands `marketing`, so the banner never offered it, so it was never granted, so `_ac` was never written — silently, on every site with the gate enabled. It now calls `require()`, which is what raises the banner, and only when the URL actually carried a click ID or UTM. A visitor arriving organically has nothing to attribute and is never asked.
|
|
66
|
+
|
|
67
|
+
The integration adds `marketing` to the offered categories when tracking is on, so the banner has a row for the category the script will demand, and warns when tracking is enabled with `consent` off.
|
|
68
|
+
|
|
69
|
+
**Fixed — revoking consent now clears `_ac`.** The marker `<Tracking />` emits carries `data-consent-cookies`, which the consent runtime's cleanup reads. There was no cleanup path for this cookie before.
|
|
70
|
+
|
|
71
|
+
**Fixed — the cookie notice discloses `_ac`.** It is first-party, so no provider table would ever name it, and a site running attribution alongside a cookieless analytics provider was previously described as setting no cookies at all.
|
|
72
|
+
|
|
73
|
+
## 3.0.0
|
|
74
|
+
|
|
75
|
+
### Major Changes
|
|
76
|
+
|
|
77
|
+
- 6e68ace: **Breaking:** the site config is now a three-section `Config` — `designSystem`,
|
|
78
|
+
`organization`, `site`.
|
|
79
|
+
|
|
80
|
+
The flat `SiteConfig` held the company (`organization`, `contact`, `locations`) and the
|
|
81
|
+
deployment (`analytics`, `environments`, `seo`, `legal`) as peers, so no single noun
|
|
82
|
+
described it, and a second site sharing the same company had no way to say so. The three
|
|
83
|
+
sections split those apart: several sites can now carry one `organization` and differ only
|
|
84
|
+
in `site`.
|
|
85
|
+
|
|
86
|
+
**Migrating.** `designSystem` stays at the root, and the fields already under
|
|
87
|
+
`organization` stay where they are. Everything else moves into one of two sections:
|
|
88
|
+
- → `site`: `defaultLocale`, `locales`, `domains`, `dns`, `cloudflare`, `environments`,
|
|
89
|
+
`abTesting`, `fonts`, `tags`, `postTypes`, `galleries`, `testimonials`, `templates`,
|
|
90
|
+
`navigation`, `seo`, `analytics`, `notifications`, `tracking`, `security`, `legal`,
|
|
91
|
+
`icons`, `favicon`, `deployment`
|
|
92
|
+
- → `organization`: `contact`, `primaryLocation`, `locations`, `services`, `links`
|
|
93
|
+
|
|
94
|
+
You do not have to work this out from the list: `vitops validate` detects a pre-3.0 flat
|
|
95
|
+
config and prints every move by name, rather than reporting a dozen unknown keys.
|
|
96
|
+
|
|
97
|
+
```jsonc
|
|
98
|
+
{
|
|
99
|
+
"designSystem": {
|
|
100
|
+
"themes": {
|
|
101
|
+
"default": {
|
|
102
|
+
/* … */
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
"organization": {
|
|
107
|
+
"name": "Acme",
|
|
108
|
+
"contact": "hq",
|
|
109
|
+
"locations": {
|
|
110
|
+
"hq": {
|
|
111
|
+
/* … */
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
"site": {
|
|
116
|
+
"defaultLocale": "en",
|
|
117
|
+
"domains": {
|
|
118
|
+
/* … */
|
|
119
|
+
},
|
|
120
|
+
"analytics": {
|
|
121
|
+
/* … */
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Renamed exports** (`@getvitops/generator`): `SiteConfigSchema` → `ConfigSchema`,
|
|
128
|
+
`SiteConfig` → `Config`, `validateSite` → `validateConfig`, `resolveSiteConfig` →
|
|
129
|
+
`resolveConfig`, `isSiteConfig` → `isConfig`, `siteJsonSchema` → `configJsonSchema`,
|
|
130
|
+
`SITE_SCHEMA_URL` → `CONFIG_SCHEMA_URL`, `SiteValidationResult` →
|
|
131
|
+
`ConfigValidationResult`. `ResolvedInput.site` is now `ResolvedInput.config`. New:
|
|
132
|
+
`OrganizationConfig` and `SiteSection` for the two sections.
|
|
133
|
+
|
|
134
|
+
**Renamed schema file:** `@getvitops/generator/site.schema.json` →
|
|
135
|
+
`@getvitops/generator/config.schema.json`. Update the `$schema` key in your config.
|
|
136
|
+
|
|
137
|
+
Option names are unchanged — `vitops({ site: { input } })`, the Vite plugin's `site`,
|
|
138
|
+
and `generate({ site })` still point at the config file.
|
|
139
|
+
|
|
140
|
+
**The Astro integration is now `vitops()`, not `getvitops()`.** It is a default export,
|
|
141
|
+
so the name is yours and nothing breaks on upgrade, but every example now reads
|
|
142
|
+
`import vitops from '@getvitops/astro'` — matching the Vite plugin, which has always been
|
|
143
|
+
`vitops`. The `@getvitops/*` package scope and the internal `virtual:getvitops/*` module
|
|
144
|
+
ids are unchanged; neither is an import name.
|
|
145
|
+
|
|
146
|
+
**Added:** a generated config authoring reference — `vitops docs config`, `docs/config.md`
|
|
147
|
+
in the OKF bundle, and _Config reference_ on the docs site. It is walked from the published
|
|
148
|
+
JSON Schema by the same helper that renders the `design-system.json` reference, so it
|
|
149
|
+
cannot describe a field validation does not accept.
|
|
150
|
+
|
|
3
151
|
## 2.1.0
|
|
4
152
|
|
|
5
153
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ for why the outputs are meant to be committed.
|
|
|
53
53
|
|
|
54
54
|
## Search-engine notification (`@getvitops/utils/indexing`)
|
|
55
55
|
|
|
56
|
-
The engine behind `vitops
|
|
56
|
+
The engine behind `vitops search notify`: sitemap parsing, the changed-URL diff against the previous
|
|
57
57
|
run's snapshot, IndexNow submission, and the Search Console API calls (sitemap resubmit + read-only
|
|
58
58
|
URL inspection). `plan()` is pure — no network, no filesystem, no clock — and `indexnow.ts` /
|
|
59
59
|
`gsc.ts` execute a plan without deciding anything.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { a as googleAccessToken, i as ServiceAccount, n as GoogleOAuth, r as SCOPES, t as GoogleCredential } from "../token--18wN9UR.mjs";
|
|
2
|
+
|
|
1
3
|
//#region src/indexing/types.d.ts
|
|
2
4
|
/**
|
|
3
5
|
* The indexing module's own option surface.
|
|
4
6
|
*
|
|
5
|
-
* Deliberately structural rather than `
|
|
7
|
+
* Deliberately structural rather than `Config['seo']['indexing']` imported
|
|
6
8
|
* from `@getvitops/generator`: the generator already depends on this package, so
|
|
7
9
|
* importing back would be a cycle. The vocabulary mirrors that block field for
|
|
8
10
|
* field, so the CLI's adapter is a flat map — the same arrangement, and for the
|
|
@@ -222,11 +224,6 @@ declare function verifyKeyFile(keyLocation: string, key: string, fetchImpl?: typ
|
|
|
222
224
|
declare function submitBatch(plan: IndexNowPlan, urls: string[], fetchImpl?: typeof fetch): Promise<SubmitResult>;
|
|
223
225
|
//#endregion
|
|
224
226
|
//#region src/indexing/gsc.d.ts
|
|
225
|
-
/** The fields this module needs from a service-account JSON key. */
|
|
226
|
-
interface ServiceAccount {
|
|
227
|
-
client_email: string;
|
|
228
|
-
private_key: string;
|
|
229
|
-
}
|
|
230
227
|
/**
|
|
231
228
|
* Parse a service-account credential from its two supported sources.
|
|
232
229
|
*
|
|
@@ -241,7 +238,7 @@ declare function parseServiceAccount(raw: string): ServiceAccount;
|
|
|
241
238
|
*
|
|
242
239
|
* `now` is a parameter so this is testable without mocking the clock.
|
|
243
240
|
*/
|
|
244
|
-
declare function
|
|
241
|
+
declare function serviceAccountToken(account: ServiceAccount, fetchImpl?: typeof fetch, now?: number): Promise<string>;
|
|
245
242
|
/**
|
|
246
243
|
* Re-submit a sitemap — the automated form of the manual resubmit in the UI.
|
|
247
244
|
*
|
|
@@ -273,5 +270,5 @@ interface InspectionResult {
|
|
|
273
270
|
*/
|
|
274
271
|
declare function inspectUrl(token: string, siteUrl: string, inspectionUrl: string, fetchImpl?: typeof fetch): Promise<InspectionResult>;
|
|
275
272
|
//#endregion
|
|
276
|
-
export { type ChannelPlan, INDEXNOW_BATCH, INDEXNOW_ENDPOINT, type IndexNowConfig, type IndexNowPlan, type IndexingConfig, type IndexingPlan, type InspectionResult, type ParsedSitemap, type PlanInput, SNAPSHOT_PATH, type SearchConsoleConfig, type SearchConsolePlan, type ServiceAccount, type SitemapEntry, type SitemapReader, type Snapshot, type SubmitResult, type UrlReason, collectEntries, formatPlan,
|
|
273
|
+
export { type ChannelPlan, type GoogleCredential, type GoogleOAuth, INDEXNOW_BATCH, INDEXNOW_ENDPOINT, type IndexNowConfig, type IndexNowPlan, type IndexingConfig, type IndexingPlan, type InspectionResult, type ParsedSitemap, type PlanInput, SCOPES, SNAPSHOT_PATH, type SearchConsoleConfig, type SearchConsolePlan, type ServiceAccount, type SitemapEntry, type SitemapReader, type Snapshot, type SubmitResult, type UrlReason, collectEntries, formatPlan, googleAccessToken, inspectUrl, keyFileContents, newKey, parseServiceAccount, parseSitemap, plan, readSnapshot, resolveKeyLocation, resolveSitemapUrl, serviceAccountToken, submitBatch, submitSitemap, toSnapshot, verifyKeyFile, writeSnapshot };
|
|
277
274
|
//# sourceMappingURL=index.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/indexing/types.ts","../../src/indexing/plan.ts","../../src/indexing/sitemap.ts","../../src/indexing/snapshot.ts","../../src/indexing/indexnow.ts","../../src/indexing/gsc.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/indexing/types.ts","../../src/indexing/plan.ts","../../src/indexing/sitemap.ts","../../src/indexing/snapshot.ts","../../src/indexing/indexnow.ts","../../src/indexing/gsc.ts"],"mappings":"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"}
|
package/dist/indexing/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { n as googleAccessToken, t as SCOPES } from "../token-BV7tPHh2.mjs";
|
|
1
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
|
-
import {
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
4
5
|
|
|
5
6
|
//#region src/indexing/plan.ts
|
|
6
7
|
const INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
|
|
@@ -423,13 +424,11 @@ async function submitBatch(plan, urls, fetchImpl = fetch) {
|
|
|
423
424
|
* general pages violates its terms with the consumer's own GCP project on the
|
|
424
425
|
* line. A toolchain should not ship that as a documented path.
|
|
425
426
|
*
|
|
426
|
-
* Auth is minted by hand
|
|
427
|
-
*
|
|
428
|
-
*
|
|
427
|
+
* Auth is minted by hand rather than through `googleapis`, which would be an
|
|
428
|
+
* enormous dependency in a CLI that installs into every consumer project, for two
|
|
429
|
+
* endpoints. The exchange itself lives in `../google/token.ts` — it is shared with
|
|
430
|
+
* the onboarding module, which needs the same token from a different grant.
|
|
429
431
|
*/
|
|
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
432
|
/**
|
|
434
433
|
* Parse a service-account credential from its two supported sources.
|
|
435
434
|
*
|
|
@@ -456,35 +455,12 @@ function parseServiceAccount(raw) {
|
|
|
456
455
|
*
|
|
457
456
|
* `now` is a parameter so this is testable without mocking the clock.
|
|
458
457
|
*/
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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;
|
|
458
|
+
function serviceAccountToken(account, fetchImpl = fetch, now = Date.now()) {
|
|
459
|
+
return googleAccessToken({
|
|
460
|
+
kind: "service-account",
|
|
461
|
+
account,
|
|
462
|
+
scope: SCOPES.webmasters
|
|
463
|
+
}, fetchImpl, now);
|
|
488
464
|
}
|
|
489
465
|
const encodeSite = (siteUrl) => encodeURIComponent(siteUrl);
|
|
490
466
|
/**
|
|
@@ -543,5 +519,5 @@ async function inspectUrl(token, siteUrl, inspectionUrl, fetchImpl = fetch) {
|
|
|
543
519
|
}
|
|
544
520
|
|
|
545
521
|
//#endregion
|
|
546
|
-
export { INDEXNOW_BATCH, INDEXNOW_ENDPOINT, SNAPSHOT_PATH, collectEntries, formatPlan,
|
|
522
|
+
export { INDEXNOW_BATCH, INDEXNOW_ENDPOINT, SCOPES, SNAPSHOT_PATH, collectEntries, formatPlan, googleAccessToken, inspectUrl, keyFileContents, newKey, parseServiceAccount, parseSitemap, plan, readSnapshot, resolveKeyLocation, resolveSitemapUrl, serviceAccountToken, submitBatch, submitSitemap, toSnapshot, verifyKeyFile, writeSnapshot };
|
|
547
523
|
//# sourceMappingURL=index.mjs.map
|
|
@@ -1 +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&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(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n // `&` last: decoding it first would let `&lt;` collapse to `<`.\n .replace(/&/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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"}
|
|
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 search notify` 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&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(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n // `&` last: decoding it first would let `&lt;` collapse to `<`.\n .replace(/&/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 rather than through `googleapis`, which would be an\n * enormous dependency in a CLI that installs into every consumer project, for two\n * endpoints. The exchange itself lives in `../google/token.ts` — it is shared with\n * the onboarding module, which needs the same token from a different grant.\n */\nimport { googleAccessToken, SCOPES, type ServiceAccount } from '../google/token.ts';\n\nexport type { ServiceAccount } from '../google/token.ts';\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 function serviceAccountToken(\n account: ServiceAccount,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n return googleAccessToken(\n { kind: 'service-account', account, scope: SCOPES.webmasters },\n fetchImpl,\n now,\n );\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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"}
|
package/dist/media/index.d.mts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* The media module's own option surface.
|
|
4
4
|
*
|
|
5
5
|
* Everything here describes *how to encode*, never *what the site is* — which is
|
|
6
|
-
* why there is no `
|
|
6
|
+
* why there is no `Config` block behind it. `legal`, `icons` and `indexing`
|
|
7
7
|
* are anchored to a site config because what they emit describes the site; an
|
|
8
8
|
* encoder setting describes a build step.
|
|
9
9
|
*/
|