vagejs 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -2
  3. package/dist/audit/a11y.d.ts +13 -0
  4. package/dist/audit/a11y.js +33 -0
  5. package/dist/audit/audit.d.ts +31 -0
  6. package/dist/audit/audit.js +143 -0
  7. package/dist/audit/budgets.d.ts +22 -0
  8. package/dist/audit/budgets.js +106 -0
  9. package/dist/audit/lighthouse.d.ts +3 -0
  10. package/dist/audit/lighthouse.js +48 -0
  11. package/dist/audit/remote.d.ts +65 -0
  12. package/dist/audit/remote.js +123 -0
  13. package/dist/audit/vitals.d.ts +28 -0
  14. package/dist/audit/vitals.js +18 -0
  15. package/dist/checks/check.d.ts +31 -0
  16. package/dist/checks/check.js +434 -0
  17. package/dist/checks/integrity.d.ts +36 -0
  18. package/dist/checks/integrity.js +145 -0
  19. package/dist/checks/links.d.ts +18 -0
  20. package/dist/checks/links.js +47 -0
  21. package/dist/checks/verify.d.ts +35 -0
  22. package/dist/checks/verify.js +167 -0
  23. package/dist/cli/create.d.ts +10 -0
  24. package/dist/cli/create.js +39 -0
  25. package/dist/cli/index.d.ts +2 -0
  26. package/dist/cli/index.js +710 -0
  27. package/dist/cli/info.d.ts +82 -0
  28. package/dist/cli/info.js +100 -0
  29. package/dist/content/images.d.ts +23 -0
  30. package/dist/content/images.js +96 -0
  31. package/dist/content/markdown.d.ts +28 -0
  32. package/dist/content/markdown.js +56 -0
  33. package/dist/content/sanitize.d.ts +16 -0
  34. package/dist/content/sanitize.js +57 -0
  35. package/dist/core/build.d.ts +25 -0
  36. package/dist/core/build.js +54 -0
  37. package/dist/core/clone.d.ts +6 -0
  38. package/dist/core/clone.js +16 -0
  39. package/dist/core/config.d.ts +180 -0
  40. package/dist/core/config.js +260 -0
  41. package/dist/core/errors.d.ts +7 -0
  42. package/dist/core/errors.js +12 -0
  43. package/dist/core/html.d.ts +24 -0
  44. package/dist/core/html.js +42 -0
  45. package/dist/core/loader.d.ts +10 -0
  46. package/dist/core/loader.js +16 -0
  47. package/dist/core/pages.d.ts +40 -0
  48. package/dist/core/pages.js +93 -0
  49. package/dist/core/project-config.d.ts +3 -0
  50. package/dist/core/project-config.js +14 -0
  51. package/dist/core/render.d.ts +53 -0
  52. package/dist/core/render.js +104 -0
  53. package/dist/core/routes.d.ts +24 -0
  54. package/dist/core/routes.js +70 -0
  55. package/dist/core/site.d.ts +42 -0
  56. package/dist/core/site.js +182 -0
  57. package/dist/core/validate.d.ts +12 -0
  58. package/dist/core/validate.js +36 -0
  59. package/dist/deploy/cloudflare-pages.d.ts +20 -0
  60. package/dist/deploy/cloudflare-pages.js +42 -0
  61. package/dist/deploy/cloudflare-workers.d.ts +59 -0
  62. package/dist/deploy/cloudflare-workers.js +223 -0
  63. package/dist/deploy/git.d.ts +20 -0
  64. package/dist/deploy/git.js +55 -0
  65. package/dist/deploy/github-pages.d.ts +33 -0
  66. package/dist/deploy/github-pages.js +99 -0
  67. package/dist/deploy/headers.d.ts +11 -0
  68. package/dist/deploy/headers.js +103 -0
  69. package/dist/deploy/publish.d.ts +45 -0
  70. package/dist/deploy/publish.js +148 -0
  71. package/dist/deploy/redirects.d.ts +7 -0
  72. package/dist/deploy/redirects.js +18 -0
  73. package/dist/deploy/secrets.d.ts +6 -0
  74. package/dist/deploy/secrets.js +66 -0
  75. package/dist/dev/guard.d.ts +9 -0
  76. package/dist/dev/guard.js +57 -0
  77. package/dist/dev/listen.d.ts +6 -0
  78. package/dist/dev/listen.js +13 -0
  79. package/dist/dev/preview.d.ts +6 -0
  80. package/dist/dev/preview.js +72 -0
  81. package/dist/dev/server.d.ts +8 -0
  82. package/dist/dev/server.js +104 -0
  83. package/dist/discovery/crawlers.d.ts +13 -0
  84. package/dist/discovery/crawlers.js +2 -0
  85. package/dist/discovery/llms.d.ts +12 -0
  86. package/dist/discovery/llms.js +19 -0
  87. package/dist/discovery/robots.d.ts +8 -0
  88. package/dist/discovery/robots.js +40 -0
  89. package/dist/discovery/sitemap.d.ts +8 -0
  90. package/dist/discovery/sitemap.js +11 -0
  91. package/dist/guidelines/monitor.d.ts +55 -0
  92. package/dist/guidelines/monitor.js +126 -0
  93. package/dist/guidelines/pack.d.ts +41 -0
  94. package/dist/guidelines/pack.js +13 -0
  95. package/dist/guidelines/rule-pack.json +611 -0
  96. package/dist/index.d.ts +14 -0
  97. package/dist/index.js +12 -0
  98. package/dist/integrations/api-keys.d.ts +26 -0
  99. package/dist/integrations/api-keys.js +65 -0
  100. package/dist/integrations/ga4-data.d.ts +13 -0
  101. package/dist/integrations/ga4-data.js +39 -0
  102. package/dist/integrations/google-auth.d.ts +47 -0
  103. package/dist/integrations/google-auth.js +157 -0
  104. package/dist/integrations/http.d.ts +15 -0
  105. package/dist/integrations/http.js +40 -0
  106. package/dist/integrations/indexnow.d.ts +18 -0
  107. package/dist/integrations/indexnow.js +38 -0
  108. package/dist/integrations/search-console.d.ts +25 -0
  109. package/dist/integrations/search-console.js +39 -0
  110. package/dist/integrations/tags.d.ts +22 -0
  111. package/dist/integrations/tags.js +67 -0
  112. package/dist/seo/head.d.ts +20 -0
  113. package/dist/seo/head.js +85 -0
  114. package/dist/seo/jsonld.d.ts +35 -0
  115. package/dist/seo/jsonld.js +31 -0
  116. package/dist/seo/urls.d.ts +3 -0
  117. package/dist/seo/urls.js +10 -0
  118. package/package.json +81 -4
  119. package/templates/starter/.env.example +12 -0
  120. package/templates/starter/.github/dependabot.yml +11 -0
  121. package/templates/starter/.github/workflows/ci.yml +30 -0
  122. package/templates/starter/.github/workflows/verify.yml +32 -0
  123. package/templates/starter/AGENTS.md +166 -0
  124. package/templates/starter/CLAUDE.md +3 -0
  125. package/templates/starter/_gitignore +8 -0
  126. package/templates/starter/links.lock.json +4 -0
  127. package/templates/starter/package.json +16 -0
  128. package/templates/starter/public/apple-touch-icon.png +0 -0
  129. package/templates/starter/public/favicon.ico +0 -0
  130. package/templates/starter/public/favicon.svg +1 -0
  131. package/templates/starter/src/layouts/blog.ts +13 -0
  132. package/templates/starter/src/layouts/default.ts +38 -0
  133. package/templates/starter/src/pages/404.md +7 -0
  134. package/templates/starter/src/pages/about.md +8 -0
  135. package/templates/starter/src/pages/blog/first-post.md +10 -0
  136. package/templates/starter/src/pages/blog/index.md +7 -0
  137. package/templates/starter/src/pages/index.md +8 -0
  138. package/templates/starter/vage.config.ts +38 -0
@@ -0,0 +1,65 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { VageError } from "../core/errors.js";
5
+ import { credentialsPath } from "./google-auth.js";
6
+ /**
7
+ * The user's own Google Cloud API keys for audits. vagejs never ships or
8
+ * shares keys: each user brings theirs (env var, or saved with `vage connect`).
9
+ * Saved keys live next to the Google token, outside any project, mode 0600.
10
+ */
11
+ export const API_KEYS = {
12
+ pagespeed: {
13
+ env: "PAGESPEED_API_KEY",
14
+ label: "PageSpeed Insights API",
15
+ howTo: "Google Cloud Console → APIs & Services → enable “PageSpeed Insights API” → Credentials → Create credentials → API key (restrict it to that API).",
16
+ },
17
+ crux: {
18
+ env: "CRUX_API_KEY",
19
+ label: "Chrome UX Report (CrUX) API",
20
+ howTo: "Google Cloud Console → APIs & Services → enable “Chrome UX Report API” → Credentials → Create credentials → API key (one key can serve both APIs).",
21
+ },
22
+ };
23
+ const KEY_FORMAT = /^[A-Za-z0-9_-]{20,200}$/;
24
+ export function apiKeysPath(env) {
25
+ return path.join(path.dirname(credentialsPath(env)), "api-keys.json");
26
+ }
27
+ function readAll(env) {
28
+ const file = apiKeysPath(env);
29
+ if (!fs.existsSync(file))
30
+ return {};
31
+ const parsed = z.record(z.string(), z.string()).safeParse(JSON.parse(fs.readFileSync(file, "utf8")));
32
+ if (!parsed.success)
33
+ throw new VageError(`Saved API keys are unreadable. Remove the file and save them again.`, file);
34
+ return parsed.data;
35
+ }
36
+ /** Environment variable first, then the key saved with `vage connect`. */
37
+ export function resolveApiKey(name, env = process.env) {
38
+ return env[API_KEYS[name].env]?.trim() || readAll(env)[name];
39
+ }
40
+ export function saveApiKey(name, value, env) {
41
+ const key = value.trim();
42
+ if (!KEY_FORMAT.test(key))
43
+ throw new VageError(`That doesn't look like a Google API key (letters, digits, "-" or "_", no spaces).`);
44
+ const file = apiKeysPath(env);
45
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
46
+ fs.writeFileSync(file, `${JSON.stringify({ ...readAll(env), [name]: key }, null, 2)}\n`, { mode: 0o600 });
47
+ fs.chmodSync(file, 0o600);
48
+ return file;
49
+ }
50
+ export function removeApiKey(name, env) {
51
+ const all = readAll(env);
52
+ if (!all[name])
53
+ return false;
54
+ delete all[name];
55
+ const file = apiKeysPath(env);
56
+ if (Object.keys(all).length)
57
+ fs.writeFileSync(file, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
58
+ else
59
+ fs.rmSync(file, { force: true });
60
+ return true;
61
+ }
62
+ export function missingKeyMessage(name) {
63
+ const k = API_KEYS[name];
64
+ return `${k.label} needs your own API key. Set ${k.env}, or save one with "vage connect ${name}". Get a key: ${k.howTo}`;
65
+ }
@@ -0,0 +1,13 @@
1
+ import { type HttpOptions } from "./http.js";
2
+ export interface AnalyticsRow {
3
+ pagePath: string;
4
+ screenPageViews: number;
5
+ activeUsers: number;
6
+ sessions: number;
7
+ }
8
+ /** Top pages by views for the last `days` days. */
9
+ export declare function analyticsReport(token: string, propertyId: string, days: number, opts?: HttpOptions): Promise<{
10
+ propertyId: string;
11
+ days: number;
12
+ rows: AnalyticsRow[];
13
+ }>;
@@ -0,0 +1,39 @@
1
+ import { z } from "zod";
2
+ import { apiError, requestWithRetry } from "./http.js";
3
+ // https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/properties/runReport
4
+ const API = "https://analyticsdata.googleapis.com/v1beta";
5
+ const METRICS = ["screenPageViews", "activeUsers", "sessions"];
6
+ const responseSchema = z.object({
7
+ rows: z
8
+ .array(z.object({
9
+ dimensionValues: z.array(z.object({ value: z.string() })),
10
+ metricValues: z.array(z.object({ value: z.string() })),
11
+ }))
12
+ .default([]),
13
+ });
14
+ /** Top pages by views for the last `days` days. */
15
+ export async function analyticsReport(token, propertyId, days, opts) {
16
+ const res = await requestWithRetry("Google Analytics Data API", `${API}/properties/${encodeURIComponent(propertyId)}:runReport`, {
17
+ method: "POST",
18
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
19
+ body: JSON.stringify({
20
+ dateRanges: [{ startDate: `${days}daysAgo`, endDate: "today" }],
21
+ dimensions: [{ name: "pagePath" }],
22
+ metrics: METRICS.map((name) => ({ name })),
23
+ orderBys: [{ metric: { metricName: "screenPageViews" }, desc: true }],
24
+ limit: 25,
25
+ }),
26
+ }, [token], opts);
27
+ if (!res.ok) {
28
+ throw await apiError("Google Analytics Data API", res, [token], {
29
+ 403: `No access to GA4 property ${propertyId}. Check integrations.ga4.propertyId and that your Google account can view it.`,
30
+ });
31
+ }
32
+ const rows = responseSchema.parse(await res.json()).rows.map((r) => ({
33
+ pagePath: r.dimensionValues[0]?.value ?? "",
34
+ screenPageViews: Number(r.metricValues[0]?.value ?? 0),
35
+ activeUsers: Number(r.metricValues[1]?.value ?? 0),
36
+ sessions: Number(r.metricValues[2]?.value ?? 0),
37
+ }));
38
+ return { propertyId, days, rows };
39
+ }
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+ import { type HttpOptions } from "./http.js";
3
+ /** Read-only scopes: least privilege for reports. */
4
+ export declare const SCOPES: {
5
+ readonly gsc: "https://www.googleapis.com/auth/webmasters.readonly";
6
+ readonly ga4: "https://www.googleapis.com/auth/analytics.readonly";
7
+ };
8
+ declare const storedSchema: z.ZodObject<{
9
+ clientId: z.ZodString;
10
+ clientSecret: z.ZodOptional<z.ZodString>;
11
+ refreshToken: z.ZodString;
12
+ scopes: z.ZodArray<z.ZodString>;
13
+ createdAt: z.ZodString;
14
+ }, z.core.$strip>;
15
+ export type StoredCredentials = z.infer<typeof storedSchema>;
16
+ /** Outside any project: $VAGE_CONFIG_DIR, else the OS user config dir. Never in the repository. */
17
+ export declare function credentialsPath(env?: Record<string, string | undefined>): string;
18
+ export declare function loadCredentials(env?: Record<string, string | undefined>): StoredCredentials | undefined;
19
+ export interface ConnectOptions {
20
+ scopes: string[];
21
+ clientId: string;
22
+ clientSecret?: string;
23
+ /** Opens (or prints) the Google consent page URL. */
24
+ open: (url: string) => void;
25
+ env?: Record<string, string | undefined>;
26
+ http?: HttpOptions;
27
+ timeoutMs?: number;
28
+ }
29
+ /**
30
+ * Runs the browser consent flow and stores a refresh token. Previously granted
31
+ * scopes are requested again so connecting GA4 keeps Search Console access.
32
+ */
33
+ export declare function connectGoogle(options: ConnectOptions): Promise<StoredCredentials>;
34
+ /** Exchanges the stored refresh token for a short-lived access token. */
35
+ export declare function getAccessToken(scope: string, service: "gsc" | "ga4", options?: {
36
+ env?: Record<string, string | undefined>;
37
+ http?: HttpOptions;
38
+ }): Promise<string>;
39
+ /** Revokes the refresh token at Google and deletes the local file (even if revocation fails). */
40
+ export declare function disconnectGoogle(options?: {
41
+ env?: Record<string, string | undefined>;
42
+ http?: HttpOptions;
43
+ }): Promise<{
44
+ hadConnection: boolean;
45
+ revoked: boolean;
46
+ }>;
47
+ export {};
@@ -0,0 +1,157 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { z } from "zod";
7
+ import { VageError } from "../core/errors.js";
8
+ import { apiError, requestWithRetry } from "./http.js";
9
+ // OAuth 2.0 for desktop apps (loopback redirect + PKCE):
10
+ // https://developers.google.com/identity/protocols/oauth2/native-app
11
+ const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
12
+ const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
13
+ const REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke";
14
+ /** Read-only scopes: least privilege for reports. */
15
+ export const SCOPES = {
16
+ gsc: "https://www.googleapis.com/auth/webmasters.readonly",
17
+ ga4: "https://www.googleapis.com/auth/analytics.readonly",
18
+ };
19
+ const storedSchema = z.object({
20
+ clientId: z.string(),
21
+ clientSecret: z.string().optional(),
22
+ refreshToken: z.string(),
23
+ scopes: z.array(z.string()),
24
+ createdAt: z.string(),
25
+ });
26
+ /** Outside any project: $VAGE_CONFIG_DIR, else the OS user config dir. Never in the repository. */
27
+ export function credentialsPath(env = process.env) {
28
+ const base = env.VAGE_CONFIG_DIR ??
29
+ (process.platform === "win32"
30
+ ? path.join(env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"), "vagejs")
31
+ : path.join(env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "vagejs"));
32
+ return path.join(base, "google.json");
33
+ }
34
+ export function loadCredentials(env) {
35
+ const file = credentialsPath(env);
36
+ if (!fs.existsSync(file))
37
+ return undefined;
38
+ const parsed = storedSchema.safeParse(JSON.parse(fs.readFileSync(file, "utf8")));
39
+ if (!parsed.success)
40
+ throw new VageError(`Stored Google credentials are unreadable. Run "vage disconnect google", then connect again.`, file);
41
+ return parsed.data;
42
+ }
43
+ function saveCredentials(creds, env) {
44
+ const file = credentialsPath(env);
45
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
46
+ fs.writeFileSync(file, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
47
+ fs.chmodSync(file, 0o600);
48
+ }
49
+ /**
50
+ * Runs the browser consent flow and stores a refresh token. Previously granted
51
+ * scopes are requested again so connecting GA4 keeps Search Console access.
52
+ */
53
+ export async function connectGoogle(options) {
54
+ const existing = loadCredentials(options.env);
55
+ const scopes = [...new Set([...(existing?.clientId === options.clientId ? existing.scopes : []), ...options.scopes])].sort();
56
+ const verifier = base64url(randomBytes(32));
57
+ const state = base64url(randomBytes(16));
58
+ const server = http.createServer();
59
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
60
+ const redirectUri = `http://127.0.0.1:${server.address().port}`;
61
+ try {
62
+ const url = new URL(AUTH_ENDPOINT);
63
+ url.search = new URLSearchParams({
64
+ client_id: options.clientId,
65
+ redirect_uri: redirectUri,
66
+ response_type: "code",
67
+ scope: scopes.join(" "),
68
+ code_challenge: base64url(createHash("sha256").update(verifier).digest()),
69
+ code_challenge_method: "S256",
70
+ state,
71
+ }).toString();
72
+ const code = waitForCode(server, state, options.timeoutMs ?? 5 * 60_000);
73
+ options.open(url.href);
74
+ const authCode = await code;
75
+ const secrets = [options.clientSecret, authCode, verifier];
76
+ const body = new URLSearchParams({ code: authCode, client_id: options.clientId, redirect_uri: redirectUri, grant_type: "authorization_code", code_verifier: verifier });
77
+ if (options.clientSecret)
78
+ body.set("client_secret", options.clientSecret);
79
+ const res = await requestWithRetry("Google OAuth", TOKEN_ENDPOINT, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body }, secrets, options.http);
80
+ if (!res.ok)
81
+ throw await apiError("Google OAuth token exchange", res, secrets);
82
+ const token = z.object({ refresh_token: z.string().optional(), scope: z.string().default("") }).parse(await res.json());
83
+ if (!token.refresh_token)
84
+ throw new VageError("Google did not return a refresh token, so the connection could not be saved. Try connecting again.");
85
+ const granted = token.scope.split(" ");
86
+ const missing = scopes.filter((s) => !granted.includes(s));
87
+ if (missing.length)
88
+ throw new VageError(`Access was not granted for: ${missing.join(", ")}. Connect again and allow all requested permissions.`);
89
+ const creds = { clientId: options.clientId, clientSecret: options.clientSecret, refreshToken: token.refresh_token, scopes, createdAt: new Date().toISOString() };
90
+ saveCredentials(creds, options.env);
91
+ return creds;
92
+ }
93
+ finally {
94
+ server.close();
95
+ }
96
+ }
97
+ function waitForCode(server, state, timeoutMs) {
98
+ return new Promise((resolve, reject) => {
99
+ const timer = setTimeout(() => reject(new VageError("Timed out waiting for Google authorization. Run the command again.")), timeoutMs);
100
+ server.on("request", (req, res) => {
101
+ const params = new URL(req.url ?? "/", "http://127.0.0.1").searchParams;
102
+ const finish = (status, text) => {
103
+ res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
104
+ res.end(text);
105
+ };
106
+ if (!params.has("code") && !params.has("error"))
107
+ return finish(404, "Not found");
108
+ if (params.get("state") !== state)
109
+ return finish(400, "State mismatch; ignoring this request.");
110
+ clearTimeout(timer);
111
+ if (params.get("error")) {
112
+ finish(200, "Authorization was not granted. You can close this tab.");
113
+ return reject(new VageError(`Google authorization was not granted (${params.get("error")}).`));
114
+ }
115
+ finish(200, "vagejs is connected. You can close this tab.");
116
+ resolve(params.get("code"));
117
+ });
118
+ });
119
+ }
120
+ /** Exchanges the stored refresh token for a short-lived access token. */
121
+ export async function getAccessToken(scope, service, options = {}) {
122
+ const creds = loadCredentials(options.env);
123
+ if (!creds)
124
+ throw new VageError(`Not connected to Google. Run "vage connect ${service}".`);
125
+ if (!creds.scopes.includes(scope))
126
+ throw new VageError(`The Google connection does not include ${service} access. Run "vage connect ${service}".`);
127
+ const secrets = [creds.refreshToken, creds.clientSecret];
128
+ const body = new URLSearchParams({ client_id: creds.clientId, refresh_token: creds.refreshToken, grant_type: "refresh_token" });
129
+ if (creds.clientSecret)
130
+ body.set("client_secret", creds.clientSecret);
131
+ const res = await requestWithRetry("Google OAuth", TOKEN_ENDPOINT, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body }, secrets, options.http);
132
+ if (res.status === 400 || res.status === 401) {
133
+ throw new VageError(`The Google connection has expired or was revoked. Run "vage connect ${service}" again.`);
134
+ }
135
+ if (!res.ok)
136
+ throw await apiError("Google OAuth token refresh", res, secrets);
137
+ return z.object({ access_token: z.string() }).parse(await res.json()).access_token;
138
+ }
139
+ /** Revokes the refresh token at Google and deletes the local file (even if revocation fails). */
140
+ export async function disconnectGoogle(options = {}) {
141
+ const creds = loadCredentials(options.env);
142
+ if (!creds)
143
+ return { hadConnection: false, revoked: false };
144
+ let revoked = false;
145
+ try {
146
+ const res = await requestWithRetry("Google OAuth", REVOKE_ENDPOINT, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token: creds.refreshToken }) }, [creds.refreshToken], options.http);
147
+ revoked = res.ok;
148
+ }
149
+ catch {
150
+ // Network failure: still remove the local token; the caller reports that revocation did not happen.
151
+ }
152
+ fs.rmSync(credentialsPath(options.env), { force: true });
153
+ return { hadConnection: true, revoked };
154
+ }
155
+ function base64url(buf) {
156
+ return buf.toString("base64url");
157
+ }
@@ -0,0 +1,15 @@
1
+ import { VageError } from "../core/errors.js";
2
+ export interface HttpOptions {
3
+ fetch?: typeof fetch;
4
+ /** Waits between retries; injectable for tests. */
5
+ sleep?: (ms: number) => Promise<void>;
6
+ attempts?: number;
7
+ }
8
+ /**
9
+ * Fetch with retries for rate limits and transient errors (exponential
10
+ * backoff: 1 s, 2 s, ...). Every value in `secrets` is scrubbed from error text.
11
+ */
12
+ export declare function requestWithRetry(service: string, url: string, init: RequestInit, secrets: (string | undefined)[], opts?: HttpOptions): Promise<Response>;
13
+ /** Builds an actionable error from a non-OK Google API response. */
14
+ export declare function apiError(service: string, res: Response, secrets: (string | undefined)[], hints?: Partial<Record<number, string>>): Promise<VageError>;
15
+ export declare function redact(text: string, secrets: (string | undefined)[]): string;
@@ -0,0 +1,40 @@
1
+ import { VageError } from "../core/errors.js";
2
+ const RETRY_STATUS = new Set([429, 500, 502, 503, 504]);
3
+ /**
4
+ * Fetch with retries for rate limits and transient errors (exponential
5
+ * backoff: 1 s, 2 s, ...). Every value in `secrets` is scrubbed from error text.
6
+ */
7
+ export async function requestWithRetry(service, url, init, secrets, opts = {}) {
8
+ const doFetch = opts.fetch ?? fetch;
9
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
10
+ const attempts = opts.attempts ?? 3;
11
+ for (let i = 1;; i++) {
12
+ try {
13
+ const res = await doFetch(url, { ...init, signal: AbortSignal.timeout(120_000) });
14
+ if (res.ok || !RETRY_STATUS.has(res.status) || i === attempts)
15
+ return res;
16
+ }
17
+ catch (err) {
18
+ if (i === attempts)
19
+ throw new VageError(redact(`${service} request failed after ${attempts} attempts: ${err.message}`, secrets));
20
+ }
21
+ await sleep(1000 * 2 ** (i - 1));
22
+ }
23
+ }
24
+ /** Builds an actionable error from a non-OK Google API response. */
25
+ export async function apiError(service, res, secrets, hints = {}) {
26
+ let detail = "";
27
+ try {
28
+ // Google APIs use { error: { message } }; the OAuth endpoints use { error, error_description }.
29
+ const body = (await res.json());
30
+ detail = body.error_description ?? (typeof body.error === "string" ? body.error : (body.error?.message ?? ""));
31
+ }
32
+ catch {
33
+ // Non-JSON error body; the status is enough.
34
+ }
35
+ const hint = hints[res.status] ?? (res.status === 429 ? "Rate limited: wait and retry." : "");
36
+ return new VageError(redact(`${service} returned HTTP ${res.status}${detail ? `: ${detail.replace(/\.+$/, "")}` : ""}.${hint ? ` ${hint}` : ""}`, secrets));
37
+ }
38
+ export function redact(text, secrets) {
39
+ return secrets.filter((s) => Boolean(s && s.length >= 4)).reduce((t, s) => t.split(s).join("[redacted]"), text);
40
+ }
@@ -0,0 +1,18 @@
1
+ import { type HttpOptions } from "./http.js";
2
+ export interface IndexNowResult {
3
+ endpoint: string;
4
+ submitted: number;
5
+ /** 200 = accepted; 202 = received, key validation pending. */
6
+ status: number;
7
+ }
8
+ /**
9
+ * Notifies IndexNow-participating search engines that URLs changed. A hint,
10
+ * not a guarantee of crawling or indexing. The key file must already be live.
11
+ */
12
+ export declare function submitIndexNow(options: {
13
+ site: string;
14
+ key: string;
15
+ endpoint: string;
16
+ urls: string[];
17
+ http?: HttpOptions;
18
+ }): Promise<IndexNowResult>;
@@ -0,0 +1,38 @@
1
+ import { VageError } from "../core/errors.js";
2
+ import { ruleData } from "../guidelines/pack.js";
3
+ import { apiError, requestWithRetry } from "./http.js";
4
+ // https://www.indexnow.org/documentation (rule pack "indexnow.protocol")
5
+ const { maxUrls } = ruleData("indexnow.protocol");
6
+ /**
7
+ * Notifies IndexNow-participating search engines that URLs changed. A hint,
8
+ * not a guarantee of crawling or indexing. The key file must already be live.
9
+ */
10
+ export async function submitIndexNow(options) {
11
+ const { site, key, endpoint, http } = options;
12
+ const host = new URL(site).host;
13
+ const urls = [...new Set(options.urls)];
14
+ if (!urls.length)
15
+ throw new VageError("No URLs to submit.");
16
+ const foreign = urls.filter((u) => new URL(u).host !== host);
17
+ if (foreign.length)
18
+ throw new VageError(`IndexNow only accepts URLs on ${host}: ${foreign.slice(0, 3).join(", ")}`);
19
+ if (urls.length > maxUrls)
20
+ throw new VageError(`IndexNow accepts at most ${maxUrls} URLs per request; got ${urls.length}.`);
21
+ // Without a live key file the endpoint answers 403; check first for a clearer message.
22
+ const keyLocation = `${new URL(site).origin}/${key}.txt`;
23
+ const doFetch = http?.fetch ?? fetch;
24
+ const live = await doFetch(keyLocation, { signal: AbortSignal.timeout(30_000) }).catch(() => undefined);
25
+ if (!live?.ok || (await live.text()).trim() !== key) {
26
+ throw new VageError(`The key file ${keyLocation} is not live yet. Deploy the site (it is generated by vage build), then submit.`);
27
+ }
28
+ const res = await requestWithRetry("IndexNow", endpoint, { method: "POST", headers: { "content-type": "application/json; charset=utf-8" }, body: JSON.stringify({ host, key, keyLocation, urlList: urls }) }, [], { ...http, attempts: 1 });
29
+ if (res.status !== 200 && res.status !== 202) {
30
+ throw await apiError("IndexNow", res, [], {
31
+ 400: "The request was malformed.",
32
+ 403: "The key is not valid: the key file was not found or doesn't contain the key.",
33
+ 422: "The URLs don't belong to the host, or the key doesn't match.",
34
+ 429: "Too many requests; IndexNow may treat this as spam. Submit only changed URLs, less often.",
35
+ });
36
+ }
37
+ return { endpoint, submitted: urls.length, status: res.status };
38
+ }
@@ -0,0 +1,25 @@
1
+ import { type HttpOptions } from "./http.js";
2
+ export interface SearchRow {
3
+ key: string;
4
+ clicks: number;
5
+ impressions: number;
6
+ ctr: number;
7
+ position: number;
8
+ }
9
+ export interface SearchReport {
10
+ property: string;
11
+ startDate: string;
12
+ endDate: string;
13
+ queries: SearchRow[];
14
+ pages: SearchRow[];
15
+ }
16
+ export declare function listSites(token: string, opts?: HttpOptions): Promise<{
17
+ siteUrl: string;
18
+ permissionLevel: string;
19
+ }[]>;
20
+ /** Top queries and pages for the last `days` days (Pacific Time dates, finalized data only). */
21
+ export declare function searchReport(token: string, property: string, days: number, opts?: HttpOptions & {
22
+ now?: Date;
23
+ }): Promise<SearchReport>;
24
+ /** YYYY-MM-DD in America/Los_Angeles, `daysBack` days before `now`. Search Console dates are Pacific Time. */
25
+ export declare function pacificDate(now: Date, daysBack: number): string;
@@ -0,0 +1,39 @@
1
+ import { z } from "zod";
2
+ import { apiError, requestWithRetry } from "./http.js";
3
+ // https://developers.google.com/webmaster-tools/v1/searchanalytics/query
4
+ const API = "https://www.googleapis.com/webmasters/v3";
5
+ const auth = (token) => ({ authorization: `Bearer ${token}` });
6
+ export async function listSites(token, opts) {
7
+ const res = await requestWithRetry("Search Console API", `${API}/sites`, { headers: auth(token) }, [token], opts);
8
+ if (!res.ok)
9
+ throw await apiError("Search Console API", res, [token]);
10
+ const body = z.object({ siteEntry: z.array(z.object({ siteUrl: z.string(), permissionLevel: z.string() })).default([]) }).parse(await res.json());
11
+ return body.siteEntry;
12
+ }
13
+ const rowsSchema = z.object({
14
+ rows: z
15
+ .array(z.object({ keys: z.array(z.string()), clicks: z.number(), impressions: z.number(), ctr: z.number(), position: z.number() }))
16
+ .default([]),
17
+ });
18
+ /** Top queries and pages for the last `days` days (Pacific Time dates, finalized data only). */
19
+ export async function searchReport(token, property, days, opts = {}) {
20
+ const endDate = pacificDate(opts.now ?? new Date(), 0);
21
+ const startDate = pacificDate(opts.now ?? new Date(), days - 1);
22
+ const query = async (dimension) => {
23
+ const res = await requestWithRetry("Search Console API", `${API}/sites/${encodeURIComponent(property)}/searchAnalytics/query`, { method: "POST", headers: { ...auth(token), "content-type": "application/json" }, body: JSON.stringify({ startDate, endDate, dimensions: [dimension], rowLimit: 25 }) }, [token], opts);
24
+ if (!res.ok) {
25
+ throw await apiError("Search Console API", res, [token], {
26
+ 403: `No access to ${property}. Check the property name with "vage connect gsc" and that your Google account can view it.`,
27
+ });
28
+ }
29
+ return rowsSchema.parse(await res.json()).rows.map(({ keys, ...m }) => ({ key: keys[0] ?? "", ...m }));
30
+ };
31
+ return { property, startDate, endDate, queries: await query("query"), pages: await query("page") };
32
+ }
33
+ /** YYYY-MM-DD in America/Los_Angeles, `daysBack` days before `now`. Search Console dates are Pacific Time. */
34
+ export function pacificDate(now, daysBack) {
35
+ const today = new Intl.DateTimeFormat("en-CA", { timeZone: "America/Los_Angeles" }).format(now);
36
+ const d = new Date(`${today}T00:00:00Z`);
37
+ d.setUTCDate(d.getUTCDate() - daysBack);
38
+ return d.toISOString().slice(0, 10);
39
+ }
@@ -0,0 +1,22 @@
1
+ import type { SiteConfig } from "../core/config.js";
2
+ /**
3
+ * Analytics tags for production builds.
4
+ *
5
+ * - Google (GA4 or GTM): consent defaults are set before any measurement command
6
+ * (https://developers.google.com/tag-platform/security/guides/consent), and nothing
7
+ * loads on localhost, so `vage preview` sends no hits.
8
+ * - Plausible / Clarity: the owner's dashboard snippet, inserted verbatim (vagejs
9
+ * can't suppress those on localhost).
10
+ * - window.vageConsent.update({ analytics_storage, ad_storage }) forwards consent to
11
+ * Google (consent update) and Clarity (consentv2). Sites provide their own consent UI.
12
+ * - One click listener sends data-vage-event / data-vage-param-* to every configured provider.
13
+ */
14
+ export declare function analyticsTags(site: SiteConfig): {
15
+ head: string;
16
+ bodyStart?: string;
17
+ } | undefined;
18
+ /** Puts the tags first in <head> (before other scripts) and the GTM noscript right after <body>. */
19
+ export declare function injectAnalytics(html: string, tags: {
20
+ head: string;
21
+ bodyStart?: string;
22
+ }, file: string): string;
@@ -0,0 +1,67 @@
1
+ import { VageError } from "../core/errors.js";
2
+ import { ruleData } from "../guidelines/pack.js";
3
+ const CONSENT_TYPES = ruleData("consent.default-order").types;
4
+ const LOCAL_HOST = String.raw `/^(localhost|127\.0\.0\.1|\[::1\])$/`;
5
+ /**
6
+ * Analytics tags for production builds.
7
+ *
8
+ * - Google (GA4 or GTM): consent defaults are set before any measurement command
9
+ * (https://developers.google.com/tag-platform/security/guides/consent), and nothing
10
+ * loads on localhost, so `vage preview` sends no hits.
11
+ * - Plausible / Clarity: the owner's dashboard snippet, inserted verbatim (vagejs
12
+ * can't suppress those on localhost).
13
+ * - window.vageConsent.update({ analytics_storage, ad_storage }) forwards consent to
14
+ * Google (consent update) and Clarity (consentv2). Sites provide their own consent UI.
15
+ * - One click listener sends data-vage-event / data-vage-param-* to every configured provider.
16
+ */
17
+ export function analyticsTags(site) {
18
+ const { ga4, gtm, plausible, clarity } = site.integrations;
19
+ const google = ga4?.measurementId ? { kind: "ga4", id: ga4.measurementId, consent: ga4.consentDefault } : gtm?.containerId ? { kind: "gtm", id: gtm.containerId, consent: gtm.consentDefault } : undefined;
20
+ if (!google && !plausible && !clarity)
21
+ return undefined;
22
+ const parts = [];
23
+ let bodyStart;
24
+ const send = [];
25
+ if (google) {
26
+ const id = JSON.stringify(google.id);
27
+ let body = `window.dataLayer=window.dataLayer||[];window.gtag=function(){dataLayer.push(arguments)};` +
28
+ `gtag("consent","default",${JSON.stringify(Object.fromEntries(CONSENT_TYPES.map((t) => [t, google.consent])))});`;
29
+ if (google.kind === "ga4") {
30
+ body += `gtag("js",new Date());gtag("config",${id});` + loadScript(`"https://www.googletagmanager.com/gtag/js?id="+${id}`);
31
+ send.push(`if(window.gtag)gtag("event",n,p);`);
32
+ }
33
+ else {
34
+ body += `dataLayer.push({"gtm.start":new Date().getTime(),event:"gtm.js"});` + loadScript(`"https://www.googletagmanager.com/gtm.js?id="+${id}`);
35
+ send.push(`if(window.dataLayer){var d={event:n};for(var k in p)d[k]=p[k];dataLayer.push(d)}`);
36
+ bodyStart = `<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=${encodeURIComponent(google.id)}" height="0" width="0" style="display:none;visibility:hidden" title="Google Tag Manager"></iframe></noscript>`;
37
+ }
38
+ parts.push(`<script>if(!${LOCAL_HOST}.test(location.hostname)){${body}}</script>`);
39
+ }
40
+ if (plausible) {
41
+ parts.push(plausible.snippet.trim());
42
+ send.push(`if(window.plausible)plausible(n,{props:p});`);
43
+ }
44
+ if (clarity)
45
+ parts.push(clarity.snippet.trim());
46
+ // Consent bridge and event listener; safe when a provider failed to load.
47
+ parts.push(`<script>(function(){var s={};window.vageConsent={update:function(c){for(var k in c)s[k]=c[k];` +
48
+ `if(window.gtag)gtag("consent","update",c);` +
49
+ `if(window.clarity)clarity("consentv2",{ad_Storage:s.ad_storage||"denied",analytics_Storage:s.analytics_storage||"denied"})}};` +
50
+ `document.addEventListener("click",function(e){var el=e.target&&e.target.closest&&e.target.closest("[data-vage-event]");if(!el)return;` +
51
+ `var n=el.getAttribute("data-vage-event"),p={};for(var i=0;i<el.attributes.length;i++){var a=el.attributes[i];` +
52
+ `if(a.name.indexOf("data-vage-param-")===0)p[a.name.slice(16).replace(/-/g,"_")]=a.value}${send.join("")}})})();</script>`);
53
+ return { head: parts.join("\n "), bodyStart };
54
+ }
55
+ function loadScript(src) {
56
+ return `var s=document.createElement("script");s.async=true;s.src=${src};document.head.appendChild(s);`;
57
+ }
58
+ /** Puts the tags first in <head> (before other scripts) and the GTM noscript right after <body>. */
59
+ export function injectAnalytics(html, tags, file) {
60
+ const head = /<head[^>]*>/i.exec(html);
61
+ if (!head)
62
+ throw new VageError("Layout output has no <head>; it is needed for analytics tags.", file);
63
+ let out = html.slice(0, head.index + head[0].length) + `\n ${tags.head}` + html.slice(head.index + head[0].length);
64
+ if (tags.bodyStart)
65
+ out = out.replace(/<body[^>]*>/i, (m) => `${m}\n ${tags.bodyStart}`);
66
+ return out;
67
+ }
@@ -0,0 +1,20 @@
1
+ import type { Frontmatter } from "../content/markdown.js";
2
+ import type { SiteConfig } from "../core/config.js";
3
+ export interface PageMeta {
4
+ route: string;
5
+ frontmatter: Frontmatter;
6
+ /** Absolute canonical URL, when `site` is configured and the page is indexable. */
7
+ canonical?: string;
8
+ noindex: boolean;
9
+ }
10
+ export declare function pageMeta(site: SiteConfig, route: string, frontmatter: Frontmatter, notFound?: boolean): PageMeta;
11
+ interface Tag {
12
+ /** Matches an equivalent tag the layout already wrote; if found, ours is skipped. */
13
+ exists: RegExp;
14
+ html: string;
15
+ }
16
+ /** Builds the SEO tags for a page. Tags are omitted when their data is unknown; nothing is invented. */
17
+ export declare function headTags(site: SiteConfig, m: PageMeta): Tag[];
18
+ /** Inserts tags before </head>, skipping any the layout already provides. */
19
+ export declare function injectHead(html: string, tags: Tag[], file: string): string;
20
+ export {};