@consilioweb/payload-seo-analyzer 1.9.0 → 1.10.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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
+ import { randomBytes, timingSafeEqual, createCipheriv, scryptSync, createDecipheriv } from 'crypto';
1
2
  import { promises } from 'dns';
2
- import { randomBytes, timingSafeEqual, createCipheriv, createDecipheriv, scryptSync } from 'crypto';
3
3
 
4
4
  // src/constants.ts
5
5
  var TITLE_LENGTH_MIN = 30;
@@ -3119,6 +3119,327 @@ function createAiOptimizeHandler(targetCollections, seoConfig, localeMapping) {
3119
3119
  }
3120
3120
  };
3121
3121
  }
3122
+ var ALGO = "aes-256-gcm";
3123
+ var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
3124
+ var FORMAT_VERSION = "v1";
3125
+ function deriveKey(secret) {
3126
+ const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
3127
+ if (explicit) {
3128
+ const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
3129
+ if (buf.length === 32) return buf;
3130
+ throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
3131
+ }
3132
+ if (!secret) {
3133
+ throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
3134
+ }
3135
+ return scryptSync(secret, KEY_NAMESPACE, 32);
3136
+ }
3137
+ function encryptToken(plaintext, secret) {
3138
+ const key = deriveKey(secret);
3139
+ const iv = randomBytes(12);
3140
+ const cipher = createCipheriv(ALGO, key, iv);
3141
+ const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
3142
+ const tag = cipher.getAuthTag();
3143
+ return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
3144
+ }
3145
+ function decryptToken(payload, secret) {
3146
+ const parts = payload.split(":");
3147
+ if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
3148
+ throw new Error("Invalid encrypted token format.");
3149
+ }
3150
+ const key = deriveKey(secret);
3151
+ const iv = Buffer.from(parts[1], "base64");
3152
+ const tag = Buffer.from(parts[2], "base64");
3153
+ const enc = Buffer.from(parts[3], "base64");
3154
+ const decipher = createDecipheriv(ALGO, key, iv);
3155
+ decipher.setAuthTag(tag);
3156
+ const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
3157
+ return dec.toString("utf8");
3158
+ }
3159
+ function safeEqual(a, b) {
3160
+ const ba = Buffer.from(a);
3161
+ const bb = Buffer.from(b);
3162
+ if (ba.length !== bb.length) return false;
3163
+ return timingSafeEqual(ba, bb);
3164
+ }
3165
+
3166
+ // src/helpers/gscClient.ts
3167
+ var GSC_AUTH_COLLECTION = "seo-gsc-auth";
3168
+ var GSC_SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
3169
+ function isGscAdmin(user) {
3170
+ if (!user) return false;
3171
+ if (user.role === "admin") return true;
3172
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
3173
+ return false;
3174
+ }
3175
+ function resolveGscSiteUrl(seoConfig) {
3176
+ return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
3177
+ }
3178
+ function getGscOAuthConfig(basePath, seoConfig) {
3179
+ const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
3180
+ const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
3181
+ const siteUrl = resolveGscSiteUrl(seoConfig);
3182
+ if (!clientId || !clientSecret || !siteUrl) return null;
3183
+ return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
3184
+ }
3185
+ async function getOrCreateGscAuthDoc(payload) {
3186
+ const found = await payload.find({ collection: GSC_AUTH_COLLECTION, limit: 1, overrideAccess: true });
3187
+ if (found.docs.length > 0) return found.docs[0];
3188
+ return payload.create({ collection: GSC_AUTH_COLLECTION, data: {}, overrideAccess: true });
3189
+ }
3190
+ async function gscTokenRequest(cfg, body) {
3191
+ const resp = await fetch("https://oauth2.googleapis.com/token", {
3192
+ method: "POST",
3193
+ headers: { "content-type": "application/x-www-form-urlencoded" },
3194
+ body: new URLSearchParams({
3195
+ client_id: cfg.clientId,
3196
+ client_secret: cfg.clientSecret,
3197
+ ...body
3198
+ }).toString()
3199
+ });
3200
+ const json = await resp.json();
3201
+ if (!resp.ok) {
3202
+ throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
3203
+ }
3204
+ return json;
3205
+ }
3206
+ async function getGscAccessToken(payload, cfg, authDoc) {
3207
+ if (!authDoc?.refreshTokenEnc) throw new Error("not_connected");
3208
+ const secret = payload.secret || "";
3209
+ let refreshToken;
3210
+ try {
3211
+ refreshToken = decryptToken(authDoc.refreshTokenEnc, secret);
3212
+ } catch {
3213
+ throw new Error("decrypt_failed");
3214
+ }
3215
+ const tokens = await gscTokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
3216
+ const accessToken = tokens.access_token;
3217
+ if (!accessToken) throw new Error("refresh_failed");
3218
+ return accessToken;
3219
+ }
3220
+ async function queryGscSearchAnalytics(accessToken, property, body) {
3221
+ const resp = await fetch(
3222
+ `https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(property)}/searchAnalytics/query`,
3223
+ {
3224
+ method: "POST",
3225
+ headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
3226
+ body: JSON.stringify(body)
3227
+ }
3228
+ );
3229
+ const json = await resp.json();
3230
+ if (!resp.ok) {
3231
+ const err = json.error?.message || resp.status;
3232
+ throw new Error(`GSC query failed: ${err}`);
3233
+ }
3234
+ return json.rows || [];
3235
+ }
3236
+
3237
+ // src/endpoints/aiAltText.ts
3238
+ var DEFAULT_MODEL2 = "claude-opus-4-8";
3239
+ var ALT_MAX = 125;
3240
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
3241
+ var SUPPORTED_MIME = {
3242
+ "image/jpeg": "image/jpeg",
3243
+ "image/jpg": "image/jpeg",
3244
+ "image/png": "image/png",
3245
+ "image/gif": "image/gif",
3246
+ "image/webp": "image/webp"
3247
+ };
3248
+ function isAdmin4(user) {
3249
+ if (!user) return false;
3250
+ if (user.role === "admin") return true;
3251
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
3252
+ return false;
3253
+ }
3254
+ function resolveImageUrl(media, siteUrl) {
3255
+ const raw = typeof media.url === "string" && media.url || (typeof media.filename === "string" ? `/media/${media.filename}` : "");
3256
+ if (!raw) return null;
3257
+ let absolute;
3258
+ if (/^https?:\/\//i.test(raw)) {
3259
+ absolute = raw;
3260
+ } else if (siteUrl) {
3261
+ absolute = `${siteUrl.replace(/\/$/, "")}${raw.startsWith("/") ? "" : "/"}${raw}`;
3262
+ } else {
3263
+ return null;
3264
+ }
3265
+ try {
3266
+ const target = new URL(absolute);
3267
+ if (target.protocol !== "http:" && target.protocol !== "https:") return null;
3268
+ const allowed = /* @__PURE__ */ new Set();
3269
+ if (siteUrl) allowed.add(new URL(siteUrl).origin);
3270
+ if (process.env.SEO_MEDIA_ORIGIN) allowed.add(new URL(process.env.SEO_MEDIA_ORIGIN).origin);
3271
+ if (allowed.size > 0 && !allowed.has(target.origin)) return null;
3272
+ if (allowed.size === 0) return null;
3273
+ return target.toString();
3274
+ } catch {
3275
+ return null;
3276
+ }
3277
+ }
3278
+ async function generateAltText(apiKey, model, base64, mediaType, language, context) {
3279
+ const systemPrompt = `You write concise, descriptive image ALT text for accessibility and SEO.
3280
+ Rules:
3281
+ - Describe what is actually visible in the image.
3282
+ - Maximum ${ALT_MAX} characters.
3283
+ - Write in ${language === "en" ? "English" : "French"}.
3284
+ - Do NOT start with "image of", "photo of", "picture of" or similar.
3285
+ - No quotes around the result. Return ONLY the alt text, nothing else.`;
3286
+ const userText = `Filename: ${context.filename}${context.title ? `
3287
+ Page/context: ${context.title}` : ""}
3288
+ Write the alt text for this image:`;
3289
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
3290
+ method: "POST",
3291
+ headers: {
3292
+ "Content-Type": "application/json",
3293
+ "x-api-key": apiKey,
3294
+ "anthropic-version": "2023-06-01"
3295
+ },
3296
+ body: JSON.stringify({
3297
+ model,
3298
+ max_tokens: 150,
3299
+ system: systemPrompt,
3300
+ messages: [
3301
+ {
3302
+ role: "user",
3303
+ content: [
3304
+ { type: "image", source: { type: "base64", media_type: mediaType, data: base64 } },
3305
+ { type: "text", text: userText }
3306
+ ]
3307
+ }
3308
+ ]
3309
+ })
3310
+ });
3311
+ if (!response.ok) {
3312
+ const body = await response.text();
3313
+ throw new Error(`Claude API error ${response.status}: ${body}`);
3314
+ }
3315
+ const data = await response.json();
3316
+ if (data.stop_reason === "refusal") return null;
3317
+ const text = (data.content?.find((b) => b.type === "text")?.text || "").trim().replace(/^["']|["']$/g, "");
3318
+ if (!text) return null;
3319
+ return text.length > ALT_MAX ? text.slice(0, ALT_MAX).trim() : text;
3320
+ }
3321
+ function createAltTextAuditHandler(uploadsCollection) {
3322
+ return async (req) => {
3323
+ try {
3324
+ if (!isAdmin4(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
3325
+ const url = new URL(req.url);
3326
+ const limit = Math.min(200, Math.max(1, parseInt(url.searchParams.get("limit") || "50", 10)));
3327
+ try {
3328
+ const missing = await req.payload.find({
3329
+ collection: uploadsCollection,
3330
+ where: { or: [{ alt: { exists: false } }, { alt: { equals: "" } }] },
3331
+ limit,
3332
+ depth: 0,
3333
+ overrideAccess: true
3334
+ });
3335
+ const items = missing.docs.map((d) => ({
3336
+ id: d.id,
3337
+ filename: d.filename || "",
3338
+ url: d.url || "",
3339
+ mimeType: d.mimeType || "",
3340
+ alt: d.alt || ""
3341
+ }));
3342
+ return Response.json(
3343
+ { collection: uploadsCollection, missingCount: missing.totalDocs, items },
3344
+ { headers: { "Cache-Control": "no-store" } }
3345
+ );
3346
+ } catch {
3347
+ return Response.json(
3348
+ { collection: uploadsCollection, missingCount: 0, items: [], note: "no_alt_field" },
3349
+ { headers: { "Cache-Control": "no-store" } }
3350
+ );
3351
+ }
3352
+ } catch (error) {
3353
+ const message = error instanceof Error ? error.message : "Internal server error";
3354
+ req.payload.logger.error(`[seo] alt-text-audit error: ${message}`);
3355
+ return Response.json({ error: message }, { status: 500 });
3356
+ }
3357
+ };
3358
+ }
3359
+ function createAiAltTextHandler(uploadsCollection, seoConfig) {
3360
+ return async (req) => {
3361
+ try {
3362
+ if (!isAdmin4(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
3363
+ const body = await parseJsonBody(req);
3364
+ const collection = typeof body.collection === "string" ? body.collection : uploadsCollection;
3365
+ const id = body.id != null ? String(body.id) : void 0;
3366
+ const apply = body.apply === true;
3367
+ const providedAlt = typeof body.altText === "string" ? body.altText.trim() : void 0;
3368
+ if (!id) return Response.json({ error: "Missing required field: id" }, { status: 400 });
3369
+ if (apply && providedAlt) {
3370
+ const alt2 = providedAlt.slice(0, ALT_MAX);
3371
+ await req.payload.update({ collection, id, data: { alt: alt2 }, overrideAccess: true });
3372
+ return Response.json({ alt: alt2, applied: true, method: "manual" });
3373
+ }
3374
+ const apiKey = process.env.ANTHROPIC_API_KEY;
3375
+ if (!apiKey) {
3376
+ return Response.json(
3377
+ { error: "AI not configured. Set ANTHROPIC_API_KEY to generate alt text.", code: "no_api_key" },
3378
+ { status: 400 }
3379
+ );
3380
+ }
3381
+ let media;
3382
+ try {
3383
+ media = await req.payload.findByID({ collection, id, depth: 0, overrideAccess: true });
3384
+ } catch {
3385
+ return Response.json({ error: `Media not found: ${collection}/${id}` }, { status: 404 });
3386
+ }
3387
+ const mime = media.mimeType || "";
3388
+ const mediaType = SUPPORTED_MIME[mime.toLowerCase()];
3389
+ if (!mediaType) {
3390
+ return Response.json(
3391
+ { error: `Unsupported image type for vision: ${mime || "unknown"} (use JPEG, PNG, GIF or WebP).` },
3392
+ { status: 422 }
3393
+ );
3394
+ }
3395
+ const siteUrl = resolveGscSiteUrl(seoConfig);
3396
+ const imageUrl = resolveImageUrl(media, siteUrl);
3397
+ if (!imageUrl) {
3398
+ return Response.json(
3399
+ { error: "Could not resolve a safe image URL (must be on the site origin or SEO_MEDIA_ORIGIN)." },
3400
+ { status: 422 }
3401
+ );
3402
+ }
3403
+ let base64;
3404
+ try {
3405
+ const imgResp = await fetch(imageUrl);
3406
+ if (!imgResp.ok) throw new Error(`fetch ${imgResp.status}`);
3407
+ const buf = Buffer.from(await imgResp.arrayBuffer());
3408
+ if (buf.byteLength > MAX_IMAGE_BYTES) {
3409
+ return Response.json({ error: "Image too large for vision (max 5 MB)." }, { status: 413 });
3410
+ }
3411
+ base64 = buf.toString("base64");
3412
+ } catch (e) {
3413
+ return Response.json({ error: `Could not fetch image: ${e instanceof Error ? e.message : "error"}` }, { status: 502 });
3414
+ }
3415
+ const model = process.env.SEO_AI_MODEL || DEFAULT_MODEL2;
3416
+ const language = seoConfig?.locale === "en" ? "en" : "fr";
3417
+ let alt;
3418
+ try {
3419
+ alt = await generateAltText(apiKey, model, base64, mediaType, language, {
3420
+ filename: media.filename || "",
3421
+ title: typeof body.context === "string" ? body.context : void 0
3422
+ });
3423
+ } catch (e) {
3424
+ req.payload.logger.error(`[seo] ai-alt-text Claude error: ${e instanceof Error ? e.message : "unknown"}`);
3425
+ return Response.json({ error: "Alt-text generation failed." }, { status: 502 });
3426
+ }
3427
+ if (!alt) {
3428
+ return Response.json({ error: "The model did not return alt text (possibly declined)." }, { status: 502 });
3429
+ }
3430
+ let applied = false;
3431
+ if (apply) {
3432
+ await req.payload.update({ collection, id, data: { alt }, overrideAccess: true });
3433
+ applied = true;
3434
+ }
3435
+ return Response.json({ alt, applied, method: "ai", model });
3436
+ } catch (error) {
3437
+ const message = error instanceof Error ? error.message : "Internal server error";
3438
+ req.payload.logger.error(`[seo] ai-alt-text error: ${message}`);
3439
+ return Response.json({ error: message }, { status: 500 });
3440
+ }
3441
+ };
3442
+ }
3122
3443
 
3123
3444
  // src/endpoints/cannibalization.ts
3124
3445
  function canonicalIntent(keyword) {
@@ -3704,7 +4025,7 @@ function getDateThreshold(period) {
3704
4025
  return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
3705
4026
  }
3706
4027
  }
3707
- function isAdmin4(user) {
4028
+ function isAdmin5(user) {
3708
4029
  if (!user) return false;
3709
4030
  if (user.role === "admin") return true;
3710
4031
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -3809,7 +4130,7 @@ function createPerformanceHandler() {
3809
4130
  });
3810
4131
  }
3811
4132
  if (method === "POST") {
3812
- if (!isAdmin4(req.user)) {
4133
+ if (!isAdmin5(req.user)) {
3813
4134
  return Response.json({ error: "Admin access required" }, { status: 403 });
3814
4135
  }
3815
4136
  const body = await parseJsonBody(req);
@@ -4107,96 +4428,12 @@ function createCoreWebVitalsHandler(seoConfig) {
4107
4428
  }
4108
4429
  };
4109
4430
  }
4110
- var ALGO = "aes-256-gcm";
4111
- var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
4112
- var FORMAT_VERSION = "v1";
4113
- function deriveKey(secret) {
4114
- const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
4115
- if (explicit) {
4116
- const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
4117
- if (buf.length === 32) return buf;
4118
- throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
4119
- }
4120
- if (!secret) {
4121
- throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
4122
- }
4123
- return scryptSync(secret, KEY_NAMESPACE, 32);
4124
- }
4125
- function encryptToken(plaintext, secret) {
4126
- const key = deriveKey(secret);
4127
- const iv = randomBytes(12);
4128
- const cipher = createCipheriv(ALGO, key, iv);
4129
- const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
4130
- const tag = cipher.getAuthTag();
4131
- return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
4132
- }
4133
- function decryptToken(payload, secret) {
4134
- const parts = payload.split(":");
4135
- if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
4136
- throw new Error("Invalid encrypted token format.");
4137
- }
4138
- const key = deriveKey(secret);
4139
- const iv = Buffer.from(parts[1], "base64");
4140
- const tag = Buffer.from(parts[2], "base64");
4141
- const enc = Buffer.from(parts[3], "base64");
4142
- const decipher = createDecipheriv(ALGO, key, iv);
4143
- decipher.setAuthTag(tag);
4144
- const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
4145
- return dec.toString("utf8");
4146
- }
4147
- function safeEqual(a, b) {
4148
- const ba = Buffer.from(a);
4149
- const bb = Buffer.from(b);
4150
- if (ba.length !== bb.length) return false;
4151
- return timingSafeEqual(ba, bb);
4152
- }
4153
-
4154
- // src/endpoints/gscOAuth.ts
4155
- var AUTH_COLLECTION = "seo-gsc-auth";
4156
- var SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
4157
- function isAdmin5(user) {
4158
- if (!user) return false;
4159
- if (user.role === "admin") return true;
4160
- if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
4161
- return false;
4162
- }
4163
- function resolveSiteUrl2(seoConfig) {
4164
- return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
4165
- }
4166
- function getOAuthConfig(basePath, seoConfig) {
4167
- const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
4168
- const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
4169
- const siteUrl = resolveSiteUrl2(seoConfig);
4170
- if (!clientId || !clientSecret || !siteUrl) return null;
4171
- return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
4172
- }
4173
- async function getOrCreateAuthDoc(payload) {
4174
- const found = await payload.find({ collection: AUTH_COLLECTION, limit: 1, overrideAccess: true });
4175
- if (found.docs.length > 0) return found.docs[0];
4176
- return payload.create({ collection: AUTH_COLLECTION, data: {}, overrideAccess: true });
4177
- }
4178
- async function tokenRequest(cfg, body) {
4179
- const resp = await fetch("https://oauth2.googleapis.com/token", {
4180
- method: "POST",
4181
- headers: { "content-type": "application/x-www-form-urlencoded" },
4182
- body: new URLSearchParams({
4183
- client_id: cfg.clientId,
4184
- client_secret: cfg.clientSecret,
4185
- ...body
4186
- }).toString()
4187
- });
4188
- const json = await resp.json();
4189
- if (!resp.ok) {
4190
- throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
4191
- }
4192
- return json;
4193
- }
4194
4431
  function createGscStatusHandler(basePath, seoConfig) {
4195
4432
  return async (req) => {
4196
4433
  try {
4197
4434
  if (!req.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
4198
- const cfg = getOAuthConfig(basePath, seoConfig);
4199
- const doc = await getOrCreateAuthDoc(req.payload);
4435
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
4436
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4200
4437
  return Response.json(
4201
4438
  {
4202
4439
  configured: !!cfg,
@@ -4218,8 +4455,8 @@ function createGscStatusHandler(basePath, seoConfig) {
4218
4455
  function createGscAuthStartHandler(basePath, seoConfig) {
4219
4456
  return async (req) => {
4220
4457
  try {
4221
- if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4222
- const cfg = getOAuthConfig(basePath, seoConfig);
4458
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4459
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
4223
4460
  if (!cfg) {
4224
4461
  return Response.json(
4225
4462
  { error: "GSC OAuth not configured. Set GSC_OAUTH_CLIENT_ID, GSC_OAUTH_CLIENT_SECRET and siteUrl." },
@@ -4227,9 +4464,9 @@ function createGscAuthStartHandler(basePath, seoConfig) {
4227
4464
  );
4228
4465
  }
4229
4466
  const state = randomBytes(24).toString("hex");
4230
- const doc = await getOrCreateAuthDoc(req.payload);
4467
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4231
4468
  await req.payload.update({
4232
- collection: AUTH_COLLECTION,
4469
+ collection: GSC_AUTH_COLLECTION,
4233
4470
  id: doc.id,
4234
4471
  data: { pendingState: state },
4235
4472
  overrideAccess: true
@@ -4238,7 +4475,7 @@ function createGscAuthStartHandler(basePath, seoConfig) {
4238
4475
  authUrl.searchParams.set("client_id", cfg.clientId);
4239
4476
  authUrl.searchParams.set("redirect_uri", cfg.redirectUri);
4240
4477
  authUrl.searchParams.set("response_type", "code");
4241
- authUrl.searchParams.set("scope", SCOPES);
4478
+ authUrl.searchParams.set("scope", GSC_SCOPES);
4242
4479
  authUrl.searchParams.set("access_type", "offline");
4243
4480
  authUrl.searchParams.set("prompt", "consent");
4244
4481
  authUrl.searchParams.set("state", state);
@@ -4257,10 +4494,10 @@ function createGscCallbackHandler(basePath, seoConfig) {
4257
4494
  { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }
4258
4495
  );
4259
4496
  try {
4260
- if (!isAdmin5(req.user)) {
4497
+ if (!isGscAdmin(req.user)) {
4261
4498
  return htmlPage("Connection failed", "You must be signed in as an admin to connect Google Search Console.");
4262
4499
  }
4263
- const cfg = getOAuthConfig(basePath, seoConfig);
4500
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
4264
4501
  if (!cfg) return htmlPage("Connection failed", "GSC OAuth is not configured on the server.");
4265
4502
  const url = new URL(req.url);
4266
4503
  const code = url.searchParams.get("code");
@@ -4268,11 +4505,11 @@ function createGscCallbackHandler(basePath, seoConfig) {
4268
4505
  const oauthError = url.searchParams.get("error");
4269
4506
  if (oauthError) return htmlPage("Connection cancelled", `Google returned: ${oauthError}`);
4270
4507
  if (!code || !state) return htmlPage("Connection failed", "Missing code or state.");
4271
- const doc = await getOrCreateAuthDoc(req.payload);
4508
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4272
4509
  if (!doc.pendingState || !safeEqual(state, doc.pendingState)) {
4273
4510
  return htmlPage("Connection failed", "Invalid state (possible CSRF). Please restart the connection.");
4274
4511
  }
4275
- const tokens = await tokenRequest(cfg, {
4512
+ const tokens = await gscTokenRequest(cfg, {
4276
4513
  code,
4277
4514
  redirect_uri: cfg.redirectUri,
4278
4515
  grant_type: "authorization_code"
@@ -4298,14 +4535,14 @@ function createGscCallbackHandler(basePath, seoConfig) {
4298
4535
  const secret = req.payload.secret || "";
4299
4536
  const refreshTokenEnc = encryptToken(refreshToken, secret);
4300
4537
  await req.payload.update({
4301
- collection: AUTH_COLLECTION,
4538
+ collection: GSC_AUTH_COLLECTION,
4302
4539
  id: doc.id,
4303
4540
  data: {
4304
4541
  refreshTokenEnc,
4305
4542
  pendingState: null,
4306
4543
  connectedEmail: email,
4307
4544
  connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
4308
- scope: tokens.scope || SCOPES,
4545
+ scope: tokens.scope || GSC_SCOPES,
4309
4546
  propertyUrl: doc.propertyUrl || cfg.siteUrl
4310
4547
  },
4311
4548
  overrideAccess: true
@@ -4321,26 +4558,26 @@ function createGscCallbackHandler(basePath, seoConfig) {
4321
4558
  function createGscDataHandler(basePath, seoConfig) {
4322
4559
  return async (req) => {
4323
4560
  try {
4324
- if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4325
- const cfg = getOAuthConfig(basePath, seoConfig);
4561
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4562
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
4326
4563
  if (!cfg) return Response.json({ error: "GSC OAuth not configured." }, { status: 400 });
4327
- const doc = await getOrCreateAuthDoc(req.payload);
4564
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4328
4565
  if (!doc.refreshTokenEnc) {
4329
4566
  return Response.json({ error: "Not connected to Google Search Console." }, { status: 409 });
4330
4567
  }
4331
- const secret = req.payload.secret || "";
4332
- let refreshToken;
4568
+ let accessToken;
4333
4569
  try {
4334
- refreshToken = decryptToken(doc.refreshTokenEnc, secret);
4335
- } catch {
4336
- return Response.json(
4337
- { error: "Stored token could not be decrypted (encryption key changed?). Reconnect GSC." },
4338
- { status: 409 }
4339
- );
4570
+ accessToken = await getGscAccessToken(req.payload, cfg, doc);
4571
+ } catch (e) {
4572
+ const code = e instanceof Error ? e.message : "refresh_failed";
4573
+ if (code === "decrypt_failed") {
4574
+ return Response.json(
4575
+ { error: "Stored token could not be decrypted (encryption key changed?). Reconnect GSC." },
4576
+ { status: 409 }
4577
+ );
4578
+ }
4579
+ return Response.json({ error: "Could not refresh access token." }, { status: 502 });
4340
4580
  }
4341
- const tokens = await tokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
4342
- const accessToken = tokens.access_token;
4343
- if (!accessToken) return Response.json({ error: "Could not refresh access token." }, { status: 502 });
4344
4581
  const url = new URL(req.url);
4345
4582
  const today = /* @__PURE__ */ new Date();
4346
4583
  const defaultEnd = today.toISOString().slice(0, 10);
@@ -4350,21 +4587,19 @@ function createGscDataHandler(basePath, seoConfig) {
4350
4587
  const dimension = url.searchParams.get("dimension") === "page" ? "page" : "query";
4351
4588
  const rowLimit = Math.min(1e3, Math.max(1, parseInt(url.searchParams.get("rowLimit") || "100", 10)));
4352
4589
  const property = doc.propertyUrl || cfg.siteUrl;
4353
- const gscResp = await fetch(
4354
- `https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(property)}/searchAnalytics/query`,
4355
- {
4356
- method: "POST",
4357
- headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
4358
- body: JSON.stringify({ startDate, endDate, dimensions: [dimension], rowLimit })
4359
- }
4360
- );
4361
- const gscJson = await gscResp.json();
4362
- if (!gscResp.ok) {
4363
- const err = gscJson.error?.message || gscResp.status;
4364
- return Response.json({ error: `GSC query failed: ${err}` }, { status: 502 });
4590
+ let rows;
4591
+ try {
4592
+ rows = await queryGscSearchAnalytics(accessToken, property, {
4593
+ startDate,
4594
+ endDate,
4595
+ dimensions: [dimension],
4596
+ rowLimit
4597
+ });
4598
+ } catch (e) {
4599
+ return Response.json({ error: e instanceof Error ? e.message : "GSC query failed" }, { status: 502 });
4365
4600
  }
4366
4601
  return Response.json(
4367
- { property, startDate, endDate, dimension, rows: gscJson.rows || [] },
4602
+ { property, startDate, endDate, dimension, rows },
4368
4603
  { headers: { "Cache-Control": "no-store" } }
4369
4604
  );
4370
4605
  } catch (error) {
@@ -4377,10 +4612,10 @@ function createGscDataHandler(basePath, seoConfig) {
4377
4612
  function createGscDisconnectHandler() {
4378
4613
  return async (req) => {
4379
4614
  try {
4380
- if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4381
- const doc = await getOrCreateAuthDoc(req.payload);
4615
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4616
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4382
4617
  await req.payload.update({
4383
- collection: AUTH_COLLECTION,
4618
+ collection: GSC_AUTH_COLLECTION,
4384
4619
  id: doc.id,
4385
4620
  data: { refreshTokenEnc: null, pendingState: null, connectedEmail: null, connectedAt: null, scope: null },
4386
4621
  overrideAccess: true
@@ -4925,7 +5160,33 @@ function createLinkGraphHandler(targetCollections, globals = []) {
4925
5160
  };
4926
5161
  }
4927
5162
 
4928
- // src/endpoints/schemaGenerator.ts
5163
+ // src/helpers/buildSchema.ts
5164
+ var SCHEMA_TYPES = [
5165
+ "Article",
5166
+ "LocalBusiness",
5167
+ "BreadcrumbList",
5168
+ "FAQPage",
5169
+ "Product",
5170
+ "Organization",
5171
+ "Person",
5172
+ "Event",
5173
+ "Recipe",
5174
+ "Video"
5175
+ ];
5176
+ function resolveSiteUrl2(explicit) {
5177
+ return (explicit || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "http://localhost:3000").replace(/\/$/, "");
5178
+ }
5179
+ function getSchemaImageUrl(metaImage, heroMedia, siteUrl) {
5180
+ const img = metaImage || heroMedia;
5181
+ if (!img) return void 0;
5182
+ if (typeof img.url === "string") {
5183
+ return img.url.startsWith("http") ? img.url : `${siteUrl}${img.url}`;
5184
+ }
5185
+ if (typeof img.filename === "string") {
5186
+ return `${siteUrl}/media/${img.filename}`;
5187
+ }
5188
+ return void 0;
5189
+ }
4929
5190
  function detectSchemaType(collection, doc) {
4930
5191
  if (collection === "posts") return "Article";
4931
5192
  const layout = doc.layout;
@@ -4946,16 +5207,25 @@ function detectSchemaType(collection, doc) {
4946
5207
  }
4947
5208
  return "Article";
4948
5209
  }
5210
+ function buildAuthors(authors) {
5211
+ return authors.filter((a) => a && typeof a === "object").map((a) => {
5212
+ const author = a;
5213
+ return {
5214
+ "@type": "Person",
5215
+ name: author.name || author.firstName || "Author"
5216
+ };
5217
+ });
5218
+ }
4949
5219
  function buildArticleSchema(doc, siteUrl) {
4950
5220
  const meta = doc.meta || {};
4951
5221
  const heroMedia = doc.hero?.media;
4952
- const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
5222
+ const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
4953
5223
  const schema = {
4954
5224
  "@context": "https://schema.org",
4955
5225
  "@type": "Article",
4956
5226
  headline: meta.title || doc.title || "",
4957
5227
  description: meta.description || "",
4958
- datePublished: doc.createdAt || void 0,
5228
+ datePublished: doc.publishedAt || doc.createdAt || void 0,
4959
5229
  dateModified: doc.updatedAt || void 0,
4960
5230
  mainEntityOfPage: {
4961
5231
  "@type": "WebPage",
@@ -5054,7 +5324,7 @@ function buildFAQSchema(doc) {
5054
5324
  function buildProductSchema(doc, siteUrl) {
5055
5325
  const meta = doc.meta || {};
5056
5326
  const heroMedia = doc.hero?.media;
5057
- const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
5327
+ const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
5058
5328
  const schema = {
5059
5329
  "@context": "https://schema.org",
5060
5330
  "@type": "Product",
@@ -5127,7 +5397,7 @@ function buildEventSchema(doc, siteUrl) {
5127
5397
  function buildRecipeSchema(doc, siteUrl) {
5128
5398
  const meta = doc.meta || {};
5129
5399
  const heroMedia = doc.hero?.media;
5130
- const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
5400
+ const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
5131
5401
  const schema = {
5132
5402
  "@context": "https://schema.org",
5133
5403
  "@type": "Recipe",
@@ -5144,7 +5414,7 @@ function buildRecipeSchema(doc, siteUrl) {
5144
5414
  function buildVideoSchema(doc, siteUrl) {
5145
5415
  const meta = doc.meta || {};
5146
5416
  const heroMedia = doc.hero?.media;
5147
- const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
5417
+ const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
5148
5418
  const schema = {
5149
5419
  "@context": "https://schema.org",
5150
5420
  "@type": "VideoObject",
@@ -5157,26 +5427,51 @@ function buildVideoSchema(doc, siteUrl) {
5157
5427
  if (doc.duration) schema.duration = doc.duration;
5158
5428
  return schema;
5159
5429
  }
5160
- function getImageUrl(metaImage, heroMedia, siteUrl) {
5161
- const img = metaImage || heroMedia;
5162
- if (!img) return void 0;
5163
- if (typeof img.url === "string") {
5164
- return img.url.startsWith("http") ? img.url : `${siteUrl}${img.url}`;
5165
- }
5166
- if (typeof img.filename === "string") {
5167
- return `${siteUrl}/media/${img.filename}`;
5168
- }
5169
- return void 0;
5170
- }
5171
- function buildAuthors(authors) {
5172
- return authors.filter((a) => a && typeof a === "object").map((a) => {
5173
- const author = a;
5174
- return {
5175
- "@type": "Person",
5176
- name: author.name || author.firstName || "Author"
5177
- };
5178
- });
5430
+ function buildJsonLd(doc, options = {}) {
5431
+ const siteUrl = resolveSiteUrl2(options.siteUrl);
5432
+ const schemaType = options.type || detectSchemaType(options.collection || "", doc);
5433
+ let jsonLd;
5434
+ switch (schemaType) {
5435
+ case "Article":
5436
+ jsonLd = buildArticleSchema(doc, siteUrl);
5437
+ break;
5438
+ case "LocalBusiness":
5439
+ jsonLd = buildLocalBusinessSchema(doc, siteUrl);
5440
+ break;
5441
+ case "BreadcrumbList":
5442
+ jsonLd = buildBreadcrumbSchema(doc, siteUrl);
5443
+ break;
5444
+ case "FAQPage":
5445
+ jsonLd = buildFAQSchema(doc);
5446
+ break;
5447
+ case "Product":
5448
+ jsonLd = buildProductSchema(doc, siteUrl);
5449
+ break;
5450
+ case "Organization":
5451
+ jsonLd = buildOrganizationSchema(doc, siteUrl);
5452
+ break;
5453
+ case "Person":
5454
+ jsonLd = buildPersonSchema(doc, siteUrl);
5455
+ break;
5456
+ case "Event":
5457
+ jsonLd = buildEventSchema(doc, siteUrl);
5458
+ break;
5459
+ case "Recipe":
5460
+ jsonLd = buildRecipeSchema(doc, siteUrl);
5461
+ break;
5462
+ case "Video":
5463
+ jsonLd = buildVideoSchema(doc, siteUrl);
5464
+ break;
5465
+ }
5466
+ const cleaned = JSON.parse(JSON.stringify(jsonLd));
5467
+ return { type: schemaType, jsonLd: cleaned };
5468
+ }
5469
+ function renderJsonLdScript(doc, options = {}) {
5470
+ const { jsonLd } = buildJsonLd(doc, options);
5471
+ return `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`;
5179
5472
  }
5473
+
5474
+ // src/endpoints/schemaGenerator.ts
5180
5475
  function createSchemaGeneratorHandler(targetCollections) {
5181
5476
  return async (req) => {
5182
5477
  try {
@@ -5188,74 +5483,30 @@ function createSchemaGeneratorHandler(targetCollections) {
5188
5483
  const id = url.searchParams.get("id");
5189
5484
  const typeOverrideRaw = url.searchParams.get("type");
5190
5485
  if (!collection || !id) {
5191
- return Response.json(
5192
- { error: "Missing required query params: collection, id" },
5193
- { status: 400 }
5194
- );
5486
+ return Response.json({ error: "Missing required query params: collection, id" }, { status: 400 });
5195
5487
  }
5196
- const validTypes = ["Article", "LocalBusiness", "BreadcrumbList", "FAQPage", "Product", "Organization", "Person", "Event", "Recipe", "Video"];
5197
- if (typeOverrideRaw !== null && !validTypes.includes(typeOverrideRaw)) {
5488
+ if (typeOverrideRaw !== null && !SCHEMA_TYPES.includes(typeOverrideRaw)) {
5198
5489
  return Response.json(
5199
- { error: `Invalid schema type. Valid types: ${validTypes.join(", ")}` },
5490
+ { error: `Invalid schema type. Valid types: ${SCHEMA_TYPES.join(", ")}` },
5200
5491
  { status: 400 }
5201
5492
  );
5202
5493
  }
5203
- const typeOverride = typeOverrideRaw;
5494
+ const typeOverride = typeOverrideRaw || void 0;
5204
5495
  if (targetCollections && !targetCollections.includes(collection)) {
5205
5496
  return Response.json({ error: "Collection not allowed" }, { status: 403 });
5206
5497
  }
5207
5498
  let doc;
5208
5499
  try {
5209
- const result = await req.payload.findByID({
5210
- collection,
5211
- id,
5212
- depth: 1,
5213
- overrideAccess: true
5214
- });
5500
+ const result = await req.payload.findByID({ collection, id, depth: 1, overrideAccess: true });
5215
5501
  doc = result;
5216
5502
  } catch {
5217
5503
  return Response.json({ error: `Document not found: ${collection}/${id}` }, { status: 404 });
5218
5504
  }
5219
- const siteUrl = (process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "http://localhost:3000").replace(/\/$/, "");
5220
- const schemaType = typeOverride || detectSchemaType(collection, doc);
5221
- let jsonLd;
5222
- switch (schemaType) {
5223
- case "Article":
5224
- jsonLd = buildArticleSchema(doc, siteUrl);
5225
- break;
5226
- case "LocalBusiness":
5227
- jsonLd = buildLocalBusinessSchema(doc, siteUrl);
5228
- break;
5229
- case "BreadcrumbList":
5230
- jsonLd = buildBreadcrumbSchema(doc, siteUrl);
5231
- break;
5232
- case "FAQPage":
5233
- jsonLd = buildFAQSchema(doc);
5234
- break;
5235
- case "Product":
5236
- jsonLd = buildProductSchema(doc, siteUrl);
5237
- break;
5238
- case "Organization":
5239
- jsonLd = buildOrganizationSchema(doc, siteUrl);
5240
- break;
5241
- case "Person":
5242
- jsonLd = buildPersonSchema(doc, siteUrl);
5243
- break;
5244
- case "Event":
5245
- jsonLd = buildEventSchema(doc, siteUrl);
5246
- break;
5247
- case "Recipe":
5248
- jsonLd = buildRecipeSchema(doc, siteUrl);
5249
- break;
5250
- case "Video":
5251
- jsonLd = buildVideoSchema(doc, siteUrl);
5252
- break;
5253
- }
5254
- const cleaned = JSON.parse(JSON.stringify(jsonLd));
5505
+ const { type, jsonLd } = buildJsonLd(doc, { collection, type: typeOverride });
5255
5506
  return Response.json({
5256
- type: schemaType,
5257
- jsonLd: cleaned,
5258
- html: `<script type="application/ld+json">${JSON.stringify(cleaned, null, 2)}</script>`
5507
+ type,
5508
+ jsonLd,
5509
+ html: `<script type="application/ld+json">${JSON.stringify(jsonLd, null, 2)}</script>`
5259
5510
  });
5260
5511
  } catch (error) {
5261
5512
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -6405,6 +6656,219 @@ function createSeoGscAuthCollection() {
6405
6656
  };
6406
6657
  }
6407
6658
 
6659
+ // src/collections/SeoRankHistory.ts
6660
+ function createSeoRankHistoryCollection() {
6661
+ return {
6662
+ slug: "seo-rank-history",
6663
+ admin: {
6664
+ custom: { navHidden: true }
6665
+ },
6666
+ access: {
6667
+ read: ({ req }) => !!req.user,
6668
+ create: ({ req }) => req.user?.role === "admin",
6669
+ update: ({ req }) => req.user?.role === "admin",
6670
+ delete: ({ req }) => req.user?.role === "admin"
6671
+ },
6672
+ timestamps: false,
6673
+ fields: [
6674
+ {
6675
+ name: "query",
6676
+ type: "text",
6677
+ required: true,
6678
+ index: true,
6679
+ admin: { description: "Search query (keyword) tracked" }
6680
+ },
6681
+ {
6682
+ name: "page",
6683
+ type: "text",
6684
+ admin: { description: "Landing page URL (when tracked by page)" }
6685
+ },
6686
+ {
6687
+ name: "position",
6688
+ type: "number",
6689
+ required: true,
6690
+ admin: { description: "Average SERP position over the snapshot window (lower is better)" }
6691
+ },
6692
+ {
6693
+ name: "clicks",
6694
+ type: "number",
6695
+ admin: { description: "Clicks over the snapshot window" }
6696
+ },
6697
+ {
6698
+ name: "impressions",
6699
+ type: "number",
6700
+ admin: { description: "Impressions over the snapshot window" }
6701
+ },
6702
+ {
6703
+ name: "ctr",
6704
+ type: "number",
6705
+ admin: { description: "Click-through rate (0-1) over the snapshot window" }
6706
+ },
6707
+ {
6708
+ name: "property",
6709
+ type: "text",
6710
+ admin: { description: "GSC property the snapshot was taken from" }
6711
+ },
6712
+ {
6713
+ // YYYY-MM-DD — used to deduplicate one snapshot per query per day.
6714
+ name: "dateKey",
6715
+ type: "text",
6716
+ required: true,
6717
+ index: true,
6718
+ admin: { description: "Snapshot day (YYYY-MM-DD), one snapshot per query per day" }
6719
+ },
6720
+ {
6721
+ name: "snapshotDate",
6722
+ type: "date",
6723
+ required: true,
6724
+ index: true,
6725
+ admin: { description: "Exact timestamp of the snapshot" }
6726
+ }
6727
+ ]
6728
+ };
6729
+ }
6730
+
6731
+ // src/endpoints/rankTracking.ts
6732
+ var RANK_COLLECTION = "seo-rank-history";
6733
+ var round1 = (n) => Math.round(n * 10) / 10;
6734
+ async function runRankSnapshot(payload, basePath, seoConfig, opts) {
6735
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
6736
+ if (!cfg) return { ok: false, reason: "not_configured" };
6737
+ const authDoc = await getOrCreateGscAuthDoc(payload);
6738
+ if (!authDoc.refreshTokenEnc) return { ok: false, reason: "not_connected" };
6739
+ let accessToken;
6740
+ try {
6741
+ accessToken = await getGscAccessToken(payload, cfg, authDoc);
6742
+ } catch (e) {
6743
+ return { ok: false, reason: e instanceof Error ? e.message : "refresh_failed" };
6744
+ }
6745
+ const property = authDoc.propertyUrl || cfg.siteUrl;
6746
+ const windowDays = Math.min(90, Math.max(1, 7));
6747
+ const rowLimit = Math.min(1e3, Math.max(1, 100));
6748
+ const end = new Date(Date.now() - 2 * 864e5);
6749
+ const start = new Date(end.getTime() - (windowDays - 1) * 864e5);
6750
+ const endDate = end.toISOString().slice(0, 10);
6751
+ const startDate = start.toISOString().slice(0, 10);
6752
+ let rows;
6753
+ try {
6754
+ rows = await queryGscSearchAnalytics(accessToken, property, {
6755
+ startDate,
6756
+ endDate,
6757
+ dimensions: ["query"],
6758
+ rowLimit
6759
+ });
6760
+ } catch (e) {
6761
+ return { ok: false, reason: e instanceof Error ? e.message : "query_failed" };
6762
+ }
6763
+ const todayKey = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6764
+ const existing = await payload.find({
6765
+ collection: RANK_COLLECTION,
6766
+ where: { dateKey: { equals: todayKey } },
6767
+ limit: 2e3,
6768
+ depth: 0,
6769
+ overrideAccess: true
6770
+ });
6771
+ const already = new Set(existing.docs.map((d) => d.query));
6772
+ let stored = 0;
6773
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
6774
+ for (const r of rows) {
6775
+ const query = r.keys?.[0];
6776
+ if (!query || already.has(query)) continue;
6777
+ try {
6778
+ await payload.create({
6779
+ collection: RANK_COLLECTION,
6780
+ data: {
6781
+ query,
6782
+ position: round1(r.position),
6783
+ clicks: r.clicks,
6784
+ impressions: r.impressions,
6785
+ ctr: r.ctr,
6786
+ property,
6787
+ dateKey: todayKey,
6788
+ snapshotDate: nowIso
6789
+ },
6790
+ overrideAccess: true
6791
+ });
6792
+ stored++;
6793
+ } catch (e) {
6794
+ payload.logger.warn(`[seo] rank-snapshot: skipped "${query}": ${e instanceof Error ? e.message : "error"}`);
6795
+ }
6796
+ }
6797
+ return { ok: true, stored, scanned: rows.length, startDate, endDate };
6798
+ }
6799
+ function createRankSnapshotHandler(basePath, seoConfig) {
6800
+ return async (req) => {
6801
+ try {
6802
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
6803
+ const result = await runRankSnapshot(req.payload, basePath, seoConfig);
6804
+ if (!result.ok) {
6805
+ const status = result.reason === "not_connected" || result.reason === "not_configured" ? 409 : 502;
6806
+ return Response.json(result, { status, headers: { "Cache-Control": "no-store" } });
6807
+ }
6808
+ return Response.json(result, { headers: { "Cache-Control": "no-store" } });
6809
+ } catch (error) {
6810
+ const message = error instanceof Error ? error.message : "Internal server error";
6811
+ req.payload.logger.error(`[seo] rank-snapshot error: ${message}`);
6812
+ return Response.json({ error: message }, { status: 500 });
6813
+ }
6814
+ };
6815
+ }
6816
+ function createRankHistoryHandler() {
6817
+ return async (req) => {
6818
+ try {
6819
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
6820
+ const url = new URL(req.url);
6821
+ const days = Math.min(180, Math.max(7, parseInt(url.searchParams.get("days") || "35", 10)));
6822
+ const since = new Date(Date.now() - days * 864e5).toISOString();
6823
+ const all = await req.payload.find({
6824
+ collection: RANK_COLLECTION,
6825
+ where: { snapshotDate: { greater_than: since } },
6826
+ sort: "-snapshotDate",
6827
+ limit: 5e3,
6828
+ depth: 0,
6829
+ overrideAccess: true
6830
+ });
6831
+ const byQuery = /* @__PURE__ */ new Map();
6832
+ for (const d of all.docs) {
6833
+ const q = d.query;
6834
+ const arr = byQuery.get(q);
6835
+ if (arr) arr.push(d);
6836
+ else byQuery.set(q, [d]);
6837
+ }
6838
+ const movers = Array.from(byQuery.entries()).map(([query, snaps]) => {
6839
+ const latest = snaps[0];
6840
+ const previous = snaps.find((s) => s.dateKey !== latest.dateKey) || null;
6841
+ const delta = previous ? round1(previous.position - latest.position) : 0;
6842
+ return {
6843
+ query,
6844
+ page: latest.page || null,
6845
+ position: latest.position,
6846
+ previousPosition: previous ? previous.position : null,
6847
+ delta,
6848
+ clicks: latest.clicks ?? 0,
6849
+ impressions: latest.impressions ?? 0,
6850
+ ctr: latest.ctr ?? 0,
6851
+ snapshotDate: latest.snapshotDate,
6852
+ history: snaps.slice(0, 30).map((s) => ({ date: s.dateKey, position: s.position })).reverse()
6853
+ };
6854
+ });
6855
+ movers.sort((a, b) => (b.impressions || 0) - (a.impressions || 0));
6856
+ return Response.json(
6857
+ {
6858
+ count: movers.length,
6859
+ lastSnapshot: all.docs[0]?.snapshotDate || null,
6860
+ movers
6861
+ },
6862
+ { headers: { "Cache-Control": "no-store" } }
6863
+ );
6864
+ } catch (error) {
6865
+ const message = error instanceof Error ? error.message : "Internal server error";
6866
+ req.payload.logger.error(`[seo] rank-history error: ${message}`);
6867
+ return Response.json({ error: message }, { status: 500 });
6868
+ }
6869
+ };
6870
+ }
6871
+
6408
6872
  // src/rateLimiter.ts
6409
6873
  function createRateLimiter(maxRequests, windowMs) {
6410
6874
  const store = /* @__PURE__ */ new Map();
@@ -6749,6 +7213,296 @@ function stopCacheWarmUp() {
6749
7213
  }
6750
7214
  }
6751
7215
 
7216
+ // src/rankTracker.ts
7217
+ var SNAPSHOT_INTERVAL = 24 * 60 * 60 * 1e3;
7218
+ var STARTUP_DELAY2 = 30 * 1e3;
7219
+ var intervalId2 = null;
7220
+ var listenersAttached2 = false;
7221
+ async function doSnapshot(payload, basePath, seoConfig) {
7222
+ try {
7223
+ const result = await runRankSnapshot(payload, basePath, seoConfig);
7224
+ if (result.ok) {
7225
+ payload.logger.info(`[seo] rank-tracker: snapshot stored ${result.stored}/${result.scanned} queries`);
7226
+ } else if (result.reason !== "not_connected" && result.reason !== "not_configured") {
7227
+ payload.logger.warn(`[seo] rank-tracker: snapshot skipped (${result.reason})`);
7228
+ }
7229
+ } catch (error) {
7230
+ payload.logger.error(`[seo] rank-tracker error: ${error instanceof Error ? error.message : "unknown"}`);
7231
+ }
7232
+ }
7233
+ function startRankTracker(payload, basePath, seoConfig) {
7234
+ setTimeout(() => {
7235
+ void doSnapshot(payload, basePath, seoConfig);
7236
+ }, STARTUP_DELAY2);
7237
+ intervalId2 = setInterval(() => {
7238
+ void doSnapshot(payload, basePath, seoConfig);
7239
+ }, SNAPSHOT_INTERVAL);
7240
+ if (!listenersAttached2) {
7241
+ const cleanup = () => stopRankTracker();
7242
+ process.on("SIGTERM", cleanup);
7243
+ process.on("SIGINT", cleanup);
7244
+ listenersAttached2 = true;
7245
+ }
7246
+ payload.logger.info("[seo] rank-tracker: scheduled startup + every 24h");
7247
+ }
7248
+ function stopRankTracker() {
7249
+ if (intervalId2) {
7250
+ clearInterval(intervalId2);
7251
+ intervalId2 = null;
7252
+ }
7253
+ }
7254
+
7255
+ // src/endpoints/alerts.ts
7256
+ function isAdmin8(user) {
7257
+ if (!user) return false;
7258
+ if (user.role === "admin") return true;
7259
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
7260
+ return false;
7261
+ }
7262
+ function getAlertConfig() {
7263
+ return {
7264
+ webhookUrl: process.env.SEO_ALERT_WEBHOOK_URL || "",
7265
+ emails: (process.env.SEO_ALERT_EMAIL || "").split(",").map((s) => s.trim()).filter(Boolean),
7266
+ scoreDrop: parseInt(process.env.SEO_ALERT_SCORE_DROP || "10", 10) || 10,
7267
+ positionDrop: parseInt(process.env.SEO_ALERT_POSITION_DROP || "5", 10) || 5,
7268
+ windowHours: Math.max(1, parseInt(process.env.SEO_ALERT_WINDOW_HOURS || "24", 10) || 24)
7269
+ };
7270
+ }
7271
+ var round12 = (n) => Math.round(n * 10) / 10;
7272
+ async function buildAlertDigest(payload, cfg) {
7273
+ const now = Date.now();
7274
+ const since = new Date(now - cfg.windowHours * 36e5).toISOString();
7275
+ const scoreRegressions = [];
7276
+ try {
7277
+ const hist = await payload.find({
7278
+ collection: "seo-score-history",
7279
+ where: { snapshotDate: { greater_than: new Date(now - 14 * 864e5).toISOString() } },
7280
+ sort: "-snapshotDate",
7281
+ limit: 5e3,
7282
+ depth: 0,
7283
+ overrideAccess: true
7284
+ });
7285
+ const byDoc = /* @__PURE__ */ new Map();
7286
+ for (const h of hist.docs) {
7287
+ const key = `${h.documentId}::${h.collection}`;
7288
+ const arr = byDoc.get(key);
7289
+ if (arr) arr.push(h);
7290
+ else byDoc.set(key, [h]);
7291
+ }
7292
+ for (const [key, snaps] of byDoc) {
7293
+ const latest = snaps[0];
7294
+ const oldest = snaps[snaps.length - 1];
7295
+ const drop = oldest.score - latest.score;
7296
+ if (drop >= cfg.scoreDrop) {
7297
+ const [documentId, collection] = key.split("::");
7298
+ scoreRegressions.push({
7299
+ documentId,
7300
+ collection,
7301
+ from: oldest.score,
7302
+ to: latest.score,
7303
+ drop
7304
+ });
7305
+ }
7306
+ }
7307
+ scoreRegressions.sort((a, b) => b.drop - a.drop);
7308
+ } catch {
7309
+ }
7310
+ const newNotFound = [];
7311
+ try {
7312
+ const logs = await payload.find({
7313
+ collection: "seo-logs",
7314
+ where: {
7315
+ and: [{ lastSeen: { greater_than: since } }, { ignored: { not_equals: true } }]
7316
+ },
7317
+ sort: "-count",
7318
+ limit: 50,
7319
+ depth: 0,
7320
+ overrideAccess: true
7321
+ });
7322
+ for (const l of logs.docs) {
7323
+ newNotFound.push({
7324
+ url: l.url || "",
7325
+ count: l.count || 1,
7326
+ lastSeen: l.lastSeen || ""
7327
+ });
7328
+ }
7329
+ } catch {
7330
+ }
7331
+ const rankDrops = [];
7332
+ try {
7333
+ const ranks = await payload.find({
7334
+ collection: "seo-rank-history",
7335
+ where: { snapshotDate: { greater_than: new Date(now - 35 * 864e5).toISOString() } },
7336
+ sort: "-snapshotDate",
7337
+ limit: 5e3,
7338
+ depth: 0,
7339
+ overrideAccess: true
7340
+ });
7341
+ const byQuery = /* @__PURE__ */ new Map();
7342
+ for (const r of ranks.docs) {
7343
+ const q = r.query;
7344
+ const arr = byQuery.get(q);
7345
+ if (arr) arr.push(r);
7346
+ else byQuery.set(q, [r]);
7347
+ }
7348
+ for (const [query, snaps] of byQuery) {
7349
+ const latest = snaps[0];
7350
+ const previous = snaps.find((s) => s.dateKey !== latest.dateKey);
7351
+ if (!previous) continue;
7352
+ const drop = round12(latest.position - previous.position);
7353
+ if (drop >= cfg.positionDrop) {
7354
+ rankDrops.push({ query, from: previous.position, to: latest.position, drop });
7355
+ }
7356
+ }
7357
+ rankDrops.sort((a, b) => b.drop - a.drop);
7358
+ } catch {
7359
+ }
7360
+ const totalIssues = scoreRegressions.length + newNotFound.length + rankDrops.length;
7361
+ return {
7362
+ since,
7363
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
7364
+ scoreRegressions,
7365
+ newNotFound,
7366
+ rankDrops,
7367
+ totalIssues
7368
+ };
7369
+ }
7370
+ function digestToHtml(digest, siteUrl) {
7371
+ const section = (title, rows) => rows.length ? `<h3 style="margin:18px 0 6px">${title}</h3><ul style="margin:0;padding-left:18px">${rows.join("")}</ul>` : "";
7372
+ const reg = digest.scoreRegressions.slice(0, 20).map((r) => `<li>${r.collection}/${r.documentId} \u2014 score ${r.from} \u2192 <b>${r.to}</b> (\u2212${r.drop})</li>`);
7373
+ const nf = digest.newNotFound.slice(0, 20).map((n) => `<li><code>${n.url}</code> \u2014 ${n.count}\xD7</li>`);
7374
+ const rd = digest.rankDrops.slice(0, 20).map((d) => `<li>\u201C${d.query}\u201D \u2014 #${round12(d.from)} \u2192 <b>#${round12(d.to)}</b> (\u25BC${d.drop})</li>`);
7375
+ return `<div style="font-family:system-ui;max-width:640px">
7376
+ <h2>SEO alert digest${siteUrl ? ` \u2014 ${siteUrl}` : ""}</h2>
7377
+ <p style="color:#6b7280;font-size:13px">${digest.totalIssues} issue(s) since ${new Date(digest.since).toLocaleString()}</p>
7378
+ ${section("\u{1F4C9} Score regressions", reg)}
7379
+ ${section("\u{1F517} New 404s", nf)}
7380
+ ${section("\u{1F53B} Ranking drops", rd)}
7381
+ ${digest.totalIssues === 0 ? "<p>No issues to report. \u{1F389}</p>" : ""}
7382
+ </div>`;
7383
+ }
7384
+ async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
7385
+ const channels = { webhook: false, email: false };
7386
+ if (digest.totalIssues === 0) {
7387
+ return { sent: false, reason: "nothing_to_report", channels };
7388
+ }
7389
+ if (cfg.webhookUrl) {
7390
+ try {
7391
+ await fetch(cfg.webhookUrl, {
7392
+ method: "POST",
7393
+ headers: { "content-type": "application/json" },
7394
+ body: JSON.stringify({ type: "seo-alert-digest", siteUrl, digest })
7395
+ });
7396
+ channels.webhook = true;
7397
+ } catch (e) {
7398
+ payload.logger.warn(`[seo] alerts: webhook delivery failed: ${e instanceof Error ? e.message : "error"}`);
7399
+ }
7400
+ }
7401
+ if (cfg.emails.length > 0) {
7402
+ const send = payload.sendEmail;
7403
+ if (typeof send === "function") {
7404
+ try {
7405
+ await send({
7406
+ to: cfg.emails,
7407
+ subject: `SEO alert digest \u2014 ${digest.totalIssues} issue(s)`,
7408
+ html: digestToHtml(digest, siteUrl)
7409
+ });
7410
+ channels.email = true;
7411
+ } catch (e) {
7412
+ payload.logger.warn(`[seo] alerts: email delivery failed: ${e instanceof Error ? e.message : "error"}`);
7413
+ }
7414
+ }
7415
+ }
7416
+ const sent = channels.webhook || channels.email;
7417
+ return { sent, reason: sent ? void 0 : "no_channel_configured", channels };
7418
+ }
7419
+ function createAlertsDigestHandler() {
7420
+ return async (req) => {
7421
+ try {
7422
+ if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7423
+ const cfg = getAlertConfig();
7424
+ const digest = await buildAlertDigest(req.payload, cfg);
7425
+ return Response.json(
7426
+ {
7427
+ digest,
7428
+ config: {
7429
+ webhookConfigured: !!cfg.webhookUrl,
7430
+ emailConfigured: cfg.emails.length > 0,
7431
+ scoreDrop: cfg.scoreDrop,
7432
+ positionDrop: cfg.positionDrop,
7433
+ windowHours: cfg.windowHours
7434
+ }
7435
+ },
7436
+ { headers: { "Cache-Control": "no-store" } }
7437
+ );
7438
+ } catch (error) {
7439
+ const message = error instanceof Error ? error.message : "Internal server error";
7440
+ req.payload.logger.error(`[seo] alerts-digest error: ${message}`);
7441
+ return Response.json({ error: message }, { status: 500 });
7442
+ }
7443
+ };
7444
+ }
7445
+ function createAlertsRunHandler(siteUrl) {
7446
+ return async (req) => {
7447
+ try {
7448
+ if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7449
+ const cfg = getAlertConfig();
7450
+ const digest = await buildAlertDigest(req.payload, cfg);
7451
+ const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
7452
+ return Response.json({ digest, delivery }, { headers: { "Cache-Control": "no-store" } });
7453
+ } catch (error) {
7454
+ const message = error instanceof Error ? error.message : "Internal server error";
7455
+ req.payload.logger.error(`[seo] alerts-run error: ${message}`);
7456
+ return Response.json({ error: message }, { status: 500 });
7457
+ }
7458
+ };
7459
+ }
7460
+
7461
+ // src/alertsScheduler.ts
7462
+ var STARTUP_DELAY3 = 60 * 1e3;
7463
+ var intervalId3 = null;
7464
+ var listenersAttached3 = false;
7465
+ async function runDigest(payload, siteUrl) {
7466
+ try {
7467
+ const cfg = getAlertConfig();
7468
+ if (!cfg.webhookUrl && cfg.emails.length === 0) {
7469
+ return;
7470
+ }
7471
+ const digest = await buildAlertDigest(payload, cfg);
7472
+ const delivery = await deliverAlertDigest(payload, digest, cfg, siteUrl);
7473
+ if (delivery.sent) {
7474
+ payload.logger.info(
7475
+ `[seo] alerts: digest delivered (${digest.totalIssues} issues; webhook=${delivery.channels.webhook} email=${delivery.channels.email})`
7476
+ );
7477
+ }
7478
+ } catch (error) {
7479
+ payload.logger.error(`[seo] alerts scheduler error: ${error instanceof Error ? error.message : "unknown"}`);
7480
+ }
7481
+ }
7482
+ function startAlertsScheduler(payload, siteUrl) {
7483
+ const intervalHours = Math.max(1, parseInt(process.env.SEO_ALERT_INTERVAL_HOURS || "24", 10) || 24);
7484
+ const intervalMs = intervalHours * 60 * 60 * 1e3;
7485
+ setTimeout(() => {
7486
+ void runDigest(payload, siteUrl);
7487
+ }, STARTUP_DELAY3);
7488
+ intervalId3 = setInterval(() => {
7489
+ void runDigest(payload, siteUrl);
7490
+ }, intervalMs);
7491
+ if (!listenersAttached3) {
7492
+ const cleanup = () => stopAlertsScheduler();
7493
+ process.on("SIGTERM", cleanup);
7494
+ process.on("SIGINT", cleanup);
7495
+ listenersAttached3 = true;
7496
+ }
7497
+ payload.logger.info(`[seo] alerts: scheduled startup + every ${intervalHours}h`);
7498
+ }
7499
+ function stopAlertsScheduler() {
7500
+ if (intervalId3) {
7501
+ clearInterval(intervalId3);
7502
+ intervalId3 = null;
7503
+ }
7504
+ }
7505
+
6752
7506
  // src/endpoints/generate.ts
6753
7507
  var TYPE_TO_CONFIG_KEY = {
6754
7508
  title: "generateTitle",
@@ -8903,6 +9657,7 @@ function buildSeoConfig(pluginConfig) {
8903
9657
  var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8904
9658
  const config = { ...incomingConfig };
8905
9659
  const targetCollections = pluginConfig.collections ?? ["pages", "posts"];
9660
+ const uploadsCollection = pluginConfig.uploadsCollection ?? "media";
8906
9661
  const targetGlobals = pluginConfig.globals ?? [];
8907
9662
  const basePath = pluginConfig.endpointBasePath ?? "/seo-plugin";
8908
9663
  const seoConfig = buildSeoConfig(pluginConfig);
@@ -8925,6 +9680,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8925
9680
  // opt-in — requires Google Cloud OAuth setup + secrets
8926
9681
  warmCache: true,
8927
9682
  // disable on low-memory hosts to skip startup pre-loading
9683
+ alerts: false,
9684
+ // opt-in — requires SEO_ALERT_WEBHOOK_URL and/or SEO_ALERT_EMAIL
8928
9685
  ...pluginConfig.features
8929
9686
  };
8930
9687
  function hasExistingSeoMeta(fields) {
@@ -9065,7 +9822,7 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
9065
9822
  if (features.redirects && !hasExistingRedirects) pluginCollections.push(createSeoRedirectsCollection(redirectsSlug));
9066
9823
  if (features.performance) pluginCollections.push(createSeoPerformanceCollection());
9067
9824
  if (features.seoLogs) pluginCollections.push(createSeoLogsCollection());
9068
- if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection());
9825
+ if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection(), createSeoRankHistoryCollection());
9069
9826
  config.collections = [
9070
9827
  ...config.collections || [],
9071
9828
  ...pluginCollections
@@ -9178,7 +9935,9 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
9178
9935
  pluginEndpoints.push(
9179
9936
  { path: `${basePath}/ai-generate`, method: "post", handler: createAiGenerateHandler() },
9180
9937
  { path: `${basePath}/ai-rewrite`, method: "post", handler: createAiRewriteHandler(targetCollections) },
9181
- { path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) }
9938
+ { path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) },
9939
+ { path: `${basePath}/alt-text-audit`, method: "get", handler: createAltTextAuditHandler(uploadsCollection) },
9940
+ { path: `${basePath}/ai-alt-text`, method: "post", handler: withRateLimit(createAiAltTextHandler(uploadsCollection, seoConfig)) }
9182
9941
  );
9183
9942
  }
9184
9943
  if (features.cannibalization) {
@@ -9209,7 +9968,15 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
9209
9968
  { path: `${basePath}/gsc/auth`, method: "get", handler: createGscAuthStartHandler(basePath, seoConfig) },
9210
9969
  { path: `${basePath}/gsc/callback`, method: "get", handler: createGscCallbackHandler(basePath, seoConfig) },
9211
9970
  { path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
9212
- { path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() }
9971
+ { path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
9972
+ { path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
9973
+ { path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() }
9974
+ );
9975
+ }
9976
+ if (features.alerts) {
9977
+ pluginEndpoints.push(
9978
+ { path: `${basePath}/alerts-digest`, method: "get", handler: createAlertsDigestHandler() },
9979
+ { path: `${basePath}/alerts-run`, method: "post", handler: withRateLimit(createAlertsRunHandler(resolveGscSiteUrl(seoConfig))) }
9213
9980
  );
9214
9981
  }
9215
9982
  if (features.keywords) {
@@ -9355,10 +10122,90 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
9355
10122
  if (features.warmCache) {
9356
10123
  startCacheWarmUp(payload, basePath, targetGlobals, targetCollections);
9357
10124
  }
10125
+ if (features.gscApi) {
10126
+ startRankTracker(payload, basePath, seoConfig);
10127
+ }
10128
+ if (features.alerts) {
10129
+ startAlertsScheduler(payload, resolveGscSiteUrl(seoConfig));
10130
+ }
9358
10131
  };
9359
10132
  return config;
9360
10133
  };
9361
10134
 
10135
+ // src/helpers/buildMetadata.ts
10136
+ function resolveSiteUrl3(explicit) {
10137
+ return (explicit || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "").replace(/\/$/, "");
10138
+ }
10139
+ function parseRobots(doc, meta) {
10140
+ const raw = typeof meta.robots === "string" && meta.robots || typeof doc.robots === "string" && doc.robots || "";
10141
+ let noindex = false;
10142
+ let nofollow = false;
10143
+ if (raw) {
10144
+ const low = raw.toLowerCase();
10145
+ noindex = low.includes("noindex");
10146
+ nofollow = low.includes("nofollow");
10147
+ }
10148
+ if (doc.noindex === true || meta.noindex === true) noindex = true;
10149
+ if (doc.nofollow === true || meta.nofollow === true) nofollow = true;
10150
+ return { index: !noindex, follow: !nofollow };
10151
+ }
10152
+ function buildLanguages(doc) {
10153
+ const raw = doc.localeAlternates || doc.alternates || doc.hreflang;
10154
+ if (!Array.isArray(raw)) return void 0;
10155
+ const out = {};
10156
+ for (const a of raw) {
10157
+ if (!a || typeof a !== "object") continue;
10158
+ const r = a;
10159
+ const lang = String(r.hreflang || r.locale || r.lang || "");
10160
+ const href = String(r.href || r.url || "");
10161
+ if (lang && href) out[lang] = href;
10162
+ }
10163
+ return Object.keys(out).length ? out : void 0;
10164
+ }
10165
+ function absoluteUrl(value, siteUrl) {
10166
+ if (/^https?:\/\//i.test(value)) return value;
10167
+ return `${siteUrl}${value.startsWith("/") ? "" : "/"}${value}`;
10168
+ }
10169
+ function buildSeoMetadata(doc, options = {}) {
10170
+ const siteUrl = resolveSiteUrl3(options.siteUrl);
10171
+ const meta = doc.meta || {};
10172
+ const rawTitle = meta.title || doc.title || "";
10173
+ const title = options.titleTemplate && rawTitle ? options.titleTemplate.replace("%s", rawTitle) : rawTitle;
10174
+ const description = meta.description || "";
10175
+ const slug = doc.slug || "";
10176
+ const heroMedia = doc.hero?.media;
10177
+ let image = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
10178
+ if (!image && options.defaultImage) image = absoluteUrl(options.defaultImage, siteUrl);
10179
+ const explicitCanonical = typeof meta.canonicalUrl === "string" && meta.canonicalUrl || typeof doc.canonicalUrl === "string" && doc.canonicalUrl || "";
10180
+ const canonical = explicitCanonical || (siteUrl ? `${siteUrl}${slug ? `/${slug}` : ""}` : void 0);
10181
+ const languages = buildLanguages(doc);
10182
+ const isPost = options.collection === "posts" || doc.isPost === true;
10183
+ const md = {};
10184
+ if (title) md.title = title;
10185
+ if (description) md.description = description;
10186
+ const alternates = {};
10187
+ if (canonical) alternates.canonical = canonical;
10188
+ if (languages) alternates.languages = languages;
10189
+ if (Object.keys(alternates).length) md.alternates = alternates;
10190
+ md.robots = parseRobots(doc, meta);
10191
+ md.openGraph = {
10192
+ ...rawTitle ? { title: rawTitle } : {},
10193
+ ...description ? { description } : {},
10194
+ ...canonical ? { url: canonical } : {},
10195
+ ...options.siteName ? { siteName: options.siteName } : {},
10196
+ type: isPost ? "article" : "website",
10197
+ ...options.locale ? { locale: options.locale } : {},
10198
+ ...image ? { images: [{ url: image }] } : {}
10199
+ };
10200
+ md.twitter = {
10201
+ card: image ? "summary_large_image" : "summary",
10202
+ ...rawTitle ? { title: rawTitle } : {},
10203
+ ...description ? { description } : {},
10204
+ ...image ? { images: [image] } : {}
10205
+ };
10206
+ return md;
10207
+ }
10208
+
9362
10209
  // src/i18n.ts
9363
10210
  var rulesFr = {
9364
10211
  title: {
@@ -13267,4 +14114,4 @@ function analyzeSeo(data, config) {
13267
14114
  return { score, level, checks, ...aiReadiness ? { aiReadiness } : {} };
13268
14115
  }
13269
14116
 
13270
- export { ACTION_VERBS, EVERGREEN_SLUGS, FLESCH_THRESHOLDS, GENERIC_ANCHORS, KEYWORD_DENSITY_MAX, KEYWORD_DENSITY_MIN, KEYWORD_DENSITY_WARN, LEGAL_SLUGS_MAP, MAX_RECURSION_DEPTH, META_DESC_LENGTH_MAX, META_DESC_LENGTH_MIN, MIN_WORDS_FORM, MIN_WORDS_GENERIC, MIN_WORDS_LEGAL, MIN_WORDS_POST, MIN_WORDS_THIN, POWER_WORDS, POWER_WORDS_FR, READABILITY_THRESHOLDS, SCORE_EXCELLENT, SCORE_GOOD, SCORE_OK, STOP_WORDS, STOP_WORD_COMPOUNDS_MAP, TITLE_LENGTH_MAX, TITLE_LENGTH_MIN, UTILITY_SLUGS, WARNING_MULTIPLIER, analyzeSeo, buildSeoInputFromDoc, calculateFlesch, calculateFleschFR, checkHeadingHierarchy, checkImagesInBlocks, countKeywordOccurrences, countLongSections, countSentences, countSyllablesEN, countSyllablesFR, countWords, createAiRewriteHandler, createDuplicateContentHandler, createGenerateHandler, createHistoryHandler, createKeywordResearchHandler, createPerformanceHandler, createRedirectChainsHandler, createSchemaGeneratorHandler, createSeoPerformanceCollection, createSeoScoreHistoryCollection, createSitemapAuditHandler, createTrackSeoScoreHook, detectPageType, detectPassiveVoice, extractHeadingsFromLexical, extractImagesFromLexical, extractLinkUrlsFromLexical, extractLinksFromLexical, extractListsFromLexical, extractTextFromLexical, fetchAllDocs, getActionVerbs, getActionVerbsFR, getDashboardT, getEvergreenSlugs, getGenericAnchors, getLegalSlugs, getPowerWords, getStopWordCompounds, getStopWords, getStopWordsFR, getUtilitySlugs, hasTransitionWord, isStopWordInCompoundExpression, keywordMatchesText, metaFields, normalizeForComparison, registerDashboardTranslations, resolveAnalysisLocale, seoAnalyzerPlugin, seoFields, seoAnalyzerPlugin as seoPlugin, slugifyKeyword };
14117
+ export { ACTION_VERBS, EVERGREEN_SLUGS, FLESCH_THRESHOLDS, GENERIC_ANCHORS, KEYWORD_DENSITY_MAX, KEYWORD_DENSITY_MIN, KEYWORD_DENSITY_WARN, LEGAL_SLUGS_MAP, MAX_RECURSION_DEPTH, META_DESC_LENGTH_MAX, META_DESC_LENGTH_MIN, MIN_WORDS_FORM, MIN_WORDS_GENERIC, MIN_WORDS_LEGAL, MIN_WORDS_POST, MIN_WORDS_THIN, POWER_WORDS, POWER_WORDS_FR, READABILITY_THRESHOLDS, SCHEMA_TYPES, SCORE_EXCELLENT, SCORE_GOOD, SCORE_OK, STOP_WORDS, STOP_WORD_COMPOUNDS_MAP, TITLE_LENGTH_MAX, TITLE_LENGTH_MIN, UTILITY_SLUGS, WARNING_MULTIPLIER, analyzeSeo, buildJsonLd, buildSeoInputFromDoc, buildSeoMetadata, calculateFlesch, calculateFleschFR, checkHeadingHierarchy, checkImagesInBlocks, countKeywordOccurrences, countLongSections, countSentences, countSyllablesEN, countSyllablesFR, countWords, createAiRewriteHandler, createDuplicateContentHandler, createGenerateHandler, createHistoryHandler, createKeywordResearchHandler, createPerformanceHandler, createRedirectChainsHandler, createSchemaGeneratorHandler, createSeoPerformanceCollection, createSeoScoreHistoryCollection, createSitemapAuditHandler, createTrackSeoScoreHook, detectPageType, detectPassiveVoice, detectSchemaType, extractHeadingsFromLexical, extractImagesFromLexical, extractLinkUrlsFromLexical, extractLinksFromLexical, extractListsFromLexical, extractTextFromLexical, fetchAllDocs, getActionVerbs, getActionVerbsFR, getDashboardT, getEvergreenSlugs, getGenericAnchors, getLegalSlugs, getPowerWords, getSchemaImageUrl, getStopWordCompounds, getStopWords, getStopWordsFR, getUtilitySlugs, hasTransitionWord, isStopWordInCompoundExpression, keywordMatchesText, metaFields, normalizeForComparison, registerDashboardTranslations, renderJsonLdScript, resolveAnalysisLocale, seoAnalyzerPlugin, seoFields, seoAnalyzerPlugin as seoPlugin, slugifyKeyword };