@consilioweb/payload-seo-analyzer 1.10.0 → 1.12.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
@@ -1705,6 +1705,7 @@ async function buildAuditCache(payload, collections, globals, seoConfig) {
1705
1705
  });
1706
1706
  for (const doc of result.docs) {
1707
1707
  if (ignoredSlugs.includes(doc.slug)) continue;
1708
+ if (doc._status === "draft") continue;
1708
1709
  if (allResults.length >= MAX_DOCS2) {
1709
1710
  capped = true;
1710
1711
  break collectionsLoop;
@@ -2680,6 +2681,9 @@ function createRedirectsHandler(redirectsCollection) {
2680
2681
  return Response.json({ error: "Missing id or ids" }, { status: 400 });
2681
2682
  }
2682
2683
  if (method === "PATCH") {
2684
+ if (!isAdmin3(req.user)) {
2685
+ return Response.json({ error: "Admin access required" }, { status: 403 });
2686
+ }
2683
2687
  const patchBody = await parseJsonBody(req);
2684
2688
  const id = typeof patchBody.id === "string" ? patchBody.id.trim() : void 0;
2685
2689
  const from = typeof patchBody.from === "string" ? patchBody.from.trim() : void 0;
@@ -3021,6 +3025,77 @@ function sanitizeSuggestions(s, currentFocusKeyword) {
3021
3025
  const rationale = s.rationale.map((r) => r.trim()).filter(Boolean).slice(0, RATIONALE_MAX_ITEMS).map((r) => r.length > RATIONALE_ITEM_MAX ? `${r.slice(0, RATIONALE_ITEM_MAX - 1)}\u2026` : r);
3022
3026
  return { metaTitle, metaDescription, focusKeyword, rationale };
3023
3027
  }
3028
+ async function optimizeDocMeta(payload, opts) {
3029
+ const { collection, id, mergedConfig } = opts;
3030
+ let doc;
3031
+ try {
3032
+ doc = await payload.findByID({ collection, id, depth: 1, overrideAccess: true });
3033
+ } catch {
3034
+ return { ok: false, error: `Document not found: ${collection}/${id}`, status: 404 };
3035
+ }
3036
+ const seoInput = buildSeoInputFromDoc(doc, collection);
3037
+ const analysis = analyzeSeo(seoInput, mergedConfig);
3038
+ const metaGroups = /* @__PURE__ */ new Set(["title", "meta-description", "content", "url", "headings"]);
3039
+ const issues = analysis.checks.filter((c) => (c.status === "fail" || c.status === "warning") && metaGroups.has(c.group)).map((c) => ({ label: c.label, message: c.message })).slice(0, 12);
3040
+ const pageTitle = doc.title || "";
3041
+ const slug = doc.slug || "";
3042
+ const currentFocusKeyword = doc.focusKeyword || "";
3043
+ const currentMetaTitle = seoInput.metaTitle || "";
3044
+ const currentMetaDescription = seoInput.metaDescription || "";
3045
+ const content = extractDocContent(doc).text;
3046
+ const apiKey = opts.apiKey;
3047
+ const model = opts.model || DEFAULT_MODEL;
3048
+ let suggestions;
3049
+ let method;
3050
+ const heuristicFallback = () => ({
3051
+ metaTitle: generateMetaTitle(pageTitle, currentFocusKeyword, slug),
3052
+ metaDescription: generateMetaDescription(content, currentFocusKeyword, slug),
3053
+ focusKeyword: currentFocusKeyword,
3054
+ rationale: []
3055
+ });
3056
+ if (apiKey) {
3057
+ try {
3058
+ const aiResult = await callClaudeOptimize(apiKey, model, {
3059
+ pageTitle,
3060
+ slug,
3061
+ focusKeyword: currentFocusKeyword,
3062
+ currentMetaTitle,
3063
+ currentMetaDescription,
3064
+ issues,
3065
+ content
3066
+ });
3067
+ if (aiResult) {
3068
+ suggestions = aiResult;
3069
+ method = "ai";
3070
+ } else {
3071
+ suggestions = heuristicFallback();
3072
+ method = "heuristic";
3073
+ }
3074
+ } catch (error) {
3075
+ payload.logger.error(`[seo] ai-optimize Claude API error: ${error instanceof Error ? error.message : "unknown"}`);
3076
+ suggestions = heuristicFallback();
3077
+ method = "heuristic";
3078
+ }
3079
+ } else {
3080
+ suggestions = heuristicFallback();
3081
+ method = "heuristic";
3082
+ }
3083
+ const sanitized = sanitizeSuggestions(suggestions, currentFocusKeyword);
3084
+ return {
3085
+ ok: true,
3086
+ title: pageTitle,
3087
+ method,
3088
+ ...method === "ai" ? { model } : {},
3089
+ score: analysis.score,
3090
+ current: {
3091
+ metaTitle: currentMetaTitle,
3092
+ metaDescription: currentMetaDescription,
3093
+ focusKeyword: currentFocusKeyword
3094
+ },
3095
+ suggestions: sanitized,
3096
+ issues
3097
+ };
3098
+ }
3024
3099
  function createAiOptimizeHandler(targetCollections, seoConfig, localeMapping) {
3025
3100
  return async (req) => {
3026
3101
  try {
@@ -3036,83 +3111,27 @@ function createAiOptimizeHandler(targetCollections, seoConfig, localeMapping) {
3036
3111
  if (targetCollections && !targetCollections.includes(collection)) {
3037
3112
  return Response.json({ error: "Collection not allowed" }, { status: 403 });
3038
3113
  }
3039
- let doc;
3040
- try {
3041
- const result = await req.payload.findByID({
3042
- collection,
3043
- id,
3044
- depth: 1,
3045
- overrideAccess: true
3046
- });
3047
- doc = result;
3048
- } catch {
3049
- return Response.json({ error: `Document not found: ${collection}/${id}` }, { status: 404 });
3050
- }
3051
3114
  const { config: mergedConfig } = await loadMergedConfig(req.payload, seoConfig, {
3052
3115
  reqLocale: req.locale,
3053
3116
  localeMapping
3054
3117
  });
3055
- const seoInput = buildSeoInputFromDoc(doc, collection);
3056
- const analysis = analyzeSeo(seoInput, mergedConfig);
3057
- const metaGroups = /* @__PURE__ */ new Set(["title", "meta-description", "content", "url", "headings"]);
3058
- const issues = analysis.checks.filter((c) => (c.status === "fail" || c.status === "warning") && metaGroups.has(c.group)).map((c) => ({ label: c.label, message: c.message })).slice(0, 12);
3059
- const pageTitle = doc.title || "";
3060
- const slug = doc.slug || "";
3061
- const currentFocusKeyword = doc.focusKeyword || "";
3062
- const currentMetaTitle = seoInput.metaTitle || "";
3063
- const currentMetaDescription = seoInput.metaDescription || "";
3064
- const content = extractDocContent(doc).text;
3065
- const apiKey = process.env.ANTHROPIC_API_KEY;
3066
- const model = process.env.SEO_AI_MODEL || DEFAULT_MODEL;
3067
- let suggestions;
3068
- let method;
3069
- const heuristicFallback = () => ({
3070
- metaTitle: generateMetaTitle(pageTitle, currentFocusKeyword, slug),
3071
- metaDescription: generateMetaDescription(content, currentFocusKeyword, slug),
3072
- focusKeyword: currentFocusKeyword,
3073
- rationale: []
3118
+ const r = await optimizeDocMeta(req.payload, {
3119
+ collection,
3120
+ id,
3121
+ mergedConfig,
3122
+ apiKey: process.env.ANTHROPIC_API_KEY,
3123
+ model: process.env.SEO_AI_MODEL
3074
3124
  });
3075
- if (apiKey) {
3076
- try {
3077
- const aiResult = await callClaudeOptimize(apiKey, model, {
3078
- pageTitle,
3079
- slug,
3080
- focusKeyword: currentFocusKeyword,
3081
- currentMetaTitle,
3082
- currentMetaDescription,
3083
- issues,
3084
- content
3085
- });
3086
- if (aiResult) {
3087
- suggestions = aiResult;
3088
- method = "ai";
3089
- } else {
3090
- suggestions = heuristicFallback();
3091
- method = "heuristic";
3092
- }
3093
- } catch (error) {
3094
- req.payload.logger.error(
3095
- `[seo] ai-optimize Claude API error: ${error instanceof Error ? error.message : "unknown"}`
3096
- );
3097
- suggestions = heuristicFallback();
3098
- method = "heuristic";
3099
- }
3100
- } else {
3101
- suggestions = heuristicFallback();
3102
- method = "heuristic";
3125
+ if (!r.ok) {
3126
+ return Response.json({ error: r.error }, { status: r.status || 500 });
3103
3127
  }
3104
- const sanitized = sanitizeSuggestions(suggestions, currentFocusKeyword);
3105
3128
  return Response.json({
3106
- method,
3107
- ...method === "ai" ? { model } : {},
3108
- score: analysis.score,
3109
- current: {
3110
- metaTitle: currentMetaTitle,
3111
- metaDescription: currentMetaDescription,
3112
- focusKeyword: currentFocusKeyword
3113
- },
3114
- suggestions: sanitized,
3115
- issues
3129
+ method: r.method,
3130
+ ...r.method === "ai" ? { model: r.model } : {},
3131
+ score: r.score,
3132
+ current: r.current,
3133
+ suggestions: r.suggestions,
3134
+ issues: r.issues
3116
3135
  });
3117
3136
  } catch (error) {
3118
3137
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -3443,6 +3462,301 @@ function createAiAltTextHandler(uploadsCollection, seoConfig) {
3443
3462
  };
3444
3463
  }
3445
3464
 
3465
+ // src/endpoints/aiContentBrief.ts
3466
+ var DEFAULT_MODEL3 = "claude-opus-4-8";
3467
+ 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) : [];
3468
+ function parseBrief(raw) {
3469
+ let s = raw.trim();
3470
+ if (s.startsWith("```")) s = s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
3471
+ if (!s.startsWith("{")) {
3472
+ const start = s.indexOf("{");
3473
+ const end = s.lastIndexOf("}");
3474
+ if (start === -1 || end === -1 || end <= start) return null;
3475
+ s = s.slice(start, end + 1);
3476
+ }
3477
+ try {
3478
+ const p = JSON.parse(s);
3479
+ return sanitizeBrief({
3480
+ outline: Array.isArray(p.outline) ? p.outline.map((o) => {
3481
+ const r = o || {};
3482
+ return { level: r.level === "h3" ? "h3" : "h2", text: typeof r.text === "string" ? r.text : "" };
3483
+ }) : [],
3484
+ entities: trimList(p.entities, 30),
3485
+ questions: trimList(p.questions, 15),
3486
+ internalLinkIdeas: trimList(p.internalLinkIdeas, 10),
3487
+ recommendedWordCount: typeof p.recommendedWordCount === "number" ? p.recommendedWordCount : 0,
3488
+ notes: trimList(p.notes, 6)
3489
+ });
3490
+ } catch {
3491
+ return null;
3492
+ }
3493
+ }
3494
+ function sanitizeBrief(b) {
3495
+ return {
3496
+ 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) })),
3497
+ entities: trimList(b.entities, 30),
3498
+ questions: trimList(b.questions, 15),
3499
+ internalLinkIdeas: trimList(b.internalLinkIdeas, 10),
3500
+ recommendedWordCount: Math.min(1e4, Math.max(0, Math.round(b.recommendedWordCount || 0))),
3501
+ notes: trimList(b.notes, 6)
3502
+ };
3503
+ }
3504
+ async function callClaudeBrief(apiKey, model, language, params) {
3505
+ const systemPrompt = `You are an SEO content strategist applying June 2026 best practices.
3506
+ Produce a concise WRITING BRIEF for the target keyword so a writer can create a page that ranks AND is citable by AI engines.
3507
+ Rules:
3508
+ - Base the brief on genuine search intent for the keyword; cover entities and questions a complete page must address.
3509
+ - Be specific and non-generic; no filler. Write in ${language === "en" ? "English" : "French"}.
3510
+ - Do not invent facts, brands, prices or statistics.
3511
+ Return ONLY a JSON object (no markdown, no prose) with EXACTLY this shape:
3512
+ {"outline":[{"level":"h2"|"h3","text":string}],"entities":[string],"questions":[string],"internalLinkIdeas":[string],"recommendedWordCount":number,"notes":[string]}
3513
+ - outline: 5-12 headings (logical H2/H3 structure).
3514
+ - entities: 8-20 key terms/concepts to mention.
3515
+ - questions: 4-10 questions the page should answer (People-Also-Ask style).
3516
+ - internalLinkIdeas: 3-8 topics worth linking to internally.
3517
+ - notes: up to 4 short strategic tips.`;
3518
+ const userPrompt = `Target keyword: ${params.keyword}
3519
+ ${params.pageTitle ? `Existing page title: ${params.pageTitle}` : ""}
3520
+ ${params.existingContent ? `Existing content (first 2000 chars, complement it \u2014 don't repeat):
3521
+ ${params.existingContent.substring(0, 2e3)}` : ""}
3522
+
3523
+ Return the JSON brief now:`;
3524
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
3525
+ method: "POST",
3526
+ headers: { "Content-Type": "application/json", "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
3527
+ body: JSON.stringify({
3528
+ model,
3529
+ max_tokens: 1500,
3530
+ system: systemPrompt,
3531
+ messages: [{ role: "user", content: userPrompt }]
3532
+ })
3533
+ });
3534
+ if (!response.ok) {
3535
+ const body = await response.text();
3536
+ throw new Error(`Claude API error ${response.status}: ${body}`);
3537
+ }
3538
+ const data = await response.json();
3539
+ if (data.stop_reason === "refusal") return null;
3540
+ const text = (data.content?.find((b) => b.type === "text")?.text || "").trim();
3541
+ if (!text) return null;
3542
+ return parseBrief(text);
3543
+ }
3544
+ function createAiContentBriefHandler(targetCollections, seoConfig) {
3545
+ return async (req) => {
3546
+ try {
3547
+ if (!req.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
3548
+ const body = await parseJsonBody(req);
3549
+ const keyword = typeof body.keyword === "string" ? body.keyword.trim() : "";
3550
+ if (!keyword) return Response.json({ error: "Missing required field: keyword" }, { status: 400 });
3551
+ const apiKey = process.env.ANTHROPIC_API_KEY;
3552
+ if (!apiKey) {
3553
+ return Response.json(
3554
+ { error: "AI not configured. Set ANTHROPIC_API_KEY to generate a content brief.", code: "no_api_key" },
3555
+ { status: 400 }
3556
+ );
3557
+ }
3558
+ let pageTitle;
3559
+ let existingContent;
3560
+ const collection = typeof body.collection === "string" ? body.collection : void 0;
3561
+ const id = body.id != null ? String(body.id) : void 0;
3562
+ if (collection && id && (!targetCollections || targetCollections.includes(collection))) {
3563
+ try {
3564
+ const doc = await req.payload.findByID({ collection, id, depth: 1, overrideAccess: true });
3565
+ pageTitle = doc.title || void 0;
3566
+ existingContent = extractDocContent(doc).text || void 0;
3567
+ } catch {
3568
+ }
3569
+ }
3570
+ const model = process.env.SEO_AI_MODEL || DEFAULT_MODEL3;
3571
+ const language = seoConfig?.locale === "en" ? "en" : "fr";
3572
+ let brief;
3573
+ try {
3574
+ brief = await callClaudeBrief(apiKey, model, language, { keyword, pageTitle, existingContent });
3575
+ } catch (e) {
3576
+ req.payload.logger.error(`[seo] ai-content-brief Claude error: ${e instanceof Error ? e.message : "unknown"}`);
3577
+ return Response.json({ error: "Content brief generation failed." }, { status: 502 });
3578
+ }
3579
+ if (!brief) return Response.json({ error: "The model did not return a brief (possibly declined)." }, { status: 502 });
3580
+ return Response.json({ keyword, brief, model });
3581
+ } catch (error) {
3582
+ const message = error instanceof Error ? error.message : "Internal server error";
3583
+ req.payload.logger.error(`[seo] ai-content-brief error: ${message}`);
3584
+ return Response.json({ error: message }, { status: 500 });
3585
+ }
3586
+ };
3587
+ }
3588
+
3589
+ // src/endpoints/aiOptimizeBulk.ts
3590
+ var TITLE_MAX = 70;
3591
+ var DESC_MAX = 160;
3592
+ var KEYWORD_MAX2 = 60;
3593
+ function isAdmin5(user) {
3594
+ if (!user) return false;
3595
+ if (user.role === "admin") return true;
3596
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
3597
+ return false;
3598
+ }
3599
+ function createAiOptimizeBulkHandler(targetCollections, seoConfig, localeMapping) {
3600
+ return async (req) => {
3601
+ try {
3602
+ if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
3603
+ const body = await parseJsonBody(req);
3604
+ const rawIds = Array.isArray(body.ids) ? body.ids.map(String) : [];
3605
+ const defaultCollection = typeof body.collection === "string" ? body.collection : void 0;
3606
+ const apply = body.apply === true;
3607
+ const MAX = 100;
3608
+ const limit = Math.min(MAX, Math.max(1, parseInt(String(body.limit ?? 50), 10) || 50));
3609
+ const corrections = Array.isArray(body.corrections) ? body.corrections : null;
3610
+ if (apply && corrections && corrections.length > 0) {
3611
+ let applied2 = 0;
3612
+ let i2 = 0;
3613
+ const rows = [];
3614
+ for (const c of corrections.slice(0, MAX)) {
3615
+ i2++;
3616
+ const collection = typeof c.collection === "string" ? c.collection : defaultCollection;
3617
+ const id = c.id != null ? String(c.id) : void 0;
3618
+ if (!collection || !id || collection.startsWith("global:")) continue;
3619
+ if (targetCollections && !targetCollections.includes(collection)) continue;
3620
+ const metaTitle = typeof c.metaTitle === "string" ? truncateWords(c.metaTitle.trim(), TITLE_MAX) : "";
3621
+ const metaDescription = typeof c.metaDescription === "string" ? truncateWords(c.metaDescription.trim(), DESC_MAX) : "";
3622
+ const focusKeyword = typeof c.focusKeyword === "string" ? c.focusKeyword.trim().slice(0, KEYWORD_MAX2) : "";
3623
+ const patch = {};
3624
+ if (metaTitle || metaDescription) patch.meta = { title: metaTitle, description: metaDescription };
3625
+ if (focusKeyword) patch.focusKeyword = focusKeyword;
3626
+ try {
3627
+ if (Object.keys(patch).length > 0) {
3628
+ await req.payload.update({ collection, id, data: patch, overrideAccess: true });
3629
+ applied2++;
3630
+ rows.push({ collection, id, applied: true });
3631
+ }
3632
+ } catch (e) {
3633
+ rows.push({ collection, id, applied: false, error: e instanceof Error ? e.message : "error" });
3634
+ }
3635
+ if (i2 % 5 === 0) await new Promise((resolve) => setImmediate(resolve));
3636
+ }
3637
+ return Response.json(
3638
+ { processed: rows.length, applied: applied2, mode: "corrections", results: rows },
3639
+ { headers: { "Cache-Control": "no-store" } }
3640
+ );
3641
+ }
3642
+ if (rawIds.length === 0) {
3643
+ return Response.json({ error: 'Provide a non-empty "ids" array' }, { status: 400 });
3644
+ }
3645
+ const targets = [];
3646
+ for (const raw of rawIds) {
3647
+ let collection;
3648
+ let id;
3649
+ if (raw.includes("::")) {
3650
+ const [c, i2] = raw.split("::");
3651
+ collection = c;
3652
+ id = i2;
3653
+ } else {
3654
+ collection = defaultCollection;
3655
+ id = raw;
3656
+ }
3657
+ if (!collection || !id) continue;
3658
+ if (collection.startsWith("global:")) continue;
3659
+ if (targetCollections && !targetCollections.includes(collection)) continue;
3660
+ targets.push({ collection, id });
3661
+ }
3662
+ const capped = targets.length > limit;
3663
+ const slice = targets.slice(0, limit);
3664
+ const { config: mergedConfig } = await loadMergedConfig(req.payload, seoConfig, {
3665
+ reqLocale: req.locale,
3666
+ localeMapping
3667
+ });
3668
+ const apiKey = process.env.ANTHROPIC_API_KEY;
3669
+ const model = process.env.SEO_AI_MODEL;
3670
+ const results = [];
3671
+ let applied = 0;
3672
+ let i = 0;
3673
+ for (const target of slice) {
3674
+ i++;
3675
+ try {
3676
+ const r = await optimizeDocMeta(req.payload, {
3677
+ collection: target.collection,
3678
+ id: target.id,
3679
+ mergedConfig,
3680
+ apiKey,
3681
+ model
3682
+ });
3683
+ if (!r.ok || !r.current || !r.suggestions) {
3684
+ results.push({
3685
+ collection: target.collection,
3686
+ id: target.id,
3687
+ title: "",
3688
+ before: { metaTitle: "", metaDescription: "", focusKeyword: "" },
3689
+ after: { metaTitle: "", metaDescription: "", focusKeyword: "" },
3690
+ changed: false,
3691
+ applied: false,
3692
+ error: r.error || "optimize_failed"
3693
+ });
3694
+ continue;
3695
+ }
3696
+ const before = r.current;
3697
+ const after = {
3698
+ metaTitle: r.suggestions.metaTitle,
3699
+ metaDescription: r.suggestions.metaDescription,
3700
+ focusKeyword: r.suggestions.focusKeyword
3701
+ };
3702
+ const changed = after.metaTitle !== before.metaTitle || after.metaDescription !== before.metaDescription || after.focusKeyword !== before.focusKeyword;
3703
+ let didApply = false;
3704
+ if (apply && changed) {
3705
+ const patch = {};
3706
+ if (after.metaTitle || after.metaDescription) {
3707
+ patch.meta = { title: after.metaTitle, description: after.metaDescription };
3708
+ }
3709
+ if (after.focusKeyword && after.focusKeyword !== before.focusKeyword) {
3710
+ patch.focusKeyword = after.focusKeyword;
3711
+ }
3712
+ if (Object.keys(patch).length > 0) {
3713
+ await req.payload.update({ collection: target.collection, id: target.id, data: patch, overrideAccess: true });
3714
+ didApply = true;
3715
+ applied++;
3716
+ }
3717
+ }
3718
+ results.push({
3719
+ collection: target.collection,
3720
+ id: target.id,
3721
+ title: r.title || "",
3722
+ before,
3723
+ after,
3724
+ changed,
3725
+ applied: didApply,
3726
+ method: r.method
3727
+ });
3728
+ } catch (e) {
3729
+ results.push({
3730
+ collection: target.collection,
3731
+ id: target.id,
3732
+ title: "",
3733
+ before: { metaTitle: "", metaDescription: "", focusKeyword: "" },
3734
+ after: { metaTitle: "", metaDescription: "", focusKeyword: "" },
3735
+ changed: false,
3736
+ applied: false,
3737
+ error: e instanceof Error ? e.message : "error"
3738
+ });
3739
+ }
3740
+ if (i % 3 === 0) await new Promise((resolve) => setImmediate(resolve));
3741
+ }
3742
+ return Response.json(
3743
+ {
3744
+ processed: results.length,
3745
+ changedCount: results.filter((r) => r.changed).length,
3746
+ applied,
3747
+ capped,
3748
+ results
3749
+ },
3750
+ { headers: { "Cache-Control": "no-store" } }
3751
+ );
3752
+ } catch (error) {
3753
+ const message = error instanceof Error ? error.message : "Internal server error";
3754
+ req.payload.logger.error(`[seo] ai-optimize-bulk error: ${message}`);
3755
+ return Response.json({ error: message }, { status: 500 });
3756
+ }
3757
+ };
3758
+ }
3759
+
3446
3760
  // src/endpoints/cannibalization.ts
3447
3761
  function canonicalIntent(keyword) {
3448
3762
  return keyword.toLowerCase().normalize("NFD").replace(/\p{Diacritic}/gu, "").replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean).sort().join(" ");
@@ -4027,7 +4341,7 @@ function getDateThreshold(period) {
4027
4341
  return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
4028
4342
  }
4029
4343
  }
4030
- function isAdmin5(user) {
4344
+ function isAdmin6(user) {
4031
4345
  if (!user) return false;
4032
4346
  if (user.role === "admin") return true;
4033
4347
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -4132,7 +4446,7 @@ function createPerformanceHandler() {
4132
4446
  });
4133
4447
  }
4134
4448
  if (method === "POST") {
4135
- if (!isAdmin5(req.user)) {
4449
+ if (!isAdmin6(req.user)) {
4136
4450
  return Response.json({ error: "Admin access required" }, { status: 403 });
4137
4451
  }
4138
4452
  const body = await parseJsonBody(req);
@@ -5240,24 +5554,46 @@ function buildArticleSchema(doc, siteUrl) {
5240
5554
  }
5241
5555
  return schema;
5242
5556
  }
5243
- function buildLocalBusinessSchema(doc, siteUrl) {
5557
+ function buildLocationNode(loc, doc, siteUrl) {
5244
5558
  const meta = doc.meta || {};
5245
- const schema = {
5246
- "@context": "https://schema.org",
5247
- "@type": "LocalBusiness",
5248
- name: doc.title || meta.title || "",
5249
- description: meta.description || "",
5250
- url: `${siteUrl}/${doc.slug || ""}`
5559
+ const node = {
5560
+ "@type": typeof loc.type === "string" && loc.type || "LocalBusiness",
5561
+ name: loc.name || doc.title || meta.title || "",
5562
+ description: loc.description || meta.description || "",
5563
+ url: loc.url || `${siteUrl}/${doc.slug || ""}`
5251
5564
  };
5252
- if (doc.telephone) schema.telephone = doc.telephone;
5253
- if (doc.email) schema.email = doc.email;
5254
- if (doc.address && typeof doc.address === "object") {
5255
- schema.address = {
5256
- "@type": "PostalAddress",
5257
- ...doc.address
5565
+ if (loc.telephone) node.telephone = loc.telephone;
5566
+ if (loc.email) node.email = loc.email;
5567
+ if (loc.priceRange) node.priceRange = loc.priceRange;
5568
+ const address = loc.address;
5569
+ if (address && typeof address === "object") {
5570
+ node.address = { "@type": "PostalAddress", ...address };
5571
+ } else if (typeof address === "string" && address) {
5572
+ node.address = address;
5573
+ }
5574
+ const geo = loc.geo || {};
5575
+ const lat = geo.latitude ?? loc.latitude ?? loc.lat;
5576
+ const lng = geo.longitude ?? loc.longitude ?? loc.lng;
5577
+ if (lat != null && lng != null) {
5578
+ node.geo = { "@type": "GeoCoordinates", latitude: lat, longitude: lng };
5579
+ }
5580
+ if (Array.isArray(loc.openingHours) && loc.openingHours.length > 0) {
5581
+ node.openingHours = loc.openingHours;
5582
+ } else if (typeof loc.openingHours === "string" && loc.openingHours) {
5583
+ node.openingHours = loc.openingHours;
5584
+ }
5585
+ return node;
5586
+ }
5587
+ function buildLocalBusinessSchema(doc, siteUrl) {
5588
+ const locations = Array.isArray(doc.locations) ? doc.locations.filter((l) => !!l && typeof l === "object") : [];
5589
+ if (locations.length > 1) {
5590
+ return {
5591
+ "@context": "https://schema.org",
5592
+ "@graph": locations.map((loc) => buildLocationNode(loc, doc, siteUrl))
5258
5593
  };
5259
5594
  }
5260
- return schema;
5595
+ const base = locations.length === 1 ? locations[0] : doc;
5596
+ return { "@context": "https://schema.org", ...buildLocationNode(base, doc, siteUrl) };
5261
5597
  }
5262
5598
  function buildBreadcrumbSchema(doc, siteUrl) {
5263
5599
  const slug = doc.slug || "";
@@ -5824,7 +6160,7 @@ function createAiRewriteHandler(targetCollections) {
5824
6160
  }
5825
6161
 
5826
6162
  // src/endpoints/robots.ts
5827
- function isAdmin6(user) {
6163
+ function isAdmin7(user) {
5828
6164
  if (!user) return false;
5829
6165
  if (user.role === "admin") return true;
5830
6166
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -5873,7 +6209,7 @@ function createRobotsUpdateHandler() {
5873
6209
  if (!req.user) {
5874
6210
  return Response.json({ error: "Unauthorized" }, { status: 401 });
5875
6211
  }
5876
- if (!isAdmin6(req.user)) {
6212
+ if (!isAdmin7(req.user)) {
5877
6213
  return Response.json({ error: "Admin access required" }, { status: 403 });
5878
6214
  }
5879
6215
  const body = await parseJsonBody(req);
@@ -6008,6 +6344,191 @@ function createSitemapHandler(targetCollections) {
6008
6344
  };
6009
6345
  }
6010
6346
 
6347
+ // src/endpoints/sitemapExtensions.ts
6348
+ function escapeXml2(str) {
6349
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
6350
+ }
6351
+ function resolveSiteUrl3(seoConfig) {
6352
+ return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "").replace(/\/$/, "");
6353
+ }
6354
+ function docPath(slug) {
6355
+ return slug === "home" || slug === "" ? "" : `/${slug}`;
6356
+ }
6357
+ function xmlResponse(xml, status = 200) {
6358
+ return new Response(xml, {
6359
+ status,
6360
+ headers: { "Content-Type": "application/xml", "Cache-Control": "public, max-age=3600, s-maxage=3600" }
6361
+ });
6362
+ }
6363
+ function mediaUrl(media, siteUrl) {
6364
+ if (typeof media.url === "string" && media.url) {
6365
+ return media.url.startsWith("http") ? media.url : `${siteUrl}${media.url}`;
6366
+ }
6367
+ if (typeof media.filename === "string" && media.filename) {
6368
+ return `${siteUrl}/media/${media.filename}`;
6369
+ }
6370
+ return void 0;
6371
+ }
6372
+ function collectMediaUrls(node, mimePrefix, siteUrl, out, depth = 0) {
6373
+ if (!node || typeof node !== "object" || depth > 8) return;
6374
+ if (Array.isArray(node)) {
6375
+ for (const item of node) collectMediaUrls(item, mimePrefix, siteUrl, out, depth + 1);
6376
+ return;
6377
+ }
6378
+ const obj = node;
6379
+ const mime = typeof obj.mimeType === "string" ? obj.mimeType : "";
6380
+ if (mime.startsWith(mimePrefix)) {
6381
+ const url = mediaUrl(obj, siteUrl);
6382
+ if (url) out.add(url);
6383
+ }
6384
+ for (const key of Object.keys(obj)) {
6385
+ if (key === "sizes" || key === "_status") continue;
6386
+ collectMediaUrls(obj[key], mimePrefix, siteUrl, out, depth + 1);
6387
+ }
6388
+ }
6389
+ async function eachPublishedDoc(payload, collections, depth, onDoc) {
6390
+ const BATCH = Math.min(100, Math.max(1, parseInt(process.env.SEO_SITEMAP_BATCH_SIZE || "50", 10) || 50));
6391
+ const MAX = Math.max(1, parseInt(process.env.SEO_SITEMAP_MAX_DOCS || "5000", 10) || 5e3);
6392
+ let count = 0;
6393
+ for (const collection of collections) {
6394
+ try {
6395
+ let page = 1;
6396
+ let hasMore = true;
6397
+ while (hasMore) {
6398
+ const res = await payload.find({ collection, limit: BATCH, page, depth, overrideAccess: true });
6399
+ for (const doc of res.docs) {
6400
+ if (doc._status === "draft") continue;
6401
+ if (count >= MAX) return;
6402
+ onDoc(doc, collection);
6403
+ count++;
6404
+ }
6405
+ hasMore = res.hasNextPage;
6406
+ page++;
6407
+ await new Promise((resolve) => setImmediate(resolve));
6408
+ }
6409
+ } catch {
6410
+ }
6411
+ }
6412
+ }
6413
+ function createNewsSitemapHandler(targetCollections, seoConfig) {
6414
+ return async (req) => {
6415
+ try {
6416
+ const siteUrl = resolveSiteUrl3(seoConfig);
6417
+ const language = seoConfig?.locale === "en" ? "en" : "fr";
6418
+ let publication = seoConfig?.siteName || "";
6419
+ if (!publication && siteUrl) {
6420
+ try {
6421
+ publication = new URL(siteUrl).hostname;
6422
+ } catch {
6423
+ }
6424
+ }
6425
+ const cutoff = Date.now() - 48 * 36e5;
6426
+ const entries = [];
6427
+ await eachPublishedDoc(req.payload, targetCollections, 0, (doc) => {
6428
+ const dateStr = typeof doc.publishedAt === "string" && doc.publishedAt || typeof doc.date === "string" && doc.date || typeof doc.createdAt === "string" && doc.createdAt || "";
6429
+ if (!dateStr) return;
6430
+ const t = new Date(dateStr).getTime();
6431
+ if (isNaN(t) || t < cutoff) return;
6432
+ const title = doc.title || doc.meta?.title || "";
6433
+ if (!title) return;
6434
+ const loc = `${siteUrl}${docPath(doc.slug || "")}`;
6435
+ entries.push(
6436
+ ` <url>
6437
+ <loc>${escapeXml2(loc)}</loc>
6438
+ <news:news>
6439
+ <news:publication>
6440
+ <news:name>${escapeXml2(publication)}</news:name>
6441
+ <news:language>${language}</news:language>
6442
+ </news:publication>
6443
+ <news:publication_date>${new Date(dateStr).toISOString()}</news:publication_date>
6444
+ <news:title>${escapeXml2(title)}</news:title>
6445
+ </news:news>
6446
+ </url>`
6447
+ );
6448
+ });
6449
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
6450
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
6451
+ ${entries.join("\n")}
6452
+ </urlset>`;
6453
+ return xmlResponse(xml);
6454
+ } catch (error) {
6455
+ req.payload.logger.error(`[seo] sitemap-news error: ${error instanceof Error ? error.message : "unknown"}`);
6456
+ return xmlResponse('<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>', 500);
6457
+ }
6458
+ };
6459
+ }
6460
+ function createImageSitemapHandler(targetCollections, seoConfig) {
6461
+ return async (req) => {
6462
+ try {
6463
+ const siteUrl = resolveSiteUrl3(seoConfig);
6464
+ const entries = [];
6465
+ await eachPublishedDoc(req.payload, targetCollections, 1, (doc) => {
6466
+ const urls = /* @__PURE__ */ new Set();
6467
+ collectMediaUrls(doc, "image/", siteUrl, urls);
6468
+ if (urls.size === 0) return;
6469
+ const loc = `${siteUrl}${docPath(doc.slug || "")}`;
6470
+ const imgs = Array.from(urls).slice(0, 1e3).map((u) => ` <image:image><image:loc>${escapeXml2(u)}</image:loc></image:image>`).join("\n");
6471
+ entries.push(` <url>
6472
+ <loc>${escapeXml2(loc)}</loc>
6473
+ ${imgs}
6474
+ </url>`);
6475
+ });
6476
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
6477
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
6478
+ ${entries.join("\n")}
6479
+ </urlset>`;
6480
+ return xmlResponse(xml);
6481
+ } catch (error) {
6482
+ req.payload.logger.error(`[seo] sitemap-images error: ${error instanceof Error ? error.message : "unknown"}`);
6483
+ return xmlResponse('<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>', 500);
6484
+ }
6485
+ };
6486
+ }
6487
+ function createVideoSitemapHandler(targetCollections, seoConfig) {
6488
+ return async (req) => {
6489
+ try {
6490
+ const siteUrl = resolveSiteUrl3(seoConfig);
6491
+ const entries = [];
6492
+ await eachPublishedDoc(req.payload, targetCollections, 1, (doc) => {
6493
+ const meta = doc.meta || {};
6494
+ const videoUrls = /* @__PURE__ */ new Set();
6495
+ collectMediaUrls(doc, "video/", siteUrl, videoUrls);
6496
+ for (const k of ["videoUrl", "contentUrl", "playerUrl"]) {
6497
+ if (typeof doc[k] === "string" && doc[k]) videoUrls.add(doc[k]);
6498
+ }
6499
+ if (videoUrls.size === 0) return;
6500
+ const title = doc.title || meta.title || "";
6501
+ const description = meta.description || title;
6502
+ const thumbs = /* @__PURE__ */ new Set();
6503
+ collectMediaUrls(meta.image, "image/", siteUrl, thumbs);
6504
+ if (thumbs.size === 0) collectMediaUrls(doc, "image/", siteUrl, thumbs);
6505
+ const thumbnail = Array.from(thumbs)[0] || "";
6506
+ const loc = `${siteUrl}${docPath(doc.slug || "")}`;
6507
+ const videos = Array.from(videoUrls).slice(0, 100).map(
6508
+ (u) => ` <video:video>
6509
+ ${thumbnail ? ` <video:thumbnail_loc>${escapeXml2(thumbnail)}</video:thumbnail_loc>
6510
+ ` : ""} <video:title>${escapeXml2(title || "Video")}</video:title>
6511
+ <video:description>${escapeXml2(description || title || "Video")}</video:description>
6512
+ <video:content_loc>${escapeXml2(u)}</video:content_loc>
6513
+ </video:video>`
6514
+ ).join("\n");
6515
+ entries.push(` <url>
6516
+ <loc>${escapeXml2(loc)}</loc>
6517
+ ${videos}
6518
+ </url>`);
6519
+ });
6520
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
6521
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
6522
+ ${entries.join("\n")}
6523
+ </urlset>`;
6524
+ return xmlResponse(xml);
6525
+ } catch (error) {
6526
+ req.payload.logger.error(`[seo] sitemap-video error: ${error instanceof Error ? error.message : "unknown"}`);
6527
+ return xmlResponse('<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>', 500);
6528
+ }
6529
+ };
6530
+ }
6531
+
6011
6532
  // src/collections/SeoScoreHistory.ts
6012
6533
  function createSeoScoreHistoryCollection() {
6013
6534
  return {
@@ -6918,7 +7439,7 @@ function getClientIp(req) {
6918
7439
 
6919
7440
  // src/endpoints/seoLogs.ts
6920
7441
  var VALID_LOG_TYPES = ["404", "redirect", "error"];
6921
- function isAdmin7(user) {
7442
+ function isAdmin8(user) {
6922
7443
  if (!user) return false;
6923
7444
  if (user.role === "admin") return true;
6924
7445
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -7015,7 +7536,7 @@ function createSeoLogsHandler(seoLogsSecret) {
7015
7536
  return Response.json({ error: "Unauthorized" }, { status: 401 });
7016
7537
  }
7017
7538
  if (method === "DELETE") {
7018
- if (!isAdmin7(req.user)) {
7539
+ if (!isAdmin8(req.user)) {
7019
7540
  return Response.json({ error: "Admin access required" }, { status: 403 });
7020
7541
  }
7021
7542
  try {
@@ -7192,6 +7713,7 @@ async function doWarmUp(payload, collections = ["pages", "posts"], globals = [])
7192
7713
  }
7193
7714
  }
7194
7715
  function startCacheWarmUp(payload, _basePath, globals = [], collections = ["pages", "posts"]) {
7716
+ stopCacheWarmUp();
7195
7717
  setTimeout(() => {
7196
7718
  void doWarmUp(payload, collections, globals);
7197
7719
  }, STARTUP_DELAY);
@@ -7233,6 +7755,7 @@ async function doSnapshot(payload, basePath, seoConfig) {
7233
7755
  }
7234
7756
  }
7235
7757
  function startRankTracker(payload, basePath, seoConfig) {
7758
+ stopRankTracker();
7236
7759
  setTimeout(() => {
7237
7760
  void doSnapshot(payload, basePath, seoConfig);
7238
7761
  }, STARTUP_DELAY2);
@@ -7255,7 +7778,7 @@ function stopRankTracker() {
7255
7778
  }
7256
7779
 
7257
7780
  // src/endpoints/alerts.ts
7258
- function isAdmin8(user) {
7781
+ function isAdmin9(user) {
7259
7782
  if (!user) return false;
7260
7783
  if (user.role === "admin") return true;
7261
7784
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -7421,7 +7944,7 @@ async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
7421
7944
  function createAlertsDigestHandler() {
7422
7945
  return async (req) => {
7423
7946
  try {
7424
- if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7947
+ if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7425
7948
  const cfg = getAlertConfig();
7426
7949
  const digest = await buildAlertDigest(req.payload, cfg);
7427
7950
  return Response.json(
@@ -7447,7 +7970,7 @@ function createAlertsDigestHandler() {
7447
7970
  function createAlertsRunHandler(siteUrl) {
7448
7971
  return async (req) => {
7449
7972
  try {
7450
- if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7973
+ if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7451
7974
  const cfg = getAlertConfig();
7452
7975
  const digest = await buildAlertDigest(req.payload, cfg);
7453
7976
  const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
@@ -7482,6 +8005,7 @@ async function runDigest(payload, siteUrl) {
7482
8005
  }
7483
8006
  }
7484
8007
  function startAlertsScheduler(payload, siteUrl) {
8008
+ stopAlertsScheduler();
7485
8009
  const intervalHours = Math.max(1, parseInt(process.env.SEO_ALERT_INTERVAL_HOURS || "24", 10) || 24);
7486
8010
  const intervalMs = intervalHours * 60 * 60 * 1e3;
7487
8011
  setTimeout(() => {
@@ -8496,6 +9020,16 @@ var fr = {
8496
9020
  pagesAnalyzed: "pages analys\xE9es",
8497
9021
  markCornerstone: "Marquer pilier",
8498
9022
  unmarkCornerstone: "D\xE9marquer pilier",
9023
+ bulkOptimizeMeta: "Optimiser m\xE9ta (IA)",
9024
+ bulkOptimizing: "Analyse\u2026",
9025
+ bulkConfirm: "Confirmer ?",
9026
+ bulkPreviewTitle: "Corrections m\xE9ta propos\xE9es",
9027
+ bulkApply: "Appliquer",
9028
+ bulkApplying: "Application\u2026",
9029
+ bulkCancel: "Annuler",
9030
+ bulkExport: "Exporter CSV",
9031
+ bulkNoChanges: "Aucune correction n\xE9cessaire sur la s\xE9lection.",
9032
+ bulkCappedNote: "limite atteinte (affine la s\xE9lection)",
8499
9033
  searchPlaceholder: "Rechercher (titre, slug, keyword)...",
8500
9034
  allCollections: "Toutes les collections",
8501
9035
  allScores: "Tous les scores",
@@ -9088,6 +9622,16 @@ var en = {
9088
9622
  pagesAnalyzed: "pages analyzed",
9089
9623
  markCornerstone: "Mark as cornerstone",
9090
9624
  unmarkCornerstone: "Unmark cornerstone",
9625
+ bulkOptimizeMeta: "Optimize meta (AI)",
9626
+ bulkOptimizing: "Analyzing\u2026",
9627
+ bulkConfirm: "Confirm?",
9628
+ bulkPreviewTitle: "Proposed meta corrections",
9629
+ bulkApply: "Apply",
9630
+ bulkApplying: "Applying\u2026",
9631
+ bulkCancel: "Cancel",
9632
+ bulkExport: "Export CSV",
9633
+ bulkNoChanges: "No corrections needed on the selection.",
9634
+ bulkCappedNote: "limit reached (narrow the selection)",
9091
9635
  searchPlaceholder: "Search (title, slug, keyword)...",
9092
9636
  allCollections: "All collections",
9093
9637
  allScores: "All scores",
@@ -9939,7 +10483,9 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
9939
10483
  { path: `${basePath}/ai-rewrite`, method: "post", handler: createAiRewriteHandler(targetCollections) },
9940
10484
  { path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) },
9941
10485
  { path: `${basePath}/alt-text-audit`, method: "get", handler: createAltTextAuditHandler(uploadsCollection) },
9942
- { path: `${basePath}/ai-alt-text`, method: "post", handler: withRateLimit(createAiAltTextHandler(uploadsCollection, seoConfig)) }
10486
+ { path: `${basePath}/ai-alt-text`, method: "post", handler: withRateLimit(createAiAltTextHandler(uploadsCollection, seoConfig)) },
10487
+ { path: `${basePath}/ai-content-brief`, method: "post", handler: withRateLimit(createAiContentBriefHandler(targetCollections, seoConfig)) },
10488
+ { path: `${basePath}/ai-optimize-bulk`, method: "post", handler: withRateLimit(createAiOptimizeBulkHandler(targetCollections, seoConfig)) }
9943
10489
  );
9944
10490
  }
9945
10491
  if (features.cannibalization) {
@@ -10031,6 +10577,21 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10031
10577
  path: `${basePath}/sitemap.xml`,
10032
10578
  method: "get",
10033
10579
  handler: createSitemapHandler(targetCollections)
10580
+ },
10581
+ {
10582
+ path: `${basePath}/sitemap-news.xml`,
10583
+ method: "get",
10584
+ handler: createNewsSitemapHandler(targetCollections, seoConfig)
10585
+ },
10586
+ {
10587
+ path: `${basePath}/sitemap-images.xml`,
10588
+ method: "get",
10589
+ handler: createImageSitemapHandler(targetCollections, seoConfig)
10590
+ },
10591
+ {
10592
+ path: `${basePath}/sitemap-video.xml`,
10593
+ method: "get",
10594
+ handler: createVideoSitemapHandler(targetCollections, seoConfig)
10034
10595
  }
10035
10596
  );
10036
10597
  config.endpoints = [
@@ -10135,7 +10696,7 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10135
10696
  };
10136
10697
 
10137
10698
  // src/helpers/buildMetadata.ts
10138
- function resolveSiteUrl3(explicit) {
10699
+ function resolveSiteUrl4(explicit) {
10139
10700
  return (explicit || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "").replace(/\/$/, "");
10140
10701
  }
10141
10702
  function parseRobots(doc, meta) {
@@ -10169,7 +10730,7 @@ function absoluteUrl(value, siteUrl) {
10169
10730
  return `${siteUrl}${value.startsWith("/") ? "" : "/"}${value}`;
10170
10731
  }
10171
10732
  function buildSeoMetadata(doc, options = {}) {
10172
- const siteUrl = resolveSiteUrl3(options.siteUrl);
10733
+ const siteUrl = resolveSiteUrl4(options.siteUrl);
10173
10734
  const meta = doc.meta || {};
10174
10735
  const rawTitle = meta.title || doc.title || "";
10175
10736
  const title = options.titleTemplate && rawTitle ? options.titleTemplate.replace("%s", rawTitle) : rawTitle;