@consilioweb/payload-seo-analyzer 1.9.0 → 1.11.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.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- var dns = require('dns');
4
3
  var crypto = require('crypto');
4
+ var dns = require('dns');
5
5
 
6
6
  // src/constants.ts
7
7
  var TITLE_LENGTH_MIN = 30;
@@ -3121,6 +3121,451 @@ function createAiOptimizeHandler(targetCollections, seoConfig, localeMapping) {
3121
3121
  }
3122
3122
  };
3123
3123
  }
3124
+ var ALGO = "aes-256-gcm";
3125
+ var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
3126
+ var FORMAT_VERSION = "v1";
3127
+ function deriveKey(secret) {
3128
+ const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
3129
+ if (explicit) {
3130
+ const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
3131
+ if (buf.length === 32) return buf;
3132
+ throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
3133
+ }
3134
+ if (!secret) {
3135
+ throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
3136
+ }
3137
+ return crypto.scryptSync(secret, KEY_NAMESPACE, 32);
3138
+ }
3139
+ function encryptToken(plaintext, secret) {
3140
+ const key = deriveKey(secret);
3141
+ const iv = crypto.randomBytes(12);
3142
+ const cipher = crypto.createCipheriv(ALGO, key, iv);
3143
+ const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
3144
+ const tag = cipher.getAuthTag();
3145
+ return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
3146
+ }
3147
+ function decryptToken(payload, secret) {
3148
+ const parts = payload.split(":");
3149
+ if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
3150
+ throw new Error("Invalid encrypted token format.");
3151
+ }
3152
+ const key = deriveKey(secret);
3153
+ const iv = Buffer.from(parts[1], "base64");
3154
+ const tag = Buffer.from(parts[2], "base64");
3155
+ const enc = Buffer.from(parts[3], "base64");
3156
+ const decipher = crypto.createDecipheriv(ALGO, key, iv);
3157
+ decipher.setAuthTag(tag);
3158
+ const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
3159
+ return dec.toString("utf8");
3160
+ }
3161
+ function safeEqual(a, b) {
3162
+ const ba = Buffer.from(a);
3163
+ const bb = Buffer.from(b);
3164
+ if (ba.length !== bb.length) return false;
3165
+ return crypto.timingSafeEqual(ba, bb);
3166
+ }
3167
+
3168
+ // src/helpers/gscClient.ts
3169
+ var GSC_AUTH_COLLECTION = "seo-gsc-auth";
3170
+ var GSC_SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
3171
+ function isGscAdmin(user) {
3172
+ if (!user) return false;
3173
+ if (user.role === "admin") return true;
3174
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
3175
+ return false;
3176
+ }
3177
+ function resolveGscSiteUrl(seoConfig) {
3178
+ return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
3179
+ }
3180
+ function getGscOAuthConfig(basePath, seoConfig) {
3181
+ const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
3182
+ const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
3183
+ const siteUrl = resolveGscSiteUrl(seoConfig);
3184
+ if (!clientId || !clientSecret || !siteUrl) return null;
3185
+ return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
3186
+ }
3187
+ async function getOrCreateGscAuthDoc(payload) {
3188
+ const found = await payload.find({ collection: GSC_AUTH_COLLECTION, limit: 1, overrideAccess: true });
3189
+ if (found.docs.length > 0) return found.docs[0];
3190
+ return payload.create({ collection: GSC_AUTH_COLLECTION, data: {}, overrideAccess: true });
3191
+ }
3192
+ async function gscTokenRequest(cfg, body) {
3193
+ const resp = await fetch("https://oauth2.googleapis.com/token", {
3194
+ method: "POST",
3195
+ headers: { "content-type": "application/x-www-form-urlencoded" },
3196
+ body: new URLSearchParams({
3197
+ client_id: cfg.clientId,
3198
+ client_secret: cfg.clientSecret,
3199
+ ...body
3200
+ }).toString()
3201
+ });
3202
+ const json = await resp.json();
3203
+ if (!resp.ok) {
3204
+ throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
3205
+ }
3206
+ return json;
3207
+ }
3208
+ async function getGscAccessToken(payload, cfg, authDoc) {
3209
+ if (!authDoc?.refreshTokenEnc) throw new Error("not_connected");
3210
+ const secret = payload.secret || "";
3211
+ let refreshToken;
3212
+ try {
3213
+ refreshToken = decryptToken(authDoc.refreshTokenEnc, secret);
3214
+ } catch {
3215
+ throw new Error("decrypt_failed");
3216
+ }
3217
+ const tokens = await gscTokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
3218
+ const accessToken = tokens.access_token;
3219
+ if (!accessToken) throw new Error("refresh_failed");
3220
+ return accessToken;
3221
+ }
3222
+ async function queryGscSearchAnalytics(accessToken, property, body) {
3223
+ const resp = await fetch(
3224
+ `https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(property)}/searchAnalytics/query`,
3225
+ {
3226
+ method: "POST",
3227
+ headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
3228
+ body: JSON.stringify(body)
3229
+ }
3230
+ );
3231
+ const json = await resp.json();
3232
+ if (!resp.ok) {
3233
+ const err = json.error?.message || resp.status;
3234
+ throw new Error(`GSC query failed: ${err}`);
3235
+ }
3236
+ return json.rows || [];
3237
+ }
3238
+
3239
+ // src/endpoints/aiAltText.ts
3240
+ var DEFAULT_MODEL2 = "claude-opus-4-8";
3241
+ var ALT_MAX = 125;
3242
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
3243
+ var SUPPORTED_MIME = {
3244
+ "image/jpeg": "image/jpeg",
3245
+ "image/jpg": "image/jpeg",
3246
+ "image/png": "image/png",
3247
+ "image/gif": "image/gif",
3248
+ "image/webp": "image/webp"
3249
+ };
3250
+ function isAdmin4(user) {
3251
+ if (!user) return false;
3252
+ if (user.role === "admin") return true;
3253
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
3254
+ return false;
3255
+ }
3256
+ function resolveImageUrl(media, siteUrl) {
3257
+ const raw = typeof media.url === "string" && media.url || (typeof media.filename === "string" ? `/media/${media.filename}` : "");
3258
+ if (!raw) return null;
3259
+ let absolute;
3260
+ if (/^https?:\/\//i.test(raw)) {
3261
+ absolute = raw;
3262
+ } else if (siteUrl) {
3263
+ absolute = `${siteUrl.replace(/\/$/, "")}${raw.startsWith("/") ? "" : "/"}${raw}`;
3264
+ } else {
3265
+ return null;
3266
+ }
3267
+ try {
3268
+ const target = new URL(absolute);
3269
+ if (target.protocol !== "http:" && target.protocol !== "https:") return null;
3270
+ const allowed = /* @__PURE__ */ new Set();
3271
+ if (siteUrl) allowed.add(new URL(siteUrl).origin);
3272
+ if (process.env.SEO_MEDIA_ORIGIN) allowed.add(new URL(process.env.SEO_MEDIA_ORIGIN).origin);
3273
+ if (allowed.size > 0 && !allowed.has(target.origin)) return null;
3274
+ if (allowed.size === 0) return null;
3275
+ return target.toString();
3276
+ } catch {
3277
+ return null;
3278
+ }
3279
+ }
3280
+ async function generateAltText(apiKey, model, base64, mediaType, language, context) {
3281
+ const systemPrompt = `You write concise, descriptive image ALT text for accessibility and SEO.
3282
+ Rules:
3283
+ - Describe what is actually visible in the image.
3284
+ - Maximum ${ALT_MAX} characters.
3285
+ - Write in ${language === "en" ? "English" : "French"}.
3286
+ - Do NOT start with "image of", "photo of", "picture of" or similar.
3287
+ - No quotes around the result. Return ONLY the alt text, nothing else.`;
3288
+ const userText = `Filename: ${context.filename}${context.title ? `
3289
+ Page/context: ${context.title}` : ""}
3290
+ Write the alt text for this image:`;
3291
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
3292
+ method: "POST",
3293
+ headers: {
3294
+ "Content-Type": "application/json",
3295
+ "x-api-key": apiKey,
3296
+ "anthropic-version": "2023-06-01"
3297
+ },
3298
+ body: JSON.stringify({
3299
+ model,
3300
+ max_tokens: 150,
3301
+ system: systemPrompt,
3302
+ messages: [
3303
+ {
3304
+ role: "user",
3305
+ content: [
3306
+ { type: "image", source: { type: "base64", media_type: mediaType, data: base64 } },
3307
+ { type: "text", text: userText }
3308
+ ]
3309
+ }
3310
+ ]
3311
+ })
3312
+ });
3313
+ if (!response.ok) {
3314
+ const body = await response.text();
3315
+ throw new Error(`Claude API error ${response.status}: ${body}`);
3316
+ }
3317
+ const data = await response.json();
3318
+ if (data.stop_reason === "refusal") return null;
3319
+ const text = (data.content?.find((b) => b.type === "text")?.text || "").trim().replace(/^["']|["']$/g, "");
3320
+ if (!text) return null;
3321
+ return text.length > ALT_MAX ? text.slice(0, ALT_MAX).trim() : text;
3322
+ }
3323
+ function createAltTextAuditHandler(uploadsCollection) {
3324
+ return async (req) => {
3325
+ try {
3326
+ if (!isAdmin4(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
3327
+ const url = new URL(req.url);
3328
+ const limit = Math.min(200, Math.max(1, parseInt(url.searchParams.get("limit") || "50", 10)));
3329
+ try {
3330
+ const missing = await req.payload.find({
3331
+ collection: uploadsCollection,
3332
+ where: { or: [{ alt: { exists: false } }, { alt: { equals: "" } }] },
3333
+ limit,
3334
+ depth: 0,
3335
+ overrideAccess: true
3336
+ });
3337
+ const items = missing.docs.map((d) => ({
3338
+ id: d.id,
3339
+ filename: d.filename || "",
3340
+ url: d.url || "",
3341
+ mimeType: d.mimeType || "",
3342
+ alt: d.alt || ""
3343
+ }));
3344
+ return Response.json(
3345
+ { collection: uploadsCollection, missingCount: missing.totalDocs, items },
3346
+ { headers: { "Cache-Control": "no-store" } }
3347
+ );
3348
+ } catch {
3349
+ return Response.json(
3350
+ { collection: uploadsCollection, missingCount: 0, items: [], note: "no_alt_field" },
3351
+ { headers: { "Cache-Control": "no-store" } }
3352
+ );
3353
+ }
3354
+ } catch (error) {
3355
+ const message = error instanceof Error ? error.message : "Internal server error";
3356
+ req.payload.logger.error(`[seo] alt-text-audit error: ${message}`);
3357
+ return Response.json({ error: message }, { status: 500 });
3358
+ }
3359
+ };
3360
+ }
3361
+ function createAiAltTextHandler(uploadsCollection, seoConfig) {
3362
+ return async (req) => {
3363
+ try {
3364
+ if (!isAdmin4(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
3365
+ const body = await parseJsonBody(req);
3366
+ const collection = typeof body.collection === "string" ? body.collection : uploadsCollection;
3367
+ const id = body.id != null ? String(body.id) : void 0;
3368
+ const apply = body.apply === true;
3369
+ const providedAlt = typeof body.altText === "string" ? body.altText.trim() : void 0;
3370
+ if (!id) return Response.json({ error: "Missing required field: id" }, { status: 400 });
3371
+ if (apply && providedAlt) {
3372
+ const alt2 = providedAlt.slice(0, ALT_MAX);
3373
+ await req.payload.update({ collection, id, data: { alt: alt2 }, overrideAccess: true });
3374
+ return Response.json({ alt: alt2, applied: true, method: "manual" });
3375
+ }
3376
+ const apiKey = process.env.ANTHROPIC_API_KEY;
3377
+ if (!apiKey) {
3378
+ return Response.json(
3379
+ { error: "AI not configured. Set ANTHROPIC_API_KEY to generate alt text.", code: "no_api_key" },
3380
+ { status: 400 }
3381
+ );
3382
+ }
3383
+ let media;
3384
+ try {
3385
+ media = await req.payload.findByID({ collection, id, depth: 0, overrideAccess: true });
3386
+ } catch {
3387
+ return Response.json({ error: `Media not found: ${collection}/${id}` }, { status: 404 });
3388
+ }
3389
+ const mime = media.mimeType || "";
3390
+ const mediaType = SUPPORTED_MIME[mime.toLowerCase()];
3391
+ if (!mediaType) {
3392
+ return Response.json(
3393
+ { error: `Unsupported image type for vision: ${mime || "unknown"} (use JPEG, PNG, GIF or WebP).` },
3394
+ { status: 422 }
3395
+ );
3396
+ }
3397
+ const siteUrl = resolveGscSiteUrl(seoConfig);
3398
+ const imageUrl = resolveImageUrl(media, siteUrl);
3399
+ if (!imageUrl) {
3400
+ return Response.json(
3401
+ { error: "Could not resolve a safe image URL (must be on the site origin or SEO_MEDIA_ORIGIN)." },
3402
+ { status: 422 }
3403
+ );
3404
+ }
3405
+ let base64;
3406
+ try {
3407
+ const imgResp = await fetch(imageUrl);
3408
+ if (!imgResp.ok) throw new Error(`fetch ${imgResp.status}`);
3409
+ const buf = Buffer.from(await imgResp.arrayBuffer());
3410
+ if (buf.byteLength > MAX_IMAGE_BYTES) {
3411
+ return Response.json({ error: "Image too large for vision (max 5 MB)." }, { status: 413 });
3412
+ }
3413
+ base64 = buf.toString("base64");
3414
+ } catch (e) {
3415
+ return Response.json({ error: `Could not fetch image: ${e instanceof Error ? e.message : "error"}` }, { status: 502 });
3416
+ }
3417
+ const model = process.env.SEO_AI_MODEL || DEFAULT_MODEL2;
3418
+ const language = seoConfig?.locale === "en" ? "en" : "fr";
3419
+ let alt;
3420
+ try {
3421
+ alt = await generateAltText(apiKey, model, base64, mediaType, language, {
3422
+ filename: media.filename || "",
3423
+ title: typeof body.context === "string" ? body.context : void 0
3424
+ });
3425
+ } catch (e) {
3426
+ req.payload.logger.error(`[seo] ai-alt-text Claude error: ${e instanceof Error ? e.message : "unknown"}`);
3427
+ return Response.json({ error: "Alt-text generation failed." }, { status: 502 });
3428
+ }
3429
+ if (!alt) {
3430
+ return Response.json({ error: "The model did not return alt text (possibly declined)." }, { status: 502 });
3431
+ }
3432
+ let applied = false;
3433
+ if (apply) {
3434
+ await req.payload.update({ collection, id, data: { alt }, overrideAccess: true });
3435
+ applied = true;
3436
+ }
3437
+ return Response.json({ alt, applied, method: "ai", model });
3438
+ } catch (error) {
3439
+ const message = error instanceof Error ? error.message : "Internal server error";
3440
+ req.payload.logger.error(`[seo] ai-alt-text error: ${message}`);
3441
+ return Response.json({ error: message }, { status: 500 });
3442
+ }
3443
+ };
3444
+ }
3445
+
3446
+ // src/endpoints/aiContentBrief.ts
3447
+ var DEFAULT_MODEL3 = "claude-opus-4-8";
3448
+ var trimList = (arr, max, itemMax = 160) => Array.isArray(arr) ? arr.filter((x) => typeof x === "string").map((x) => x.trim()).filter(Boolean).slice(0, max).map((x) => x.length > itemMax ? `${x.slice(0, itemMax - 1)}\u2026` : x) : [];
3449
+ function parseBrief(raw) {
3450
+ let s = raw.trim();
3451
+ if (s.startsWith("```")) s = s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
3452
+ if (!s.startsWith("{")) {
3453
+ const start = s.indexOf("{");
3454
+ const end = s.lastIndexOf("}");
3455
+ if (start === -1 || end === -1 || end <= start) return null;
3456
+ s = s.slice(start, end + 1);
3457
+ }
3458
+ try {
3459
+ const p = JSON.parse(s);
3460
+ return sanitizeBrief({
3461
+ outline: Array.isArray(p.outline) ? p.outline.map((o) => {
3462
+ const r = o || {};
3463
+ return { level: r.level === "h3" ? "h3" : "h2", text: typeof r.text === "string" ? r.text : "" };
3464
+ }) : [],
3465
+ entities: trimList(p.entities, 30),
3466
+ questions: trimList(p.questions, 15),
3467
+ internalLinkIdeas: trimList(p.internalLinkIdeas, 10),
3468
+ recommendedWordCount: typeof p.recommendedWordCount === "number" ? p.recommendedWordCount : 0,
3469
+ notes: trimList(p.notes, 6)
3470
+ });
3471
+ } catch {
3472
+ return null;
3473
+ }
3474
+ }
3475
+ function sanitizeBrief(b) {
3476
+ return {
3477
+ outline: b.outline.filter((o) => o.text && o.text.trim()).slice(0, 25).map((o) => ({ level: o.level === "h3" ? "h3" : "h2", text: o.text.trim().slice(0, 160) })),
3478
+ entities: trimList(b.entities, 30),
3479
+ questions: trimList(b.questions, 15),
3480
+ internalLinkIdeas: trimList(b.internalLinkIdeas, 10),
3481
+ recommendedWordCount: Math.min(1e4, Math.max(0, Math.round(b.recommendedWordCount || 0))),
3482
+ notes: trimList(b.notes, 6)
3483
+ };
3484
+ }
3485
+ async function callClaudeBrief(apiKey, model, language, params) {
3486
+ const systemPrompt = `You are an SEO content strategist applying June 2026 best practices.
3487
+ Produce a concise WRITING BRIEF for the target keyword so a writer can create a page that ranks AND is citable by AI engines.
3488
+ Rules:
3489
+ - Base the brief on genuine search intent for the keyword; cover entities and questions a complete page must address.
3490
+ - Be specific and non-generic; no filler. Write in ${language === "en" ? "English" : "French"}.
3491
+ - Do not invent facts, brands, prices or statistics.
3492
+ Return ONLY a JSON object (no markdown, no prose) with EXACTLY this shape:
3493
+ {"outline":[{"level":"h2"|"h3","text":string}],"entities":[string],"questions":[string],"internalLinkIdeas":[string],"recommendedWordCount":number,"notes":[string]}
3494
+ - outline: 5-12 headings (logical H2/H3 structure).
3495
+ - entities: 8-20 key terms/concepts to mention.
3496
+ - questions: 4-10 questions the page should answer (People-Also-Ask style).
3497
+ - internalLinkIdeas: 3-8 topics worth linking to internally.
3498
+ - notes: up to 4 short strategic tips.`;
3499
+ const userPrompt = `Target keyword: ${params.keyword}
3500
+ ${params.pageTitle ? `Existing page title: ${params.pageTitle}` : ""}
3501
+ ${params.existingContent ? `Existing content (first 2000 chars, complement it \u2014 don't repeat):
3502
+ ${params.existingContent.substring(0, 2e3)}` : ""}
3503
+
3504
+ Return the JSON brief now:`;
3505
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
3506
+ method: "POST",
3507
+ headers: { "Content-Type": "application/json", "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
3508
+ body: JSON.stringify({
3509
+ model,
3510
+ max_tokens: 1500,
3511
+ system: systemPrompt,
3512
+ messages: [{ role: "user", content: userPrompt }]
3513
+ })
3514
+ });
3515
+ if (!response.ok) {
3516
+ const body = await response.text();
3517
+ throw new Error(`Claude API error ${response.status}: ${body}`);
3518
+ }
3519
+ const data = await response.json();
3520
+ if (data.stop_reason === "refusal") return null;
3521
+ const text = (data.content?.find((b) => b.type === "text")?.text || "").trim();
3522
+ if (!text) return null;
3523
+ return parseBrief(text);
3524
+ }
3525
+ function createAiContentBriefHandler(targetCollections, seoConfig) {
3526
+ return async (req) => {
3527
+ try {
3528
+ if (!req.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
3529
+ const body = await parseJsonBody(req);
3530
+ const keyword = typeof body.keyword === "string" ? body.keyword.trim() : "";
3531
+ if (!keyword) return Response.json({ error: "Missing required field: keyword" }, { status: 400 });
3532
+ const apiKey = process.env.ANTHROPIC_API_KEY;
3533
+ if (!apiKey) {
3534
+ return Response.json(
3535
+ { error: "AI not configured. Set ANTHROPIC_API_KEY to generate a content brief.", code: "no_api_key" },
3536
+ { status: 400 }
3537
+ );
3538
+ }
3539
+ let pageTitle;
3540
+ let existingContent;
3541
+ const collection = typeof body.collection === "string" ? body.collection : void 0;
3542
+ const id = body.id != null ? String(body.id) : void 0;
3543
+ if (collection && id && (!targetCollections || targetCollections.includes(collection))) {
3544
+ try {
3545
+ const doc = await req.payload.findByID({ collection, id, depth: 1, overrideAccess: true });
3546
+ pageTitle = doc.title || void 0;
3547
+ existingContent = extractDocContent(doc).text || void 0;
3548
+ } catch {
3549
+ }
3550
+ }
3551
+ const model = process.env.SEO_AI_MODEL || DEFAULT_MODEL3;
3552
+ const language = seoConfig?.locale === "en" ? "en" : "fr";
3553
+ let brief;
3554
+ try {
3555
+ brief = await callClaudeBrief(apiKey, model, language, { keyword, pageTitle, existingContent });
3556
+ } catch (e) {
3557
+ req.payload.logger.error(`[seo] ai-content-brief Claude error: ${e instanceof Error ? e.message : "unknown"}`);
3558
+ return Response.json({ error: "Content brief generation failed." }, { status: 502 });
3559
+ }
3560
+ if (!brief) return Response.json({ error: "The model did not return a brief (possibly declined)." }, { status: 502 });
3561
+ return Response.json({ keyword, brief, model });
3562
+ } catch (error) {
3563
+ const message = error instanceof Error ? error.message : "Internal server error";
3564
+ req.payload.logger.error(`[seo] ai-content-brief error: ${message}`);
3565
+ return Response.json({ error: message }, { status: 500 });
3566
+ }
3567
+ };
3568
+ }
3124
3569
 
3125
3570
  // src/endpoints/cannibalization.ts
3126
3571
  function canonicalIntent(keyword) {
@@ -3706,7 +4151,7 @@ function getDateThreshold(period) {
3706
4151
  return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
3707
4152
  }
3708
4153
  }
3709
- function isAdmin4(user) {
4154
+ function isAdmin5(user) {
3710
4155
  if (!user) return false;
3711
4156
  if (user.role === "admin") return true;
3712
4157
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -3811,7 +4256,7 @@ function createPerformanceHandler() {
3811
4256
  });
3812
4257
  }
3813
4258
  if (method === "POST") {
3814
- if (!isAdmin4(req.user)) {
4259
+ if (!isAdmin5(req.user)) {
3815
4260
  return Response.json({ error: "Admin access required" }, { status: 403 });
3816
4261
  }
3817
4262
  const body = await parseJsonBody(req);
@@ -4109,96 +4554,12 @@ function createCoreWebVitalsHandler(seoConfig) {
4109
4554
  }
4110
4555
  };
4111
4556
  }
4112
- var ALGO = "aes-256-gcm";
4113
- var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
4114
- var FORMAT_VERSION = "v1";
4115
- function deriveKey(secret) {
4116
- const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
4117
- if (explicit) {
4118
- const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
4119
- if (buf.length === 32) return buf;
4120
- throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
4121
- }
4122
- if (!secret) {
4123
- throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
4124
- }
4125
- return crypto.scryptSync(secret, KEY_NAMESPACE, 32);
4126
- }
4127
- function encryptToken(plaintext, secret) {
4128
- const key = deriveKey(secret);
4129
- const iv = crypto.randomBytes(12);
4130
- const cipher = crypto.createCipheriv(ALGO, key, iv);
4131
- const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
4132
- const tag = cipher.getAuthTag();
4133
- return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
4134
- }
4135
- function decryptToken(payload, secret) {
4136
- const parts = payload.split(":");
4137
- if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
4138
- throw new Error("Invalid encrypted token format.");
4139
- }
4140
- const key = deriveKey(secret);
4141
- const iv = Buffer.from(parts[1], "base64");
4142
- const tag = Buffer.from(parts[2], "base64");
4143
- const enc = Buffer.from(parts[3], "base64");
4144
- const decipher = crypto.createDecipheriv(ALGO, key, iv);
4145
- decipher.setAuthTag(tag);
4146
- const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
4147
- return dec.toString("utf8");
4148
- }
4149
- function safeEqual(a, b) {
4150
- const ba = Buffer.from(a);
4151
- const bb = Buffer.from(b);
4152
- if (ba.length !== bb.length) return false;
4153
- return crypto.timingSafeEqual(ba, bb);
4154
- }
4155
-
4156
- // src/endpoints/gscOAuth.ts
4157
- var AUTH_COLLECTION = "seo-gsc-auth";
4158
- var SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
4159
- function isAdmin5(user) {
4160
- if (!user) return false;
4161
- if (user.role === "admin") return true;
4162
- if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
4163
- return false;
4164
- }
4165
- function resolveSiteUrl2(seoConfig) {
4166
- return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
4167
- }
4168
- function getOAuthConfig(basePath, seoConfig) {
4169
- const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
4170
- const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
4171
- const siteUrl = resolveSiteUrl2(seoConfig);
4172
- if (!clientId || !clientSecret || !siteUrl) return null;
4173
- return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
4174
- }
4175
- async function getOrCreateAuthDoc(payload) {
4176
- const found = await payload.find({ collection: AUTH_COLLECTION, limit: 1, overrideAccess: true });
4177
- if (found.docs.length > 0) return found.docs[0];
4178
- return payload.create({ collection: AUTH_COLLECTION, data: {}, overrideAccess: true });
4179
- }
4180
- async function tokenRequest(cfg, body) {
4181
- const resp = await fetch("https://oauth2.googleapis.com/token", {
4182
- method: "POST",
4183
- headers: { "content-type": "application/x-www-form-urlencoded" },
4184
- body: new URLSearchParams({
4185
- client_id: cfg.clientId,
4186
- client_secret: cfg.clientSecret,
4187
- ...body
4188
- }).toString()
4189
- });
4190
- const json = await resp.json();
4191
- if (!resp.ok) {
4192
- throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
4193
- }
4194
- return json;
4195
- }
4196
4557
  function createGscStatusHandler(basePath, seoConfig) {
4197
4558
  return async (req) => {
4198
4559
  try {
4199
4560
  if (!req.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
4200
- const cfg = getOAuthConfig(basePath, seoConfig);
4201
- const doc = await getOrCreateAuthDoc(req.payload);
4561
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
4562
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4202
4563
  return Response.json(
4203
4564
  {
4204
4565
  configured: !!cfg,
@@ -4220,8 +4581,8 @@ function createGscStatusHandler(basePath, seoConfig) {
4220
4581
  function createGscAuthStartHandler(basePath, seoConfig) {
4221
4582
  return async (req) => {
4222
4583
  try {
4223
- if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4224
- const cfg = getOAuthConfig(basePath, seoConfig);
4584
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4585
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
4225
4586
  if (!cfg) {
4226
4587
  return Response.json(
4227
4588
  { error: "GSC OAuth not configured. Set GSC_OAUTH_CLIENT_ID, GSC_OAUTH_CLIENT_SECRET and siteUrl." },
@@ -4229,9 +4590,9 @@ function createGscAuthStartHandler(basePath, seoConfig) {
4229
4590
  );
4230
4591
  }
4231
4592
  const state = crypto.randomBytes(24).toString("hex");
4232
- const doc = await getOrCreateAuthDoc(req.payload);
4593
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4233
4594
  await req.payload.update({
4234
- collection: AUTH_COLLECTION,
4595
+ collection: GSC_AUTH_COLLECTION,
4235
4596
  id: doc.id,
4236
4597
  data: { pendingState: state },
4237
4598
  overrideAccess: true
@@ -4240,7 +4601,7 @@ function createGscAuthStartHandler(basePath, seoConfig) {
4240
4601
  authUrl.searchParams.set("client_id", cfg.clientId);
4241
4602
  authUrl.searchParams.set("redirect_uri", cfg.redirectUri);
4242
4603
  authUrl.searchParams.set("response_type", "code");
4243
- authUrl.searchParams.set("scope", SCOPES);
4604
+ authUrl.searchParams.set("scope", GSC_SCOPES);
4244
4605
  authUrl.searchParams.set("access_type", "offline");
4245
4606
  authUrl.searchParams.set("prompt", "consent");
4246
4607
  authUrl.searchParams.set("state", state);
@@ -4259,10 +4620,10 @@ function createGscCallbackHandler(basePath, seoConfig) {
4259
4620
  { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }
4260
4621
  );
4261
4622
  try {
4262
- if (!isAdmin5(req.user)) {
4623
+ if (!isGscAdmin(req.user)) {
4263
4624
  return htmlPage("Connection failed", "You must be signed in as an admin to connect Google Search Console.");
4264
4625
  }
4265
- const cfg = getOAuthConfig(basePath, seoConfig);
4626
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
4266
4627
  if (!cfg) return htmlPage("Connection failed", "GSC OAuth is not configured on the server.");
4267
4628
  const url = new URL(req.url);
4268
4629
  const code = url.searchParams.get("code");
@@ -4270,11 +4631,11 @@ function createGscCallbackHandler(basePath, seoConfig) {
4270
4631
  const oauthError = url.searchParams.get("error");
4271
4632
  if (oauthError) return htmlPage("Connection cancelled", `Google returned: ${oauthError}`);
4272
4633
  if (!code || !state) return htmlPage("Connection failed", "Missing code or state.");
4273
- const doc = await getOrCreateAuthDoc(req.payload);
4634
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4274
4635
  if (!doc.pendingState || !safeEqual(state, doc.pendingState)) {
4275
4636
  return htmlPage("Connection failed", "Invalid state (possible CSRF). Please restart the connection.");
4276
4637
  }
4277
- const tokens = await tokenRequest(cfg, {
4638
+ const tokens = await gscTokenRequest(cfg, {
4278
4639
  code,
4279
4640
  redirect_uri: cfg.redirectUri,
4280
4641
  grant_type: "authorization_code"
@@ -4300,14 +4661,14 @@ function createGscCallbackHandler(basePath, seoConfig) {
4300
4661
  const secret = req.payload.secret || "";
4301
4662
  const refreshTokenEnc = encryptToken(refreshToken, secret);
4302
4663
  await req.payload.update({
4303
- collection: AUTH_COLLECTION,
4664
+ collection: GSC_AUTH_COLLECTION,
4304
4665
  id: doc.id,
4305
4666
  data: {
4306
4667
  refreshTokenEnc,
4307
4668
  pendingState: null,
4308
4669
  connectedEmail: email,
4309
4670
  connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
4310
- scope: tokens.scope || SCOPES,
4671
+ scope: tokens.scope || GSC_SCOPES,
4311
4672
  propertyUrl: doc.propertyUrl || cfg.siteUrl
4312
4673
  },
4313
4674
  overrideAccess: true
@@ -4323,26 +4684,26 @@ function createGscCallbackHandler(basePath, seoConfig) {
4323
4684
  function createGscDataHandler(basePath, seoConfig) {
4324
4685
  return async (req) => {
4325
4686
  try {
4326
- if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4327
- const cfg = getOAuthConfig(basePath, seoConfig);
4687
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4688
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
4328
4689
  if (!cfg) return Response.json({ error: "GSC OAuth not configured." }, { status: 400 });
4329
- const doc = await getOrCreateAuthDoc(req.payload);
4690
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4330
4691
  if (!doc.refreshTokenEnc) {
4331
4692
  return Response.json({ error: "Not connected to Google Search Console." }, { status: 409 });
4332
4693
  }
4333
- const secret = req.payload.secret || "";
4334
- let refreshToken;
4694
+ let accessToken;
4335
4695
  try {
4336
- refreshToken = decryptToken(doc.refreshTokenEnc, secret);
4337
- } catch {
4338
- return Response.json(
4339
- { error: "Stored token could not be decrypted (encryption key changed?). Reconnect GSC." },
4340
- { status: 409 }
4341
- );
4696
+ accessToken = await getGscAccessToken(req.payload, cfg, doc);
4697
+ } catch (e) {
4698
+ const code = e instanceof Error ? e.message : "refresh_failed";
4699
+ if (code === "decrypt_failed") {
4700
+ return Response.json(
4701
+ { error: "Stored token could not be decrypted (encryption key changed?). Reconnect GSC." },
4702
+ { status: 409 }
4703
+ );
4704
+ }
4705
+ return Response.json({ error: "Could not refresh access token." }, { status: 502 });
4342
4706
  }
4343
- const tokens = await tokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
4344
- const accessToken = tokens.access_token;
4345
- if (!accessToken) return Response.json({ error: "Could not refresh access token." }, { status: 502 });
4346
4707
  const url = new URL(req.url);
4347
4708
  const today = /* @__PURE__ */ new Date();
4348
4709
  const defaultEnd = today.toISOString().slice(0, 10);
@@ -4352,21 +4713,19 @@ function createGscDataHandler(basePath, seoConfig) {
4352
4713
  const dimension = url.searchParams.get("dimension") === "page" ? "page" : "query";
4353
4714
  const rowLimit = Math.min(1e3, Math.max(1, parseInt(url.searchParams.get("rowLimit") || "100", 10)));
4354
4715
  const property = doc.propertyUrl || cfg.siteUrl;
4355
- const gscResp = await fetch(
4356
- `https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(property)}/searchAnalytics/query`,
4357
- {
4358
- method: "POST",
4359
- headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
4360
- body: JSON.stringify({ startDate, endDate, dimensions: [dimension], rowLimit })
4361
- }
4362
- );
4363
- const gscJson = await gscResp.json();
4364
- if (!gscResp.ok) {
4365
- const err = gscJson.error?.message || gscResp.status;
4366
- return Response.json({ error: `GSC query failed: ${err}` }, { status: 502 });
4716
+ let rows;
4717
+ try {
4718
+ rows = await queryGscSearchAnalytics(accessToken, property, {
4719
+ startDate,
4720
+ endDate,
4721
+ dimensions: [dimension],
4722
+ rowLimit
4723
+ });
4724
+ } catch (e) {
4725
+ return Response.json({ error: e instanceof Error ? e.message : "GSC query failed" }, { status: 502 });
4367
4726
  }
4368
4727
  return Response.json(
4369
- { property, startDate, endDate, dimension, rows: gscJson.rows || [] },
4728
+ { property, startDate, endDate, dimension, rows },
4370
4729
  { headers: { "Cache-Control": "no-store" } }
4371
4730
  );
4372
4731
  } catch (error) {
@@ -4379,10 +4738,10 @@ function createGscDataHandler(basePath, seoConfig) {
4379
4738
  function createGscDisconnectHandler() {
4380
4739
  return async (req) => {
4381
4740
  try {
4382
- if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4383
- const doc = await getOrCreateAuthDoc(req.payload);
4741
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4742
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4384
4743
  await req.payload.update({
4385
- collection: AUTH_COLLECTION,
4744
+ collection: GSC_AUTH_COLLECTION,
4386
4745
  id: doc.id,
4387
4746
  data: { refreshTokenEnc: null, pendingState: null, connectedEmail: null, connectedAt: null, scope: null },
4388
4747
  overrideAccess: true
@@ -4927,7 +5286,33 @@ function createLinkGraphHandler(targetCollections, globals = []) {
4927
5286
  };
4928
5287
  }
4929
5288
 
4930
- // src/endpoints/schemaGenerator.ts
5289
+ // src/helpers/buildSchema.ts
5290
+ var SCHEMA_TYPES = [
5291
+ "Article",
5292
+ "LocalBusiness",
5293
+ "BreadcrumbList",
5294
+ "FAQPage",
5295
+ "Product",
5296
+ "Organization",
5297
+ "Person",
5298
+ "Event",
5299
+ "Recipe",
5300
+ "Video"
5301
+ ];
5302
+ function resolveSiteUrl2(explicit) {
5303
+ return (explicit || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "http://localhost:3000").replace(/\/$/, "");
5304
+ }
5305
+ function getSchemaImageUrl(metaImage, heroMedia, siteUrl) {
5306
+ const img = metaImage || heroMedia;
5307
+ if (!img) return void 0;
5308
+ if (typeof img.url === "string") {
5309
+ return img.url.startsWith("http") ? img.url : `${siteUrl}${img.url}`;
5310
+ }
5311
+ if (typeof img.filename === "string") {
5312
+ return `${siteUrl}/media/${img.filename}`;
5313
+ }
5314
+ return void 0;
5315
+ }
4931
5316
  function detectSchemaType(collection, doc) {
4932
5317
  if (collection === "posts") return "Article";
4933
5318
  const layout = doc.layout;
@@ -4948,16 +5333,25 @@ function detectSchemaType(collection, doc) {
4948
5333
  }
4949
5334
  return "Article";
4950
5335
  }
5336
+ function buildAuthors(authors) {
5337
+ return authors.filter((a) => a && typeof a === "object").map((a) => {
5338
+ const author = a;
5339
+ return {
5340
+ "@type": "Person",
5341
+ name: author.name || author.firstName || "Author"
5342
+ };
5343
+ });
5344
+ }
4951
5345
  function buildArticleSchema(doc, siteUrl) {
4952
5346
  const meta = doc.meta || {};
4953
5347
  const heroMedia = doc.hero?.media;
4954
- const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
5348
+ const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
4955
5349
  const schema = {
4956
5350
  "@context": "https://schema.org",
4957
5351
  "@type": "Article",
4958
5352
  headline: meta.title || doc.title || "",
4959
5353
  description: meta.description || "",
4960
- datePublished: doc.createdAt || void 0,
5354
+ datePublished: doc.publishedAt || doc.createdAt || void 0,
4961
5355
  dateModified: doc.updatedAt || void 0,
4962
5356
  mainEntityOfPage: {
4963
5357
  "@type": "WebPage",
@@ -4970,24 +5364,46 @@ function buildArticleSchema(doc, siteUrl) {
4970
5364
  }
4971
5365
  return schema;
4972
5366
  }
4973
- function buildLocalBusinessSchema(doc, siteUrl) {
5367
+ function buildLocationNode(loc, doc, siteUrl) {
4974
5368
  const meta = doc.meta || {};
4975
- const schema = {
4976
- "@context": "https://schema.org",
4977
- "@type": "LocalBusiness",
4978
- name: doc.title || meta.title || "",
4979
- description: meta.description || "",
4980
- url: `${siteUrl}/${doc.slug || ""}`
5369
+ const node = {
5370
+ "@type": typeof loc.type === "string" && loc.type || "LocalBusiness",
5371
+ name: loc.name || doc.title || meta.title || "",
5372
+ description: loc.description || meta.description || "",
5373
+ url: loc.url || `${siteUrl}/${doc.slug || ""}`
4981
5374
  };
4982
- if (doc.telephone) schema.telephone = doc.telephone;
4983
- if (doc.email) schema.email = doc.email;
4984
- if (doc.address && typeof doc.address === "object") {
4985
- schema.address = {
4986
- "@type": "PostalAddress",
4987
- ...doc.address
5375
+ if (loc.telephone) node.telephone = loc.telephone;
5376
+ if (loc.email) node.email = loc.email;
5377
+ if (loc.priceRange) node.priceRange = loc.priceRange;
5378
+ const address = loc.address;
5379
+ if (address && typeof address === "object") {
5380
+ node.address = { "@type": "PostalAddress", ...address };
5381
+ } else if (typeof address === "string" && address) {
5382
+ node.address = address;
5383
+ }
5384
+ const geo = loc.geo || {};
5385
+ const lat = geo.latitude ?? loc.latitude ?? loc.lat;
5386
+ const lng = geo.longitude ?? loc.longitude ?? loc.lng;
5387
+ if (lat != null && lng != null) {
5388
+ node.geo = { "@type": "GeoCoordinates", latitude: lat, longitude: lng };
5389
+ }
5390
+ if (Array.isArray(loc.openingHours) && loc.openingHours.length > 0) {
5391
+ node.openingHours = loc.openingHours;
5392
+ } else if (typeof loc.openingHours === "string" && loc.openingHours) {
5393
+ node.openingHours = loc.openingHours;
5394
+ }
5395
+ return node;
5396
+ }
5397
+ function buildLocalBusinessSchema(doc, siteUrl) {
5398
+ const locations = Array.isArray(doc.locations) ? doc.locations.filter((l) => !!l && typeof l === "object") : [];
5399
+ if (locations.length > 1) {
5400
+ return {
5401
+ "@context": "https://schema.org",
5402
+ "@graph": locations.map((loc) => buildLocationNode(loc, doc, siteUrl))
4988
5403
  };
4989
5404
  }
4990
- return schema;
5405
+ const base = locations.length === 1 ? locations[0] : doc;
5406
+ return { "@context": "https://schema.org", ...buildLocationNode(base, doc, siteUrl) };
4991
5407
  }
4992
5408
  function buildBreadcrumbSchema(doc, siteUrl) {
4993
5409
  const slug = doc.slug || "";
@@ -5056,7 +5472,7 @@ function buildFAQSchema(doc) {
5056
5472
  function buildProductSchema(doc, siteUrl) {
5057
5473
  const meta = doc.meta || {};
5058
5474
  const heroMedia = doc.hero?.media;
5059
- const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
5475
+ const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
5060
5476
  const schema = {
5061
5477
  "@context": "https://schema.org",
5062
5478
  "@type": "Product",
@@ -5129,7 +5545,7 @@ function buildEventSchema(doc, siteUrl) {
5129
5545
  function buildRecipeSchema(doc, siteUrl) {
5130
5546
  const meta = doc.meta || {};
5131
5547
  const heroMedia = doc.hero?.media;
5132
- const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
5548
+ const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
5133
5549
  const schema = {
5134
5550
  "@context": "https://schema.org",
5135
5551
  "@type": "Recipe",
@@ -5146,7 +5562,7 @@ function buildRecipeSchema(doc, siteUrl) {
5146
5562
  function buildVideoSchema(doc, siteUrl) {
5147
5563
  const meta = doc.meta || {};
5148
5564
  const heroMedia = doc.hero?.media;
5149
- const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
5565
+ const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
5150
5566
  const schema = {
5151
5567
  "@context": "https://schema.org",
5152
5568
  "@type": "VideoObject",
@@ -5159,26 +5575,51 @@ function buildVideoSchema(doc, siteUrl) {
5159
5575
  if (doc.duration) schema.duration = doc.duration;
5160
5576
  return schema;
5161
5577
  }
5162
- function getImageUrl(metaImage, heroMedia, siteUrl) {
5163
- const img = metaImage || heroMedia;
5164
- if (!img) return void 0;
5165
- if (typeof img.url === "string") {
5166
- return img.url.startsWith("http") ? img.url : `${siteUrl}${img.url}`;
5167
- }
5168
- if (typeof img.filename === "string") {
5169
- return `${siteUrl}/media/${img.filename}`;
5170
- }
5171
- return void 0;
5172
- }
5173
- function buildAuthors(authors) {
5174
- return authors.filter((a) => a && typeof a === "object").map((a) => {
5175
- const author = a;
5176
- return {
5177
- "@type": "Person",
5178
- name: author.name || author.firstName || "Author"
5179
- };
5180
- });
5578
+ function buildJsonLd(doc, options = {}) {
5579
+ const siteUrl = resolveSiteUrl2(options.siteUrl);
5580
+ const schemaType = options.type || detectSchemaType(options.collection || "", doc);
5581
+ let jsonLd;
5582
+ switch (schemaType) {
5583
+ case "Article":
5584
+ jsonLd = buildArticleSchema(doc, siteUrl);
5585
+ break;
5586
+ case "LocalBusiness":
5587
+ jsonLd = buildLocalBusinessSchema(doc, siteUrl);
5588
+ break;
5589
+ case "BreadcrumbList":
5590
+ jsonLd = buildBreadcrumbSchema(doc, siteUrl);
5591
+ break;
5592
+ case "FAQPage":
5593
+ jsonLd = buildFAQSchema(doc);
5594
+ break;
5595
+ case "Product":
5596
+ jsonLd = buildProductSchema(doc, siteUrl);
5597
+ break;
5598
+ case "Organization":
5599
+ jsonLd = buildOrganizationSchema(doc, siteUrl);
5600
+ break;
5601
+ case "Person":
5602
+ jsonLd = buildPersonSchema(doc, siteUrl);
5603
+ break;
5604
+ case "Event":
5605
+ jsonLd = buildEventSchema(doc, siteUrl);
5606
+ break;
5607
+ case "Recipe":
5608
+ jsonLd = buildRecipeSchema(doc, siteUrl);
5609
+ break;
5610
+ case "Video":
5611
+ jsonLd = buildVideoSchema(doc, siteUrl);
5612
+ break;
5613
+ }
5614
+ const cleaned = JSON.parse(JSON.stringify(jsonLd));
5615
+ return { type: schemaType, jsonLd: cleaned };
5616
+ }
5617
+ function renderJsonLdScript(doc, options = {}) {
5618
+ const { jsonLd } = buildJsonLd(doc, options);
5619
+ return `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`;
5181
5620
  }
5621
+
5622
+ // src/endpoints/schemaGenerator.ts
5182
5623
  function createSchemaGeneratorHandler(targetCollections) {
5183
5624
  return async (req) => {
5184
5625
  try {
@@ -5190,74 +5631,30 @@ function createSchemaGeneratorHandler(targetCollections) {
5190
5631
  const id = url.searchParams.get("id");
5191
5632
  const typeOverrideRaw = url.searchParams.get("type");
5192
5633
  if (!collection || !id) {
5193
- return Response.json(
5194
- { error: "Missing required query params: collection, id" },
5195
- { status: 400 }
5196
- );
5634
+ return Response.json({ error: "Missing required query params: collection, id" }, { status: 400 });
5197
5635
  }
5198
- const validTypes = ["Article", "LocalBusiness", "BreadcrumbList", "FAQPage", "Product", "Organization", "Person", "Event", "Recipe", "Video"];
5199
- if (typeOverrideRaw !== null && !validTypes.includes(typeOverrideRaw)) {
5636
+ if (typeOverrideRaw !== null && !SCHEMA_TYPES.includes(typeOverrideRaw)) {
5200
5637
  return Response.json(
5201
- { error: `Invalid schema type. Valid types: ${validTypes.join(", ")}` },
5638
+ { error: `Invalid schema type. Valid types: ${SCHEMA_TYPES.join(", ")}` },
5202
5639
  { status: 400 }
5203
5640
  );
5204
5641
  }
5205
- const typeOverride = typeOverrideRaw;
5642
+ const typeOverride = typeOverrideRaw || void 0;
5206
5643
  if (targetCollections && !targetCollections.includes(collection)) {
5207
5644
  return Response.json({ error: "Collection not allowed" }, { status: 403 });
5208
5645
  }
5209
5646
  let doc;
5210
5647
  try {
5211
- const result = await req.payload.findByID({
5212
- collection,
5213
- id,
5214
- depth: 1,
5215
- overrideAccess: true
5216
- });
5648
+ const result = await req.payload.findByID({ collection, id, depth: 1, overrideAccess: true });
5217
5649
  doc = result;
5218
5650
  } catch {
5219
5651
  return Response.json({ error: `Document not found: ${collection}/${id}` }, { status: 404 });
5220
5652
  }
5221
- const siteUrl = (process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "http://localhost:3000").replace(/\/$/, "");
5222
- const schemaType = typeOverride || detectSchemaType(collection, doc);
5223
- let jsonLd;
5224
- switch (schemaType) {
5225
- case "Article":
5226
- jsonLd = buildArticleSchema(doc, siteUrl);
5227
- break;
5228
- case "LocalBusiness":
5229
- jsonLd = buildLocalBusinessSchema(doc, siteUrl);
5230
- break;
5231
- case "BreadcrumbList":
5232
- jsonLd = buildBreadcrumbSchema(doc, siteUrl);
5233
- break;
5234
- case "FAQPage":
5235
- jsonLd = buildFAQSchema(doc);
5236
- break;
5237
- case "Product":
5238
- jsonLd = buildProductSchema(doc, siteUrl);
5239
- break;
5240
- case "Organization":
5241
- jsonLd = buildOrganizationSchema(doc, siteUrl);
5242
- break;
5243
- case "Person":
5244
- jsonLd = buildPersonSchema(doc, siteUrl);
5245
- break;
5246
- case "Event":
5247
- jsonLd = buildEventSchema(doc, siteUrl);
5248
- break;
5249
- case "Recipe":
5250
- jsonLd = buildRecipeSchema(doc, siteUrl);
5251
- break;
5252
- case "Video":
5253
- jsonLd = buildVideoSchema(doc, siteUrl);
5254
- break;
5255
- }
5256
- const cleaned = JSON.parse(JSON.stringify(jsonLd));
5653
+ const { type, jsonLd } = buildJsonLd(doc, { collection, type: typeOverride });
5257
5654
  return Response.json({
5258
- type: schemaType,
5259
- jsonLd: cleaned,
5260
- html: `<script type="application/ld+json">${JSON.stringify(cleaned, null, 2)}</script>`
5655
+ type,
5656
+ jsonLd,
5657
+ html: `<script type="application/ld+json">${JSON.stringify(jsonLd, null, 2)}</script>`
5261
5658
  });
5262
5659
  } catch (error) {
5263
5660
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -5757,6 +6154,191 @@ function createSitemapHandler(targetCollections) {
5757
6154
  };
5758
6155
  }
5759
6156
 
6157
+ // src/endpoints/sitemapExtensions.ts
6158
+ function escapeXml2(str) {
6159
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
6160
+ }
6161
+ function resolveSiteUrl3(seoConfig) {
6162
+ return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "").replace(/\/$/, "");
6163
+ }
6164
+ function docPath(slug) {
6165
+ return slug === "home" || slug === "" ? "" : `/${slug}`;
6166
+ }
6167
+ function xmlResponse(xml, status = 200) {
6168
+ return new Response(xml, {
6169
+ status,
6170
+ headers: { "Content-Type": "application/xml", "Cache-Control": "public, max-age=3600, s-maxage=3600" }
6171
+ });
6172
+ }
6173
+ function mediaUrl(media, siteUrl) {
6174
+ if (typeof media.url === "string" && media.url) {
6175
+ return media.url.startsWith("http") ? media.url : `${siteUrl}${media.url}`;
6176
+ }
6177
+ if (typeof media.filename === "string" && media.filename) {
6178
+ return `${siteUrl}/media/${media.filename}`;
6179
+ }
6180
+ return void 0;
6181
+ }
6182
+ function collectMediaUrls(node, mimePrefix, siteUrl, out, depth = 0) {
6183
+ if (!node || typeof node !== "object" || depth > 8) return;
6184
+ if (Array.isArray(node)) {
6185
+ for (const item of node) collectMediaUrls(item, mimePrefix, siteUrl, out, depth + 1);
6186
+ return;
6187
+ }
6188
+ const obj = node;
6189
+ const mime = typeof obj.mimeType === "string" ? obj.mimeType : "";
6190
+ if (mime.startsWith(mimePrefix)) {
6191
+ const url = mediaUrl(obj, siteUrl);
6192
+ if (url) out.add(url);
6193
+ }
6194
+ for (const key of Object.keys(obj)) {
6195
+ if (key === "sizes" || key === "_status") continue;
6196
+ collectMediaUrls(obj[key], mimePrefix, siteUrl, out, depth + 1);
6197
+ }
6198
+ }
6199
+ async function eachPublishedDoc(payload, collections, depth, onDoc) {
6200
+ const BATCH = Math.min(100, Math.max(1, parseInt(process.env.SEO_SITEMAP_BATCH_SIZE || "50", 10) || 50));
6201
+ const MAX = Math.max(1, parseInt(process.env.SEO_SITEMAP_MAX_DOCS || "5000", 10) || 5e3);
6202
+ let count = 0;
6203
+ for (const collection of collections) {
6204
+ try {
6205
+ let page = 1;
6206
+ let hasMore = true;
6207
+ while (hasMore) {
6208
+ const res = await payload.find({ collection, limit: BATCH, page, depth, overrideAccess: true });
6209
+ for (const doc of res.docs) {
6210
+ if (doc._status === "draft") continue;
6211
+ if (count >= MAX) return;
6212
+ onDoc(doc, collection);
6213
+ count++;
6214
+ }
6215
+ hasMore = res.hasNextPage;
6216
+ page++;
6217
+ await new Promise((resolve) => setImmediate(resolve));
6218
+ }
6219
+ } catch {
6220
+ }
6221
+ }
6222
+ }
6223
+ function createNewsSitemapHandler(targetCollections, seoConfig) {
6224
+ return async (req) => {
6225
+ try {
6226
+ const siteUrl = resolveSiteUrl3(seoConfig);
6227
+ const language = seoConfig?.locale === "en" ? "en" : "fr";
6228
+ let publication = seoConfig?.siteName || "";
6229
+ if (!publication && siteUrl) {
6230
+ try {
6231
+ publication = new URL(siteUrl).hostname;
6232
+ } catch {
6233
+ }
6234
+ }
6235
+ const cutoff = Date.now() - 48 * 36e5;
6236
+ const entries = [];
6237
+ await eachPublishedDoc(req.payload, targetCollections, 0, (doc) => {
6238
+ const dateStr = typeof doc.publishedAt === "string" && doc.publishedAt || typeof doc.date === "string" && doc.date || typeof doc.createdAt === "string" && doc.createdAt || "";
6239
+ if (!dateStr) return;
6240
+ const t = new Date(dateStr).getTime();
6241
+ if (isNaN(t) || t < cutoff) return;
6242
+ const title = doc.title || doc.meta?.title || "";
6243
+ if (!title) return;
6244
+ const loc = `${siteUrl}${docPath(doc.slug || "")}`;
6245
+ entries.push(
6246
+ ` <url>
6247
+ <loc>${escapeXml2(loc)}</loc>
6248
+ <news:news>
6249
+ <news:publication>
6250
+ <news:name>${escapeXml2(publication)}</news:name>
6251
+ <news:language>${language}</news:language>
6252
+ </news:publication>
6253
+ <news:publication_date>${new Date(dateStr).toISOString()}</news:publication_date>
6254
+ <news:title>${escapeXml2(title)}</news:title>
6255
+ </news:news>
6256
+ </url>`
6257
+ );
6258
+ });
6259
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
6260
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
6261
+ ${entries.join("\n")}
6262
+ </urlset>`;
6263
+ return xmlResponse(xml);
6264
+ } catch (error) {
6265
+ req.payload.logger.error(`[seo] sitemap-news error: ${error instanceof Error ? error.message : "unknown"}`);
6266
+ return xmlResponse('<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>', 500);
6267
+ }
6268
+ };
6269
+ }
6270
+ function createImageSitemapHandler(targetCollections, seoConfig) {
6271
+ return async (req) => {
6272
+ try {
6273
+ const siteUrl = resolveSiteUrl3(seoConfig);
6274
+ const entries = [];
6275
+ await eachPublishedDoc(req.payload, targetCollections, 1, (doc) => {
6276
+ const urls = /* @__PURE__ */ new Set();
6277
+ collectMediaUrls(doc, "image/", siteUrl, urls);
6278
+ if (urls.size === 0) return;
6279
+ const loc = `${siteUrl}${docPath(doc.slug || "")}`;
6280
+ const imgs = Array.from(urls).slice(0, 1e3).map((u) => ` <image:image><image:loc>${escapeXml2(u)}</image:loc></image:image>`).join("\n");
6281
+ entries.push(` <url>
6282
+ <loc>${escapeXml2(loc)}</loc>
6283
+ ${imgs}
6284
+ </url>`);
6285
+ });
6286
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
6287
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
6288
+ ${entries.join("\n")}
6289
+ </urlset>`;
6290
+ return xmlResponse(xml);
6291
+ } catch (error) {
6292
+ req.payload.logger.error(`[seo] sitemap-images error: ${error instanceof Error ? error.message : "unknown"}`);
6293
+ return xmlResponse('<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>', 500);
6294
+ }
6295
+ };
6296
+ }
6297
+ function createVideoSitemapHandler(targetCollections, seoConfig) {
6298
+ return async (req) => {
6299
+ try {
6300
+ const siteUrl = resolveSiteUrl3(seoConfig);
6301
+ const entries = [];
6302
+ await eachPublishedDoc(req.payload, targetCollections, 1, (doc) => {
6303
+ const meta = doc.meta || {};
6304
+ const videoUrls = /* @__PURE__ */ new Set();
6305
+ collectMediaUrls(doc, "video/", siteUrl, videoUrls);
6306
+ for (const k of ["videoUrl", "contentUrl", "playerUrl"]) {
6307
+ if (typeof doc[k] === "string" && doc[k]) videoUrls.add(doc[k]);
6308
+ }
6309
+ if (videoUrls.size === 0) return;
6310
+ const title = doc.title || meta.title || "";
6311
+ const description = meta.description || title;
6312
+ const thumbs = /* @__PURE__ */ new Set();
6313
+ collectMediaUrls(meta.image, "image/", siteUrl, thumbs);
6314
+ if (thumbs.size === 0) collectMediaUrls(doc, "image/", siteUrl, thumbs);
6315
+ const thumbnail = Array.from(thumbs)[0] || "";
6316
+ const loc = `${siteUrl}${docPath(doc.slug || "")}`;
6317
+ const videos = Array.from(videoUrls).slice(0, 100).map(
6318
+ (u) => ` <video:video>
6319
+ ${thumbnail ? ` <video:thumbnail_loc>${escapeXml2(thumbnail)}</video:thumbnail_loc>
6320
+ ` : ""} <video:title>${escapeXml2(title || "Video")}</video:title>
6321
+ <video:description>${escapeXml2(description || title || "Video")}</video:description>
6322
+ <video:content_loc>${escapeXml2(u)}</video:content_loc>
6323
+ </video:video>`
6324
+ ).join("\n");
6325
+ entries.push(` <url>
6326
+ <loc>${escapeXml2(loc)}</loc>
6327
+ ${videos}
6328
+ </url>`);
6329
+ });
6330
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
6331
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
6332
+ ${entries.join("\n")}
6333
+ </urlset>`;
6334
+ return xmlResponse(xml);
6335
+ } catch (error) {
6336
+ req.payload.logger.error(`[seo] sitemap-video error: ${error instanceof Error ? error.message : "unknown"}`);
6337
+ return xmlResponse('<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>', 500);
6338
+ }
6339
+ };
6340
+ }
6341
+
5760
6342
  // src/collections/SeoScoreHistory.ts
5761
6343
  function createSeoScoreHistoryCollection() {
5762
6344
  return {
@@ -6407,6 +6989,219 @@ function createSeoGscAuthCollection() {
6407
6989
  };
6408
6990
  }
6409
6991
 
6992
+ // src/collections/SeoRankHistory.ts
6993
+ function createSeoRankHistoryCollection() {
6994
+ return {
6995
+ slug: "seo-rank-history",
6996
+ admin: {
6997
+ custom: { navHidden: true }
6998
+ },
6999
+ access: {
7000
+ read: ({ req }) => !!req.user,
7001
+ create: ({ req }) => req.user?.role === "admin",
7002
+ update: ({ req }) => req.user?.role === "admin",
7003
+ delete: ({ req }) => req.user?.role === "admin"
7004
+ },
7005
+ timestamps: false,
7006
+ fields: [
7007
+ {
7008
+ name: "query",
7009
+ type: "text",
7010
+ required: true,
7011
+ index: true,
7012
+ admin: { description: "Search query (keyword) tracked" }
7013
+ },
7014
+ {
7015
+ name: "page",
7016
+ type: "text",
7017
+ admin: { description: "Landing page URL (when tracked by page)" }
7018
+ },
7019
+ {
7020
+ name: "position",
7021
+ type: "number",
7022
+ required: true,
7023
+ admin: { description: "Average SERP position over the snapshot window (lower is better)" }
7024
+ },
7025
+ {
7026
+ name: "clicks",
7027
+ type: "number",
7028
+ admin: { description: "Clicks over the snapshot window" }
7029
+ },
7030
+ {
7031
+ name: "impressions",
7032
+ type: "number",
7033
+ admin: { description: "Impressions over the snapshot window" }
7034
+ },
7035
+ {
7036
+ name: "ctr",
7037
+ type: "number",
7038
+ admin: { description: "Click-through rate (0-1) over the snapshot window" }
7039
+ },
7040
+ {
7041
+ name: "property",
7042
+ type: "text",
7043
+ admin: { description: "GSC property the snapshot was taken from" }
7044
+ },
7045
+ {
7046
+ // YYYY-MM-DD — used to deduplicate one snapshot per query per day.
7047
+ name: "dateKey",
7048
+ type: "text",
7049
+ required: true,
7050
+ index: true,
7051
+ admin: { description: "Snapshot day (YYYY-MM-DD), one snapshot per query per day" }
7052
+ },
7053
+ {
7054
+ name: "snapshotDate",
7055
+ type: "date",
7056
+ required: true,
7057
+ index: true,
7058
+ admin: { description: "Exact timestamp of the snapshot" }
7059
+ }
7060
+ ]
7061
+ };
7062
+ }
7063
+
7064
+ // src/endpoints/rankTracking.ts
7065
+ var RANK_COLLECTION = "seo-rank-history";
7066
+ var round1 = (n) => Math.round(n * 10) / 10;
7067
+ async function runRankSnapshot(payload, basePath, seoConfig, opts) {
7068
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
7069
+ if (!cfg) return { ok: false, reason: "not_configured" };
7070
+ const authDoc = await getOrCreateGscAuthDoc(payload);
7071
+ if (!authDoc.refreshTokenEnc) return { ok: false, reason: "not_connected" };
7072
+ let accessToken;
7073
+ try {
7074
+ accessToken = await getGscAccessToken(payload, cfg, authDoc);
7075
+ } catch (e) {
7076
+ return { ok: false, reason: e instanceof Error ? e.message : "refresh_failed" };
7077
+ }
7078
+ const property = authDoc.propertyUrl || cfg.siteUrl;
7079
+ const windowDays = Math.min(90, Math.max(1, 7));
7080
+ const rowLimit = Math.min(1e3, Math.max(1, 100));
7081
+ const end = new Date(Date.now() - 2 * 864e5);
7082
+ const start = new Date(end.getTime() - (windowDays - 1) * 864e5);
7083
+ const endDate = end.toISOString().slice(0, 10);
7084
+ const startDate = start.toISOString().slice(0, 10);
7085
+ let rows;
7086
+ try {
7087
+ rows = await queryGscSearchAnalytics(accessToken, property, {
7088
+ startDate,
7089
+ endDate,
7090
+ dimensions: ["query"],
7091
+ rowLimit
7092
+ });
7093
+ } catch (e) {
7094
+ return { ok: false, reason: e instanceof Error ? e.message : "query_failed" };
7095
+ }
7096
+ const todayKey = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
7097
+ const existing = await payload.find({
7098
+ collection: RANK_COLLECTION,
7099
+ where: { dateKey: { equals: todayKey } },
7100
+ limit: 2e3,
7101
+ depth: 0,
7102
+ overrideAccess: true
7103
+ });
7104
+ const already = new Set(existing.docs.map((d) => d.query));
7105
+ let stored = 0;
7106
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
7107
+ for (const r of rows) {
7108
+ const query = r.keys?.[0];
7109
+ if (!query || already.has(query)) continue;
7110
+ try {
7111
+ await payload.create({
7112
+ collection: RANK_COLLECTION,
7113
+ data: {
7114
+ query,
7115
+ position: round1(r.position),
7116
+ clicks: r.clicks,
7117
+ impressions: r.impressions,
7118
+ ctr: r.ctr,
7119
+ property,
7120
+ dateKey: todayKey,
7121
+ snapshotDate: nowIso
7122
+ },
7123
+ overrideAccess: true
7124
+ });
7125
+ stored++;
7126
+ } catch (e) {
7127
+ payload.logger.warn(`[seo] rank-snapshot: skipped "${query}": ${e instanceof Error ? e.message : "error"}`);
7128
+ }
7129
+ }
7130
+ return { ok: true, stored, scanned: rows.length, startDate, endDate };
7131
+ }
7132
+ function createRankSnapshotHandler(basePath, seoConfig) {
7133
+ return async (req) => {
7134
+ try {
7135
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7136
+ const result = await runRankSnapshot(req.payload, basePath, seoConfig);
7137
+ if (!result.ok) {
7138
+ const status = result.reason === "not_connected" || result.reason === "not_configured" ? 409 : 502;
7139
+ return Response.json(result, { status, headers: { "Cache-Control": "no-store" } });
7140
+ }
7141
+ return Response.json(result, { headers: { "Cache-Control": "no-store" } });
7142
+ } catch (error) {
7143
+ const message = error instanceof Error ? error.message : "Internal server error";
7144
+ req.payload.logger.error(`[seo] rank-snapshot error: ${message}`);
7145
+ return Response.json({ error: message }, { status: 500 });
7146
+ }
7147
+ };
7148
+ }
7149
+ function createRankHistoryHandler() {
7150
+ return async (req) => {
7151
+ try {
7152
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7153
+ const url = new URL(req.url);
7154
+ const days = Math.min(180, Math.max(7, parseInt(url.searchParams.get("days") || "35", 10)));
7155
+ const since = new Date(Date.now() - days * 864e5).toISOString();
7156
+ const all = await req.payload.find({
7157
+ collection: RANK_COLLECTION,
7158
+ where: { snapshotDate: { greater_than: since } },
7159
+ sort: "-snapshotDate",
7160
+ limit: 5e3,
7161
+ depth: 0,
7162
+ overrideAccess: true
7163
+ });
7164
+ const byQuery = /* @__PURE__ */ new Map();
7165
+ for (const d of all.docs) {
7166
+ const q = d.query;
7167
+ const arr = byQuery.get(q);
7168
+ if (arr) arr.push(d);
7169
+ else byQuery.set(q, [d]);
7170
+ }
7171
+ const movers = Array.from(byQuery.entries()).map(([query, snaps]) => {
7172
+ const latest = snaps[0];
7173
+ const previous = snaps.find((s) => s.dateKey !== latest.dateKey) || null;
7174
+ const delta = previous ? round1(previous.position - latest.position) : 0;
7175
+ return {
7176
+ query,
7177
+ page: latest.page || null,
7178
+ position: latest.position,
7179
+ previousPosition: previous ? previous.position : null,
7180
+ delta,
7181
+ clicks: latest.clicks ?? 0,
7182
+ impressions: latest.impressions ?? 0,
7183
+ ctr: latest.ctr ?? 0,
7184
+ snapshotDate: latest.snapshotDate,
7185
+ history: snaps.slice(0, 30).map((s) => ({ date: s.dateKey, position: s.position })).reverse()
7186
+ };
7187
+ });
7188
+ movers.sort((a, b) => (b.impressions || 0) - (a.impressions || 0));
7189
+ return Response.json(
7190
+ {
7191
+ count: movers.length,
7192
+ lastSnapshot: all.docs[0]?.snapshotDate || null,
7193
+ movers
7194
+ },
7195
+ { headers: { "Cache-Control": "no-store" } }
7196
+ );
7197
+ } catch (error) {
7198
+ const message = error instanceof Error ? error.message : "Internal server error";
7199
+ req.payload.logger.error(`[seo] rank-history error: ${message}`);
7200
+ return Response.json({ error: message }, { status: 500 });
7201
+ }
7202
+ };
7203
+ }
7204
+
6410
7205
  // src/rateLimiter.ts
6411
7206
  function createRateLimiter(maxRequests, windowMs) {
6412
7207
  const store = /* @__PURE__ */ new Map();
@@ -6751,6 +7546,296 @@ function stopCacheWarmUp() {
6751
7546
  }
6752
7547
  }
6753
7548
 
7549
+ // src/rankTracker.ts
7550
+ var SNAPSHOT_INTERVAL = 24 * 60 * 60 * 1e3;
7551
+ var STARTUP_DELAY2 = 30 * 1e3;
7552
+ var intervalId2 = null;
7553
+ var listenersAttached2 = false;
7554
+ async function doSnapshot(payload, basePath, seoConfig) {
7555
+ try {
7556
+ const result = await runRankSnapshot(payload, basePath, seoConfig);
7557
+ if (result.ok) {
7558
+ payload.logger.info(`[seo] rank-tracker: snapshot stored ${result.stored}/${result.scanned} queries`);
7559
+ } else if (result.reason !== "not_connected" && result.reason !== "not_configured") {
7560
+ payload.logger.warn(`[seo] rank-tracker: snapshot skipped (${result.reason})`);
7561
+ }
7562
+ } catch (error) {
7563
+ payload.logger.error(`[seo] rank-tracker error: ${error instanceof Error ? error.message : "unknown"}`);
7564
+ }
7565
+ }
7566
+ function startRankTracker(payload, basePath, seoConfig) {
7567
+ setTimeout(() => {
7568
+ void doSnapshot(payload, basePath, seoConfig);
7569
+ }, STARTUP_DELAY2);
7570
+ intervalId2 = setInterval(() => {
7571
+ void doSnapshot(payload, basePath, seoConfig);
7572
+ }, SNAPSHOT_INTERVAL);
7573
+ if (!listenersAttached2) {
7574
+ const cleanup = () => stopRankTracker();
7575
+ process.on("SIGTERM", cleanup);
7576
+ process.on("SIGINT", cleanup);
7577
+ listenersAttached2 = true;
7578
+ }
7579
+ payload.logger.info("[seo] rank-tracker: scheduled startup + every 24h");
7580
+ }
7581
+ function stopRankTracker() {
7582
+ if (intervalId2) {
7583
+ clearInterval(intervalId2);
7584
+ intervalId2 = null;
7585
+ }
7586
+ }
7587
+
7588
+ // src/endpoints/alerts.ts
7589
+ function isAdmin8(user) {
7590
+ if (!user) return false;
7591
+ if (user.role === "admin") return true;
7592
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
7593
+ return false;
7594
+ }
7595
+ function getAlertConfig() {
7596
+ return {
7597
+ webhookUrl: process.env.SEO_ALERT_WEBHOOK_URL || "",
7598
+ emails: (process.env.SEO_ALERT_EMAIL || "").split(",").map((s) => s.trim()).filter(Boolean),
7599
+ scoreDrop: parseInt(process.env.SEO_ALERT_SCORE_DROP || "10", 10) || 10,
7600
+ positionDrop: parseInt(process.env.SEO_ALERT_POSITION_DROP || "5", 10) || 5,
7601
+ windowHours: Math.max(1, parseInt(process.env.SEO_ALERT_WINDOW_HOURS || "24", 10) || 24)
7602
+ };
7603
+ }
7604
+ var round12 = (n) => Math.round(n * 10) / 10;
7605
+ async function buildAlertDigest(payload, cfg) {
7606
+ const now = Date.now();
7607
+ const since = new Date(now - cfg.windowHours * 36e5).toISOString();
7608
+ const scoreRegressions = [];
7609
+ try {
7610
+ const hist = await payload.find({
7611
+ collection: "seo-score-history",
7612
+ where: { snapshotDate: { greater_than: new Date(now - 14 * 864e5).toISOString() } },
7613
+ sort: "-snapshotDate",
7614
+ limit: 5e3,
7615
+ depth: 0,
7616
+ overrideAccess: true
7617
+ });
7618
+ const byDoc = /* @__PURE__ */ new Map();
7619
+ for (const h of hist.docs) {
7620
+ const key = `${h.documentId}::${h.collection}`;
7621
+ const arr = byDoc.get(key);
7622
+ if (arr) arr.push(h);
7623
+ else byDoc.set(key, [h]);
7624
+ }
7625
+ for (const [key, snaps] of byDoc) {
7626
+ const latest = snaps[0];
7627
+ const oldest = snaps[snaps.length - 1];
7628
+ const drop = oldest.score - latest.score;
7629
+ if (drop >= cfg.scoreDrop) {
7630
+ const [documentId, collection] = key.split("::");
7631
+ scoreRegressions.push({
7632
+ documentId,
7633
+ collection,
7634
+ from: oldest.score,
7635
+ to: latest.score,
7636
+ drop
7637
+ });
7638
+ }
7639
+ }
7640
+ scoreRegressions.sort((a, b) => b.drop - a.drop);
7641
+ } catch {
7642
+ }
7643
+ const newNotFound = [];
7644
+ try {
7645
+ const logs = await payload.find({
7646
+ collection: "seo-logs",
7647
+ where: {
7648
+ and: [{ lastSeen: { greater_than: since } }, { ignored: { not_equals: true } }]
7649
+ },
7650
+ sort: "-count",
7651
+ limit: 50,
7652
+ depth: 0,
7653
+ overrideAccess: true
7654
+ });
7655
+ for (const l of logs.docs) {
7656
+ newNotFound.push({
7657
+ url: l.url || "",
7658
+ count: l.count || 1,
7659
+ lastSeen: l.lastSeen || ""
7660
+ });
7661
+ }
7662
+ } catch {
7663
+ }
7664
+ const rankDrops = [];
7665
+ try {
7666
+ const ranks = await payload.find({
7667
+ collection: "seo-rank-history",
7668
+ where: { snapshotDate: { greater_than: new Date(now - 35 * 864e5).toISOString() } },
7669
+ sort: "-snapshotDate",
7670
+ limit: 5e3,
7671
+ depth: 0,
7672
+ overrideAccess: true
7673
+ });
7674
+ const byQuery = /* @__PURE__ */ new Map();
7675
+ for (const r of ranks.docs) {
7676
+ const q = r.query;
7677
+ const arr = byQuery.get(q);
7678
+ if (arr) arr.push(r);
7679
+ else byQuery.set(q, [r]);
7680
+ }
7681
+ for (const [query, snaps] of byQuery) {
7682
+ const latest = snaps[0];
7683
+ const previous = snaps.find((s) => s.dateKey !== latest.dateKey);
7684
+ if (!previous) continue;
7685
+ const drop = round12(latest.position - previous.position);
7686
+ if (drop >= cfg.positionDrop) {
7687
+ rankDrops.push({ query, from: previous.position, to: latest.position, drop });
7688
+ }
7689
+ }
7690
+ rankDrops.sort((a, b) => b.drop - a.drop);
7691
+ } catch {
7692
+ }
7693
+ const totalIssues = scoreRegressions.length + newNotFound.length + rankDrops.length;
7694
+ return {
7695
+ since,
7696
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
7697
+ scoreRegressions,
7698
+ newNotFound,
7699
+ rankDrops,
7700
+ totalIssues
7701
+ };
7702
+ }
7703
+ function digestToHtml(digest, siteUrl) {
7704
+ const section = (title, rows) => rows.length ? `<h3 style="margin:18px 0 6px">${title}</h3><ul style="margin:0;padding-left:18px">${rows.join("")}</ul>` : "";
7705
+ 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>`);
7706
+ const nf = digest.newNotFound.slice(0, 20).map((n) => `<li><code>${n.url}</code> \u2014 ${n.count}\xD7</li>`);
7707
+ 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>`);
7708
+ return `<div style="font-family:system-ui;max-width:640px">
7709
+ <h2>SEO alert digest${siteUrl ? ` \u2014 ${siteUrl}` : ""}</h2>
7710
+ <p style="color:#6b7280;font-size:13px">${digest.totalIssues} issue(s) since ${new Date(digest.since).toLocaleString()}</p>
7711
+ ${section("\u{1F4C9} Score regressions", reg)}
7712
+ ${section("\u{1F517} New 404s", nf)}
7713
+ ${section("\u{1F53B} Ranking drops", rd)}
7714
+ ${digest.totalIssues === 0 ? "<p>No issues to report. \u{1F389}</p>" : ""}
7715
+ </div>`;
7716
+ }
7717
+ async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
7718
+ const channels = { webhook: false, email: false };
7719
+ if (digest.totalIssues === 0) {
7720
+ return { sent: false, reason: "nothing_to_report", channels };
7721
+ }
7722
+ if (cfg.webhookUrl) {
7723
+ try {
7724
+ await fetch(cfg.webhookUrl, {
7725
+ method: "POST",
7726
+ headers: { "content-type": "application/json" },
7727
+ body: JSON.stringify({ type: "seo-alert-digest", siteUrl, digest })
7728
+ });
7729
+ channels.webhook = true;
7730
+ } catch (e) {
7731
+ payload.logger.warn(`[seo] alerts: webhook delivery failed: ${e instanceof Error ? e.message : "error"}`);
7732
+ }
7733
+ }
7734
+ if (cfg.emails.length > 0) {
7735
+ const send = payload.sendEmail;
7736
+ if (typeof send === "function") {
7737
+ try {
7738
+ await send({
7739
+ to: cfg.emails,
7740
+ subject: `SEO alert digest \u2014 ${digest.totalIssues} issue(s)`,
7741
+ html: digestToHtml(digest, siteUrl)
7742
+ });
7743
+ channels.email = true;
7744
+ } catch (e) {
7745
+ payload.logger.warn(`[seo] alerts: email delivery failed: ${e instanceof Error ? e.message : "error"}`);
7746
+ }
7747
+ }
7748
+ }
7749
+ const sent = channels.webhook || channels.email;
7750
+ return { sent, reason: sent ? void 0 : "no_channel_configured", channels };
7751
+ }
7752
+ function createAlertsDigestHandler() {
7753
+ return async (req) => {
7754
+ try {
7755
+ if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7756
+ const cfg = getAlertConfig();
7757
+ const digest = await buildAlertDigest(req.payload, cfg);
7758
+ return Response.json(
7759
+ {
7760
+ digest,
7761
+ config: {
7762
+ webhookConfigured: !!cfg.webhookUrl,
7763
+ emailConfigured: cfg.emails.length > 0,
7764
+ scoreDrop: cfg.scoreDrop,
7765
+ positionDrop: cfg.positionDrop,
7766
+ windowHours: cfg.windowHours
7767
+ }
7768
+ },
7769
+ { headers: { "Cache-Control": "no-store" } }
7770
+ );
7771
+ } catch (error) {
7772
+ const message = error instanceof Error ? error.message : "Internal server error";
7773
+ req.payload.logger.error(`[seo] alerts-digest error: ${message}`);
7774
+ return Response.json({ error: message }, { status: 500 });
7775
+ }
7776
+ };
7777
+ }
7778
+ function createAlertsRunHandler(siteUrl) {
7779
+ return async (req) => {
7780
+ try {
7781
+ if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7782
+ const cfg = getAlertConfig();
7783
+ const digest = await buildAlertDigest(req.payload, cfg);
7784
+ const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
7785
+ return Response.json({ digest, delivery }, { headers: { "Cache-Control": "no-store" } });
7786
+ } catch (error) {
7787
+ const message = error instanceof Error ? error.message : "Internal server error";
7788
+ req.payload.logger.error(`[seo] alerts-run error: ${message}`);
7789
+ return Response.json({ error: message }, { status: 500 });
7790
+ }
7791
+ };
7792
+ }
7793
+
7794
+ // src/alertsScheduler.ts
7795
+ var STARTUP_DELAY3 = 60 * 1e3;
7796
+ var intervalId3 = null;
7797
+ var listenersAttached3 = false;
7798
+ async function runDigest(payload, siteUrl) {
7799
+ try {
7800
+ const cfg = getAlertConfig();
7801
+ if (!cfg.webhookUrl && cfg.emails.length === 0) {
7802
+ return;
7803
+ }
7804
+ const digest = await buildAlertDigest(payload, cfg);
7805
+ const delivery = await deliverAlertDigest(payload, digest, cfg, siteUrl);
7806
+ if (delivery.sent) {
7807
+ payload.logger.info(
7808
+ `[seo] alerts: digest delivered (${digest.totalIssues} issues; webhook=${delivery.channels.webhook} email=${delivery.channels.email})`
7809
+ );
7810
+ }
7811
+ } catch (error) {
7812
+ payload.logger.error(`[seo] alerts scheduler error: ${error instanceof Error ? error.message : "unknown"}`);
7813
+ }
7814
+ }
7815
+ function startAlertsScheduler(payload, siteUrl) {
7816
+ const intervalHours = Math.max(1, parseInt(process.env.SEO_ALERT_INTERVAL_HOURS || "24", 10) || 24);
7817
+ const intervalMs = intervalHours * 60 * 60 * 1e3;
7818
+ setTimeout(() => {
7819
+ void runDigest(payload, siteUrl);
7820
+ }, STARTUP_DELAY3);
7821
+ intervalId3 = setInterval(() => {
7822
+ void runDigest(payload, siteUrl);
7823
+ }, intervalMs);
7824
+ if (!listenersAttached3) {
7825
+ const cleanup = () => stopAlertsScheduler();
7826
+ process.on("SIGTERM", cleanup);
7827
+ process.on("SIGINT", cleanup);
7828
+ listenersAttached3 = true;
7829
+ }
7830
+ payload.logger.info(`[seo] alerts: scheduled startup + every ${intervalHours}h`);
7831
+ }
7832
+ function stopAlertsScheduler() {
7833
+ if (intervalId3) {
7834
+ clearInterval(intervalId3);
7835
+ intervalId3 = null;
7836
+ }
7837
+ }
7838
+
6754
7839
  // src/endpoints/generate.ts
6755
7840
  var TYPE_TO_CONFIG_KEY = {
6756
7841
  title: "generateTitle",
@@ -7742,6 +8827,9 @@ var fr = {
7742
8827
  pagesAnalyzed: "pages analys\xE9es",
7743
8828
  markCornerstone: "Marquer pilier",
7744
8829
  unmarkCornerstone: "D\xE9marquer pilier",
8830
+ bulkOptimizeMeta: "Optimiser m\xE9ta (IA)",
8831
+ bulkOptimizing: "Optimisation\u2026",
8832
+ bulkConfirm: "Confirmer ?",
7745
8833
  searchPlaceholder: "Rechercher (titre, slug, keyword)...",
7746
8834
  allCollections: "Toutes les collections",
7747
8835
  allScores: "Tous les scores",
@@ -8334,6 +9422,9 @@ var en = {
8334
9422
  pagesAnalyzed: "pages analyzed",
8335
9423
  markCornerstone: "Mark as cornerstone",
8336
9424
  unmarkCornerstone: "Unmark cornerstone",
9425
+ bulkOptimizeMeta: "Optimize meta (AI)",
9426
+ bulkOptimizing: "Optimizing\u2026",
9427
+ bulkConfirm: "Confirm?",
8337
9428
  searchPlaceholder: "Search (title, slug, keyword)...",
8338
9429
  allCollections: "All collections",
8339
9430
  allScores: "All scores",
@@ -8905,6 +9996,7 @@ function buildSeoConfig(pluginConfig) {
8905
9996
  var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8906
9997
  const config = { ...incomingConfig };
8907
9998
  const targetCollections = pluginConfig.collections ?? ["pages", "posts"];
9999
+ const uploadsCollection = pluginConfig.uploadsCollection ?? "media";
8908
10000
  const targetGlobals = pluginConfig.globals ?? [];
8909
10001
  const basePath = pluginConfig.endpointBasePath ?? "/seo-plugin";
8910
10002
  const seoConfig = buildSeoConfig(pluginConfig);
@@ -8927,6 +10019,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8927
10019
  // opt-in — requires Google Cloud OAuth setup + secrets
8928
10020
  warmCache: true,
8929
10021
  // disable on low-memory hosts to skip startup pre-loading
10022
+ alerts: false,
10023
+ // opt-in — requires SEO_ALERT_WEBHOOK_URL and/or SEO_ALERT_EMAIL
8930
10024
  ...pluginConfig.features
8931
10025
  };
8932
10026
  function hasExistingSeoMeta(fields) {
@@ -9067,7 +10161,7 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
9067
10161
  if (features.redirects && !hasExistingRedirects) pluginCollections.push(createSeoRedirectsCollection(redirectsSlug));
9068
10162
  if (features.performance) pluginCollections.push(createSeoPerformanceCollection());
9069
10163
  if (features.seoLogs) pluginCollections.push(createSeoLogsCollection());
9070
- if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection());
10164
+ if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection(), createSeoRankHistoryCollection());
9071
10165
  config.collections = [
9072
10166
  ...config.collections || [],
9073
10167
  ...pluginCollections
@@ -9180,7 +10274,10 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
9180
10274
  pluginEndpoints.push(
9181
10275
  { path: `${basePath}/ai-generate`, method: "post", handler: createAiGenerateHandler() },
9182
10276
  { path: `${basePath}/ai-rewrite`, method: "post", handler: createAiRewriteHandler(targetCollections) },
9183
- { path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) }
10277
+ { path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) },
10278
+ { path: `${basePath}/alt-text-audit`, method: "get", handler: createAltTextAuditHandler(uploadsCollection) },
10279
+ { path: `${basePath}/ai-alt-text`, method: "post", handler: withRateLimit(createAiAltTextHandler(uploadsCollection, seoConfig)) },
10280
+ { path: `${basePath}/ai-content-brief`, method: "post", handler: withRateLimit(createAiContentBriefHandler(targetCollections, seoConfig)) }
9184
10281
  );
9185
10282
  }
9186
10283
  if (features.cannibalization) {
@@ -9211,7 +10308,15 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
9211
10308
  { path: `${basePath}/gsc/auth`, method: "get", handler: createGscAuthStartHandler(basePath, seoConfig) },
9212
10309
  { path: `${basePath}/gsc/callback`, method: "get", handler: createGscCallbackHandler(basePath, seoConfig) },
9213
10310
  { path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
9214
- { path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() }
10311
+ { path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
10312
+ { path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
10313
+ { path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() }
10314
+ );
10315
+ }
10316
+ if (features.alerts) {
10317
+ pluginEndpoints.push(
10318
+ { path: `${basePath}/alerts-digest`, method: "get", handler: createAlertsDigestHandler() },
10319
+ { path: `${basePath}/alerts-run`, method: "post", handler: withRateLimit(createAlertsRunHandler(resolveGscSiteUrl(seoConfig))) }
9215
10320
  );
9216
10321
  }
9217
10322
  if (features.keywords) {
@@ -9264,6 +10369,21 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
9264
10369
  path: `${basePath}/sitemap.xml`,
9265
10370
  method: "get",
9266
10371
  handler: createSitemapHandler(targetCollections)
10372
+ },
10373
+ {
10374
+ path: `${basePath}/sitemap-news.xml`,
10375
+ method: "get",
10376
+ handler: createNewsSitemapHandler(targetCollections, seoConfig)
10377
+ },
10378
+ {
10379
+ path: `${basePath}/sitemap-images.xml`,
10380
+ method: "get",
10381
+ handler: createImageSitemapHandler(targetCollections, seoConfig)
10382
+ },
10383
+ {
10384
+ path: `${basePath}/sitemap-video.xml`,
10385
+ method: "get",
10386
+ handler: createVideoSitemapHandler(targetCollections, seoConfig)
9267
10387
  }
9268
10388
  );
9269
10389
  config.endpoints = [
@@ -9357,10 +10477,90 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
9357
10477
  if (features.warmCache) {
9358
10478
  startCacheWarmUp(payload, basePath, targetGlobals, targetCollections);
9359
10479
  }
10480
+ if (features.gscApi) {
10481
+ startRankTracker(payload, basePath, seoConfig);
10482
+ }
10483
+ if (features.alerts) {
10484
+ startAlertsScheduler(payload, resolveGscSiteUrl(seoConfig));
10485
+ }
9360
10486
  };
9361
10487
  return config;
9362
10488
  };
9363
10489
 
10490
+ // src/helpers/buildMetadata.ts
10491
+ function resolveSiteUrl4(explicit) {
10492
+ return (explicit || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "").replace(/\/$/, "");
10493
+ }
10494
+ function parseRobots(doc, meta) {
10495
+ const raw = typeof meta.robots === "string" && meta.robots || typeof doc.robots === "string" && doc.robots || "";
10496
+ let noindex = false;
10497
+ let nofollow = false;
10498
+ if (raw) {
10499
+ const low = raw.toLowerCase();
10500
+ noindex = low.includes("noindex");
10501
+ nofollow = low.includes("nofollow");
10502
+ }
10503
+ if (doc.noindex === true || meta.noindex === true) noindex = true;
10504
+ if (doc.nofollow === true || meta.nofollow === true) nofollow = true;
10505
+ return { index: !noindex, follow: !nofollow };
10506
+ }
10507
+ function buildLanguages(doc) {
10508
+ const raw = doc.localeAlternates || doc.alternates || doc.hreflang;
10509
+ if (!Array.isArray(raw)) return void 0;
10510
+ const out = {};
10511
+ for (const a of raw) {
10512
+ if (!a || typeof a !== "object") continue;
10513
+ const r = a;
10514
+ const lang = String(r.hreflang || r.locale || r.lang || "");
10515
+ const href = String(r.href || r.url || "");
10516
+ if (lang && href) out[lang] = href;
10517
+ }
10518
+ return Object.keys(out).length ? out : void 0;
10519
+ }
10520
+ function absoluteUrl(value, siteUrl) {
10521
+ if (/^https?:\/\//i.test(value)) return value;
10522
+ return `${siteUrl}${value.startsWith("/") ? "" : "/"}${value}`;
10523
+ }
10524
+ function buildSeoMetadata(doc, options = {}) {
10525
+ const siteUrl = resolveSiteUrl4(options.siteUrl);
10526
+ const meta = doc.meta || {};
10527
+ const rawTitle = meta.title || doc.title || "";
10528
+ const title = options.titleTemplate && rawTitle ? options.titleTemplate.replace("%s", rawTitle) : rawTitle;
10529
+ const description = meta.description || "";
10530
+ const slug = doc.slug || "";
10531
+ const heroMedia = doc.hero?.media;
10532
+ let image = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
10533
+ if (!image && options.defaultImage) image = absoluteUrl(options.defaultImage, siteUrl);
10534
+ const explicitCanonical = typeof meta.canonicalUrl === "string" && meta.canonicalUrl || typeof doc.canonicalUrl === "string" && doc.canonicalUrl || "";
10535
+ const canonical = explicitCanonical || (siteUrl ? `${siteUrl}${slug ? `/${slug}` : ""}` : void 0);
10536
+ const languages = buildLanguages(doc);
10537
+ const isPost = options.collection === "posts" || doc.isPost === true;
10538
+ const md = {};
10539
+ if (title) md.title = title;
10540
+ if (description) md.description = description;
10541
+ const alternates = {};
10542
+ if (canonical) alternates.canonical = canonical;
10543
+ if (languages) alternates.languages = languages;
10544
+ if (Object.keys(alternates).length) md.alternates = alternates;
10545
+ md.robots = parseRobots(doc, meta);
10546
+ md.openGraph = {
10547
+ ...rawTitle ? { title: rawTitle } : {},
10548
+ ...description ? { description } : {},
10549
+ ...canonical ? { url: canonical } : {},
10550
+ ...options.siteName ? { siteName: options.siteName } : {},
10551
+ type: isPost ? "article" : "website",
10552
+ ...options.locale ? { locale: options.locale } : {},
10553
+ ...image ? { images: [{ url: image }] } : {}
10554
+ };
10555
+ md.twitter = {
10556
+ card: image ? "summary_large_image" : "summary",
10557
+ ...rawTitle ? { title: rawTitle } : {},
10558
+ ...description ? { description } : {},
10559
+ ...image ? { images: [image] } : {}
10560
+ };
10561
+ return md;
10562
+ }
10563
+
9364
10564
  // src/i18n.ts
9365
10565
  var rulesFr = {
9366
10566
  title: {
@@ -13288,6 +14488,7 @@ exports.MIN_WORDS_THIN = MIN_WORDS_THIN;
13288
14488
  exports.POWER_WORDS = POWER_WORDS;
13289
14489
  exports.POWER_WORDS_FR = POWER_WORDS_FR;
13290
14490
  exports.READABILITY_THRESHOLDS = READABILITY_THRESHOLDS;
14491
+ exports.SCHEMA_TYPES = SCHEMA_TYPES;
13291
14492
  exports.SCORE_EXCELLENT = SCORE_EXCELLENT;
13292
14493
  exports.SCORE_GOOD = SCORE_GOOD;
13293
14494
  exports.SCORE_OK = SCORE_OK;
@@ -13298,7 +14499,9 @@ exports.TITLE_LENGTH_MIN = TITLE_LENGTH_MIN;
13298
14499
  exports.UTILITY_SLUGS = UTILITY_SLUGS;
13299
14500
  exports.WARNING_MULTIPLIER = WARNING_MULTIPLIER;
13300
14501
  exports.analyzeSeo = analyzeSeo;
14502
+ exports.buildJsonLd = buildJsonLd;
13301
14503
  exports.buildSeoInputFromDoc = buildSeoInputFromDoc;
14504
+ exports.buildSeoMetadata = buildSeoMetadata;
13302
14505
  exports.calculateFlesch = calculateFlesch;
13303
14506
  exports.calculateFleschFR = calculateFleschFR;
13304
14507
  exports.checkHeadingHierarchy = checkHeadingHierarchy;
@@ -13323,6 +14526,7 @@ exports.createSitemapAuditHandler = createSitemapAuditHandler;
13323
14526
  exports.createTrackSeoScoreHook = createTrackSeoScoreHook;
13324
14527
  exports.detectPageType = detectPageType;
13325
14528
  exports.detectPassiveVoice = detectPassiveVoice;
14529
+ exports.detectSchemaType = detectSchemaType;
13326
14530
  exports.extractHeadingsFromLexical = extractHeadingsFromLexical;
13327
14531
  exports.extractImagesFromLexical = extractImagesFromLexical;
13328
14532
  exports.extractLinkUrlsFromLexical = extractLinkUrlsFromLexical;
@@ -13337,6 +14541,7 @@ exports.getEvergreenSlugs = getEvergreenSlugs;
13337
14541
  exports.getGenericAnchors = getGenericAnchors;
13338
14542
  exports.getLegalSlugs = getLegalSlugs;
13339
14543
  exports.getPowerWords = getPowerWords;
14544
+ exports.getSchemaImageUrl = getSchemaImageUrl;
13340
14545
  exports.getStopWordCompounds = getStopWordCompounds;
13341
14546
  exports.getStopWords = getStopWords;
13342
14547
  exports.getStopWordsFR = getStopWordsFR;
@@ -13347,6 +14552,7 @@ exports.keywordMatchesText = keywordMatchesText;
13347
14552
  exports.metaFields = metaFields;
13348
14553
  exports.normalizeForComparison = normalizeForComparison;
13349
14554
  exports.registerDashboardTranslations = registerDashboardTranslations;
14555
+ exports.renderJsonLdScript = renderJsonLdScript;
13350
14556
  exports.resolveAnalysisLocale = resolveAnalysisLocale;
13351
14557
  exports.seoAnalyzerPlugin = seoAnalyzerPlugin;
13352
14558
  exports.seoFields = seoFields;