@rankcli/agent-runtime 0.0.9 → 0.0.11
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/README.md +90 -196
- package/dist/analyzer-GMURJADU.mjs +7 -0
- package/dist/chunk-2JADKV3Z.mjs +244 -0
- package/dist/chunk-3ZSCLNTW.mjs +557 -0
- package/dist/chunk-4E4MQOSP.mjs +374 -0
- package/dist/chunk-6BWS3CLP.mjs +16 -0
- package/dist/chunk-AK2IC22C.mjs +206 -0
- package/dist/chunk-K6VSXDD6.mjs +293 -0
- package/dist/chunk-M27NQCWW.mjs +303 -0
- package/dist/{chunk-YNZYHEYM.mjs → chunk-PJLNXOLN.mjs} +0 -14
- package/dist/chunk-VSQD74I7.mjs +474 -0
- package/dist/core-web-vitals-analyzer-TE6LQJMS.mjs +7 -0
- package/dist/geo-analyzer-D47LTMMA.mjs +25 -0
- package/dist/image-optimization-analyzer-XP4OQGRP.mjs +9 -0
- package/dist/index.d.mts +612 -17
- package/dist/index.d.ts +612 -17
- package/dist/index.js +9020 -2686
- package/dist/index.mjs +4177 -328
- package/dist/internal-linking-analyzer-MRMBV7NM.mjs +9 -0
- package/dist/mobile-seo-analyzer-67HNQ7IO.mjs +7 -0
- package/dist/security-headers-analyzer-3ZUQARS5.mjs +9 -0
- package/dist/structured-data-analyzer-2I4NQAUP.mjs +9 -0
- package/package.json +2 -2
- package/src/analyzers/core-web-vitals-analyzer.test.ts +236 -0
- package/src/analyzers/core-web-vitals-analyzer.ts +557 -0
- package/src/analyzers/geo-analyzer.test.ts +310 -0
- package/src/analyzers/geo-analyzer.ts +814 -0
- package/src/analyzers/image-optimization-analyzer.test.ts +145 -0
- package/src/analyzers/image-optimization-analyzer.ts +348 -0
- package/src/analyzers/index.ts +233 -0
- package/src/analyzers/internal-linking-analyzer.test.ts +141 -0
- package/src/analyzers/internal-linking-analyzer.ts +419 -0
- package/src/analyzers/mobile-seo-analyzer.test.ts +140 -0
- package/src/analyzers/mobile-seo-analyzer.ts +455 -0
- package/src/analyzers/security-headers-analyzer.test.ts +115 -0
- package/src/analyzers/security-headers-analyzer.ts +318 -0
- package/src/analyzers/structured-data-analyzer.test.ts +210 -0
- package/src/analyzers/structured-data-analyzer.ts +590 -0
- package/src/audit/engine.ts +3 -3
- package/src/audit/types.ts +3 -2
- package/src/fixer/framework-fixes.test.ts +489 -0
- package/src/fixer/framework-fixes.ts +3418 -0
- package/src/frameworks/detector.ts +642 -114
- package/src/frameworks/suggestion-engine.ts +38 -1
- package/src/index.ts +3 -0
- package/src/types.ts +15 -1
- package/dist/analyzer-2CSWIQGD.mjs +0 -6
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
analyzeMobileSEO
|
|
3
|
+
} from "./chunk-M27NQCWW.mjs";
|
|
4
|
+
import {
|
|
4
5
|
analyzeHeadings,
|
|
5
6
|
analyzeUrl,
|
|
6
7
|
checkRobots,
|
|
@@ -13,7 +14,41 @@ import {
|
|
|
13
14
|
httpGet,
|
|
14
15
|
httpHead,
|
|
15
16
|
httpPost
|
|
16
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-PJLNXOLN.mjs";
|
|
18
|
+
import {
|
|
19
|
+
AI_CRAWLERS_INFO,
|
|
20
|
+
analyzeCitationReadiness,
|
|
21
|
+
analyzeContentStructure,
|
|
22
|
+
analyzeEntityExtraction,
|
|
23
|
+
analyzeGEO,
|
|
24
|
+
analyzeRobotsTxtForAI,
|
|
25
|
+
calculateLLMSignals,
|
|
26
|
+
detectRenderingMode,
|
|
27
|
+
generateAIFriendlyRobotsTxt
|
|
28
|
+
} from "./chunk-3ZSCLNTW.mjs";
|
|
29
|
+
import {
|
|
30
|
+
analyzeCoreWebVitals
|
|
31
|
+
} from "./chunk-4E4MQOSP.mjs";
|
|
32
|
+
import {
|
|
33
|
+
analyzeSecurityHeaders,
|
|
34
|
+
generateSecurityHeaders
|
|
35
|
+
} from "./chunk-AK2IC22C.mjs";
|
|
36
|
+
import {
|
|
37
|
+
analyzeStructuredData,
|
|
38
|
+
generateSchemaTemplate
|
|
39
|
+
} from "./chunk-VSQD74I7.mjs";
|
|
40
|
+
import {
|
|
41
|
+
analyzeImages,
|
|
42
|
+
generateResponsiveImage
|
|
43
|
+
} from "./chunk-2JADKV3Z.mjs";
|
|
44
|
+
import {
|
|
45
|
+
analyzeInternalLinking,
|
|
46
|
+
suggestInternalLinks
|
|
47
|
+
} from "./chunk-K6VSXDD6.mjs";
|
|
48
|
+
import {
|
|
49
|
+
__export,
|
|
50
|
+
__require
|
|
51
|
+
} from "./chunk-6BWS3CLP.mjs";
|
|
17
52
|
|
|
18
53
|
// src/audit/types.ts
|
|
19
54
|
var ISSUE_DEFINITIONS = {
|
|
@@ -2356,7 +2391,7 @@ async function analyzeLinks(html, baseUrl, checkBroken = false) {
|
|
|
2356
2391
|
|
|
2357
2392
|
// src/audit/checks/images.ts
|
|
2358
2393
|
import * as cheerio3 from "cheerio";
|
|
2359
|
-
async function
|
|
2394
|
+
async function analyzeImages2(html, baseUrl, checkBroken = false) {
|
|
2360
2395
|
const issues = [];
|
|
2361
2396
|
const $ = cheerio3.load(html);
|
|
2362
2397
|
const missingAlt = [];
|
|
@@ -2714,7 +2749,7 @@ var SCHEMA_RECOMMENDED_PROPERTIES = {
|
|
|
2714
2749
|
"SoftwareApplication": ["applicationCategory", "offers", "aggregateRating"],
|
|
2715
2750
|
"VideoObject": ["duration", "embedUrl"]
|
|
2716
2751
|
};
|
|
2717
|
-
function
|
|
2752
|
+
function analyzeStructuredData2(html, url) {
|
|
2718
2753
|
const issues = [];
|
|
2719
2754
|
const $ = cheerio5.load(html);
|
|
2720
2755
|
const schemas = [];
|
|
@@ -4508,7 +4543,7 @@ function parseHSTS(header) {
|
|
|
4508
4543
|
result.preload = /preload/i.test(header);
|
|
4509
4544
|
return result;
|
|
4510
4545
|
}
|
|
4511
|
-
async function
|
|
4546
|
+
async function analyzeSecurityHeaders2(url) {
|
|
4512
4547
|
const issues = [];
|
|
4513
4548
|
try {
|
|
4514
4549
|
const response = await httpGet(url, {
|
|
@@ -16645,7 +16680,7 @@ async function runFullAudit(options) {
|
|
|
16645
16680
|
const headers = fetchResult.headers;
|
|
16646
16681
|
console.log(`\u{1F4DD} Phase 2: Running synchronous HTML checks (tier: ${tier}, limit: ${checksLimit})...`);
|
|
16647
16682
|
const onPageResult = analyzeOnPage(html, url);
|
|
16648
|
-
const structuredDataResult =
|
|
16683
|
+
const structuredDataResult = analyzeStructuredData2(html, url);
|
|
16649
16684
|
const mobileResult = analyzeMobile(html, url);
|
|
16650
16685
|
const mobileResourceIssues = checkMobileResources(html, url);
|
|
16651
16686
|
const socialResult = analyzeSocialMeta(html, url);
|
|
@@ -16780,7 +16815,7 @@ async function runFullAudit(options) {
|
|
|
16780
16815
|
});
|
|
16781
16816
|
const asyncChecks = [];
|
|
16782
16817
|
asyncChecks.push(safeAsync("Links", () => analyzeLinks(html, url, checkBrokenLinks)));
|
|
16783
|
-
asyncChecks.push(safeAsync("Images", () =>
|
|
16818
|
+
asyncChecks.push(safeAsync("Images", () => analyzeImages2(html, url, checkBrokenLinks)));
|
|
16784
16819
|
asyncChecks.push(performancePromise);
|
|
16785
16820
|
asyncChecks.push(safeAsync("Security", () => analyzeSecurity(html, url, headers)));
|
|
16786
16821
|
asyncChecks.push(safeAsyncIssues("Certificate", async () => (await checkCertificate(url)).issues));
|
|
@@ -16790,7 +16825,7 @@ async function runFullAudit(options) {
|
|
|
16790
16825
|
if (runExtendedChecks) {
|
|
16791
16826
|
asyncChecks.push(safeAsync("Hreflang", () => analyzeHreflang(html, url, { validateUrls: checkHreflangUrls })));
|
|
16792
16827
|
asyncChecks.push(safeAsync("Canonical Advanced", () => analyzeCanonicalAdvanced(html, url, { checkChain: checkCanonicalChain })));
|
|
16793
|
-
asyncChecks.push(safeAsync("Security Headers", () =>
|
|
16828
|
+
asyncChecks.push(safeAsync("Security Headers", () => analyzeSecurityHeaders2(url)));
|
|
16794
16829
|
asyncChecks.push(safeAsync("Caching Headers", () => analyzeCachingHeaders(url, headers)));
|
|
16795
16830
|
asyncChecks.push(safeAsync("HTML Compliance", () => analyzeHtmlCompliance(html, url, headers)));
|
|
16796
16831
|
asyncChecks.push(safeAsync("Redirect Chain", () => analyzeRedirectChain(url)));
|
|
@@ -16890,7 +16925,7 @@ function createReport(url, domain, issues, pages) {
|
|
|
16890
16925
|
};
|
|
16891
16926
|
}
|
|
16892
16927
|
function calculateHealthScore(issues) {
|
|
16893
|
-
const weights = { error: 10, warning: 3, notice: 1 };
|
|
16928
|
+
const weights = { critical: 15, error: 10, warning: 3, info: 1, notice: 1 };
|
|
16894
16929
|
const categoryDeductions = {
|
|
16895
16930
|
crawlability: 0,
|
|
16896
16931
|
indexability: 0,
|
|
@@ -17010,8 +17045,8 @@ function groupIssuesByCategory(issues) {
|
|
|
17010
17045
|
}
|
|
17011
17046
|
for (const category of Object.keys(grouped)) {
|
|
17012
17047
|
grouped[category].sort((a, b) => {
|
|
17013
|
-
const order = {
|
|
17014
|
-
return order[a.severity] - order[b.severity];
|
|
17048
|
+
const order = { critical: 0, error: 1, warning: 2, info: 3, notice: 4 };
|
|
17049
|
+
return (order[a.severity] ?? 5) - (order[b.severity] ?? 5);
|
|
17015
17050
|
});
|
|
17016
17051
|
}
|
|
17017
17052
|
return grouped;
|
|
@@ -25639,7 +25674,7 @@ async function executeAgent(agent, options = {}) {
|
|
|
25639
25674
|
}
|
|
25640
25675
|
}
|
|
25641
25676
|
async function runDirectAnalysis(url) {
|
|
25642
|
-
const { analyzeUrl: analyzeUrl2 } = await import("./analyzer-
|
|
25677
|
+
const { analyzeUrl: analyzeUrl2 } = await import("./analyzer-GMURJADU.mjs");
|
|
25643
25678
|
const result = await analyzeUrl2({ url });
|
|
25644
25679
|
if (!result.success || !result.data) {
|
|
25645
25680
|
throw new Error(result.error || "Analysis failed");
|
|
@@ -27425,213 +27460,3539 @@ export function SEOHead({
|
|
|
27425
27460
|
Usage: <SEOHead title="Page" description="..." />`
|
|
27426
27461
|
};
|
|
27427
27462
|
}
|
|
27463
|
+
function generateRailsSEOHelper(options) {
|
|
27464
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
27465
|
+
return {
|
|
27466
|
+
file: "app/helpers/seo_helper.rb",
|
|
27467
|
+
code: `# frozen_string_literal: true
|
|
27468
|
+
|
|
27469
|
+
# Comprehensive SEO Helper for Rails
|
|
27470
|
+
#
|
|
27471
|
+
# Features:
|
|
27472
|
+
# - Full Open Graph support
|
|
27473
|
+
# - Twitter Cards
|
|
27474
|
+
# - JSON-LD structured data
|
|
27475
|
+
# - Canonical URLs
|
|
27476
|
+
# - Dynamic meta tags
|
|
27477
|
+
#
|
|
27478
|
+
# Installation: gem 'meta-tags'
|
|
27479
|
+
# Include in ApplicationHelper or use directly
|
|
27480
|
+
module SeoHelper
|
|
27481
|
+
SITE_NAME = '${siteName}'.freeze
|
|
27482
|
+
SITE_URL = '${siteUrl}'.freeze
|
|
27483
|
+
DEFAULT_IMAGE = '${image || `${siteUrl}/og-image.png`}'.freeze
|
|
27484
|
+
DEFAULT_DESCRIPTION = '${description || `${siteName} - A compelling description.`}'.freeze
|
|
27485
|
+
TWITTER_HANDLE = '${twitterHandle || ""}'.freeze
|
|
27486
|
+
|
|
27487
|
+
# Set comprehensive page SEO
|
|
27488
|
+
#
|
|
27489
|
+
# @param title [String] Page title
|
|
27490
|
+
# @param description [String] Meta description
|
|
27491
|
+
# @param image [String] OG/Twitter image URL
|
|
27492
|
+
# @param type [Symbol] :website, :article, :product
|
|
27493
|
+
# @param options [Hash] Additional options
|
|
27494
|
+
#
|
|
27495
|
+
# @example
|
|
27496
|
+
# set_page_seo(
|
|
27497
|
+
# title: 'Product Name',
|
|
27498
|
+
# description: 'Product description',
|
|
27499
|
+
# type: :product,
|
|
27500
|
+
# schema: product_schema(@product)
|
|
27501
|
+
# )
|
|
27502
|
+
def set_page_seo(title: nil, description: nil, image: nil, type: :website, **options)
|
|
27503
|
+
page_title = title || SITE_NAME
|
|
27504
|
+
page_description = description || DEFAULT_DESCRIPTION
|
|
27505
|
+
page_image = image || DEFAULT_IMAGE
|
|
27506
|
+
page_url = request.original_url
|
|
27507
|
+
|
|
27508
|
+
set_meta_tags(
|
|
27509
|
+
title: page_title,
|
|
27510
|
+
site: SITE_NAME,
|
|
27511
|
+
description: page_description,
|
|
27512
|
+
canonical: page_url,
|
|
27513
|
+
robots: options[:noindex] ? 'noindex, nofollow' : 'index, follow',
|
|
27514
|
+
|
|
27515
|
+
# Open Graph
|
|
27516
|
+
og: {
|
|
27517
|
+
title: page_title,
|
|
27518
|
+
description: page_description,
|
|
27519
|
+
type: type.to_s,
|
|
27520
|
+
url: page_url,
|
|
27521
|
+
image: {
|
|
27522
|
+
_: page_image,
|
|
27523
|
+
width: 1200,
|
|
27524
|
+
height: 630,
|
|
27525
|
+
alt: page_title
|
|
27526
|
+
},
|
|
27527
|
+
site_name: SITE_NAME,
|
|
27528
|
+
locale: options[:locale] || 'en_US'
|
|
27529
|
+
},
|
|
27428
27530
|
|
|
27429
|
-
|
|
27430
|
-
|
|
27431
|
-
|
|
27432
|
-
|
|
27433
|
-
|
|
27434
|
-
|
|
27435
|
-
|
|
27436
|
-
|
|
27437
|
-
|
|
27438
|
-
|
|
27439
|
-
|
|
27440
|
-
|
|
27441
|
-
|
|
27442
|
-
|
|
27443
|
-
|
|
27444
|
-
|
|
27445
|
-
|
|
27446
|
-
|
|
27447
|
-
|
|
27448
|
-
|
|
27449
|
-
|
|
27450
|
-
|
|
27531
|
+
# Twitter Cards
|
|
27532
|
+
twitter: {
|
|
27533
|
+
card: 'summary_large_image',
|
|
27534
|
+
title: page_title,
|
|
27535
|
+
description: page_description,
|
|
27536
|
+
image: page_image,
|
|
27537
|
+
site: TWITTER_HANDLE.presence,
|
|
27538
|
+
creator: TWITTER_HANDLE.presence
|
|
27539
|
+
}.compact,
|
|
27540
|
+
|
|
27541
|
+
# Article-specific
|
|
27542
|
+
article: if type == :article
|
|
27543
|
+
{
|
|
27544
|
+
published_time: options[:published_at]&.iso8601,
|
|
27545
|
+
modified_time: options[:updated_at]&.iso8601,
|
|
27546
|
+
author: options[:author],
|
|
27547
|
+
section: options[:section],
|
|
27548
|
+
tag: options[:tags]
|
|
27549
|
+
}.compact
|
|
27550
|
+
end
|
|
27551
|
+
)
|
|
27552
|
+
|
|
27553
|
+
# Store schema for JSON-LD rendering
|
|
27554
|
+
@json_ld_schemas = [default_website_schema]
|
|
27555
|
+
@json_ld_schemas << options[:schema] if options[:schema]
|
|
27556
|
+
end
|
|
27557
|
+
|
|
27558
|
+
# Render JSON-LD structured data
|
|
27559
|
+
def json_ld_tags
|
|
27560
|
+
return unless @json_ld_schemas&.any?
|
|
27561
|
+
|
|
27562
|
+
safe_join(@json_ld_schemas.map do |schema|
|
|
27563
|
+
content_tag(:script, schema.to_json.html_safe, type: 'application/ld+json')
|
|
27564
|
+
end)
|
|
27565
|
+
end
|
|
27566
|
+
|
|
27567
|
+
# Schema generators
|
|
27568
|
+
def default_website_schema
|
|
27569
|
+
{
|
|
27570
|
+
'@context': 'https://schema.org',
|
|
27571
|
+
'@type': 'WebSite',
|
|
27572
|
+
name: SITE_NAME,
|
|
27573
|
+
url: SITE_URL
|
|
27451
27574
|
}
|
|
27452
|
-
|
|
27453
|
-
|
|
27454
|
-
|
|
27455
|
-
|
|
27456
|
-
|
|
27457
|
-
|
|
27458
|
-
|
|
27459
|
-
|
|
27460
|
-
|
|
27461
|
-
|
|
27462
|
-
|
|
27463
|
-
|
|
27464
|
-
|
|
27465
|
-
|
|
27466
|
-
|
|
27467
|
-
|
|
27468
|
-
|
|
27469
|
-
|
|
27470
|
-
|
|
27471
|
-
|
|
27472
|
-
|
|
27473
|
-
|
|
27474
|
-
|
|
27475
|
-
|
|
27476
|
-
|
|
27477
|
-
|
|
27478
|
-
|
|
27479
|
-
|
|
27480
|
-
|
|
27481
|
-
|
|
27482
|
-
|
|
27483
|
-
return generateViewportFix(context);
|
|
27484
|
-
// Open Graph issues
|
|
27485
|
-
case "MISSING_OG_TAGS":
|
|
27486
|
-
case "OG_TITLE_MISSING":
|
|
27487
|
-
case "OG_DESC_MISSING":
|
|
27488
|
-
case "OG_DESCRIPTION_MISSING":
|
|
27489
|
-
// Full audit code
|
|
27490
|
-
case "OG_IMAGE_MISSING":
|
|
27491
|
-
case "OG_URL_MISSING":
|
|
27492
|
-
case "OG_TYPE_MISSING":
|
|
27493
|
-
return generateOGFix(context, siteName, fullUrl);
|
|
27494
|
-
// Twitter Card issues
|
|
27495
|
-
case "MISSING_TWITTER_CARD":
|
|
27496
|
-
case "TWITTER_CARD_MISSING":
|
|
27497
|
-
case "TWITTER_TITLE_MISSING":
|
|
27498
|
-
case "TWITTER_DESC_MISSING":
|
|
27499
|
-
case "TWITTER_DESCRIPTION_MISSING":
|
|
27500
|
-
// Full audit code
|
|
27501
|
-
case "TWITTER_IMAGE_MISSING":
|
|
27502
|
-
return generateTwitterFix(context, siteName);
|
|
27503
|
-
// Schema/structured data issues
|
|
27504
|
-
case "MISSING_SCHEMA":
|
|
27505
|
-
case "SCHEMA_MISSING":
|
|
27506
|
-
// Full audit code
|
|
27507
|
-
case "SCHEMA_ORG_MISSING":
|
|
27508
|
-
case "NO_ORGANIZATION_SCHEMA":
|
|
27509
|
-
case "NO_ENTITY_SCHEMA":
|
|
27510
|
-
case "FAQ_SCHEMA_MISSING":
|
|
27511
|
-
return generateSchemaFix(context, siteName, fullUrl);
|
|
27512
|
-
// Robots.txt issues
|
|
27513
|
-
case "MISSING_ROBOTS":
|
|
27514
|
-
case "ROBOTS_TXT_MISSING":
|
|
27515
|
-
// Full audit code
|
|
27516
|
-
case "ROBOTS_TXT_WARNINGS":
|
|
27517
|
-
case "ROBOTS_TXT_INVALID_SYNTAX":
|
|
27518
|
-
return generateRobotsFix(context, fullUrl);
|
|
27519
|
-
// Sitemap issues
|
|
27520
|
-
case "MISSING_SITEMAP":
|
|
27521
|
-
case "SITEMAP_MISSING":
|
|
27522
|
-
// Full audit code
|
|
27523
|
-
case "BING_SITEMAP_MISSING":
|
|
27524
|
-
return generateSitemapFix(context, fullUrl);
|
|
27525
|
-
// H1 issues
|
|
27526
|
-
case "MISSING_H1":
|
|
27527
|
-
case "H1_MISSING":
|
|
27528
|
-
// Full audit code
|
|
27529
|
-
case "NO_VISIBLE_HEADLINE":
|
|
27530
|
-
case "H1_MISSING_KEYWORD":
|
|
27531
|
-
return await generateH1Fix({ cwd });
|
|
27532
|
-
// SPA-specific: add meta management library recommendation
|
|
27533
|
-
case "SPA_NO_META_MANAGEMENT":
|
|
27534
|
-
case "SPA_WITHOUT_SSR":
|
|
27535
|
-
case "CLIENT_SIDE_RENDERING":
|
|
27536
|
-
return generateSPAMetaFix(context, framework);
|
|
27537
|
-
// Internal links - suggest adding links
|
|
27538
|
-
case "NO_INTERNAL_LINKS":
|
|
27539
|
-
case "LINKS_NO_INTERNAL":
|
|
27540
|
-
case "NO_CONTENT_INTERNAL_LINKS":
|
|
27541
|
-
return {
|
|
27542
|
-
issue: { code: issue.code, message: "No internal links found", severity: "warning" },
|
|
27543
|
-
file: "src/components/Footer.tsx",
|
|
27544
|
-
before: null,
|
|
27545
|
-
after: `// Add internal navigation links to your footer or content
|
|
27546
|
-
// Example: <Link to="/about">About</Link>`,
|
|
27547
|
-
explanation: "Add internal links to help search engines discover your content",
|
|
27548
|
-
skipped: true,
|
|
27549
|
-
skipReason: "Requires manual content updates - add links to your navigation and content"
|
|
27550
|
-
};
|
|
27551
|
-
// Preconnect issues
|
|
27552
|
-
case "GOOGLE_FONTS_NO_PRECONNECT":
|
|
27553
|
-
case "MISSING_PRECONNECT":
|
|
27554
|
-
return generatePreconnectFix(context);
|
|
27555
|
-
// Favicon/icons
|
|
27556
|
-
case "HTML_NO_FAVICON":
|
|
27557
|
-
case "HTML_NO_APPLE_TOUCH_ICON":
|
|
27558
|
-
case "FAVICON_MISSING":
|
|
27559
|
-
return generateFaviconFix(context);
|
|
27560
|
-
// Charset/lang
|
|
27561
|
-
case "HTML_NO_CHARSET":
|
|
27562
|
-
case "HTML_NOT_UTF8":
|
|
27563
|
-
return generateCharsetFix(context);
|
|
27564
|
-
case "HTML_NO_LANG":
|
|
27565
|
-
case "LANG_ATTR_MISSING":
|
|
27566
|
-
return generateLangFix(context);
|
|
27567
|
-
// AI/LLMs.txt
|
|
27568
|
-
case "AI_NO_LLMS_TXT":
|
|
27569
|
-
return generateLlmsTxtFix(context, siteName, fullUrl);
|
|
27570
|
-
default:
|
|
27571
|
-
return null;
|
|
27572
|
-
}
|
|
27573
|
-
}
|
|
27574
|
-
function generateTitleFix(context, siteName) {
|
|
27575
|
-
const { htmlPath, htmlContent, framework } = context;
|
|
27576
|
-
if (framework.metaPattern === "html-head") {
|
|
27577
|
-
const titleTag = `<title>${siteName} - Your Product Tagline</title>`;
|
|
27578
|
-
if (htmlContent.includes("<title>")) {
|
|
27579
|
-
const before = htmlContent.match(/<title>.*?<\/title>/)?.[0] || "";
|
|
27580
|
-
return {
|
|
27581
|
-
issue: { code: "MISSING_TITLE", message: "Missing or empty title tag", severity: "critical" },
|
|
27582
|
-
file: htmlPath,
|
|
27583
|
-
before,
|
|
27584
|
-
after: titleTag,
|
|
27585
|
-
explanation: "Updated the title tag with a descriptive title"
|
|
27586
|
-
};
|
|
27587
|
-
} else {
|
|
27588
|
-
return {
|
|
27589
|
-
issue: { code: "MISSING_TITLE", message: "Missing title tag", severity: "critical" },
|
|
27590
|
-
file: htmlPath,
|
|
27591
|
-
before: "<head>",
|
|
27592
|
-
after: `<head>
|
|
27593
|
-
${titleTag}`,
|
|
27594
|
-
explanation: "Added a title tag to the document head"
|
|
27595
|
-
};
|
|
27575
|
+
end
|
|
27576
|
+
|
|
27577
|
+
def organization_schema(name: SITE_NAME, url: SITE_URL, logo: nil, social: [])
|
|
27578
|
+
{
|
|
27579
|
+
'@context': 'https://schema.org',
|
|
27580
|
+
'@type': 'Organization',
|
|
27581
|
+
name: name,
|
|
27582
|
+
url: url,
|
|
27583
|
+
logo: logo,
|
|
27584
|
+
sameAs: social
|
|
27585
|
+
}.compact
|
|
27586
|
+
end
|
|
27587
|
+
|
|
27588
|
+
def article_schema(article)
|
|
27589
|
+
{
|
|
27590
|
+
'@context': 'https://schema.org',
|
|
27591
|
+
'@type': 'Article',
|
|
27592
|
+
headline: article.title,
|
|
27593
|
+
description: article.excerpt || truncate(strip_tags(article.content), length: 160),
|
|
27594
|
+
image: article.image_url || DEFAULT_IMAGE,
|
|
27595
|
+
datePublished: article.published_at&.iso8601,
|
|
27596
|
+
dateModified: article.updated_at&.iso8601,
|
|
27597
|
+
author: {
|
|
27598
|
+
'@type': 'Person',
|
|
27599
|
+
name: article.author_name
|
|
27600
|
+
},
|
|
27601
|
+
publisher: {
|
|
27602
|
+
'@type': 'Organization',
|
|
27603
|
+
name: SITE_NAME,
|
|
27604
|
+
logo: { '@type': 'ImageObject', url: "#{SITE_URL}/logo.png" }
|
|
27605
|
+
}
|
|
27596
27606
|
}
|
|
27597
|
-
|
|
27598
|
-
|
|
27599
|
-
|
|
27600
|
-
|
|
27601
|
-
|
|
27602
|
-
|
|
27603
|
-
|
|
27604
|
-
|
|
27605
|
-
|
|
27606
|
-
|
|
27607
|
-
|
|
27608
|
-
|
|
27609
|
-
|
|
27610
|
-
|
|
27611
|
-
|
|
27612
|
-
|
|
27613
|
-
|
|
27614
|
-
|
|
27615
|
-
|
|
27616
|
-
|
|
27617
|
-
|
|
27618
|
-
|
|
27619
|
-
|
|
27620
|
-
|
|
27621
|
-
|
|
27622
|
-
|
|
27623
|
-
|
|
27624
|
-
|
|
27625
|
-
|
|
27626
|
-
|
|
27627
|
-
|
|
27628
|
-
|
|
27607
|
+
end
|
|
27608
|
+
|
|
27609
|
+
def product_schema(product)
|
|
27610
|
+
{
|
|
27611
|
+
'@context': 'https://schema.org',
|
|
27612
|
+
'@type': 'Product',
|
|
27613
|
+
name: product.name,
|
|
27614
|
+
description: product.description,
|
|
27615
|
+
image: product.image_url,
|
|
27616
|
+
brand: product.brand.present? ? { '@type': 'Brand', name: product.brand } : nil,
|
|
27617
|
+
sku: product.sku,
|
|
27618
|
+
offers: {
|
|
27619
|
+
'@type': 'Offer',
|
|
27620
|
+
price: product.price.to_s,
|
|
27621
|
+
priceCurrency: product.currency || 'USD',
|
|
27622
|
+
availability: "https://schema.org/#{product.in_stock? ? 'InStock' : 'OutOfStock'}"
|
|
27623
|
+
},
|
|
27624
|
+
aggregateRating: product.reviews_count.positive? ? {
|
|
27625
|
+
'@type': 'AggregateRating',
|
|
27626
|
+
ratingValue: product.average_rating,
|
|
27627
|
+
reviewCount: product.reviews_count
|
|
27628
|
+
} : nil
|
|
27629
|
+
}.compact
|
|
27630
|
+
end
|
|
27631
|
+
|
|
27632
|
+
def faq_schema(items)
|
|
27633
|
+
{
|
|
27634
|
+
'@context': 'https://schema.org',
|
|
27635
|
+
'@type': 'FAQPage',
|
|
27636
|
+
mainEntity: items.map do |item|
|
|
27637
|
+
{
|
|
27638
|
+
'@type': 'Question',
|
|
27639
|
+
name: item[:question],
|
|
27640
|
+
acceptedAnswer: { '@type': 'Answer', text: item[:answer] }
|
|
27641
|
+
}
|
|
27642
|
+
end
|
|
27629
27643
|
}
|
|
27630
|
-
|
|
27631
|
-
|
|
27632
|
-
|
|
27633
|
-
|
|
27634
|
-
|
|
27644
|
+
end
|
|
27645
|
+
|
|
27646
|
+
def breadcrumb_schema(items)
|
|
27647
|
+
{
|
|
27648
|
+
'@context': 'https://schema.org',
|
|
27649
|
+
'@type': 'BreadcrumbList',
|
|
27650
|
+
itemListElement: items.each_with_index.map do |item, index|
|
|
27651
|
+
{
|
|
27652
|
+
'@type': 'ListItem',
|
|
27653
|
+
position: index + 1,
|
|
27654
|
+
name: item[:name],
|
|
27655
|
+
item: item[:url]
|
|
27656
|
+
}
|
|
27657
|
+
end
|
|
27658
|
+
}
|
|
27659
|
+
end
|
|
27660
|
+
|
|
27661
|
+
def local_business_schema(business)
|
|
27662
|
+
{
|
|
27663
|
+
'@context': 'https://schema.org',
|
|
27664
|
+
'@type': 'LocalBusiness',
|
|
27665
|
+
name: business[:name],
|
|
27666
|
+
description: business[:description],
|
|
27667
|
+
url: business[:url],
|
|
27668
|
+
telephone: business[:phone],
|
|
27669
|
+
address: {
|
|
27670
|
+
'@type': 'PostalAddress',
|
|
27671
|
+
streetAddress: business.dig(:address, :street),
|
|
27672
|
+
addressLocality: business.dig(:address, :city),
|
|
27673
|
+
addressRegion: business.dig(:address, :state),
|
|
27674
|
+
postalCode: business.dig(:address, :zip),
|
|
27675
|
+
addressCountry: business.dig(:address, :country)
|
|
27676
|
+
},
|
|
27677
|
+
geo: business[:coordinates].present? ? {
|
|
27678
|
+
'@type': 'GeoCoordinates',
|
|
27679
|
+
latitude: business.dig(:coordinates, :lat),
|
|
27680
|
+
longitude: business.dig(:coordinates, :lng)
|
|
27681
|
+
} : nil,
|
|
27682
|
+
openingHours: business[:hours],
|
|
27683
|
+
priceRange: business[:price_range]
|
|
27684
|
+
}.compact
|
|
27685
|
+
end
|
|
27686
|
+
end`,
|
|
27687
|
+
explanation: `Ruby on Rails comprehensive SEO helper with:
|
|
27688
|
+
\u2022 meta-tags gem integration
|
|
27689
|
+
\u2022 Full Open Graph with article support
|
|
27690
|
+
\u2022 Twitter Cards
|
|
27691
|
+
\u2022 JSON-LD schema generators for all common types
|
|
27692
|
+
\u2022 Canonical URLs and robots directives
|
|
27693
|
+
|
|
27694
|
+
Setup:
|
|
27695
|
+
1. Add to Gemfile: gem 'meta-tags'
|
|
27696
|
+
2. Include helper in ApplicationHelper
|
|
27697
|
+
3. Call set_page_seo in controllers
|
|
27698
|
+
4. Add <%= display_meta_tags %> and <%= json_ld_tags %> to layout`,
|
|
27699
|
+
installCommands: ["bundle add meta-tags"],
|
|
27700
|
+
additionalFiles: [
|
|
27701
|
+
{
|
|
27702
|
+
file: "app/views/layouts/application.html.erb",
|
|
27703
|
+
code: `<!DOCTYPE html>
|
|
27704
|
+
<html lang="<%= I18n.locale %>">
|
|
27705
|
+
<head>
|
|
27706
|
+
<meta charset="utf-8">
|
|
27707
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
27708
|
+
|
|
27709
|
+
<%= display_meta_tags %>
|
|
27710
|
+
<%= csrf_meta_tags %>
|
|
27711
|
+
<%= csp_meta_tag %>
|
|
27712
|
+
|
|
27713
|
+
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
|
27714
|
+
<%= javascript_importmap_tags %>
|
|
27715
|
+
|
|
27716
|
+
<!-- Favicon -->
|
|
27717
|
+
<%= favicon_link_tag 'favicon.ico' %>
|
|
27718
|
+
<%= favicon_link_tag 'apple-touch-icon.png', rel: 'apple-touch-icon', type: 'image/png', sizes: '180x180' %>
|
|
27719
|
+
|
|
27720
|
+
<!-- JSON-LD Structured Data -->
|
|
27721
|
+
<%= json_ld_tags %>
|
|
27722
|
+
</head>
|
|
27723
|
+
<body>
|
|
27724
|
+
<%= yield %>
|
|
27725
|
+
</body>
|
|
27726
|
+
</html>`,
|
|
27727
|
+
explanation: "Application layout with SEO meta tags and JSON-LD."
|
|
27728
|
+
},
|
|
27729
|
+
{
|
|
27730
|
+
file: "config/initializers/meta_tags.rb",
|
|
27731
|
+
code: `# frozen_string_literal: true
|
|
27732
|
+
|
|
27733
|
+
MetaTags.configure do |config|
|
|
27734
|
+
# Use title template
|
|
27735
|
+
config.title_limit = 70
|
|
27736
|
+
config.description_limit = 160
|
|
27737
|
+
config.keywords_limit = 255
|
|
27738
|
+
|
|
27739
|
+
# Truncate long strings
|
|
27740
|
+
config.truncate_on_bytesize = true
|
|
27741
|
+
|
|
27742
|
+
# Separate site name with |
|
|
27743
|
+
config.title_separator = ' | '
|
|
27744
|
+
end`,
|
|
27745
|
+
explanation: "meta-tags gem configuration."
|
|
27746
|
+
},
|
|
27747
|
+
{
|
|
27748
|
+
file: "public/robots.txt",
|
|
27749
|
+
code: `User-agent: *
|
|
27750
|
+
Allow: /
|
|
27751
|
+
Disallow: /admin/
|
|
27752
|
+
Disallow: /api/
|
|
27753
|
+
|
|
27754
|
+
User-agent: GPTBot
|
|
27755
|
+
Allow: /
|
|
27756
|
+
|
|
27757
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
27758
|
+
explanation: "Robots.txt with AI crawler support."
|
|
27759
|
+
}
|
|
27760
|
+
]
|
|
27761
|
+
};
|
|
27762
|
+
}
|
|
27763
|
+
function generateDjangoSEOHelper(options) {
|
|
27764
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
27765
|
+
return {
|
|
27766
|
+
file: "core/seo.py",
|
|
27767
|
+
code: `"""
|
|
27768
|
+
Comprehensive SEO utilities for Django
|
|
27769
|
+
|
|
27770
|
+
Features:
|
|
27771
|
+
- Full Open Graph support
|
|
27772
|
+
- Twitter Cards
|
|
27773
|
+
- JSON-LD structured data
|
|
27774
|
+
- Canonical URLs
|
|
27775
|
+
- Template tags and context processors
|
|
27776
|
+
|
|
27777
|
+
Installation: pip install django-meta
|
|
27778
|
+
"""
|
|
27779
|
+
|
|
27780
|
+
from django.conf import settings
|
|
27781
|
+
from django.utils.safestring import mark_safe
|
|
27782
|
+
import json
|
|
27783
|
+
from typing import Any, Optional
|
|
27784
|
+
from dataclasses import dataclass, field
|
|
27785
|
+
|
|
27786
|
+
|
|
27787
|
+
# Configuration
|
|
27788
|
+
SITE_NAME = '${siteName}'
|
|
27789
|
+
SITE_URL = '${siteUrl}'
|
|
27790
|
+
DEFAULT_IMAGE = '${image || `${siteUrl}/og-image.png`}'
|
|
27791
|
+
DEFAULT_DESCRIPTION = '${description || `${siteName} - A compelling description.`}'
|
|
27792
|
+
TWITTER_HANDLE = '${twitterHandle || ""}'
|
|
27793
|
+
|
|
27794
|
+
|
|
27795
|
+
@dataclass
|
|
27796
|
+
class SEOMeta:
|
|
27797
|
+
"""SEO metadata container"""
|
|
27798
|
+
title: Optional[str] = None
|
|
27799
|
+
description: str = DEFAULT_DESCRIPTION
|
|
27800
|
+
image: str = DEFAULT_IMAGE
|
|
27801
|
+
url: Optional[str] = None
|
|
27802
|
+
type: str = 'website'
|
|
27803
|
+
|
|
27804
|
+
# Article specific
|
|
27805
|
+
published_time: Optional[str] = None
|
|
27806
|
+
modified_time: Optional[str] = None
|
|
27807
|
+
author: Optional[str] = None
|
|
27808
|
+
tags: list = field(default_factory=list)
|
|
27809
|
+
|
|
27810
|
+
# Robots
|
|
27811
|
+
noindex: bool = False
|
|
27812
|
+
nofollow: bool = False
|
|
27813
|
+
|
|
27814
|
+
# Structured data
|
|
27815
|
+
schema: Optional[dict] = None
|
|
27816
|
+
|
|
27817
|
+
def get_full_title(self) -> str:
|
|
27818
|
+
if not self.title:
|
|
27819
|
+
return SITE_NAME
|
|
27820
|
+
if SITE_NAME in self.title:
|
|
27821
|
+
return self.title
|
|
27822
|
+
return f"{self.title} | {SITE_NAME}"
|
|
27823
|
+
|
|
27824
|
+
def get_image_url(self) -> str:
|
|
27825
|
+
if self.image.startswith('http'):
|
|
27826
|
+
return self.image
|
|
27827
|
+
return f"{SITE_URL}{self.image}"
|
|
27828
|
+
|
|
27829
|
+
def get_robots(self) -> str:
|
|
27830
|
+
index = 'noindex' if self.noindex else 'index'
|
|
27831
|
+
follow = 'nofollow' if self.nofollow else 'follow'
|
|
27832
|
+
return f"{index}, {follow}"
|
|
27833
|
+
|
|
27834
|
+
|
|
27835
|
+
class SEOContextMixin:
|
|
27836
|
+
"""
|
|
27837
|
+
Mixin for class-based views to add SEO context
|
|
27838
|
+
|
|
27839
|
+
Usage:
|
|
27840
|
+
class ArticleDetailView(SEOContextMixin, DetailView):
|
|
27841
|
+
model = Article
|
|
27842
|
+
|
|
27843
|
+
def get_seo_meta(self):
|
|
27844
|
+
return SEOMeta(
|
|
27845
|
+
title=self.object.title,
|
|
27846
|
+
description=self.object.excerpt,
|
|
27847
|
+
image=self.object.image.url if self.object.image else None,
|
|
27848
|
+
type='article',
|
|
27849
|
+
published_time=self.object.published_at.isoformat(),
|
|
27850
|
+
schema=article_schema(self.object)
|
|
27851
|
+
)
|
|
27852
|
+
"""
|
|
27853
|
+
|
|
27854
|
+
def get_seo_meta(self) -> SEOMeta:
|
|
27855
|
+
return SEOMeta()
|
|
27856
|
+
|
|
27857
|
+
def get_context_data(self, **kwargs):
|
|
27858
|
+
context = super().get_context_data(**kwargs)
|
|
27859
|
+
context['seo'] = self.get_seo_meta()
|
|
27860
|
+
return context
|
|
27861
|
+
|
|
27862
|
+
|
|
27863
|
+
def seo_context_processor(request):
|
|
27864
|
+
"""
|
|
27865
|
+
Context processor to add default SEO values
|
|
27866
|
+
|
|
27867
|
+
Add to settings.TEMPLATES[0]['OPTIONS']['context_processors']
|
|
27868
|
+
"""
|
|
27869
|
+
return {
|
|
27870
|
+
'site_name': SITE_NAME,
|
|
27871
|
+
'site_url': SITE_URL,
|
|
27872
|
+
'default_og_image': DEFAULT_IMAGE,
|
|
27873
|
+
}
|
|
27874
|
+
|
|
27875
|
+
|
|
27876
|
+
# JSON-LD Schema Generators
|
|
27877
|
+
|
|
27878
|
+
def website_schema() -> dict:
|
|
27879
|
+
return {
|
|
27880
|
+
'@context': 'https://schema.org',
|
|
27881
|
+
'@type': 'WebSite',
|
|
27882
|
+
'name': SITE_NAME,
|
|
27883
|
+
'url': SITE_URL,
|
|
27884
|
+
}
|
|
27885
|
+
|
|
27886
|
+
|
|
27887
|
+
def organization_schema(
|
|
27888
|
+
name: str = SITE_NAME,
|
|
27889
|
+
url: str = SITE_URL,
|
|
27890
|
+
logo: Optional[str] = None,
|
|
27891
|
+
social_profiles: list = None
|
|
27892
|
+
) -> dict:
|
|
27893
|
+
schema = {
|
|
27894
|
+
'@context': 'https://schema.org',
|
|
27895
|
+
'@type': 'Organization',
|
|
27896
|
+
'name': name,
|
|
27897
|
+
'url': url,
|
|
27898
|
+
}
|
|
27899
|
+
if logo:
|
|
27900
|
+
schema['logo'] = logo
|
|
27901
|
+
if social_profiles:
|
|
27902
|
+
schema['sameAs'] = social_profiles
|
|
27903
|
+
return schema
|
|
27904
|
+
|
|
27905
|
+
|
|
27906
|
+
def article_schema(article) -> dict:
|
|
27907
|
+
"""Generate Article schema from model instance"""
|
|
27908
|
+
return {
|
|
27909
|
+
'@context': 'https://schema.org',
|
|
27910
|
+
'@type': 'Article',
|
|
27911
|
+
'headline': article.title,
|
|
27912
|
+
'description': getattr(article, 'excerpt', '') or article.content[:160],
|
|
27913
|
+
'image': article.image.url if hasattr(article, 'image') and article.image else DEFAULT_IMAGE,
|
|
27914
|
+
'datePublished': article.published_at.isoformat() if hasattr(article, 'published_at') else None,
|
|
27915
|
+
'dateModified': article.updated_at.isoformat() if hasattr(article, 'updated_at') else None,
|
|
27916
|
+
'author': {
|
|
27917
|
+
'@type': 'Person',
|
|
27918
|
+
'name': str(article.author) if hasattr(article, 'author') else 'Unknown',
|
|
27919
|
+
},
|
|
27920
|
+
'publisher': {
|
|
27921
|
+
'@type': 'Organization',
|
|
27922
|
+
'name': SITE_NAME,
|
|
27923
|
+
'logo': {'@type': 'ImageObject', 'url': f'{SITE_URL}/static/logo.png'},
|
|
27924
|
+
},
|
|
27925
|
+
}
|
|
27926
|
+
|
|
27927
|
+
|
|
27928
|
+
def product_schema(product) -> dict:
|
|
27929
|
+
"""Generate Product schema from model instance"""
|
|
27930
|
+
schema = {
|
|
27931
|
+
'@context': 'https://schema.org',
|
|
27932
|
+
'@type': 'Product',
|
|
27933
|
+
'name': product.name,
|
|
27934
|
+
'description': product.description,
|
|
27935
|
+
'image': product.image.url if hasattr(product, 'image') and product.image else DEFAULT_IMAGE,
|
|
27936
|
+
'offers': {
|
|
27937
|
+
'@type': 'Offer',
|
|
27938
|
+
'price': str(product.price),
|
|
27939
|
+
'priceCurrency': getattr(product, 'currency', 'USD'),
|
|
27940
|
+
'availability': f"https://schema.org/{'InStock' if product.in_stock else 'OutOfStock'}",
|
|
27941
|
+
},
|
|
27942
|
+
}
|
|
27943
|
+
|
|
27944
|
+
if hasattr(product, 'brand') and product.brand:
|
|
27945
|
+
schema['brand'] = {'@type': 'Brand', 'name': product.brand}
|
|
27946
|
+
|
|
27947
|
+
if hasattr(product, 'sku') and product.sku:
|
|
27948
|
+
schema['sku'] = product.sku
|
|
27949
|
+
|
|
27950
|
+
if hasattr(product, 'average_rating') and hasattr(product, 'review_count'):
|
|
27951
|
+
if product.review_count > 0:
|
|
27952
|
+
schema['aggregateRating'] = {
|
|
27953
|
+
'@type': 'AggregateRating',
|
|
27954
|
+
'ratingValue': product.average_rating,
|
|
27955
|
+
'reviewCount': product.review_count,
|
|
27956
|
+
}
|
|
27957
|
+
|
|
27958
|
+
return schema
|
|
27959
|
+
|
|
27960
|
+
|
|
27961
|
+
def faq_schema(items: list[dict]) -> dict:
|
|
27962
|
+
"""Generate FAQ schema from list of {question, answer} dicts"""
|
|
27963
|
+
return {
|
|
27964
|
+
'@context': 'https://schema.org',
|
|
27965
|
+
'@type': 'FAQPage',
|
|
27966
|
+
'mainEntity': [
|
|
27967
|
+
{
|
|
27968
|
+
'@type': 'Question',
|
|
27969
|
+
'name': item['question'],
|
|
27970
|
+
'acceptedAnswer': {'@type': 'Answer', 'text': item['answer']},
|
|
27971
|
+
}
|
|
27972
|
+
for item in items
|
|
27973
|
+
],
|
|
27974
|
+
}
|
|
27975
|
+
|
|
27976
|
+
|
|
27977
|
+
def breadcrumb_schema(items: list[dict]) -> dict:
|
|
27978
|
+
"""Generate Breadcrumb schema from list of {name, url} dicts"""
|
|
27979
|
+
return {
|
|
27980
|
+
'@context': 'https://schema.org',
|
|
27981
|
+
'@type': 'BreadcrumbList',
|
|
27982
|
+
'itemListElement': [
|
|
27983
|
+
{
|
|
27984
|
+
'@type': 'ListItem',
|
|
27985
|
+
'position': i + 1,
|
|
27986
|
+
'name': item['name'],
|
|
27987
|
+
'item': item['url'],
|
|
27988
|
+
}
|
|
27989
|
+
for i, item in enumerate(items)
|
|
27990
|
+
],
|
|
27991
|
+
}
|
|
27992
|
+
|
|
27993
|
+
|
|
27994
|
+
def local_business_schema(business: dict) -> dict:
|
|
27995
|
+
"""Generate LocalBusiness schema"""
|
|
27996
|
+
schema = {
|
|
27997
|
+
'@context': 'https://schema.org',
|
|
27998
|
+
'@type': 'LocalBusiness',
|
|
27999
|
+
'name': business['name'],
|
|
28000
|
+
'description': business.get('description', ''),
|
|
28001
|
+
'url': business.get('url', SITE_URL),
|
|
28002
|
+
'telephone': business.get('phone'),
|
|
28003
|
+
'address': {
|
|
28004
|
+
'@type': 'PostalAddress',
|
|
28005
|
+
'streetAddress': business.get('address', {}).get('street'),
|
|
28006
|
+
'addressLocality': business.get('address', {}).get('city'),
|
|
28007
|
+
'addressRegion': business.get('address', {}).get('state'),
|
|
28008
|
+
'postalCode': business.get('address', {}).get('zip'),
|
|
28009
|
+
'addressCountry': business.get('address', {}).get('country'),
|
|
28010
|
+
},
|
|
28011
|
+
}
|
|
28012
|
+
|
|
28013
|
+
if business.get('coordinates'):
|
|
28014
|
+
schema['geo'] = {
|
|
28015
|
+
'@type': 'GeoCoordinates',
|
|
28016
|
+
'latitude': business['coordinates']['lat'],
|
|
28017
|
+
'longitude': business['coordinates']['lng'],
|
|
28018
|
+
}
|
|
28019
|
+
|
|
28020
|
+
if business.get('hours'):
|
|
28021
|
+
schema['openingHours'] = business['hours']
|
|
28022
|
+
|
|
28023
|
+
if business.get('price_range'):
|
|
28024
|
+
schema['priceRange'] = business['price_range']
|
|
28025
|
+
|
|
28026
|
+
return schema
|
|
28027
|
+
|
|
28028
|
+
|
|
28029
|
+
def render_json_ld(*schemas) -> str:
|
|
28030
|
+
"""Render JSON-LD script tags for templates"""
|
|
28031
|
+
all_schemas = [website_schema()] + list(schemas)
|
|
28032
|
+
scripts = '\\n'.join(
|
|
28033
|
+
f'<script type="application/ld+json">{json.dumps(s)}</script>'
|
|
28034
|
+
for s in all_schemas if s
|
|
28035
|
+
)
|
|
28036
|
+
return mark_safe(scripts)`,
|
|
28037
|
+
explanation: `Django comprehensive SEO module with:
|
|
28038
|
+
\u2022 SEOMeta dataclass for structured metadata
|
|
28039
|
+
\u2022 SEOContextMixin for class-based views
|
|
28040
|
+
\u2022 Context processor for global SEO values
|
|
28041
|
+
\u2022 JSON-LD schema generators
|
|
28042
|
+
\u2022 Template-ready helpers
|
|
28043
|
+
|
|
28044
|
+
Setup:
|
|
28045
|
+
1. pip install django-meta
|
|
28046
|
+
2. Add context processor to settings
|
|
28047
|
+
3. Use SEOContextMixin in views
|
|
28048
|
+
4. Include seo template tags in base.html`,
|
|
28049
|
+
installCommands: ["pip install django-meta"],
|
|
28050
|
+
additionalFiles: [
|
|
28051
|
+
{
|
|
28052
|
+
file: "templates/base.html",
|
|
28053
|
+
code: `<!DOCTYPE html>
|
|
28054
|
+
<html lang="{{ LANGUAGE_CODE }}">
|
|
28055
|
+
<head>
|
|
28056
|
+
<meta charset="utf-8">
|
|
28057
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
28058
|
+
|
|
28059
|
+
{% if seo %}
|
|
28060
|
+
<!-- Primary Meta Tags -->
|
|
28061
|
+
<title>{{ seo.get_full_title }}</title>
|
|
28062
|
+
<meta name="title" content="{{ seo.get_full_title }}">
|
|
28063
|
+
<meta name="description" content="{{ seo.description }}">
|
|
28064
|
+
<meta name="robots" content="{{ seo.get_robots }}">
|
|
28065
|
+
<link rel="canonical" href="{{ seo.url|default:request.build_absolute_uri }}">
|
|
28066
|
+
|
|
28067
|
+
<!-- Open Graph / Facebook -->
|
|
28068
|
+
<meta property="og:type" content="{{ seo.type }}">
|
|
28069
|
+
<meta property="og:url" content="{{ seo.url|default:request.build_absolute_uri }}">
|
|
28070
|
+
<meta property="og:title" content="{{ seo.get_full_title }}">
|
|
28071
|
+
<meta property="og:description" content="{{ seo.description }}">
|
|
28072
|
+
<meta property="og:image" content="{{ seo.get_image_url }}">
|
|
28073
|
+
<meta property="og:image:width" content="1200">
|
|
28074
|
+
<meta property="og:image:height" content="630">
|
|
28075
|
+
<meta property="og:site_name" content="{{ site_name }}">
|
|
28076
|
+
|
|
28077
|
+
{% if seo.type == 'article' %}
|
|
28078
|
+
{% if seo.published_time %}<meta property="article:published_time" content="{{ seo.published_time }}">{% endif %}
|
|
28079
|
+
{% if seo.modified_time %}<meta property="article:modified_time" content="{{ seo.modified_time }}">{% endif %}
|
|
28080
|
+
{% if seo.author %}<meta property="article:author" content="{{ seo.author }}">{% endif %}
|
|
28081
|
+
{% for tag in seo.tags %}<meta property="article:tag" content="{{ tag }}">{% endfor %}
|
|
28082
|
+
{% endif %}
|
|
28083
|
+
|
|
28084
|
+
<!-- Twitter -->
|
|
28085
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
28086
|
+
<meta name="twitter:title" content="{{ seo.get_full_title }}">
|
|
28087
|
+
<meta name="twitter:description" content="{{ seo.description }}">
|
|
28088
|
+
<meta name="twitter:image" content="{{ seo.get_image_url }}">
|
|
28089
|
+
{% else %}
|
|
28090
|
+
<title>{{ site_name }}</title>
|
|
28091
|
+
{% endif %}
|
|
28092
|
+
|
|
28093
|
+
{% load static %}
|
|
28094
|
+
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
|
|
28095
|
+
|
|
28096
|
+
<!-- JSON-LD Structured Data -->
|
|
28097
|
+
{% block json_ld %}{% endblock %}
|
|
28098
|
+
</head>
|
|
28099
|
+
<body>
|
|
28100
|
+
{% block content %}{% endblock %}
|
|
28101
|
+
</body>
|
|
28102
|
+
</html>`,
|
|
28103
|
+
explanation: "Django base template with comprehensive SEO meta tags."
|
|
28104
|
+
},
|
|
28105
|
+
{
|
|
28106
|
+
file: "robots.txt",
|
|
28107
|
+
code: `User-agent: *
|
|
28108
|
+
Allow: /
|
|
28109
|
+
Disallow: /admin/
|
|
28110
|
+
Disallow: /api/
|
|
28111
|
+
|
|
28112
|
+
User-agent: GPTBot
|
|
28113
|
+
Allow: /
|
|
28114
|
+
|
|
28115
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
28116
|
+
explanation: "Robots.txt with AI crawler support."
|
|
28117
|
+
}
|
|
28118
|
+
]
|
|
28119
|
+
};
|
|
28120
|
+
}
|
|
28121
|
+
function generateLaravelSEOHelper(options) {
|
|
28122
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
28123
|
+
return {
|
|
28124
|
+
file: "app/Services/SEOService.php",
|
|
28125
|
+
code: `<?php
|
|
28126
|
+
|
|
28127
|
+
namespace App\\Services;
|
|
28128
|
+
|
|
28129
|
+
use Illuminate\\Support\\Facades\\View;
|
|
28130
|
+
use Illuminate\\Support\\HtmlString;
|
|
28131
|
+
|
|
28132
|
+
/**
|
|
28133
|
+
* Comprehensive SEO Service for Laravel
|
|
28134
|
+
*
|
|
28135
|
+
* Features:
|
|
28136
|
+
* - Full Open Graph support
|
|
28137
|
+
* - Twitter Cards
|
|
28138
|
+
* - JSON-LD structured data
|
|
28139
|
+
* - Canonical URLs
|
|
28140
|
+
* - Blade integration
|
|
28141
|
+
*/
|
|
28142
|
+
class SEOService
|
|
28143
|
+
{
|
|
28144
|
+
public const SITE_NAME = '${siteName}';
|
|
28145
|
+
public const SITE_URL = '${siteUrl}';
|
|
28146
|
+
public const DEFAULT_IMAGE = '${image || `${siteUrl}/og-image.png`}';
|
|
28147
|
+
public const DEFAULT_DESCRIPTION = '${description || `${siteName} - A compelling description.`}';
|
|
28148
|
+
public const TWITTER_HANDLE = '${twitterHandle || ""}';
|
|
28149
|
+
|
|
28150
|
+
protected array $meta = [];
|
|
28151
|
+
protected array $schemas = [];
|
|
28152
|
+
|
|
28153
|
+
/**
|
|
28154
|
+
* Set page SEO metadata
|
|
28155
|
+
*/
|
|
28156
|
+
public function setMeta(array $options): self
|
|
28157
|
+
{
|
|
28158
|
+
$this->meta = array_merge([
|
|
28159
|
+
'title' => null,
|
|
28160
|
+
'description' => self::DEFAULT_DESCRIPTION,
|
|
28161
|
+
'image' => self::DEFAULT_IMAGE,
|
|
28162
|
+
'url' => request()->url(),
|
|
28163
|
+
'type' => 'website',
|
|
28164
|
+
'published_time' => null,
|
|
28165
|
+
'modified_time' => null,
|
|
28166
|
+
'author' => null,
|
|
28167
|
+
'tags' => [],
|
|
28168
|
+
'noindex' => false,
|
|
28169
|
+
'nofollow' => false,
|
|
28170
|
+
], $options);
|
|
28171
|
+
|
|
28172
|
+
return $this;
|
|
28173
|
+
}
|
|
28174
|
+
|
|
28175
|
+
/**
|
|
28176
|
+
* Add JSON-LD schema
|
|
28177
|
+
*/
|
|
28178
|
+
public function addSchema(array $schema): self
|
|
28179
|
+
{
|
|
28180
|
+
$this->schemas[] = $schema;
|
|
28181
|
+
return $this;
|
|
28182
|
+
}
|
|
28183
|
+
|
|
28184
|
+
/**
|
|
28185
|
+
* Get full page title
|
|
28186
|
+
*/
|
|
28187
|
+
public function getFullTitle(): string
|
|
28188
|
+
{
|
|
28189
|
+
$title = $this->meta['title'] ?? null;
|
|
28190
|
+
|
|
28191
|
+
if (!$title) {
|
|
28192
|
+
return self::SITE_NAME;
|
|
28193
|
+
}
|
|
28194
|
+
|
|
28195
|
+
if (str_contains($title, self::SITE_NAME)) {
|
|
28196
|
+
return $title;
|
|
28197
|
+
}
|
|
28198
|
+
|
|
28199
|
+
return "{$title} | " . self::SITE_NAME;
|
|
28200
|
+
}
|
|
28201
|
+
|
|
28202
|
+
/**
|
|
28203
|
+
* Get absolute image URL
|
|
28204
|
+
*/
|
|
28205
|
+
public function getImageUrl(): string
|
|
28206
|
+
{
|
|
28207
|
+
$image = $this->meta['image'] ?? self::DEFAULT_IMAGE;
|
|
28208
|
+
|
|
28209
|
+
if (str_starts_with($image, 'http')) {
|
|
28210
|
+
return $image;
|
|
28211
|
+
}
|
|
28212
|
+
|
|
28213
|
+
return self::SITE_URL . $image;
|
|
28214
|
+
}
|
|
28215
|
+
|
|
28216
|
+
/**
|
|
28217
|
+
* Get robots directive
|
|
28218
|
+
*/
|
|
28219
|
+
public function getRobots(): string
|
|
28220
|
+
{
|
|
28221
|
+
$index = ($this->meta['noindex'] ?? false) ? 'noindex' : 'index';
|
|
28222
|
+
$follow = ($this->meta['nofollow'] ?? false) ? 'nofollow' : 'follow';
|
|
28223
|
+
return "{$index}, {$follow}";
|
|
28224
|
+
}
|
|
28225
|
+
|
|
28226
|
+
/**
|
|
28227
|
+
* Render all meta tags
|
|
28228
|
+
*/
|
|
28229
|
+
public function render(): HtmlString
|
|
28230
|
+
{
|
|
28231
|
+
$html = view('components.seo-meta', [
|
|
28232
|
+
'seo' => $this,
|
|
28233
|
+
'meta' => $this->meta,
|
|
28234
|
+
])->render();
|
|
28235
|
+
|
|
28236
|
+
return new HtmlString($html);
|
|
28237
|
+
}
|
|
28238
|
+
|
|
28239
|
+
/**
|
|
28240
|
+
* Render JSON-LD scripts
|
|
28241
|
+
*/
|
|
28242
|
+
public function renderJsonLd(): HtmlString
|
|
28243
|
+
{
|
|
28244
|
+
$allSchemas = array_merge(
|
|
28245
|
+
[$this->websiteSchema()],
|
|
28246
|
+
$this->schemas
|
|
28247
|
+
);
|
|
28248
|
+
|
|
28249
|
+
$scripts = collect($allSchemas)
|
|
28250
|
+
->filter()
|
|
28251
|
+
->map(fn($schema) => '<script type="application/ld+json">' . json_encode($schema) . '</script>')
|
|
28252
|
+
->implode("\\n");
|
|
28253
|
+
|
|
28254
|
+
return new HtmlString($scripts);
|
|
28255
|
+
}
|
|
28256
|
+
|
|
28257
|
+
// Schema Generators
|
|
28258
|
+
|
|
28259
|
+
public function websiteSchema(): array
|
|
28260
|
+
{
|
|
28261
|
+
return [
|
|
28262
|
+
'@context' => 'https://schema.org',
|
|
28263
|
+
'@type' => 'WebSite',
|
|
28264
|
+
'name' => self::SITE_NAME,
|
|
28265
|
+
'url' => self::SITE_URL,
|
|
28266
|
+
];
|
|
28267
|
+
}
|
|
28268
|
+
|
|
28269
|
+
public function organizationSchema(
|
|
28270
|
+
?string $name = null,
|
|
28271
|
+
?string $url = null,
|
|
28272
|
+
?string $logo = null,
|
|
28273
|
+
array $socialProfiles = []
|
|
28274
|
+
): array {
|
|
28275
|
+
return array_filter([
|
|
28276
|
+
'@context' => 'https://schema.org',
|
|
28277
|
+
'@type' => 'Organization',
|
|
28278
|
+
'name' => $name ?? self::SITE_NAME,
|
|
28279
|
+
'url' => $url ?? self::SITE_URL,
|
|
28280
|
+
'logo' => $logo,
|
|
28281
|
+
'sameAs' => $socialProfiles ?: null,
|
|
28282
|
+
]);
|
|
28283
|
+
}
|
|
28284
|
+
|
|
28285
|
+
public function articleSchema($article): array
|
|
28286
|
+
{
|
|
28287
|
+
return [
|
|
28288
|
+
'@context' => 'https://schema.org',
|
|
28289
|
+
'@type' => 'Article',
|
|
28290
|
+
'headline' => $article->title,
|
|
28291
|
+
'description' => $article->excerpt ?? substr(strip_tags($article->content), 0, 160),
|
|
28292
|
+
'image' => $article->image_url ?? self::DEFAULT_IMAGE,
|
|
28293
|
+
'datePublished' => optional($article->published_at)->toIso8601String(),
|
|
28294
|
+
'dateModified' => optional($article->updated_at)->toIso8601String(),
|
|
28295
|
+
'author' => [
|
|
28296
|
+
'@type' => 'Person',
|
|
28297
|
+
'name' => $article->author?->name ?? 'Unknown',
|
|
28298
|
+
],
|
|
28299
|
+
'publisher' => [
|
|
28300
|
+
'@type' => 'Organization',
|
|
28301
|
+
'name' => self::SITE_NAME,
|
|
28302
|
+
'logo' => [
|
|
28303
|
+
'@type' => 'ImageObject',
|
|
28304
|
+
'url' => self::SITE_URL . '/logo.png',
|
|
28305
|
+
],
|
|
28306
|
+
],
|
|
28307
|
+
];
|
|
28308
|
+
}
|
|
28309
|
+
|
|
28310
|
+
public function productSchema($product): array
|
|
28311
|
+
{
|
|
28312
|
+
$schema = [
|
|
28313
|
+
'@context' => 'https://schema.org',
|
|
28314
|
+
'@type' => 'Product',
|
|
28315
|
+
'name' => $product->name,
|
|
28316
|
+
'description' => $product->description,
|
|
28317
|
+
'image' => $product->image_url ?? self::DEFAULT_IMAGE,
|
|
28318
|
+
'offers' => [
|
|
28319
|
+
'@type' => 'Offer',
|
|
28320
|
+
'price' => (string) $product->price,
|
|
28321
|
+
'priceCurrency' => $product->currency ?? 'USD',
|
|
28322
|
+
'availability' => 'https://schema.org/' . ($product->in_stock ? 'InStock' : 'OutOfStock'),
|
|
28323
|
+
],
|
|
28324
|
+
];
|
|
28325
|
+
|
|
28326
|
+
if ($product->brand) {
|
|
28327
|
+
$schema['brand'] = ['@type' => 'Brand', 'name' => $product->brand];
|
|
28328
|
+
}
|
|
28329
|
+
|
|
28330
|
+
if ($product->sku) {
|
|
28331
|
+
$schema['sku'] = $product->sku;
|
|
28332
|
+
}
|
|
28333
|
+
|
|
28334
|
+
if ($product->reviews_count > 0) {
|
|
28335
|
+
$schema['aggregateRating'] = [
|
|
28336
|
+
'@type' => 'AggregateRating',
|
|
28337
|
+
'ratingValue' => $product->average_rating,
|
|
28338
|
+
'reviewCount' => $product->reviews_count,
|
|
28339
|
+
];
|
|
28340
|
+
}
|
|
28341
|
+
|
|
28342
|
+
return $schema;
|
|
28343
|
+
}
|
|
28344
|
+
|
|
28345
|
+
public function faqSchema(array $items): array
|
|
28346
|
+
{
|
|
28347
|
+
return [
|
|
28348
|
+
'@context' => 'https://schema.org',
|
|
28349
|
+
'@type' => 'FAQPage',
|
|
28350
|
+
'mainEntity' => collect($items)->map(fn($item) => [
|
|
28351
|
+
'@type' => 'Question',
|
|
28352
|
+
'name' => $item['question'],
|
|
28353
|
+
'acceptedAnswer' => ['@type' => 'Answer', 'text' => $item['answer']],
|
|
28354
|
+
])->all(),
|
|
28355
|
+
];
|
|
28356
|
+
}
|
|
28357
|
+
|
|
28358
|
+
public function breadcrumbSchema(array $items): array
|
|
28359
|
+
{
|
|
28360
|
+
return [
|
|
28361
|
+
'@context' => 'https://schema.org',
|
|
28362
|
+
'@type' => 'BreadcrumbList',
|
|
28363
|
+
'itemListElement' => collect($items)->map(fn($item, $index) => [
|
|
28364
|
+
'@type' => 'ListItem',
|
|
28365
|
+
'position' => $index + 1,
|
|
28366
|
+
'name' => $item['name'],
|
|
28367
|
+
'item' => $item['url'],
|
|
28368
|
+
])->all(),
|
|
28369
|
+
];
|
|
28370
|
+
}
|
|
28371
|
+
|
|
28372
|
+
public function localBusinessSchema(array $business): array
|
|
28373
|
+
{
|
|
28374
|
+
return array_filter([
|
|
28375
|
+
'@context' => 'https://schema.org',
|
|
28376
|
+
'@type' => 'LocalBusiness',
|
|
28377
|
+
'name' => $business['name'],
|
|
28378
|
+
'description' => $business['description'] ?? null,
|
|
28379
|
+
'url' => $business['url'] ?? self::SITE_URL,
|
|
28380
|
+
'telephone' => $business['phone'] ?? null,
|
|
28381
|
+
'address' => [
|
|
28382
|
+
'@type' => 'PostalAddress',
|
|
28383
|
+
'streetAddress' => $business['address']['street'] ?? null,
|
|
28384
|
+
'addressLocality' => $business['address']['city'] ?? null,
|
|
28385
|
+
'addressRegion' => $business['address']['state'] ?? null,
|
|
28386
|
+
'postalCode' => $business['address']['zip'] ?? null,
|
|
28387
|
+
'addressCountry' => $business['address']['country'] ?? null,
|
|
28388
|
+
],
|
|
28389
|
+
'geo' => isset($business['coordinates']) ? [
|
|
28390
|
+
'@type' => 'GeoCoordinates',
|
|
28391
|
+
'latitude' => $business['coordinates']['lat'],
|
|
28392
|
+
'longitude' => $business['coordinates']['lng'],
|
|
28393
|
+
] : null,
|
|
28394
|
+
'openingHours' => $business['hours'] ?? null,
|
|
28395
|
+
'priceRange' => $business['price_range'] ?? null,
|
|
28396
|
+
]);
|
|
28397
|
+
}
|
|
28398
|
+
}`,
|
|
28399
|
+
explanation: `Laravel comprehensive SEO service with:
|
|
28400
|
+
\u2022 Fluent API for setting metadata
|
|
28401
|
+
\u2022 Full Open Graph with article support
|
|
28402
|
+
\u2022 Twitter Cards
|
|
28403
|
+
\u2022 JSON-LD schema generators
|
|
28404
|
+
\u2022 Blade component integration
|
|
28405
|
+
|
|
28406
|
+
Setup:
|
|
28407
|
+
1. Register SEOService in AppServiceProvider
|
|
28408
|
+
2. Use @seo directive in Blade templates
|
|
28409
|
+
3. Call SEO()->setMeta() in controllers`,
|
|
28410
|
+
additionalFiles: [
|
|
28411
|
+
{
|
|
28412
|
+
file: "resources/views/components/seo-meta.blade.php",
|
|
28413
|
+
code: `{{-- Primary Meta Tags --}}
|
|
28414
|
+
<title>{{ $seo->getFullTitle() }}</title>
|
|
28415
|
+
<meta name="title" content="{{ $seo->getFullTitle() }}">
|
|
28416
|
+
<meta name="description" content="{{ $meta['description'] }}">
|
|
28417
|
+
<meta name="robots" content="{{ $seo->getRobots() }}">
|
|
28418
|
+
<link rel="canonical" href="{{ $meta['url'] }}">
|
|
28419
|
+
|
|
28420
|
+
{{-- Open Graph / Facebook --}}
|
|
28421
|
+
<meta property="og:type" content="{{ $meta['type'] }}">
|
|
28422
|
+
<meta property="og:url" content="{{ $meta['url'] }}">
|
|
28423
|
+
<meta property="og:title" content="{{ $seo->getFullTitle() }}">
|
|
28424
|
+
<meta property="og:description" content="{{ $meta['description'] }}">
|
|
28425
|
+
<meta property="og:image" content="{{ $seo->getImageUrl() }}">
|
|
28426
|
+
<meta property="og:image:width" content="1200">
|
|
28427
|
+
<meta property="og:image:height" content="630">
|
|
28428
|
+
<meta property="og:site_name" content="{{ $seo::SITE_NAME }}">
|
|
28429
|
+
|
|
28430
|
+
@if($meta['type'] === 'article')
|
|
28431
|
+
@if($meta['published_time'])
|
|
28432
|
+
<meta property="article:published_time" content="{{ $meta['published_time'] }}">
|
|
28433
|
+
@endif
|
|
28434
|
+
@if($meta['modified_time'])
|
|
28435
|
+
<meta property="article:modified_time" content="{{ $meta['modified_time'] }}">
|
|
28436
|
+
@endif
|
|
28437
|
+
@if($meta['author'])
|
|
28438
|
+
<meta property="article:author" content="{{ $meta['author'] }}">
|
|
28439
|
+
@endif
|
|
28440
|
+
@foreach($meta['tags'] as $tag)
|
|
28441
|
+
<meta property="article:tag" content="{{ $tag }}">
|
|
28442
|
+
@endforeach
|
|
28443
|
+
@endif
|
|
28444
|
+
|
|
28445
|
+
{{-- Twitter --}}
|
|
28446
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
28447
|
+
<meta name="twitter:title" content="{{ $seo->getFullTitle() }}">
|
|
28448
|
+
<meta name="twitter:description" content="{{ $meta['description'] }}">
|
|
28449
|
+
<meta name="twitter:image" content="{{ $seo->getImageUrl() }}">
|
|
28450
|
+
@if($seo::TWITTER_HANDLE)
|
|
28451
|
+
<meta name="twitter:site" content="{{ $seo::TWITTER_HANDLE }}">
|
|
28452
|
+
<meta name="twitter:creator" content="{{ $seo::TWITTER_HANDLE }}">
|
|
28453
|
+
@endif`,
|
|
28454
|
+
explanation: "Blade component for rendering SEO meta tags."
|
|
28455
|
+
},
|
|
28456
|
+
{
|
|
28457
|
+
file: "resources/views/layouts/app.blade.php",
|
|
28458
|
+
code: `<!DOCTYPE html>
|
|
28459
|
+
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
|
28460
|
+
<head>
|
|
28461
|
+
<meta charset="utf-8">
|
|
28462
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
28463
|
+
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
28464
|
+
|
|
28465
|
+
{{-- SEO Meta Tags --}}
|
|
28466
|
+
{!! SEO()->render() !!}
|
|
28467
|
+
|
|
28468
|
+
{{-- Favicon --}}
|
|
28469
|
+
<link rel="icon" href="/favicon.ico">
|
|
28470
|
+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
28471
|
+
|
|
28472
|
+
{{-- Styles --}}
|
|
28473
|
+
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
|
28474
|
+
|
|
28475
|
+
{{-- JSON-LD --}}
|
|
28476
|
+
{!! SEO()->renderJsonLd() !!}
|
|
28477
|
+
</head>
|
|
28478
|
+
<body>
|
|
28479
|
+
@yield('content')
|
|
28480
|
+
</body>
|
|
28481
|
+
</html>`,
|
|
28482
|
+
explanation: "Laravel layout with SEO integration."
|
|
28483
|
+
},
|
|
28484
|
+
{
|
|
28485
|
+
file: "app/Providers/AppServiceProvider.php",
|
|
28486
|
+
code: `<?php
|
|
28487
|
+
|
|
28488
|
+
namespace App\\Providers;
|
|
28489
|
+
|
|
28490
|
+
use App\\Services\\SEOService;
|
|
28491
|
+
use Illuminate\\Support\\ServiceProvider;
|
|
28492
|
+
|
|
28493
|
+
class AppServiceProvider extends ServiceProvider
|
|
28494
|
+
{
|
|
28495
|
+
public function register(): void
|
|
28496
|
+
{
|
|
28497
|
+
$this->app->singleton(SEOService::class);
|
|
28498
|
+
}
|
|
28499
|
+
|
|
28500
|
+
public function boot(): void
|
|
28501
|
+
{
|
|
28502
|
+
// Global SEO helper
|
|
28503
|
+
if (!function_exists('SEO')) {
|
|
28504
|
+
function SEO(): SEOService {
|
|
28505
|
+
return app(SEOService::class);
|
|
28506
|
+
}
|
|
28507
|
+
}
|
|
28508
|
+
}
|
|
28509
|
+
}`,
|
|
28510
|
+
explanation: "Service provider registration."
|
|
28511
|
+
},
|
|
28512
|
+
{
|
|
28513
|
+
file: "public/robots.txt",
|
|
28514
|
+
code: `User-agent: *
|
|
28515
|
+
Allow: /
|
|
28516
|
+
Disallow: /admin/
|
|
28517
|
+
Disallow: /api/
|
|
28518
|
+
|
|
28519
|
+
User-agent: GPTBot
|
|
28520
|
+
Allow: /
|
|
28521
|
+
|
|
28522
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
28523
|
+
explanation: "Robots.txt with AI crawler support."
|
|
28524
|
+
}
|
|
28525
|
+
]
|
|
28526
|
+
};
|
|
28527
|
+
}
|
|
28528
|
+
function generateSpringBootSEO(options) {
|
|
28529
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
28530
|
+
return {
|
|
28531
|
+
file: "src/main/java/com/example/seo/SEOService.java",
|
|
28532
|
+
code: `package com.example.seo;
|
|
28533
|
+
|
|
28534
|
+
import org.springframework.stereotype.Service;
|
|
28535
|
+
import java.util.*;
|
|
28536
|
+
|
|
28537
|
+
/**
|
|
28538
|
+
* Comprehensive SEO Service for Spring Boot
|
|
28539
|
+
*
|
|
28540
|
+
* Features:
|
|
28541
|
+
* - Full Open Graph support
|
|
28542
|
+
* - Twitter Cards
|
|
28543
|
+
* - JSON-LD structured data
|
|
28544
|
+
* - Thymeleaf integration
|
|
28545
|
+
*/
|
|
28546
|
+
@Service
|
|
28547
|
+
public class SEOService {
|
|
28548
|
+
|
|
28549
|
+
public static final String SITE_NAME = "${siteName}";
|
|
28550
|
+
public static final String SITE_URL = "${siteUrl}";
|
|
28551
|
+
public static final String DEFAULT_IMAGE = "${image || `${siteUrl}/og-image.png`}";
|
|
28552
|
+
public static final String DEFAULT_DESCRIPTION = "${description || `${siteName} - A compelling description.`}";
|
|
28553
|
+
public static final String TWITTER_HANDLE = "${twitterHandle || ""}";
|
|
28554
|
+
|
|
28555
|
+
/**
|
|
28556
|
+
* Build SEO metadata for a page
|
|
28557
|
+
*/
|
|
28558
|
+
public SEOMeta buildMeta(String title, String description, String image, String type) {
|
|
28559
|
+
return SEOMeta.builder()
|
|
28560
|
+
.title(title)
|
|
28561
|
+
.description(description != null ? description : DEFAULT_DESCRIPTION)
|
|
28562
|
+
.image(image != null ? image : DEFAULT_IMAGE)
|
|
28563
|
+
.type(type != null ? type : "website")
|
|
28564
|
+
.build();
|
|
28565
|
+
}
|
|
28566
|
+
|
|
28567
|
+
/**
|
|
28568
|
+
* Get full page title with site name
|
|
28569
|
+
*/
|
|
28570
|
+
public String getFullTitle(String title) {
|
|
28571
|
+
if (title == null || title.isEmpty()) {
|
|
28572
|
+
return SITE_NAME;
|
|
28573
|
+
}
|
|
28574
|
+
if (title.contains(SITE_NAME)) {
|
|
28575
|
+
return title;
|
|
28576
|
+
}
|
|
28577
|
+
return title + " | " + SITE_NAME;
|
|
28578
|
+
}
|
|
28579
|
+
|
|
28580
|
+
/**
|
|
28581
|
+
* Get absolute image URL
|
|
28582
|
+
*/
|
|
28583
|
+
public String getAbsoluteImageUrl(String image) {
|
|
28584
|
+
if (image == null) {
|
|
28585
|
+
return DEFAULT_IMAGE;
|
|
28586
|
+
}
|
|
28587
|
+
if (image.startsWith("http")) {
|
|
28588
|
+
return image;
|
|
28589
|
+
}
|
|
28590
|
+
return SITE_URL + image;
|
|
28591
|
+
}
|
|
28592
|
+
|
|
28593
|
+
// JSON-LD Schema Generators
|
|
28594
|
+
|
|
28595
|
+
public Map<String, Object> websiteSchema() {
|
|
28596
|
+
Map<String, Object> schema = new LinkedHashMap<>();
|
|
28597
|
+
schema.put("@context", "https://schema.org");
|
|
28598
|
+
schema.put("@type", "WebSite");
|
|
28599
|
+
schema.put("name", SITE_NAME);
|
|
28600
|
+
schema.put("url", SITE_URL);
|
|
28601
|
+
return schema;
|
|
28602
|
+
}
|
|
28603
|
+
|
|
28604
|
+
public Map<String, Object> organizationSchema(String name, String url, String logo, List<String> socialProfiles) {
|
|
28605
|
+
Map<String, Object> schema = new LinkedHashMap<>();
|
|
28606
|
+
schema.put("@context", "https://schema.org");
|
|
28607
|
+
schema.put("@type", "Organization");
|
|
28608
|
+
schema.put("name", name != null ? name : SITE_NAME);
|
|
28609
|
+
schema.put("url", url != null ? url : SITE_URL);
|
|
28610
|
+
if (logo != null) schema.put("logo", logo);
|
|
28611
|
+
if (socialProfiles != null && !socialProfiles.isEmpty()) {
|
|
28612
|
+
schema.put("sameAs", socialProfiles);
|
|
28613
|
+
}
|
|
28614
|
+
return schema;
|
|
28615
|
+
}
|
|
28616
|
+
|
|
28617
|
+
public Map<String, Object> articleSchema(
|
|
28618
|
+
String headline,
|
|
28619
|
+
String description,
|
|
28620
|
+
String image,
|
|
28621
|
+
String datePublished,
|
|
28622
|
+
String dateModified,
|
|
28623
|
+
String authorName
|
|
28624
|
+
) {
|
|
28625
|
+
Map<String, Object> schema = new LinkedHashMap<>();
|
|
28626
|
+
schema.put("@context", "https://schema.org");
|
|
28627
|
+
schema.put("@type", "Article");
|
|
28628
|
+
schema.put("headline", headline);
|
|
28629
|
+
schema.put("description", description);
|
|
28630
|
+
schema.put("image", image != null ? image : DEFAULT_IMAGE);
|
|
28631
|
+
schema.put("datePublished", datePublished);
|
|
28632
|
+
schema.put("dateModified", dateModified != null ? dateModified : datePublished);
|
|
28633
|
+
|
|
28634
|
+
Map<String, Object> author = new LinkedHashMap<>();
|
|
28635
|
+
author.put("@type", "Person");
|
|
28636
|
+
author.put("name", authorName);
|
|
28637
|
+
schema.put("author", author);
|
|
28638
|
+
|
|
28639
|
+
Map<String, Object> publisher = new LinkedHashMap<>();
|
|
28640
|
+
publisher.put("@type", "Organization");
|
|
28641
|
+
publisher.put("name", SITE_NAME);
|
|
28642
|
+
Map<String, Object> logo = new LinkedHashMap<>();
|
|
28643
|
+
logo.put("@type", "ImageObject");
|
|
28644
|
+
logo.put("url", SITE_URL + "/logo.png");
|
|
28645
|
+
publisher.put("logo", logo);
|
|
28646
|
+
schema.put("publisher", publisher);
|
|
28647
|
+
|
|
28648
|
+
return schema;
|
|
28649
|
+
}
|
|
28650
|
+
|
|
28651
|
+
public Map<String, Object> productSchema(
|
|
28652
|
+
String name,
|
|
28653
|
+
String description,
|
|
28654
|
+
String image,
|
|
28655
|
+
String price,
|
|
28656
|
+
String currency,
|
|
28657
|
+
boolean inStock
|
|
28658
|
+
) {
|
|
28659
|
+
Map<String, Object> schema = new LinkedHashMap<>();
|
|
28660
|
+
schema.put("@context", "https://schema.org");
|
|
28661
|
+
schema.put("@type", "Product");
|
|
28662
|
+
schema.put("name", name);
|
|
28663
|
+
schema.put("description", description);
|
|
28664
|
+
schema.put("image", image);
|
|
28665
|
+
|
|
28666
|
+
Map<String, Object> offers = new LinkedHashMap<>();
|
|
28667
|
+
offers.put("@type", "Offer");
|
|
28668
|
+
offers.put("price", price);
|
|
28669
|
+
offers.put("priceCurrency", currency != null ? currency : "USD");
|
|
28670
|
+
offers.put("availability", "https://schema.org/" + (inStock ? "InStock" : "OutOfStock"));
|
|
28671
|
+
schema.put("offers", offers);
|
|
28672
|
+
|
|
28673
|
+
return schema;
|
|
28674
|
+
}
|
|
28675
|
+
|
|
28676
|
+
public Map<String, Object> faqSchema(List<Map<String, String>> items) {
|
|
28677
|
+
Map<String, Object> schema = new LinkedHashMap<>();
|
|
28678
|
+
schema.put("@context", "https://schema.org");
|
|
28679
|
+
schema.put("@type", "FAQPage");
|
|
28680
|
+
|
|
28681
|
+
List<Map<String, Object>> mainEntity = new ArrayList<>();
|
|
28682
|
+
for (Map<String, String> item : items) {
|
|
28683
|
+
Map<String, Object> question = new LinkedHashMap<>();
|
|
28684
|
+
question.put("@type", "Question");
|
|
28685
|
+
question.put("name", item.get("question"));
|
|
28686
|
+
|
|
28687
|
+
Map<String, Object> answer = new LinkedHashMap<>();
|
|
28688
|
+
answer.put("@type", "Answer");
|
|
28689
|
+
answer.put("text", item.get("answer"));
|
|
28690
|
+
question.put("acceptedAnswer", answer);
|
|
28691
|
+
|
|
28692
|
+
mainEntity.add(question);
|
|
28693
|
+
}
|
|
28694
|
+
schema.put("mainEntity", mainEntity);
|
|
28695
|
+
|
|
28696
|
+
return schema;
|
|
28697
|
+
}
|
|
28698
|
+
|
|
28699
|
+
public Map<String, Object> breadcrumbSchema(List<Map<String, String>> items) {
|
|
28700
|
+
Map<String, Object> schema = new LinkedHashMap<>();
|
|
28701
|
+
schema.put("@context", "https://schema.org");
|
|
28702
|
+
schema.put("@type", "BreadcrumbList");
|
|
28703
|
+
|
|
28704
|
+
List<Map<String, Object>> itemList = new ArrayList<>();
|
|
28705
|
+
for (int i = 0; i < items.size(); i++) {
|
|
28706
|
+
Map<String, String> item = items.get(i);
|
|
28707
|
+
Map<String, Object> listItem = new LinkedHashMap<>();
|
|
28708
|
+
listItem.put("@type", "ListItem");
|
|
28709
|
+
listItem.put("position", i + 1);
|
|
28710
|
+
listItem.put("name", item.get("name"));
|
|
28711
|
+
listItem.put("item", item.get("url"));
|
|
28712
|
+
itemList.add(listItem);
|
|
28713
|
+
}
|
|
28714
|
+
schema.put("itemListElement", itemList);
|
|
28715
|
+
|
|
28716
|
+
return schema;
|
|
28717
|
+
}
|
|
28718
|
+
}`,
|
|
28719
|
+
explanation: `Spring Boot comprehensive SEO service with:
|
|
28720
|
+
\u2022 Full Open Graph support
|
|
28721
|
+
\u2022 Twitter Cards
|
|
28722
|
+
\u2022 JSON-LD schema generators
|
|
28723
|
+
\u2022 Thymeleaf integration
|
|
28724
|
+
|
|
28725
|
+
Setup:
|
|
28726
|
+
1. Add SEOService to your controllers
|
|
28727
|
+
2. Pass SEOMeta to model
|
|
28728
|
+
3. Use Thymeleaf fragments in templates`,
|
|
28729
|
+
additionalFiles: [
|
|
28730
|
+
{
|
|
28731
|
+
file: "src/main/java/com/example/seo/SEOMeta.java",
|
|
28732
|
+
code: `package com.example.seo;
|
|
28733
|
+
|
|
28734
|
+
import lombok.Builder;
|
|
28735
|
+
import lombok.Data;
|
|
28736
|
+
import java.util.List;
|
|
28737
|
+
|
|
28738
|
+
@Data
|
|
28739
|
+
@Builder
|
|
28740
|
+
public class SEOMeta {
|
|
28741
|
+
private String title;
|
|
28742
|
+
private String description;
|
|
28743
|
+
private String image;
|
|
28744
|
+
private String url;
|
|
28745
|
+
private String type;
|
|
28746
|
+
|
|
28747
|
+
// Article specific
|
|
28748
|
+
private String publishedTime;
|
|
28749
|
+
private String modifiedTime;
|
|
28750
|
+
private String author;
|
|
28751
|
+
private List<String> tags;
|
|
28752
|
+
|
|
28753
|
+
// Robots
|
|
28754
|
+
@Builder.Default
|
|
28755
|
+
private boolean noindex = false;
|
|
28756
|
+
@Builder.Default
|
|
28757
|
+
private boolean nofollow = false;
|
|
28758
|
+
|
|
28759
|
+
public String getFullTitle() {
|
|
28760
|
+
if (title == null || title.isEmpty()) {
|
|
28761
|
+
return SEOService.SITE_NAME;
|
|
28762
|
+
}
|
|
28763
|
+
if (title.contains(SEOService.SITE_NAME)) {
|
|
28764
|
+
return title;
|
|
28765
|
+
}
|
|
28766
|
+
return title + " | " + SEOService.SITE_NAME;
|
|
28767
|
+
}
|
|
28768
|
+
|
|
28769
|
+
public String getImageUrl() {
|
|
28770
|
+
if (image == null) {
|
|
28771
|
+
return SEOService.DEFAULT_IMAGE;
|
|
28772
|
+
}
|
|
28773
|
+
if (image.startsWith("http")) {
|
|
28774
|
+
return image;
|
|
28775
|
+
}
|
|
28776
|
+
return SEOService.SITE_URL + image;
|
|
28777
|
+
}
|
|
28778
|
+
|
|
28779
|
+
public String getRobots() {
|
|
28780
|
+
String index = noindex ? "noindex" : "index";
|
|
28781
|
+
String follow = nofollow ? "nofollow" : "follow";
|
|
28782
|
+
return index + ", " + follow;
|
|
28783
|
+
}
|
|
28784
|
+
}`,
|
|
28785
|
+
explanation: "SEO metadata DTO with Lombok."
|
|
28786
|
+
},
|
|
28787
|
+
{
|
|
28788
|
+
file: "src/main/resources/templates/fragments/seo.html",
|
|
28789
|
+
code: `<!DOCTYPE html>
|
|
28790
|
+
<html xmlns:th="http://www.thymeleaf.org">
|
|
28791
|
+
<head th:fragment="meta(seo)">
|
|
28792
|
+
<!-- Primary Meta Tags -->
|
|
28793
|
+
<title th:text="\${seo.getFullTitle()}">Page Title</title>
|
|
28794
|
+
<meta name="title" th:content="\${seo.getFullTitle()}">
|
|
28795
|
+
<meta name="description" th:content="\${seo.description}">
|
|
28796
|
+
<meta name="robots" th:content="\${seo.getRobots()}">
|
|
28797
|
+
<link rel="canonical" th:href="\${seo.url}">
|
|
28798
|
+
|
|
28799
|
+
<!-- Open Graph / Facebook -->
|
|
28800
|
+
<meta property="og:type" th:content="\${seo.type}">
|
|
28801
|
+
<meta property="og:url" th:content="\${seo.url}">
|
|
28802
|
+
<meta property="og:title" th:content="\${seo.getFullTitle()}">
|
|
28803
|
+
<meta property="og:description" th:content="\${seo.description}">
|
|
28804
|
+
<meta property="og:image" th:content="\${seo.getImageUrl()}">
|
|
28805
|
+
<meta property="og:image:width" content="1200">
|
|
28806
|
+
<meta property="og:image:height" content="630">
|
|
28807
|
+
<meta property="og:site_name" th:content="@{${siteName}}">
|
|
28808
|
+
|
|
28809
|
+
<th:block th:if="\${seo.type == 'article'}">
|
|
28810
|
+
<meta th:if="\${seo.publishedTime}" property="article:published_time" th:content="\${seo.publishedTime}">
|
|
28811
|
+
<meta th:if="\${seo.modifiedTime}" property="article:modified_time" th:content="\${seo.modifiedTime}">
|
|
28812
|
+
<meta th:if="\${seo.author}" property="article:author" th:content="\${seo.author}">
|
|
28813
|
+
<meta th:each="tag : \${seo.tags}" property="article:tag" th:content="\${tag}">
|
|
28814
|
+
</th:block>
|
|
28815
|
+
|
|
28816
|
+
<!-- Twitter -->
|
|
28817
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
28818
|
+
<meta name="twitter:title" th:content="\${seo.getFullTitle()}">
|
|
28819
|
+
<meta name="twitter:description" th:content="\${seo.description}">
|
|
28820
|
+
<meta name="twitter:image" th:content="\${seo.getImageUrl()}">
|
|
28821
|
+
<meta th:if="${twitterHandle}" name="twitter:site" content="${twitterHandle}">
|
|
28822
|
+
</head>
|
|
28823
|
+
</html>`,
|
|
28824
|
+
explanation: "Thymeleaf fragment for SEO meta tags."
|
|
28825
|
+
},
|
|
28826
|
+
{
|
|
28827
|
+
file: "src/main/resources/templates/layout/base.html",
|
|
28828
|
+
code: `<!DOCTYPE html>
|
|
28829
|
+
<html xmlns:th="http://www.thymeleaf.org" th:lang="\${#locale.language}">
|
|
28830
|
+
<head>
|
|
28831
|
+
<meta charset="UTF-8">
|
|
28832
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
28833
|
+
|
|
28834
|
+
<!-- SEO Meta Tags -->
|
|
28835
|
+
<th:block th:replace="~{fragments/seo :: meta(seo=\${seo})}"></th:block>
|
|
28836
|
+
|
|
28837
|
+
<!-- Favicon -->
|
|
28838
|
+
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
28839
|
+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
28840
|
+
|
|
28841
|
+
<!-- Styles -->
|
|
28842
|
+
<link rel="stylesheet" th:href="@{/css/styles.css}">
|
|
28843
|
+
|
|
28844
|
+
<!-- JSON-LD -->
|
|
28845
|
+
<script th:if="\${jsonLd}" type="application/ld+json" th:utext="\${jsonLd}"></script>
|
|
28846
|
+
</head>
|
|
28847
|
+
<body>
|
|
28848
|
+
<div th:replace="~{:: content}"></div>
|
|
28849
|
+
</body>
|
|
28850
|
+
</html>`,
|
|
28851
|
+
explanation: "Base layout with SEO integration."
|
|
28852
|
+
},
|
|
28853
|
+
{
|
|
28854
|
+
file: "src/main/resources/static/robots.txt",
|
|
28855
|
+
code: `User-agent: *
|
|
28856
|
+
Allow: /
|
|
28857
|
+
Disallow: /admin/
|
|
28858
|
+
Disallow: /api/
|
|
28859
|
+
|
|
28860
|
+
User-agent: GPTBot
|
|
28861
|
+
Allow: /
|
|
28862
|
+
|
|
28863
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
28864
|
+
explanation: "Robots.txt with AI crawler support."
|
|
28865
|
+
}
|
|
28866
|
+
]
|
|
28867
|
+
};
|
|
28868
|
+
}
|
|
28869
|
+
function generatePhoenixSEO(options) {
|
|
28870
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
28871
|
+
return {
|
|
28872
|
+
file: "lib/my_app_web/components/seo.ex",
|
|
28873
|
+
code: `defmodule MyAppWeb.Components.SEO do
|
|
28874
|
+
@moduledoc """
|
|
28875
|
+
Comprehensive SEO components for Phoenix
|
|
28876
|
+
|
|
28877
|
+
Features:
|
|
28878
|
+
- Full Open Graph support
|
|
28879
|
+
- Twitter Cards
|
|
28880
|
+
- JSON-LD structured data
|
|
28881
|
+
- HEEx integration
|
|
28882
|
+
"""
|
|
28883
|
+
|
|
28884
|
+
use Phoenix.Component
|
|
28885
|
+
|
|
28886
|
+
@site_name "${siteName}"
|
|
28887
|
+
@site_url "${siteUrl}"
|
|
28888
|
+
@default_image "${image || `${siteUrl}/og-image.png`}"
|
|
28889
|
+
@default_description "${description || `${siteName} - A compelling description.`}"
|
|
28890
|
+
@twitter_handle "${twitterHandle || ""}"
|
|
28891
|
+
|
|
28892
|
+
def site_name, do: @site_name
|
|
28893
|
+
def site_url, do: @site_url
|
|
28894
|
+
|
|
28895
|
+
@doc """
|
|
28896
|
+
SEO meta tags component
|
|
28897
|
+
|
|
28898
|
+
## Examples
|
|
28899
|
+
|
|
28900
|
+
<.seo_meta
|
|
28901
|
+
title="Page Title"
|
|
28902
|
+
description="Page description"
|
|
28903
|
+
type="article"
|
|
28904
|
+
published_time={@article.published_at}
|
|
28905
|
+
/>
|
|
28906
|
+
"""
|
|
28907
|
+
attr :title, :string, default: nil
|
|
28908
|
+
attr :description, :string, default: @default_description
|
|
28909
|
+
attr :image, :string, default: @default_image
|
|
28910
|
+
attr :url, :string, default: nil
|
|
28911
|
+
attr :type, :string, default: "website"
|
|
28912
|
+
attr :published_time, :any, default: nil
|
|
28913
|
+
attr :modified_time, :any, default: nil
|
|
28914
|
+
attr :author, :string, default: nil
|
|
28915
|
+
attr :tags, :list, default: []
|
|
28916
|
+
attr :noindex, :boolean, default: false
|
|
28917
|
+
attr :nofollow, :boolean, default: false
|
|
28918
|
+
|
|
28919
|
+
def seo_meta(assigns) do
|
|
28920
|
+
assigns =
|
|
28921
|
+
assigns
|
|
28922
|
+
|> assign(:full_title, get_full_title(assigns.title))
|
|
28923
|
+
|> assign(:image_url, get_absolute_url(assigns.image))
|
|
28924
|
+
|> assign(:canonical_url, assigns.url || @site_url)
|
|
28925
|
+
|> assign(:robots, get_robots(assigns.noindex, assigns.nofollow))
|
|
28926
|
+
|
|
28927
|
+
~H"""
|
|
28928
|
+
<!-- Primary Meta Tags -->
|
|
28929
|
+
<title><%= @full_title %></title>
|
|
28930
|
+
<meta name="title" content={@full_title}>
|
|
28931
|
+
<meta name="description" content={@description}>
|
|
28932
|
+
<meta name="robots" content={@robots}>
|
|
28933
|
+
<link rel="canonical" href={@canonical_url}>
|
|
28934
|
+
|
|
28935
|
+
<!-- Open Graph / Facebook -->
|
|
28936
|
+
<meta property="og:type" content={@type}>
|
|
28937
|
+
<meta property="og:url" content={@canonical_url}>
|
|
28938
|
+
<meta property="og:title" content={@full_title}>
|
|
28939
|
+
<meta property="og:description" content={@description}>
|
|
28940
|
+
<meta property="og:image" content={@image_url}>
|
|
28941
|
+
<meta property="og:image:width" content="1200">
|
|
28942
|
+
<meta property="og:image:height" content="630">
|
|
28943
|
+
<meta property="og:site_name" content={@site_name}>
|
|
28944
|
+
|
|
28945
|
+
<%= if @type == "article" do %>
|
|
28946
|
+
<%= if @published_time do %>
|
|
28947
|
+
<meta property="article:published_time" content={format_datetime(@published_time)}>
|
|
28948
|
+
<% end %>
|
|
28949
|
+
<%= if @modified_time do %>
|
|
28950
|
+
<meta property="article:modified_time" content={format_datetime(@modified_time)}>
|
|
28951
|
+
<% end %>
|
|
28952
|
+
<%= if @author do %>
|
|
28953
|
+
<meta property="article:author" content={@author}>
|
|
28954
|
+
<% end %>
|
|
28955
|
+
<%= for tag <- @tags do %>
|
|
28956
|
+
<meta property="article:tag" content={tag}>
|
|
28957
|
+
<% end %>
|
|
28958
|
+
<% end %>
|
|
28959
|
+
|
|
28960
|
+
<!-- Twitter -->
|
|
28961
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
28962
|
+
<meta name="twitter:title" content={@full_title}>
|
|
28963
|
+
<meta name="twitter:description" content={@description}>
|
|
28964
|
+
<meta name="twitter:image" content={@image_url}>
|
|
28965
|
+
<%= if @twitter_handle != "" do %>
|
|
28966
|
+
<meta name="twitter:site" content={@twitter_handle}>
|
|
28967
|
+
<meta name="twitter:creator" content={@twitter_handle}>
|
|
28968
|
+
<% end %>
|
|
28969
|
+
"""
|
|
28970
|
+
end
|
|
28971
|
+
|
|
28972
|
+
@doc """
|
|
28973
|
+
JSON-LD structured data component
|
|
28974
|
+
"""
|
|
28975
|
+
attr :schemas, :list, required: true
|
|
28976
|
+
|
|
28977
|
+
def json_ld(assigns) do
|
|
28978
|
+
all_schemas = [website_schema() | assigns.schemas]
|
|
28979
|
+
|
|
28980
|
+
~H"""
|
|
28981
|
+
<%= for schema <- @all_schemas do %>
|
|
28982
|
+
<script type="application/ld+json">
|
|
28983
|
+
<%= raw(Jason.encode!(schema)) %>
|
|
28984
|
+
</script>
|
|
28985
|
+
<% end %>
|
|
28986
|
+
"""
|
|
28987
|
+
end
|
|
28988
|
+
|
|
28989
|
+
# Schema generators
|
|
28990
|
+
|
|
28991
|
+
def website_schema do
|
|
28992
|
+
%{
|
|
28993
|
+
"@context" => "https://schema.org",
|
|
28994
|
+
"@type" => "WebSite",
|
|
28995
|
+
"name" => @site_name,
|
|
28996
|
+
"url" => @site_url
|
|
28997
|
+
}
|
|
28998
|
+
end
|
|
28999
|
+
|
|
29000
|
+
def organization_schema(opts \\\\ []) do
|
|
29001
|
+
%{
|
|
29002
|
+
"@context" => "https://schema.org",
|
|
29003
|
+
"@type" => "Organization",
|
|
29004
|
+
"name" => opts[:name] || @site_name,
|
|
29005
|
+
"url" => opts[:url] || @site_url
|
|
29006
|
+
}
|
|
29007
|
+
|> maybe_put("logo", opts[:logo])
|
|
29008
|
+
|> maybe_put("sameAs", opts[:social_profiles])
|
|
29009
|
+
end
|
|
29010
|
+
|
|
29011
|
+
def article_schema(article) do
|
|
29012
|
+
%{
|
|
29013
|
+
"@context" => "https://schema.org",
|
|
29014
|
+
"@type" => "Article",
|
|
29015
|
+
"headline" => article.title,
|
|
29016
|
+
"description" => article.excerpt || String.slice(article.content, 0, 160),
|
|
29017
|
+
"image" => article.image_url || @default_image,
|
|
29018
|
+
"datePublished" => format_datetime(article.published_at),
|
|
29019
|
+
"dateModified" => format_datetime(article.updated_at || article.published_at),
|
|
29020
|
+
"author" => %{
|
|
29021
|
+
"@type" => "Person",
|
|
29022
|
+
"name" => article.author_name || "Unknown"
|
|
29023
|
+
},
|
|
29024
|
+
"publisher" => %{
|
|
29025
|
+
"@type" => "Organization",
|
|
29026
|
+
"name" => @site_name,
|
|
29027
|
+
"logo" => %{"@type" => "ImageObject", "url" => "#{@site_url}/logo.png"}
|
|
29028
|
+
}
|
|
29029
|
+
}
|
|
29030
|
+
end
|
|
29031
|
+
|
|
29032
|
+
def product_schema(product) do
|
|
29033
|
+
%{
|
|
29034
|
+
"@context" => "https://schema.org",
|
|
29035
|
+
"@type" => "Product",
|
|
29036
|
+
"name" => product.name,
|
|
29037
|
+
"description" => product.description,
|
|
29038
|
+
"image" => product.image_url || @default_image,
|
|
29039
|
+
"offers" => %{
|
|
29040
|
+
"@type" => "Offer",
|
|
29041
|
+
"price" => to_string(product.price),
|
|
29042
|
+
"priceCurrency" => product.currency || "USD",
|
|
29043
|
+
"availability" => "https://schema.org/#{if product.in_stock, do: "InStock", else: "OutOfStock"}"
|
|
29044
|
+
}
|
|
29045
|
+
}
|
|
29046
|
+
|> maybe_put("brand", product.brand && %{"@type" => "Brand", "name" => product.brand})
|
|
29047
|
+
|> maybe_put("sku", product.sku)
|
|
29048
|
+
end
|
|
29049
|
+
|
|
29050
|
+
def faq_schema(items) do
|
|
29051
|
+
%{
|
|
29052
|
+
"@context" => "https://schema.org",
|
|
29053
|
+
"@type" => "FAQPage",
|
|
29054
|
+
"mainEntity" =>
|
|
29055
|
+
Enum.map(items, fn item ->
|
|
29056
|
+
%{
|
|
29057
|
+
"@type" => "Question",
|
|
29058
|
+
"name" => item.question,
|
|
29059
|
+
"acceptedAnswer" => %{"@type" => "Answer", "text" => item.answer}
|
|
29060
|
+
}
|
|
29061
|
+
end)
|
|
29062
|
+
}
|
|
29063
|
+
end
|
|
29064
|
+
|
|
29065
|
+
def breadcrumb_schema(items) do
|
|
29066
|
+
%{
|
|
29067
|
+
"@context" => "https://schema.org",
|
|
29068
|
+
"@type" => "BreadcrumbList",
|
|
29069
|
+
"itemListElement" =>
|
|
29070
|
+
items
|
|
29071
|
+
|> Enum.with_index(1)
|
|
29072
|
+
|> Enum.map(fn {item, position} ->
|
|
29073
|
+
%{
|
|
29074
|
+
"@type" => "ListItem",
|
|
29075
|
+
"position" => position,
|
|
29076
|
+
"name" => item.name,
|
|
29077
|
+
"item" => item.url
|
|
29078
|
+
}
|
|
29079
|
+
end)
|
|
29080
|
+
}
|
|
29081
|
+
end
|
|
29082
|
+
|
|
29083
|
+
# Helpers
|
|
29084
|
+
|
|
29085
|
+
defp get_full_title(nil), do: @site_name
|
|
29086
|
+
defp get_full_title(title) when is_binary(title) do
|
|
29087
|
+
if String.contains?(title, @site_name), do: title, else: "#{title} | #{@site_name}"
|
|
29088
|
+
end
|
|
29089
|
+
|
|
29090
|
+
defp get_absolute_url(nil), do: @default_image
|
|
29091
|
+
defp get_absolute_url(url) when is_binary(url) do
|
|
29092
|
+
if String.starts_with?(url, "http"), do: url, else: @site_url <> url
|
|
29093
|
+
end
|
|
29094
|
+
|
|
29095
|
+
defp get_robots(noindex, nofollow) do
|
|
29096
|
+
index = if noindex, do: "noindex", else: "index"
|
|
29097
|
+
follow = if nofollow, do: "nofollow", else: "follow"
|
|
29098
|
+
"#{index}, #{follow}"
|
|
29099
|
+
end
|
|
29100
|
+
|
|
29101
|
+
defp format_datetime(nil), do: nil
|
|
29102
|
+
defp format_datetime(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
|
29103
|
+
defp format_datetime(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
|
29104
|
+
defp format_datetime(dt) when is_binary(dt), do: dt
|
|
29105
|
+
|
|
29106
|
+
defp maybe_put(map, _key, nil), do: map
|
|
29107
|
+
defp maybe_put(map, _key, []), do: map
|
|
29108
|
+
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
|
29109
|
+
end`,
|
|
29110
|
+
explanation: `Phoenix/Elixir comprehensive SEO module with:
|
|
29111
|
+
\u2022 HEEx function components
|
|
29112
|
+
\u2022 Full Open Graph with article support
|
|
29113
|
+
\u2022 Twitter Cards
|
|
29114
|
+
\u2022 JSON-LD schema generators
|
|
29115
|
+
\u2022 Elixir-idiomatic API
|
|
29116
|
+
|
|
29117
|
+
Setup:
|
|
29118
|
+
1. Import in your components module
|
|
29119
|
+
2. Use <.seo_meta> in layouts
|
|
29120
|
+
3. Add <.json_ld schemas={[@article_schema]} />`,
|
|
29121
|
+
additionalFiles: [
|
|
29122
|
+
{
|
|
29123
|
+
file: "lib/my_app_web/components/layouts/root.html.heex",
|
|
29124
|
+
code: `<!DOCTYPE html>
|
|
29125
|
+
<html lang={@locale || "en"}>
|
|
29126
|
+
<head>
|
|
29127
|
+
<meta charset="utf-8">
|
|
29128
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
29129
|
+
<meta name="csrf-token" content={get_csrf_token()}>
|
|
29130
|
+
|
|
29131
|
+
<.seo_meta
|
|
29132
|
+
title={assigns[:page_title]}
|
|
29133
|
+
description={assigns[:page_description]}
|
|
29134
|
+
image={assigns[:page_image]}
|
|
29135
|
+
type={assigns[:page_type] || "website"}
|
|
29136
|
+
/>
|
|
29137
|
+
|
|
29138
|
+
<link rel="icon" href="/favicon.ico">
|
|
29139
|
+
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"}>
|
|
29140
|
+
<script defer phx-track-static src={~p"/assets/app.js"}></script>
|
|
29141
|
+
|
|
29142
|
+
<%= if assigns[:json_ld_schemas] do %>
|
|
29143
|
+
<.json_ld schemas={@json_ld_schemas} />
|
|
29144
|
+
<% end %>
|
|
29145
|
+
</head>
|
|
29146
|
+
<body>
|
|
29147
|
+
<%= @inner_content %>
|
|
29148
|
+
</body>
|
|
29149
|
+
</html>`,
|
|
29150
|
+
explanation: "Phoenix root layout with SEO components."
|
|
29151
|
+
},
|
|
29152
|
+
{
|
|
29153
|
+
file: "priv/static/robots.txt",
|
|
29154
|
+
code: `User-agent: *
|
|
29155
|
+
Allow: /
|
|
29156
|
+
Disallow: /admin/
|
|
29157
|
+
Disallow: /api/
|
|
29158
|
+
|
|
29159
|
+
User-agent: GPTBot
|
|
29160
|
+
Allow: /
|
|
29161
|
+
|
|
29162
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
29163
|
+
explanation: "Robots.txt with AI crawler support."
|
|
29164
|
+
}
|
|
29165
|
+
]
|
|
29166
|
+
};
|
|
29167
|
+
}
|
|
29168
|
+
function generateGoSEO(options) {
|
|
29169
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
29170
|
+
return {
|
|
29171
|
+
file: "internal/seo/seo.go",
|
|
29172
|
+
code: `package seo
|
|
29173
|
+
|
|
29174
|
+
import (
|
|
29175
|
+
"encoding/json"
|
|
29176
|
+
"html/template"
|
|
29177
|
+
"strings"
|
|
29178
|
+
"time"
|
|
29179
|
+
)
|
|
29180
|
+
|
|
29181
|
+
// Configuration
|
|
29182
|
+
const (
|
|
29183
|
+
SiteName = "${siteName}"
|
|
29184
|
+
SiteURL = "${siteUrl}"
|
|
29185
|
+
DefaultImage = "${image || `${siteUrl}/og-image.png`}"
|
|
29186
|
+
DefaultDescription = "${description || `${siteName} - A compelling description.`}"
|
|
29187
|
+
TwitterHandle = "${twitterHandle || ""}"
|
|
29188
|
+
)
|
|
29189
|
+
|
|
29190
|
+
// Meta contains SEO metadata for a page
|
|
29191
|
+
type Meta struct {
|
|
29192
|
+
Title string
|
|
29193
|
+
Description string
|
|
29194
|
+
Image string
|
|
29195
|
+
URL string
|
|
29196
|
+
Type string // website, article, product
|
|
29197
|
+
PublishedTime *time.Time
|
|
29198
|
+
ModifiedTime *time.Time
|
|
29199
|
+
Author string
|
|
29200
|
+
Tags []string
|
|
29201
|
+
NoIndex bool
|
|
29202
|
+
NoFollow bool
|
|
29203
|
+
}
|
|
29204
|
+
|
|
29205
|
+
// NewMeta creates a Meta with defaults
|
|
29206
|
+
func NewMeta() *Meta {
|
|
29207
|
+
return &Meta{
|
|
29208
|
+
Description: DefaultDescription,
|
|
29209
|
+
Image: DefaultImage,
|
|
29210
|
+
Type: "website",
|
|
29211
|
+
}
|
|
29212
|
+
}
|
|
29213
|
+
|
|
29214
|
+
// FullTitle returns the title with site name
|
|
29215
|
+
func (m *Meta) FullTitle() string {
|
|
29216
|
+
if m.Title == "" {
|
|
29217
|
+
return SiteName
|
|
29218
|
+
}
|
|
29219
|
+
if strings.Contains(m.Title, SiteName) {
|
|
29220
|
+
return m.Title
|
|
29221
|
+
}
|
|
29222
|
+
return m.Title + " | " + SiteName
|
|
29223
|
+
}
|
|
29224
|
+
|
|
29225
|
+
// AbsoluteImageURL returns the absolute image URL
|
|
29226
|
+
func (m *Meta) AbsoluteImageURL() string {
|
|
29227
|
+
if m.Image == "" {
|
|
29228
|
+
return DefaultImage
|
|
29229
|
+
}
|
|
29230
|
+
if strings.HasPrefix(m.Image, "http") {
|
|
29231
|
+
return m.Image
|
|
29232
|
+
}
|
|
29233
|
+
return SiteURL + m.Image
|
|
29234
|
+
}
|
|
29235
|
+
|
|
29236
|
+
// Robots returns the robots meta content
|
|
29237
|
+
func (m *Meta) Robots() string {
|
|
29238
|
+
index := "index"
|
|
29239
|
+
if m.NoIndex {
|
|
29240
|
+
index = "noindex"
|
|
29241
|
+
}
|
|
29242
|
+
follow := "follow"
|
|
29243
|
+
if m.NoFollow {
|
|
29244
|
+
follow = "nofollow"
|
|
29245
|
+
}
|
|
29246
|
+
return index + ", " + follow
|
|
29247
|
+
}
|
|
29248
|
+
|
|
29249
|
+
// Schema types for JSON-LD
|
|
29250
|
+
|
|
29251
|
+
type Schema map[string]interface{}
|
|
29252
|
+
|
|
29253
|
+
// WebsiteSchema returns the default website schema
|
|
29254
|
+
func WebsiteSchema() Schema {
|
|
29255
|
+
return Schema{
|
|
29256
|
+
"@context": "https://schema.org",
|
|
29257
|
+
"@type": "WebSite",
|
|
29258
|
+
"name": SiteName,
|
|
29259
|
+
"url": SiteURL,
|
|
29260
|
+
}
|
|
29261
|
+
}
|
|
29262
|
+
|
|
29263
|
+
// OrganizationSchema creates an organization schema
|
|
29264
|
+
func OrganizationSchema(name, url, logo string, socialProfiles []string) Schema {
|
|
29265
|
+
schema := Schema{
|
|
29266
|
+
"@context": "https://schema.org",
|
|
29267
|
+
"@type": "Organization",
|
|
29268
|
+
"name": name,
|
|
29269
|
+
"url": url,
|
|
29270
|
+
}
|
|
29271
|
+
if logo != "" {
|
|
29272
|
+
schema["logo"] = logo
|
|
29273
|
+
}
|
|
29274
|
+
if len(socialProfiles) > 0 {
|
|
29275
|
+
schema["sameAs"] = socialProfiles
|
|
29276
|
+
}
|
|
29277
|
+
return schema
|
|
29278
|
+
}
|
|
29279
|
+
|
|
29280
|
+
// ArticleSchema creates an article schema
|
|
29281
|
+
func ArticleSchema(headline, description, image string, datePublished, dateModified time.Time, authorName string) Schema {
|
|
29282
|
+
return Schema{
|
|
29283
|
+
"@context": "https://schema.org",
|
|
29284
|
+
"@type": "Article",
|
|
29285
|
+
"headline": headline,
|
|
29286
|
+
"description": description,
|
|
29287
|
+
"image": image,
|
|
29288
|
+
"datePublished": datePublished.Format(time.RFC3339),
|
|
29289
|
+
"dateModified": dateModified.Format(time.RFC3339),
|
|
29290
|
+
"author": Schema{
|
|
29291
|
+
"@type": "Person",
|
|
29292
|
+
"name": authorName,
|
|
29293
|
+
},
|
|
29294
|
+
"publisher": Schema{
|
|
29295
|
+
"@type": "Organization",
|
|
29296
|
+
"name": SiteName,
|
|
29297
|
+
"logo": Schema{
|
|
29298
|
+
"@type": "ImageObject",
|
|
29299
|
+
"url": SiteURL + "/logo.png",
|
|
29300
|
+
},
|
|
29301
|
+
},
|
|
29302
|
+
}
|
|
29303
|
+
}
|
|
29304
|
+
|
|
29305
|
+
// ProductSchema creates a product schema
|
|
29306
|
+
func ProductSchema(name, description, image, price, currency string, inStock bool) Schema {
|
|
29307
|
+
availability := "https://schema.org/InStock"
|
|
29308
|
+
if !inStock {
|
|
29309
|
+
availability = "https://schema.org/OutOfStock"
|
|
29310
|
+
}
|
|
29311
|
+
|
|
29312
|
+
return Schema{
|
|
29313
|
+
"@context": "https://schema.org",
|
|
29314
|
+
"@type": "Product",
|
|
29315
|
+
"name": name,
|
|
29316
|
+
"description": description,
|
|
29317
|
+
"image": image,
|
|
29318
|
+
"offers": Schema{
|
|
29319
|
+
"@type": "Offer",
|
|
29320
|
+
"price": price,
|
|
29321
|
+
"priceCurrency": currency,
|
|
29322
|
+
"availability": availability,
|
|
29323
|
+
},
|
|
29324
|
+
}
|
|
29325
|
+
}
|
|
29326
|
+
|
|
29327
|
+
// FAQItem represents a single FAQ entry
|
|
29328
|
+
type FAQItem struct {
|
|
29329
|
+
Question string
|
|
29330
|
+
Answer string
|
|
29331
|
+
}
|
|
29332
|
+
|
|
29333
|
+
// FAQSchema creates a FAQ schema
|
|
29334
|
+
func FAQSchema(items []FAQItem) Schema {
|
|
29335
|
+
mainEntity := make([]Schema, len(items))
|
|
29336
|
+
for i, item := range items {
|
|
29337
|
+
mainEntity[i] = Schema{
|
|
29338
|
+
"@type": "Question",
|
|
29339
|
+
"name": item.Question,
|
|
29340
|
+
"acceptedAnswer": Schema{
|
|
29341
|
+
"@type": "Answer",
|
|
29342
|
+
"text": item.Answer,
|
|
29343
|
+
},
|
|
29344
|
+
}
|
|
29345
|
+
}
|
|
29346
|
+
return Schema{
|
|
29347
|
+
"@context": "https://schema.org",
|
|
29348
|
+
"@type": "FAQPage",
|
|
29349
|
+
"mainEntity": mainEntity,
|
|
29350
|
+
}
|
|
29351
|
+
}
|
|
29352
|
+
|
|
29353
|
+
// BreadcrumbItem represents a breadcrumb entry
|
|
29354
|
+
type BreadcrumbItem struct {
|
|
29355
|
+
Name string
|
|
29356
|
+
URL string
|
|
29357
|
+
}
|
|
29358
|
+
|
|
29359
|
+
// BreadcrumbSchema creates a breadcrumb schema
|
|
29360
|
+
func BreadcrumbSchema(items []BreadcrumbItem) Schema {
|
|
29361
|
+
itemList := make([]Schema, len(items))
|
|
29362
|
+
for i, item := range items {
|
|
29363
|
+
itemList[i] = Schema{
|
|
29364
|
+
"@type": "ListItem",
|
|
29365
|
+
"position": i + 1,
|
|
29366
|
+
"name": item.Name,
|
|
29367
|
+
"item": item.URL,
|
|
29368
|
+
}
|
|
29369
|
+
}
|
|
29370
|
+
return Schema{
|
|
29371
|
+
"@context": "https://schema.org",
|
|
29372
|
+
"@type": "BreadcrumbList",
|
|
29373
|
+
"itemListElement": itemList,
|
|
29374
|
+
}
|
|
29375
|
+
}
|
|
29376
|
+
|
|
29377
|
+
// ToJSON converts a schema to JSON string
|
|
29378
|
+
func (s Schema) ToJSON() template.JS {
|
|
29379
|
+
b, err := json.Marshal(s)
|
|
29380
|
+
if err != nil {
|
|
29381
|
+
return ""
|
|
29382
|
+
}
|
|
29383
|
+
return template.JS(b)
|
|
29384
|
+
}
|
|
29385
|
+
|
|
29386
|
+
// RenderJSONLD renders multiple schemas as script tags
|
|
29387
|
+
func RenderJSONLD(schemas ...Schema) template.HTML {
|
|
29388
|
+
all := append([]Schema{WebsiteSchema()}, schemas...)
|
|
29389
|
+
var sb strings.Builder
|
|
29390
|
+
for _, schema := range all {
|
|
29391
|
+
b, err := json.Marshal(schema)
|
|
29392
|
+
if err != nil {
|
|
29393
|
+
continue
|
|
29394
|
+
}
|
|
29395
|
+
sb.WriteString(\`<script type="application/ld+json">\`)
|
|
29396
|
+
sb.Write(b)
|
|
29397
|
+
sb.WriteString(\`</script>\\n\`)
|
|
29398
|
+
}
|
|
29399
|
+
return template.HTML(sb.String())
|
|
29400
|
+
}`,
|
|
29401
|
+
explanation: `Go comprehensive SEO package with:
|
|
29402
|
+
\u2022 Meta struct for page metadata
|
|
29403
|
+
\u2022 JSON-LD schema generators
|
|
29404
|
+
\u2022 Template-friendly helpers
|
|
29405
|
+
\u2022 Works with Gin, Echo, Fiber, or stdlib
|
|
29406
|
+
|
|
29407
|
+
Setup:
|
|
29408
|
+
1. Import in your handlers
|
|
29409
|
+
2. Pass Meta to templates
|
|
29410
|
+
3. Use schema helpers for JSON-LD`,
|
|
29411
|
+
additionalFiles: [
|
|
29412
|
+
{
|
|
29413
|
+
file: "templates/partials/seo.html",
|
|
29414
|
+
code: `{{define "seo"}}
|
|
29415
|
+
<!-- Primary Meta Tags -->
|
|
29416
|
+
<title>{{.SEO.FullTitle}}</title>
|
|
29417
|
+
<meta name="title" content="{{.SEO.FullTitle}}">
|
|
29418
|
+
<meta name="description" content="{{.SEO.Description}}">
|
|
29419
|
+
<meta name="robots" content="{{.SEO.Robots}}">
|
|
29420
|
+
<link rel="canonical" href="{{.SEO.URL}}">
|
|
29421
|
+
|
|
29422
|
+
<!-- Open Graph / Facebook -->
|
|
29423
|
+
<meta property="og:type" content="{{.SEO.Type}}">
|
|
29424
|
+
<meta property="og:url" content="{{.SEO.URL}}">
|
|
29425
|
+
<meta property="og:title" content="{{.SEO.FullTitle}}">
|
|
29426
|
+
<meta property="og:description" content="{{.SEO.Description}}">
|
|
29427
|
+
<meta property="og:image" content="{{.SEO.AbsoluteImageURL}}">
|
|
29428
|
+
<meta property="og:image:width" content="1200">
|
|
29429
|
+
<meta property="og:image:height" content="630">
|
|
29430
|
+
<meta property="og:site_name" content="${siteName}">
|
|
29431
|
+
|
|
29432
|
+
{{if eq .SEO.Type "article"}}
|
|
29433
|
+
{{if .SEO.PublishedTime}}
|
|
29434
|
+
<meta property="article:published_time" content="{{.SEO.PublishedTime.Format "2006-01-02T15:04:05Z07:00"}}">
|
|
29435
|
+
{{end}}
|
|
29436
|
+
{{if .SEO.ModifiedTime}}
|
|
29437
|
+
<meta property="article:modified_time" content="{{.SEO.ModifiedTime.Format "2006-01-02T15:04:05Z07:00"}}">
|
|
29438
|
+
{{end}}
|
|
29439
|
+
{{if .SEO.Author}}
|
|
29440
|
+
<meta property="article:author" content="{{.SEO.Author}}">
|
|
29441
|
+
{{end}}
|
|
29442
|
+
{{range .SEO.Tags}}
|
|
29443
|
+
<meta property="article:tag" content="{{.}}">
|
|
29444
|
+
{{end}}
|
|
29445
|
+
{{end}}
|
|
29446
|
+
|
|
29447
|
+
<!-- Twitter -->
|
|
29448
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
29449
|
+
<meta name="twitter:title" content="{{.SEO.FullTitle}}">
|
|
29450
|
+
<meta name="twitter:description" content="{{.SEO.Description}}">
|
|
29451
|
+
<meta name="twitter:image" content="{{.SEO.AbsoluteImageURL}}">
|
|
29452
|
+
{{if ne "${twitterHandle}" ""}}
|
|
29453
|
+
<meta name="twitter:site" content="${twitterHandle}">
|
|
29454
|
+
<meta name="twitter:creator" content="${twitterHandle}">
|
|
29455
|
+
{{end}}
|
|
29456
|
+
{{end}}`,
|
|
29457
|
+
explanation: "Go html/template partial for SEO meta tags."
|
|
29458
|
+
},
|
|
29459
|
+
{
|
|
29460
|
+
file: "templates/layouts/base.html",
|
|
29461
|
+
code: `<!DOCTYPE html>
|
|
29462
|
+
<html lang="{{.Lang}}">
|
|
29463
|
+
<head>
|
|
29464
|
+
<meta charset="utf-8">
|
|
29465
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
29466
|
+
|
|
29467
|
+
{{template "seo" .}}
|
|
29468
|
+
|
|
29469
|
+
<link rel="icon" href="/static/favicon.ico">
|
|
29470
|
+
<link rel="stylesheet" href="/static/css/styles.css">
|
|
29471
|
+
|
|
29472
|
+
{{if .JSONLDSchemas}}
|
|
29473
|
+
{{.JSONLDSchemas}}
|
|
29474
|
+
{{end}}
|
|
29475
|
+
</head>
|
|
29476
|
+
<body>
|
|
29477
|
+
{{template "content" .}}
|
|
29478
|
+
</body>
|
|
29479
|
+
</html>`,
|
|
29480
|
+
explanation: "Go base template layout."
|
|
29481
|
+
},
|
|
29482
|
+
{
|
|
29483
|
+
file: "static/robots.txt",
|
|
29484
|
+
code: `User-agent: *
|
|
29485
|
+
Allow: /
|
|
29486
|
+
Disallow: /admin/
|
|
29487
|
+
Disallow: /api/
|
|
29488
|
+
|
|
29489
|
+
User-agent: GPTBot
|
|
29490
|
+
Allow: /
|
|
29491
|
+
|
|
29492
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
29493
|
+
explanation: "Robots.txt with AI crawler support."
|
|
29494
|
+
}
|
|
29495
|
+
]
|
|
29496
|
+
};
|
|
29497
|
+
}
|
|
29498
|
+
function generateAspNetCoreSEO(options) {
|
|
29499
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
29500
|
+
return {
|
|
29501
|
+
file: "Services/SEOService.cs",
|
|
29502
|
+
code: `using System.Text.Json;
|
|
29503
|
+
|
|
29504
|
+
namespace MyApp.Services;
|
|
29505
|
+
|
|
29506
|
+
/// <summary>
|
|
29507
|
+
/// Comprehensive SEO Service for ASP.NET Core
|
|
29508
|
+
///
|
|
29509
|
+
/// Features:
|
|
29510
|
+
/// - Full Open Graph support
|
|
29511
|
+
/// - Twitter Cards
|
|
29512
|
+
/// - JSON-LD structured data
|
|
29513
|
+
/// - Razor integration
|
|
29514
|
+
/// </summary>
|
|
29515
|
+
public class SEOService
|
|
29516
|
+
{
|
|
29517
|
+
public const string SiteName = "${siteName}";
|
|
29518
|
+
public const string SiteUrl = "${siteUrl}";
|
|
29519
|
+
public const string DefaultImage = "${image || `${siteUrl}/og-image.png`}";
|
|
29520
|
+
public const string DefaultDescription = "${description || `${siteName} - A compelling description.`}";
|
|
29521
|
+
public const string TwitterHandle = "${twitterHandle || ""}";
|
|
29522
|
+
|
|
29523
|
+
/// <summary>
|
|
29524
|
+
/// Create SEO metadata for a page
|
|
29525
|
+
/// </summary>
|
|
29526
|
+
public SEOMeta CreateMeta(
|
|
29527
|
+
string? title = null,
|
|
29528
|
+
string? description = null,
|
|
29529
|
+
string? image = null,
|
|
29530
|
+
string? type = "website")
|
|
29531
|
+
{
|
|
29532
|
+
return new SEOMeta
|
|
29533
|
+
{
|
|
29534
|
+
Title = title,
|
|
29535
|
+
Description = description ?? DefaultDescription,
|
|
29536
|
+
Image = image ?? DefaultImage,
|
|
29537
|
+
Type = type ?? "website"
|
|
29538
|
+
};
|
|
29539
|
+
}
|
|
29540
|
+
|
|
29541
|
+
// JSON-LD Schema Generators
|
|
29542
|
+
|
|
29543
|
+
public object WebsiteSchema() => new
|
|
29544
|
+
{
|
|
29545
|
+
@context = "https://schema.org",
|
|
29546
|
+
@type = "WebSite",
|
|
29547
|
+
name = SiteName,
|
|
29548
|
+
url = SiteUrl
|
|
29549
|
+
};
|
|
29550
|
+
|
|
29551
|
+
public object OrganizationSchema(
|
|
29552
|
+
string? name = null,
|
|
29553
|
+
string? url = null,
|
|
29554
|
+
string? logo = null,
|
|
29555
|
+
string[]? socialProfiles = null) => new
|
|
29556
|
+
{
|
|
29557
|
+
@context = "https://schema.org",
|
|
29558
|
+
@type = "Organization",
|
|
29559
|
+
name = name ?? SiteName,
|
|
29560
|
+
url = url ?? SiteUrl,
|
|
29561
|
+
logo,
|
|
29562
|
+
sameAs = socialProfiles
|
|
29563
|
+
};
|
|
29564
|
+
|
|
29565
|
+
public object ArticleSchema(
|
|
29566
|
+
string headline,
|
|
29567
|
+
string description,
|
|
29568
|
+
string? image,
|
|
29569
|
+
DateTime datePublished,
|
|
29570
|
+
DateTime? dateModified,
|
|
29571
|
+
string authorName) => new
|
|
29572
|
+
{
|
|
29573
|
+
@context = "https://schema.org",
|
|
29574
|
+
@type = "Article",
|
|
29575
|
+
headline,
|
|
29576
|
+
description,
|
|
29577
|
+
image = image ?? DefaultImage,
|
|
29578
|
+
datePublished = datePublished.ToString("O"),
|
|
29579
|
+
dateModified = (dateModified ?? datePublished).ToString("O"),
|
|
29580
|
+
author = new { @type = "Person", name = authorName },
|
|
29581
|
+
publisher = new
|
|
29582
|
+
{
|
|
29583
|
+
@type = "Organization",
|
|
29584
|
+
name = SiteName,
|
|
29585
|
+
logo = new { @type = "ImageObject", url = SiteUrl + "/logo.png" }
|
|
29586
|
+
}
|
|
29587
|
+
};
|
|
29588
|
+
|
|
29589
|
+
public object ProductSchema(
|
|
29590
|
+
string name,
|
|
29591
|
+
string description,
|
|
29592
|
+
string? image,
|
|
29593
|
+
decimal price,
|
|
29594
|
+
string currency = "USD",
|
|
29595
|
+
bool inStock = true) => new
|
|
29596
|
+
{
|
|
29597
|
+
@context = "https://schema.org",
|
|
29598
|
+
@type = "Product",
|
|
29599
|
+
name,
|
|
29600
|
+
description,
|
|
29601
|
+
image = image ?? DefaultImage,
|
|
29602
|
+
offers = new
|
|
29603
|
+
{
|
|
29604
|
+
@type = "Offer",
|
|
29605
|
+
price = price.ToString("F2"),
|
|
29606
|
+
priceCurrency = currency,
|
|
29607
|
+
availability = $"https://schema.org/{(inStock ? "InStock" : "OutOfStock")}"
|
|
29608
|
+
}
|
|
29609
|
+
};
|
|
29610
|
+
|
|
29611
|
+
public object FAQSchema(IEnumerable<(string Question, string Answer)> items) => new
|
|
29612
|
+
{
|
|
29613
|
+
@context = "https://schema.org",
|
|
29614
|
+
@type = "FAQPage",
|
|
29615
|
+
mainEntity = items.Select(item => new
|
|
29616
|
+
{
|
|
29617
|
+
@type = "Question",
|
|
29618
|
+
name = item.Question,
|
|
29619
|
+
acceptedAnswer = new { @type = "Answer", text = item.Answer }
|
|
29620
|
+
})
|
|
29621
|
+
};
|
|
29622
|
+
|
|
29623
|
+
public object BreadcrumbSchema(IEnumerable<(string Name, string Url)> items) => new
|
|
29624
|
+
{
|
|
29625
|
+
@context = "https://schema.org",
|
|
29626
|
+
@type = "BreadcrumbList",
|
|
29627
|
+
itemListElement = items.Select((item, index) => new
|
|
29628
|
+
{
|
|
29629
|
+
@type = "ListItem",
|
|
29630
|
+
position = index + 1,
|
|
29631
|
+
name = item.Name,
|
|
29632
|
+
item = item.Url
|
|
29633
|
+
})
|
|
29634
|
+
};
|
|
29635
|
+
|
|
29636
|
+
/// <summary>
|
|
29637
|
+
/// Serialize schema to JSON for embedding in HTML
|
|
29638
|
+
/// </summary>
|
|
29639
|
+
public string ToJson(object schema)
|
|
29640
|
+
{
|
|
29641
|
+
return JsonSerializer.Serialize(schema, new JsonSerializerOptions
|
|
29642
|
+
{
|
|
29643
|
+
WriteIndented = false,
|
|
29644
|
+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
29645
|
+
});
|
|
29646
|
+
}
|
|
29647
|
+
}
|
|
29648
|
+
|
|
29649
|
+
public class SEOMeta
|
|
29650
|
+
{
|
|
29651
|
+
public string? Title { get; set; }
|
|
29652
|
+
public string Description { get; set; } = SEOService.DefaultDescription;
|
|
29653
|
+
public string Image { get; set; } = SEOService.DefaultImage;
|
|
29654
|
+
public string? Url { get; set; }
|
|
29655
|
+
public string Type { get; set; } = "website";
|
|
29656
|
+
public DateTime? PublishedTime { get; set; }
|
|
29657
|
+
public DateTime? ModifiedTime { get; set; }
|
|
29658
|
+
public string? Author { get; set; }
|
|
29659
|
+
public List<string> Tags { get; set; } = new();
|
|
29660
|
+
public bool NoIndex { get; set; }
|
|
29661
|
+
public bool NoFollow { get; set; }
|
|
29662
|
+
|
|
29663
|
+
public string FullTitle => string.IsNullOrEmpty(Title)
|
|
29664
|
+
? SEOService.SiteName
|
|
29665
|
+
: Title.Contains(SEOService.SiteName)
|
|
29666
|
+
? Title
|
|
29667
|
+
: $"{Title} | {SEOService.SiteName}";
|
|
29668
|
+
|
|
29669
|
+
public string AbsoluteImageUrl => Image.StartsWith("http")
|
|
29670
|
+
? Image
|
|
29671
|
+
: SEOService.SiteUrl + Image;
|
|
29672
|
+
|
|
29673
|
+
public string Robots
|
|
29674
|
+
{
|
|
29675
|
+
get
|
|
29676
|
+
{
|
|
29677
|
+
var index = NoIndex ? "noindex" : "index";
|
|
29678
|
+
var follow = NoFollow ? "nofollow" : "follow";
|
|
29679
|
+
return $"{index}, {follow}";
|
|
29680
|
+
}
|
|
29681
|
+
}
|
|
29682
|
+
}`,
|
|
29683
|
+
explanation: `ASP.NET Core comprehensive SEO service with:
|
|
29684
|
+
\u2022 SEOMeta class for page metadata
|
|
29685
|
+
\u2022 JSON-LD schema generators
|
|
29686
|
+
\u2022 Razor integration
|
|
29687
|
+
\u2022 Fluent API
|
|
29688
|
+
|
|
29689
|
+
Setup:
|
|
29690
|
+
1. Register SEOService as singleton
|
|
29691
|
+
2. Inject in controllers/pages
|
|
29692
|
+
3. Use in Razor views`,
|
|
29693
|
+
additionalFiles: [
|
|
29694
|
+
{
|
|
29695
|
+
file: "Pages/Shared/_SEOMeta.cshtml",
|
|
29696
|
+
code: `@model SEOMeta
|
|
29697
|
+
|
|
29698
|
+
<!-- Primary Meta Tags -->
|
|
29699
|
+
<title>@Model.FullTitle</title>
|
|
29700
|
+
<meta name="title" content="@Model.FullTitle">
|
|
29701
|
+
<meta name="description" content="@Model.Description">
|
|
29702
|
+
<meta name="robots" content="@Model.Robots">
|
|
29703
|
+
<link rel="canonical" href="@Model.Url">
|
|
29704
|
+
|
|
29705
|
+
<!-- Open Graph / Facebook -->
|
|
29706
|
+
<meta property="og:type" content="@Model.Type">
|
|
29707
|
+
<meta property="og:url" content="@Model.Url">
|
|
29708
|
+
<meta property="og:title" content="@Model.FullTitle">
|
|
29709
|
+
<meta property="og:description" content="@Model.Description">
|
|
29710
|
+
<meta property="og:image" content="@Model.AbsoluteImageUrl">
|
|
29711
|
+
<meta property="og:image:width" content="1200">
|
|
29712
|
+
<meta property="og:image:height" content="630">
|
|
29713
|
+
<meta property="og:site_name" content="${siteName}">
|
|
29714
|
+
|
|
29715
|
+
@if (Model.Type == "article")
|
|
29716
|
+
{
|
|
29717
|
+
if (Model.PublishedTime.HasValue)
|
|
29718
|
+
{
|
|
29719
|
+
<meta property="article:published_time" content="@Model.PublishedTime.Value.ToString("O")">
|
|
29720
|
+
}
|
|
29721
|
+
if (Model.ModifiedTime.HasValue)
|
|
29722
|
+
{
|
|
29723
|
+
<meta property="article:modified_time" content="@Model.ModifiedTime.Value.ToString("O")">
|
|
29724
|
+
}
|
|
29725
|
+
if (!string.IsNullOrEmpty(Model.Author))
|
|
29726
|
+
{
|
|
29727
|
+
<meta property="article:author" content="@Model.Author">
|
|
29728
|
+
}
|
|
29729
|
+
foreach (var tag in Model.Tags)
|
|
29730
|
+
{
|
|
29731
|
+
<meta property="article:tag" content="@tag">
|
|
29732
|
+
}
|
|
29733
|
+
}
|
|
29734
|
+
|
|
29735
|
+
<!-- Twitter -->
|
|
29736
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
29737
|
+
<meta name="twitter:title" content="@Model.FullTitle">
|
|
29738
|
+
<meta name="twitter:description" content="@Model.Description">
|
|
29739
|
+
<meta name="twitter:image" content="@Model.AbsoluteImageUrl">
|
|
29740
|
+
@if (!string.IsNullOrEmpty("${twitterHandle}"))
|
|
29741
|
+
{
|
|
29742
|
+
<meta name="twitter:site" content="${twitterHandle}">
|
|
29743
|
+
<meta name="twitter:creator" content="${twitterHandle}">
|
|
29744
|
+
}`,
|
|
29745
|
+
explanation: "Razor partial view for SEO meta tags."
|
|
29746
|
+
},
|
|
29747
|
+
{
|
|
29748
|
+
file: "Pages/Shared/_Layout.cshtml",
|
|
29749
|
+
code: `<!DOCTYPE html>
|
|
29750
|
+
<html lang="en">
|
|
29751
|
+
<head>
|
|
29752
|
+
<meta charset="utf-8">
|
|
29753
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
29754
|
+
|
|
29755
|
+
@if (ViewData["SEO"] is SEOMeta seo)
|
|
29756
|
+
{
|
|
29757
|
+
<partial name="_SEOMeta" model="seo" />
|
|
29758
|
+
}
|
|
29759
|
+
else
|
|
29760
|
+
{
|
|
29761
|
+
<title>${siteName}</title>
|
|
29762
|
+
}
|
|
29763
|
+
|
|
29764
|
+
<link rel="icon" type="image/x-icon" href="~/favicon.ico">
|
|
29765
|
+
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true">
|
|
29766
|
+
|
|
29767
|
+
@if (ViewData["JsonLd"] is string jsonLd)
|
|
29768
|
+
{
|
|
29769
|
+
<script type="application/ld+json">@Html.Raw(jsonLd)</script>
|
|
29770
|
+
}
|
|
29771
|
+
</head>
|
|
29772
|
+
<body>
|
|
29773
|
+
@RenderBody()
|
|
29774
|
+
|
|
29775
|
+
<script src="~/js/site.js" asp-append-version="true"></script>
|
|
29776
|
+
@await RenderSectionAsync("Scripts", required: false)
|
|
29777
|
+
</body>
|
|
29778
|
+
</html>`,
|
|
29779
|
+
explanation: "Razor layout with SEO integration."
|
|
29780
|
+
},
|
|
29781
|
+
{
|
|
29782
|
+
file: "wwwroot/robots.txt",
|
|
29783
|
+
code: `User-agent: *
|
|
29784
|
+
Allow: /
|
|
29785
|
+
Disallow: /admin/
|
|
29786
|
+
Disallow: /api/
|
|
29787
|
+
|
|
29788
|
+
User-agent: GPTBot
|
|
29789
|
+
Allow: /
|
|
29790
|
+
|
|
29791
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
29792
|
+
explanation: "Robots.txt with AI crawler support."
|
|
29793
|
+
}
|
|
29794
|
+
]
|
|
29795
|
+
};
|
|
29796
|
+
}
|
|
29797
|
+
function generateHTMXSEO(options) {
|
|
29798
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
29799
|
+
return {
|
|
29800
|
+
file: "templates/seo_head.html",
|
|
29801
|
+
code: `<!--
|
|
29802
|
+
HTMX SEO Template
|
|
29803
|
+
|
|
29804
|
+
This template provides comprehensive SEO for HTMX applications.
|
|
29805
|
+
HTMX apps are server-rendered, so SEO is straightforward - just
|
|
29806
|
+
include proper meta tags in your initial HTML response.
|
|
29807
|
+
|
|
29808
|
+
Features:
|
|
29809
|
+
- Full Open Graph support
|
|
29810
|
+
- Twitter Cards
|
|
29811
|
+
- JSON-LD structured data
|
|
29812
|
+
- Works with any backend (Python, Ruby, Go, PHP, etc.)
|
|
29813
|
+
|
|
29814
|
+
Usage:
|
|
29815
|
+
Include this template in your base layout, passing the required variables.
|
|
29816
|
+
-->
|
|
29817
|
+
|
|
29818
|
+
<!-- Primary Meta Tags -->
|
|
29819
|
+
<title>{{ full_title or '${siteName}' }}</title>
|
|
29820
|
+
<meta name="title" content="{{ full_title or '${siteName}' }}">
|
|
29821
|
+
<meta name="description" content="{{ description or '${description || `${siteName} - A compelling description.`}' }}">
|
|
29822
|
+
<meta name="robots" content="{{ 'noindex, nofollow' if noindex else 'index, follow' }}">
|
|
29823
|
+
<link rel="canonical" href="{{ canonical_url }}">
|
|
29824
|
+
|
|
29825
|
+
<!-- Open Graph / Facebook -->
|
|
29826
|
+
<meta property="og:type" content="{{ og_type or 'website' }}">
|
|
29827
|
+
<meta property="og:url" content="{{ canonical_url }}">
|
|
29828
|
+
<meta property="og:title" content="{{ full_title or '${siteName}' }}">
|
|
29829
|
+
<meta property="og:description" content="{{ description or '${description || `${siteName} - A compelling description.`}' }}">
|
|
29830
|
+
<meta property="og:image" content="{{ image_url or '${image || `${siteUrl}/og-image.png`}' }}">
|
|
29831
|
+
<meta property="og:image:width" content="1200">
|
|
29832
|
+
<meta property="og:image:height" content="630">
|
|
29833
|
+
<meta property="og:site_name" content="${siteName}">
|
|
29834
|
+
<meta property="og:locale" content="{{ locale or 'en_US' }}">
|
|
29835
|
+
|
|
29836
|
+
{% if og_type == 'article' %}
|
|
29837
|
+
{% if published_time %}
|
|
29838
|
+
<meta property="article:published_time" content="{{ published_time }}">
|
|
29839
|
+
{% endif %}
|
|
29840
|
+
{% if modified_time %}
|
|
29841
|
+
<meta property="article:modified_time" content="{{ modified_time }}">
|
|
29842
|
+
{% endif %}
|
|
29843
|
+
{% if author %}
|
|
29844
|
+
<meta property="article:author" content="{{ author }}">
|
|
29845
|
+
{% endif %}
|
|
29846
|
+
{% for tag in tags or [] %}
|
|
29847
|
+
<meta property="article:tag" content="{{ tag }}">
|
|
29848
|
+
{% endfor %}
|
|
29849
|
+
{% endif %}
|
|
29850
|
+
|
|
29851
|
+
<!-- Twitter -->
|
|
29852
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
29853
|
+
<meta name="twitter:title" content="{{ full_title or '${siteName}' }}">
|
|
29854
|
+
<meta name="twitter:description" content="{{ description or '${description || `${siteName} - A compelling description.`}' }}">
|
|
29855
|
+
<meta name="twitter:image" content="{{ image_url or '${image || `${siteUrl}/og-image.png`}' }}">
|
|
29856
|
+
{% if '${twitterHandle}' %}
|
|
29857
|
+
<meta name="twitter:site" content="${twitterHandle}">
|
|
29858
|
+
<meta name="twitter:creator" content="${twitterHandle}">
|
|
29859
|
+
{% endif %}
|
|
29860
|
+
|
|
29861
|
+
<!-- JSON-LD Structured Data -->
|
|
29862
|
+
<script type="application/ld+json">
|
|
29863
|
+
{
|
|
29864
|
+
"@context": "https://schema.org",
|
|
29865
|
+
"@type": "WebSite",
|
|
29866
|
+
"name": "${siteName}",
|
|
29867
|
+
"url": "${siteUrl}"
|
|
29868
|
+
}
|
|
29869
|
+
</script>
|
|
29870
|
+
{% if schema_json %}
|
|
29871
|
+
<script type="application/ld+json">
|
|
29872
|
+
{{ schema_json | safe }}
|
|
29873
|
+
</script>
|
|
29874
|
+
{% endif %}`,
|
|
29875
|
+
explanation: `HTMX SEO template (framework-agnostic) with:
|
|
29876
|
+
\u2022 Full Open Graph support
|
|
29877
|
+
\u2022 Twitter Cards
|
|
29878
|
+
\u2022 JSON-LD structured data
|
|
29879
|
+
\u2022 Works with any template engine (Jinja2, Liquid, Go templates, etc.)
|
|
29880
|
+
|
|
29881
|
+
HTMX apps are server-rendered, so SEO is built-in!
|
|
29882
|
+
Just include proper meta tags in your initial HTML response.
|
|
29883
|
+
|
|
29884
|
+
Usage:
|
|
29885
|
+
1. Include in your base layout
|
|
29886
|
+
2. Pass SEO variables from your backend
|
|
29887
|
+
3. JSON-LD schemas can be generated server-side`,
|
|
29888
|
+
additionalFiles: [
|
|
29889
|
+
{
|
|
29890
|
+
file: "templates/base.html",
|
|
29891
|
+
code: `<!DOCTYPE html>
|
|
29892
|
+
<html lang="{{ lang or 'en' }}">
|
|
29893
|
+
<head>
|
|
29894
|
+
<meta charset="utf-8">
|
|
29895
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
29896
|
+
|
|
29897
|
+
{% include 'seo_head.html' %}
|
|
29898
|
+
|
|
29899
|
+
<!-- HTMX -->
|
|
29900
|
+
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
29901
|
+
|
|
29902
|
+
<!-- Styles -->
|
|
29903
|
+
<link rel="stylesheet" href="/static/css/styles.css">
|
|
29904
|
+
|
|
29905
|
+
<!-- Favicon -->
|
|
29906
|
+
<link rel="icon" href="/static/favicon.ico">
|
|
29907
|
+
</head>
|
|
29908
|
+
<body hx-boost="true">
|
|
29909
|
+
<main id="content">
|
|
29910
|
+
{% block content %}{% endblock %}
|
|
29911
|
+
</main>
|
|
29912
|
+
</body>
|
|
29913
|
+
</html>`,
|
|
29914
|
+
explanation: "Base HTMX layout with SEO integration."
|
|
29915
|
+
},
|
|
29916
|
+
{
|
|
29917
|
+
file: "static/robots.txt",
|
|
29918
|
+
code: `User-agent: *
|
|
29919
|
+
Allow: /
|
|
29920
|
+
Disallow: /admin/
|
|
29921
|
+
Disallow: /api/
|
|
29922
|
+
|
|
29923
|
+
User-agent: GPTBot
|
|
29924
|
+
Allow: /
|
|
29925
|
+
|
|
29926
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
29927
|
+
explanation: "Robots.txt with AI crawler support."
|
|
29928
|
+
}
|
|
29929
|
+
]
|
|
29930
|
+
};
|
|
29931
|
+
}
|
|
29932
|
+
function generateHugoSEO(options) {
|
|
29933
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
29934
|
+
return {
|
|
29935
|
+
file: "layouts/partials/seo.html",
|
|
29936
|
+
code: `{{- /* Comprehensive SEO partial for Hugo */ -}}
|
|
29937
|
+
{{- $title := .Title | default site.Title -}}
|
|
29938
|
+
{{- $description := .Description | default .Summary | default site.Params.description | default "${description || `${siteName} - A compelling description.`}" -}}
|
|
29939
|
+
{{- $image := .Params.image | default site.Params.defaultImage | default "${image || "/og-image.png"}" -}}
|
|
29940
|
+
{{- $imageURL := $image | absURL -}}
|
|
29941
|
+
{{- $canonical := .Permalink -}}
|
|
29942
|
+
{{- $type := cond (eq .Kind "page") "article" "website" -}}
|
|
29943
|
+
|
|
29944
|
+
{{- /* Full title with site name */ -}}
|
|
29945
|
+
{{- $fullTitle := $title -}}
|
|
29946
|
+
{{- if and (ne $title site.Title) (not (in $title site.Title)) -}}
|
|
29947
|
+
{{- $fullTitle = printf "%s | %s" $title site.Title -}}
|
|
29948
|
+
{{- end -}}
|
|
29949
|
+
|
|
29950
|
+
<!-- Primary Meta Tags -->
|
|
29951
|
+
<title>{{ $fullTitle }}</title>
|
|
29952
|
+
<meta name="title" content="{{ $fullTitle }}">
|
|
29953
|
+
<meta name="description" content="{{ $description | truncate 160 }}">
|
|
29954
|
+
<meta name="robots" content="{{ if .Params.noindex }}noindex, nofollow{{ else }}index, follow{{ end }}">
|
|
29955
|
+
<link rel="canonical" href="{{ $canonical }}">
|
|
29956
|
+
|
|
29957
|
+
<!-- Open Graph / Facebook -->
|
|
29958
|
+
<meta property="og:type" content="{{ $type }}">
|
|
29959
|
+
<meta property="og:url" content="{{ $canonical }}">
|
|
29960
|
+
<meta property="og:title" content="{{ $fullTitle }}">
|
|
29961
|
+
<meta property="og:description" content="{{ $description | truncate 160 }}">
|
|
29962
|
+
<meta property="og:image" content="{{ $imageURL }}">
|
|
29963
|
+
<meta property="og:image:width" content="1200">
|
|
29964
|
+
<meta property="og:image:height" content="630">
|
|
29965
|
+
<meta property="og:site_name" content="{{ site.Title }}">
|
|
29966
|
+
<meta property="og:locale" content="{{ site.Language.Lang | default "en" }}_{{ site.Language.Lang | default "US" | upper }}">
|
|
29967
|
+
|
|
29968
|
+
{{- if eq $type "article" }}
|
|
29969
|
+
{{- with .PublishDate }}
|
|
29970
|
+
<meta property="article:published_time" content="{{ .Format "2006-01-02T15:04:05Z07:00" }}">
|
|
29971
|
+
{{- end }}
|
|
29972
|
+
{{- with .Lastmod }}
|
|
29973
|
+
<meta property="article:modified_time" content="{{ .Format "2006-01-02T15:04:05Z07:00" }}">
|
|
29974
|
+
{{- end }}
|
|
29975
|
+
{{- with .Params.author }}
|
|
29976
|
+
<meta property="article:author" content="{{ . }}">
|
|
29977
|
+
{{- end }}
|
|
29978
|
+
{{- range .Params.tags }}
|
|
29979
|
+
<meta property="article:tag" content="{{ . }}">
|
|
29980
|
+
{{- end }}
|
|
29981
|
+
{{- end }}
|
|
29982
|
+
|
|
29983
|
+
<!-- Twitter -->
|
|
29984
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
29985
|
+
<meta name="twitter:title" content="{{ $fullTitle }}">
|
|
29986
|
+
<meta name="twitter:description" content="{{ $description | truncate 160 }}">
|
|
29987
|
+
<meta name="twitter:image" content="{{ $imageURL }}">
|
|
29988
|
+
{{- with site.Params.twitterHandle }}
|
|
29989
|
+
<meta name="twitter:site" content="{{ . }}">
|
|
29990
|
+
<meta name="twitter:creator" content="{{ . }}">
|
|
29991
|
+
{{- end }}
|
|
29992
|
+
|
|
29993
|
+
<!-- JSON-LD: WebSite -->
|
|
29994
|
+
<script type="application/ld+json">
|
|
29995
|
+
{
|
|
29996
|
+
"@context": "https://schema.org",
|
|
29997
|
+
"@type": "WebSite",
|
|
29998
|
+
"name": "{{ site.Title }}",
|
|
29999
|
+
"url": "{{ site.BaseURL }}"
|
|
30000
|
+
}
|
|
30001
|
+
</script>
|
|
30002
|
+
|
|
30003
|
+
{{- /* Article Schema */ -}}
|
|
30004
|
+
{{- if eq $type "article" }}
|
|
30005
|
+
<script type="application/ld+json">
|
|
30006
|
+
{
|
|
30007
|
+
"@context": "https://schema.org",
|
|
30008
|
+
"@type": "Article",
|
|
30009
|
+
"headline": "{{ $title }}",
|
|
30010
|
+
"description": "{{ $description | truncate 160 }}",
|
|
30011
|
+
"image": "{{ $imageURL }}",
|
|
30012
|
+
"datePublished": "{{ .PublishDate.Format "2006-01-02T15:04:05Z07:00" }}",
|
|
30013
|
+
"dateModified": "{{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" }}",
|
|
30014
|
+
"author": {
|
|
30015
|
+
"@type": "Person",
|
|
30016
|
+
"name": "{{ .Params.author | default site.Params.author | default "Author" }}"
|
|
30017
|
+
},
|
|
30018
|
+
"publisher": {
|
|
30019
|
+
"@type": "Organization",
|
|
30020
|
+
"name": "{{ site.Title }}",
|
|
30021
|
+
"logo": {
|
|
30022
|
+
"@type": "ImageObject",
|
|
30023
|
+
"url": "{{ "logo.png" | absURL }}"
|
|
30024
|
+
}
|
|
30025
|
+
}
|
|
30026
|
+
}
|
|
30027
|
+
</script>
|
|
30028
|
+
{{- end }}
|
|
30029
|
+
|
|
30030
|
+
{{- /* Breadcrumbs */ -}}
|
|
30031
|
+
{{- if .Parent }}
|
|
30032
|
+
<script type="application/ld+json">
|
|
30033
|
+
{
|
|
30034
|
+
"@context": "https://schema.org",
|
|
30035
|
+
"@type": "BreadcrumbList",
|
|
30036
|
+
"itemListElement": [
|
|
30037
|
+
{{- $breadcrumbs := slice -}}
|
|
30038
|
+
{{- range .Ancestors.Reverse }}
|
|
30039
|
+
{{- $breadcrumbs = $breadcrumbs | append (dict "name" .Title "url" .Permalink) -}}
|
|
30040
|
+
{{- end }}
|
|
30041
|
+
{{- $breadcrumbs = $breadcrumbs | append (dict "name" .Title "url" .Permalink) -}}
|
|
30042
|
+
{{- range $i, $item := $breadcrumbs }}
|
|
30043
|
+
{{- if $i }},{{ end }}
|
|
30044
|
+
{
|
|
30045
|
+
"@type": "ListItem",
|
|
30046
|
+
"position": {{ add $i 1 }},
|
|
30047
|
+
"name": "{{ $item.name }}",
|
|
30048
|
+
"item": "{{ $item.url }}"
|
|
30049
|
+
}
|
|
30050
|
+
{{- end }}
|
|
30051
|
+
]
|
|
30052
|
+
}
|
|
30053
|
+
</script>
|
|
30054
|
+
{{- end }}`,
|
|
30055
|
+
explanation: `Hugo comprehensive SEO partial with:
|
|
30056
|
+
\u2022 Automatic title handling
|
|
30057
|
+
\u2022 Full Open Graph with article support
|
|
30058
|
+
\u2022 Twitter Cards
|
|
30059
|
+
\u2022 JSON-LD schemas (WebSite, Article, Breadcrumbs)
|
|
30060
|
+
\u2022 Content summaries for descriptions
|
|
30061
|
+
|
|
30062
|
+
Usage: {{ partial "seo.html" . }}`,
|
|
30063
|
+
additionalFiles: [
|
|
30064
|
+
{
|
|
30065
|
+
file: "layouts/_default/baseof.html",
|
|
30066
|
+
code: `<!DOCTYPE html>
|
|
30067
|
+
<html lang="{{ site.Language.Lang | default "en" }}">
|
|
30068
|
+
<head>
|
|
30069
|
+
<meta charset="utf-8">
|
|
30070
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
30071
|
+
|
|
30072
|
+
{{ partial "seo.html" . }}
|
|
30073
|
+
|
|
30074
|
+
<!-- Favicon -->
|
|
30075
|
+
<link rel="icon" href="{{ "favicon.ico" | relURL }}">
|
|
30076
|
+
<link rel="apple-touch-icon" sizes="180x180" href="{{ "apple-touch-icon.png" | relURL }}">
|
|
30077
|
+
|
|
30078
|
+
<!-- Styles -->
|
|
30079
|
+
{{ $styles := resources.Get "css/styles.css" | minify | fingerprint }}
|
|
30080
|
+
<link rel="stylesheet" href="{{ $styles.Permalink }}">
|
|
30081
|
+
</head>
|
|
30082
|
+
<body>
|
|
30083
|
+
{{ block "main" . }}{{ end }}
|
|
30084
|
+
</body>
|
|
30085
|
+
</html>`,
|
|
30086
|
+
explanation: "Hugo base template with SEO partial."
|
|
30087
|
+
},
|
|
30088
|
+
{
|
|
30089
|
+
file: "config.toml",
|
|
30090
|
+
code: `baseURL = '${siteUrl}/'
|
|
30091
|
+
languageCode = 'en-us'
|
|
30092
|
+
title = '${siteName}'
|
|
30093
|
+
|
|
30094
|
+
[params]
|
|
30095
|
+
description = '${description || `${siteName} - A compelling description.`}'
|
|
30096
|
+
defaultImage = '${image || "/og-image.png"}'
|
|
30097
|
+
twitterHandle = '${twitterHandle || ""}'
|
|
30098
|
+
author = 'Your Name'
|
|
30099
|
+
|
|
30100
|
+
[sitemap]
|
|
30101
|
+
changefreq = 'weekly'
|
|
30102
|
+
filename = 'sitemap.xml'
|
|
30103
|
+
priority = 0.5
|
|
30104
|
+
|
|
30105
|
+
[outputs]
|
|
30106
|
+
home = ['HTML', 'RSS', 'JSON']
|
|
30107
|
+
|
|
30108
|
+
# Robots.txt
|
|
30109
|
+
enableRobotsTXT = true`,
|
|
30110
|
+
explanation: "Hugo configuration with SEO settings."
|
|
30111
|
+
},
|
|
30112
|
+
{
|
|
30113
|
+
file: "layouts/robots.txt",
|
|
30114
|
+
code: `User-agent: *
|
|
30115
|
+
Allow: /
|
|
30116
|
+
Disallow: /admin/
|
|
30117
|
+
|
|
30118
|
+
User-agent: GPTBot
|
|
30119
|
+
Allow: /
|
|
30120
|
+
|
|
30121
|
+
Sitemap: {{ .Site.BaseURL }}sitemap.xml`,
|
|
30122
|
+
explanation: "Hugo robots.txt template."
|
|
30123
|
+
}
|
|
30124
|
+
]
|
|
30125
|
+
};
|
|
30126
|
+
}
|
|
30127
|
+
function generateJekyllSEO(options) {
|
|
30128
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
30129
|
+
return {
|
|
30130
|
+
file: "_includes/seo.html",
|
|
30131
|
+
code: `{% comment %}
|
|
30132
|
+
Comprehensive SEO include for Jekyll
|
|
30133
|
+
|
|
30134
|
+
Features:
|
|
30135
|
+
- Full Open Graph support
|
|
30136
|
+
- Twitter Cards
|
|
30137
|
+
- JSON-LD structured data
|
|
30138
|
+
- jekyll-seo-tag compatible
|
|
30139
|
+
|
|
30140
|
+
Usage: {% include seo.html %}
|
|
30141
|
+
{% endcomment %}
|
|
30142
|
+
|
|
30143
|
+
{%- assign seo_title = page.title | default: site.title -%}
|
|
30144
|
+
{%- if page.title and site.title and page.title != site.title -%}
|
|
30145
|
+
{%- assign seo_title = page.title | append: " | " | append: site.title -%}
|
|
30146
|
+
{%- endif -%}
|
|
30147
|
+
|
|
30148
|
+
{%- assign seo_description = page.description | default: page.excerpt | default: site.description | default: "${description || `${siteName} - A compelling description.`}" | strip_html | truncate: 160 -%}
|
|
30149
|
+
|
|
30150
|
+
{%- assign seo_image = page.image | default: site.og_image | default: "${image || "/og-image.png"}" -%}
|
|
30151
|
+
{%- unless seo_image contains "://" -%}
|
|
30152
|
+
{%- assign seo_image = seo_image | absolute_url -%}
|
|
30153
|
+
{%- endunless -%}
|
|
30154
|
+
|
|
30155
|
+
{%- assign seo_type = "website" -%}
|
|
30156
|
+
{%- if page.layout == "post" -%}
|
|
30157
|
+
{%- assign seo_type = "article" -%}
|
|
30158
|
+
{%- endif -%}
|
|
30159
|
+
|
|
30160
|
+
<!-- Primary Meta Tags -->
|
|
30161
|
+
<title>{{ seo_title }}</title>
|
|
30162
|
+
<meta name="title" content="{{ seo_title }}">
|
|
30163
|
+
<meta name="description" content="{{ seo_description }}">
|
|
30164
|
+
<meta name="robots" content="{% if page.noindex %}noindex, nofollow{% else %}index, follow{% endif %}">
|
|
30165
|
+
<link rel="canonical" href="{{ page.url | absolute_url }}">
|
|
30166
|
+
|
|
30167
|
+
<!-- Open Graph / Facebook -->
|
|
30168
|
+
<meta property="og:type" content="{{ seo_type }}">
|
|
30169
|
+
<meta property="og:url" content="{{ page.url | absolute_url }}">
|
|
30170
|
+
<meta property="og:title" content="{{ seo_title }}">
|
|
30171
|
+
<meta property="og:description" content="{{ seo_description }}">
|
|
30172
|
+
<meta property="og:image" content="{{ seo_image }}">
|
|
30173
|
+
<meta property="og:image:width" content="1200">
|
|
30174
|
+
<meta property="og:image:height" content="630">
|
|
30175
|
+
<meta property="og:site_name" content="{{ site.title }}">
|
|
30176
|
+
<meta property="og:locale" content="{{ site.locale | default: "en_US" }}">
|
|
30177
|
+
|
|
30178
|
+
{% if seo_type == "article" %}
|
|
30179
|
+
{% if page.date %}
|
|
30180
|
+
<meta property="article:published_time" content="{{ page.date | date_to_xmlschema }}">
|
|
30181
|
+
{% endif %}
|
|
30182
|
+
{% if page.last_modified_at %}
|
|
30183
|
+
<meta property="article:modified_time" content="{{ page.last_modified_at | date_to_xmlschema }}">
|
|
30184
|
+
{% endif %}
|
|
30185
|
+
{% if page.author %}
|
|
30186
|
+
<meta property="article:author" content="{{ page.author }}">
|
|
30187
|
+
{% endif %}
|
|
30188
|
+
{% for tag in page.tags %}
|
|
30189
|
+
<meta property="article:tag" content="{{ tag }}">
|
|
30190
|
+
{% endfor %}
|
|
30191
|
+
{% endif %}
|
|
30192
|
+
|
|
30193
|
+
<!-- Twitter -->
|
|
30194
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
30195
|
+
<meta name="twitter:title" content="{{ seo_title }}">
|
|
30196
|
+
<meta name="twitter:description" content="{{ seo_description }}">
|
|
30197
|
+
<meta name="twitter:image" content="{{ seo_image }}">
|
|
30198
|
+
{% if site.twitter_handle %}
|
|
30199
|
+
<meta name="twitter:site" content="{{ site.twitter_handle }}">
|
|
30200
|
+
<meta name="twitter:creator" content="{{ site.twitter_handle }}">
|
|
30201
|
+
{% endif %}
|
|
30202
|
+
|
|
30203
|
+
<!-- JSON-LD: WebSite -->
|
|
30204
|
+
<script type="application/ld+json">
|
|
30205
|
+
{
|
|
30206
|
+
"@context": "https://schema.org",
|
|
30207
|
+
"@type": "WebSite",
|
|
30208
|
+
"name": "{{ site.title }}",
|
|
30209
|
+
"url": "{{ site.url }}"
|
|
30210
|
+
}
|
|
30211
|
+
</script>
|
|
30212
|
+
|
|
30213
|
+
{% if seo_type == "article" %}
|
|
30214
|
+
<!-- JSON-LD: Article -->
|
|
30215
|
+
<script type="application/ld+json">
|
|
30216
|
+
{
|
|
30217
|
+
"@context": "https://schema.org",
|
|
30218
|
+
"@type": "Article",
|
|
30219
|
+
"headline": "{{ page.title | escape }}",
|
|
30220
|
+
"description": "{{ seo_description | escape }}",
|
|
30221
|
+
"image": "{{ seo_image }}",
|
|
30222
|
+
"datePublished": "{{ page.date | date_to_xmlschema }}",
|
|
30223
|
+
"dateModified": "{{ page.last_modified_at | default: page.date | date_to_xmlschema }}",
|
|
30224
|
+
"author": {
|
|
30225
|
+
"@type": "Person",
|
|
30226
|
+
"name": "{{ page.author | default: site.author | default: "Author" }}"
|
|
30227
|
+
},
|
|
30228
|
+
"publisher": {
|
|
30229
|
+
"@type": "Organization",
|
|
30230
|
+
"name": "{{ site.title }}",
|
|
30231
|
+
"logo": {
|
|
30232
|
+
"@type": "ImageObject",
|
|
30233
|
+
"url": "{{ '/logo.png' | absolute_url }}"
|
|
30234
|
+
}
|
|
30235
|
+
}
|
|
30236
|
+
}
|
|
30237
|
+
</script>
|
|
30238
|
+
{% endif %}`,
|
|
30239
|
+
explanation: `Jekyll comprehensive SEO include with:
|
|
30240
|
+
\u2022 Full Open Graph with article support
|
|
30241
|
+
\u2022 Twitter Cards
|
|
30242
|
+
\u2022 JSON-LD schemas
|
|
30243
|
+
\u2022 Liquid template integration
|
|
30244
|
+
|
|
30245
|
+
Usage: {% include seo.html %}`,
|
|
30246
|
+
additionalFiles: [
|
|
30247
|
+
{
|
|
30248
|
+
file: "_layouts/default.html",
|
|
30249
|
+
code: `<!DOCTYPE html>
|
|
30250
|
+
<html lang="{{ site.locale | default: "en" | slice: 0, 2 }}">
|
|
30251
|
+
<head>
|
|
30252
|
+
<meta charset="utf-8">
|
|
30253
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
30254
|
+
|
|
30255
|
+
{% include seo.html %}
|
|
30256
|
+
|
|
30257
|
+
<link rel="icon" href="{{ '/favicon.ico' | relative_url }}">
|
|
30258
|
+
<link rel="apple-touch-icon" sizes="180x180" href="{{ '/apple-touch-icon.png' | relative_url }}">
|
|
30259
|
+
|
|
30260
|
+
<link rel="stylesheet" href="{{ '/assets/css/styles.css' | relative_url }}">
|
|
30261
|
+
</head>
|
|
30262
|
+
<body>
|
|
30263
|
+
{{ content }}
|
|
30264
|
+
</body>
|
|
30265
|
+
</html>`,
|
|
30266
|
+
explanation: "Jekyll default layout with SEO include."
|
|
30267
|
+
},
|
|
30268
|
+
{
|
|
30269
|
+
file: "_config.yml",
|
|
30270
|
+
code: `title: ${siteName}
|
|
30271
|
+
description: "${description || `${siteName} - A compelling description.`}"
|
|
30272
|
+
url: "${siteUrl}"
|
|
30273
|
+
baseurl: ""
|
|
30274
|
+
|
|
30275
|
+
# SEO
|
|
30276
|
+
og_image: "${image || "/og-image.png"}"
|
|
30277
|
+
twitter_handle: "${twitterHandle || ""}"
|
|
30278
|
+
locale: "en_US"
|
|
30279
|
+
author: "Your Name"
|
|
30280
|
+
|
|
30281
|
+
# Plugins
|
|
30282
|
+
plugins:
|
|
30283
|
+
- jekyll-sitemap
|
|
30284
|
+
- jekyll-feed
|
|
30285
|
+
|
|
30286
|
+
# Defaults
|
|
30287
|
+
defaults:
|
|
30288
|
+
- scope:
|
|
30289
|
+
path: ""
|
|
30290
|
+
type: "posts"
|
|
30291
|
+
values:
|
|
30292
|
+
layout: "post"
|
|
30293
|
+
- scope:
|
|
30294
|
+
path: ""
|
|
30295
|
+
values:
|
|
30296
|
+
layout: "default"`,
|
|
30297
|
+
explanation: "Jekyll configuration with SEO settings."
|
|
30298
|
+
},
|
|
30299
|
+
{
|
|
30300
|
+
file: "robots.txt",
|
|
30301
|
+
code: `---
|
|
30302
|
+
---
|
|
30303
|
+
User-agent: *
|
|
30304
|
+
Allow: /
|
|
30305
|
+
Disallow: /admin/
|
|
30306
|
+
|
|
30307
|
+
User-agent: GPTBot
|
|
30308
|
+
Allow: /
|
|
30309
|
+
|
|
30310
|
+
Sitemap: {{ site.url }}/sitemap.xml`,
|
|
30311
|
+
explanation: "Jekyll robots.txt with front matter."
|
|
30312
|
+
}
|
|
30313
|
+
]
|
|
30314
|
+
};
|
|
30315
|
+
}
|
|
30316
|
+
function generateEleventySEO(options) {
|
|
30317
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
30318
|
+
return {
|
|
30319
|
+
file: "src/_includes/seo.njk",
|
|
30320
|
+
code: `{#
|
|
30321
|
+
Comprehensive SEO partial for Eleventy (11ty)
|
|
30322
|
+
|
|
30323
|
+
Features:
|
|
30324
|
+
- Full Open Graph support
|
|
30325
|
+
- Twitter Cards
|
|
30326
|
+
- JSON-LD structured data
|
|
30327
|
+
- Nunjucks template
|
|
30328
|
+
|
|
30329
|
+
Usage: {% include "seo.njk" %}
|
|
30330
|
+
#}
|
|
30331
|
+
|
|
30332
|
+
{%- set seoTitle = title or metadata.title -%}
|
|
30333
|
+
{%- set fullTitle = seoTitle -%}
|
|
30334
|
+
{%- if seoTitle and metadata.title and seoTitle != metadata.title -%}
|
|
30335
|
+
{%- set fullTitle = seoTitle + " | " + metadata.title -%}
|
|
30336
|
+
{%- endif -%}
|
|
30337
|
+
|
|
30338
|
+
{%- set seoDescription = description or excerpt or metadata.description or "${description || `${siteName} - A compelling description.`}" -%}
|
|
30339
|
+
{%- set seoImage = image or metadata.defaultImage or "${image || "/og-image.png"}" -%}
|
|
30340
|
+
{%- if not seoImage.startsWith("http") -%}
|
|
30341
|
+
{%- set seoImage = metadata.url + seoImage -%}
|
|
30342
|
+
{%- endif -%}
|
|
30343
|
+
|
|
30344
|
+
{%- set pageUrl = page.url | url | absoluteUrl(metadata.url) -%}
|
|
30345
|
+
{%- set seoType = "article" if layout == "post" else "website" -%}
|
|
30346
|
+
|
|
30347
|
+
<!-- Primary Meta Tags -->
|
|
30348
|
+
<title>{{ fullTitle }}</title>
|
|
30349
|
+
<meta name="title" content="{{ fullTitle }}">
|
|
30350
|
+
<meta name="description" content="{{ seoDescription | truncate(160) }}">
|
|
30351
|
+
<meta name="robots" content="{{ 'noindex, nofollow' if noindex else 'index, follow' }}">
|
|
30352
|
+
<link rel="canonical" href="{{ pageUrl }}">
|
|
30353
|
+
|
|
30354
|
+
<!-- Open Graph / Facebook -->
|
|
30355
|
+
<meta property="og:type" content="{{ seoType }}">
|
|
30356
|
+
<meta property="og:url" content="{{ pageUrl }}">
|
|
30357
|
+
<meta property="og:title" content="{{ fullTitle }}">
|
|
30358
|
+
<meta property="og:description" content="{{ seoDescription | truncate(160) }}">
|
|
30359
|
+
<meta property="og:image" content="{{ seoImage }}">
|
|
30360
|
+
<meta property="og:image:width" content="1200">
|
|
30361
|
+
<meta property="og:image:height" content="630">
|
|
30362
|
+
<meta property="og:site_name" content="{{ metadata.title }}">
|
|
30363
|
+
<meta property="og:locale" content="{{ metadata.locale or 'en_US' }}">
|
|
30364
|
+
|
|
30365
|
+
{% if seoType == "article" %}
|
|
30366
|
+
{% if date %}
|
|
30367
|
+
<meta property="article:published_time" content="{{ date | dateToISO }}">
|
|
30368
|
+
{% endif %}
|
|
30369
|
+
{% if lastModified %}
|
|
30370
|
+
<meta property="article:modified_time" content="{{ lastModified | dateToISO }}">
|
|
30371
|
+
{% endif %}
|
|
30372
|
+
{% if author %}
|
|
30373
|
+
<meta property="article:author" content="{{ author }}">
|
|
30374
|
+
{% endif %}
|
|
30375
|
+
{% for tag in tags %}
|
|
30376
|
+
{% if tag != "posts" and tag != "all" %}
|
|
30377
|
+
<meta property="article:tag" content="{{ tag }}">
|
|
30378
|
+
{% endif %}
|
|
30379
|
+
{% endfor %}
|
|
30380
|
+
{% endif %}
|
|
30381
|
+
|
|
30382
|
+
<!-- Twitter -->
|
|
30383
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
30384
|
+
<meta name="twitter:title" content="{{ fullTitle }}">
|
|
30385
|
+
<meta name="twitter:description" content="{{ seoDescription | truncate(160) }}">
|
|
30386
|
+
<meta name="twitter:image" content="{{ seoImage }}">
|
|
30387
|
+
{% if metadata.twitterHandle %}
|
|
30388
|
+
<meta name="twitter:site" content="{{ metadata.twitterHandle }}">
|
|
30389
|
+
<meta name="twitter:creator" content="{{ metadata.twitterHandle }}">
|
|
30390
|
+
{% endif %}
|
|
30391
|
+
|
|
30392
|
+
<!-- JSON-LD: WebSite -->
|
|
30393
|
+
<script type="application/ld+json">
|
|
30394
|
+
{
|
|
30395
|
+
"@context": "https://schema.org",
|
|
30396
|
+
"@type": "WebSite",
|
|
30397
|
+
"name": "{{ metadata.title }}",
|
|
30398
|
+
"url": "{{ metadata.url }}"
|
|
30399
|
+
}
|
|
30400
|
+
</script>
|
|
30401
|
+
|
|
30402
|
+
{% if seoType == "article" %}
|
|
30403
|
+
<!-- JSON-LD: Article -->
|
|
30404
|
+
<script type="application/ld+json">
|
|
30405
|
+
{
|
|
30406
|
+
"@context": "https://schema.org",
|
|
30407
|
+
"@type": "Article",
|
|
30408
|
+
"headline": "{{ title }}",
|
|
30409
|
+
"description": "{{ seoDescription | truncate(160) | escape }}",
|
|
30410
|
+
"image": "{{ seoImage }}",
|
|
30411
|
+
"datePublished": "{{ date | dateToISO }}",
|
|
30412
|
+
"dateModified": "{{ (lastModified or date) | dateToISO }}",
|
|
30413
|
+
"author": {
|
|
30414
|
+
"@type": "Person",
|
|
30415
|
+
"name": "{{ author or metadata.author or 'Author' }}"
|
|
30416
|
+
},
|
|
30417
|
+
"publisher": {
|
|
30418
|
+
"@type": "Organization",
|
|
30419
|
+
"name": "{{ metadata.title }}",
|
|
30420
|
+
"logo": {
|
|
30421
|
+
"@type": "ImageObject",
|
|
30422
|
+
"url": "{{ metadata.url }}/logo.png"
|
|
30423
|
+
}
|
|
30424
|
+
}
|
|
30425
|
+
}
|
|
30426
|
+
</script>
|
|
30427
|
+
{% endif %}`,
|
|
30428
|
+
explanation: `Eleventy comprehensive SEO partial with:
|
|
30429
|
+
\u2022 Full Open Graph with article support
|
|
30430
|
+
\u2022 Twitter Cards
|
|
30431
|
+
\u2022 JSON-LD schemas
|
|
30432
|
+
\u2022 Nunjucks templating
|
|
30433
|
+
|
|
30434
|
+
Usage: {% include "seo.njk" %}`,
|
|
30435
|
+
additionalFiles: [
|
|
30436
|
+
{
|
|
30437
|
+
file: "src/_includes/base.njk",
|
|
30438
|
+
code: `<!DOCTYPE html>
|
|
30439
|
+
<html lang="{{ metadata.locale | default('en') | truncate(2, true, '') }}">
|
|
30440
|
+
<head>
|
|
30441
|
+
<meta charset="utf-8">
|
|
30442
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
30443
|
+
|
|
30444
|
+
{% include "seo.njk" %}
|
|
30445
|
+
|
|
30446
|
+
<link rel="icon" href="/favicon.ico">
|
|
30447
|
+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
30448
|
+
|
|
30449
|
+
<link rel="stylesheet" href="/css/styles.css">
|
|
30450
|
+
</head>
|
|
30451
|
+
<body>
|
|
30452
|
+
{{ content | safe }}
|
|
30453
|
+
</body>
|
|
30454
|
+
</html>`,
|
|
30455
|
+
explanation: "Eleventy base layout with SEO include."
|
|
30456
|
+
},
|
|
30457
|
+
{
|
|
30458
|
+
file: "src/_data/metadata.json",
|
|
30459
|
+
code: `{
|
|
30460
|
+
"title": "${siteName}",
|
|
30461
|
+
"url": "${siteUrl}",
|
|
30462
|
+
"description": "${description || `${siteName} - A compelling description.`}",
|
|
30463
|
+
"defaultImage": "${image || "/og-image.png"}",
|
|
30464
|
+
"twitterHandle": "${twitterHandle || ""}",
|
|
30465
|
+
"locale": "en_US",
|
|
30466
|
+
"author": "Your Name"
|
|
30467
|
+
}`,
|
|
30468
|
+
explanation: "Eleventy global data file with SEO metadata."
|
|
30469
|
+
},
|
|
30470
|
+
{
|
|
30471
|
+
file: "src/robots.njk",
|
|
30472
|
+
code: `---
|
|
30473
|
+
permalink: /robots.txt
|
|
30474
|
+
eleventyExcludeFromCollections: true
|
|
30475
|
+
---
|
|
30476
|
+
User-agent: *
|
|
30477
|
+
Allow: /
|
|
30478
|
+
Disallow: /admin/
|
|
30479
|
+
|
|
30480
|
+
User-agent: GPTBot
|
|
30481
|
+
Allow: /
|
|
30482
|
+
|
|
30483
|
+
Sitemap: {{ metadata.url }}/sitemap.xml`,
|
|
30484
|
+
explanation: "Eleventy robots.txt template."
|
|
30485
|
+
}
|
|
30486
|
+
]
|
|
30487
|
+
};
|
|
30488
|
+
}
|
|
30489
|
+
function generateRemixSEO(options) {
|
|
30490
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
30491
|
+
return {
|
|
30492
|
+
file: "app/utils/seo.ts",
|
|
30493
|
+
code: `import type { V2_MetaFunction, V2_MetaDescriptor } from '@remix-run/node';
|
|
30494
|
+
|
|
30495
|
+
/**
|
|
30496
|
+
* Comprehensive SEO utilities for Remix
|
|
30497
|
+
*
|
|
30498
|
+
* Features:
|
|
30499
|
+
* - Full Open Graph support
|
|
30500
|
+
* - Twitter Cards
|
|
30501
|
+
* - JSON-LD structured data
|
|
30502
|
+
* - Type-safe meta function helpers
|
|
30503
|
+
*/
|
|
30504
|
+
|
|
30505
|
+
const SITE_NAME = '${siteName}';
|
|
30506
|
+
const SITE_URL = '${siteUrl}';
|
|
30507
|
+
const DEFAULT_IMAGE = '${image || `${siteUrl}/og-image.png`}';
|
|
30508
|
+
const DEFAULT_DESCRIPTION = '${description || `${siteName} - A compelling description.`}';
|
|
30509
|
+
const TWITTER_HANDLE = '${twitterHandle || ""}';
|
|
30510
|
+
|
|
30511
|
+
interface SEOConfig {
|
|
30512
|
+
title?: string;
|
|
30513
|
+
description?: string;
|
|
30514
|
+
image?: string;
|
|
30515
|
+
url?: string;
|
|
30516
|
+
type?: 'website' | 'article';
|
|
30517
|
+
publishedTime?: string;
|
|
30518
|
+
modifiedTime?: string;
|
|
30519
|
+
author?: string;
|
|
30520
|
+
tags?: string[];
|
|
30521
|
+
noIndex?: boolean;
|
|
30522
|
+
}
|
|
30523
|
+
|
|
30524
|
+
/**
|
|
30525
|
+
* Generate meta tags for a page
|
|
30526
|
+
*/
|
|
30527
|
+
export function generateMeta(config: SEOConfig = {}): V2_MetaDescriptor[] {
|
|
30528
|
+
const {
|
|
30529
|
+
title,
|
|
30530
|
+
description = DEFAULT_DESCRIPTION,
|
|
30531
|
+
image = DEFAULT_IMAGE,
|
|
30532
|
+
url = SITE_URL,
|
|
30533
|
+
type = 'website',
|
|
30534
|
+
publishedTime,
|
|
30535
|
+
modifiedTime,
|
|
30536
|
+
author,
|
|
30537
|
+
tags = [],
|
|
30538
|
+
noIndex = false,
|
|
30539
|
+
} = config;
|
|
30540
|
+
|
|
30541
|
+
const fullTitle = title
|
|
30542
|
+
? title.includes(SITE_NAME)
|
|
30543
|
+
? title
|
|
30544
|
+
: \`\${title} | \${SITE_NAME}\`
|
|
30545
|
+
: SITE_NAME;
|
|
30546
|
+
|
|
30547
|
+
const imageUrl = image.startsWith('http') ? image : \`\${SITE_URL}\${image}\`;
|
|
30548
|
+
const robotsContent = noIndex ? 'noindex, nofollow' : 'index, follow';
|
|
30549
|
+
|
|
30550
|
+
const meta: V2_MetaDescriptor[] = [
|
|
30551
|
+
{ title: fullTitle },
|
|
30552
|
+
{ name: 'description', content: description },
|
|
30553
|
+
{ name: 'robots', content: robotsContent },
|
|
30554
|
+
|
|
30555
|
+
// Open Graph
|
|
30556
|
+
{ property: 'og:type', content: type },
|
|
30557
|
+
{ property: 'og:url', content: url },
|
|
30558
|
+
{ property: 'og:title', content: fullTitle },
|
|
30559
|
+
{ property: 'og:description', content: description },
|
|
30560
|
+
{ property: 'og:image', content: imageUrl },
|
|
30561
|
+
{ property: 'og:image:width', content: '1200' },
|
|
30562
|
+
{ property: 'og:image:height', content: '630' },
|
|
30563
|
+
{ property: 'og:site_name', content: SITE_NAME },
|
|
30564
|
+
|
|
30565
|
+
// Twitter
|
|
30566
|
+
{ name: 'twitter:card', content: 'summary_large_image' },
|
|
30567
|
+
{ name: 'twitter:title', content: fullTitle },
|
|
30568
|
+
{ name: 'twitter:description', content: description },
|
|
30569
|
+
{ name: 'twitter:image', content: imageUrl },
|
|
30570
|
+
];
|
|
30571
|
+
|
|
30572
|
+
if (TWITTER_HANDLE) {
|
|
30573
|
+
meta.push(
|
|
30574
|
+
{ name: 'twitter:site', content: TWITTER_HANDLE },
|
|
30575
|
+
{ name: 'twitter:creator', content: TWITTER_HANDLE }
|
|
30576
|
+
);
|
|
30577
|
+
}
|
|
30578
|
+
|
|
30579
|
+
// Article-specific meta
|
|
30580
|
+
if (type === 'article') {
|
|
30581
|
+
if (publishedTime) {
|
|
30582
|
+
meta.push({ property: 'article:published_time', content: publishedTime });
|
|
30583
|
+
}
|
|
30584
|
+
if (modifiedTime) {
|
|
30585
|
+
meta.push({ property: 'article:modified_time', content: modifiedTime });
|
|
30586
|
+
}
|
|
30587
|
+
if (author) {
|
|
30588
|
+
meta.push({ property: 'article:author', content: author });
|
|
30589
|
+
}
|
|
30590
|
+
tags.forEach((tag) => {
|
|
30591
|
+
meta.push({ property: 'article:tag', content: tag });
|
|
30592
|
+
});
|
|
30593
|
+
}
|
|
30594
|
+
|
|
30595
|
+
return meta;
|
|
30596
|
+
}
|
|
30597
|
+
|
|
30598
|
+
/**
|
|
30599
|
+
* Generate canonical link
|
|
30600
|
+
*/
|
|
30601
|
+
export function generateLinks(url: string) {
|
|
30602
|
+
return [{ rel: 'canonical', href: url }];
|
|
30603
|
+
}
|
|
30604
|
+
|
|
30605
|
+
// JSON-LD Schema Generators
|
|
30606
|
+
|
|
30607
|
+
export function websiteSchema() {
|
|
30608
|
+
return {
|
|
30609
|
+
'@context': 'https://schema.org',
|
|
30610
|
+
'@type': 'WebSite',
|
|
30611
|
+
name: SITE_NAME,
|
|
30612
|
+
url: SITE_URL,
|
|
30613
|
+
};
|
|
30614
|
+
}
|
|
30615
|
+
|
|
30616
|
+
export function articleSchema(data: {
|
|
30617
|
+
headline: string;
|
|
30618
|
+
description: string;
|
|
30619
|
+
image?: string;
|
|
30620
|
+
datePublished: string;
|
|
30621
|
+
dateModified?: string;
|
|
30622
|
+
author: { name: string; url?: string };
|
|
30623
|
+
}) {
|
|
30624
|
+
return {
|
|
30625
|
+
'@context': 'https://schema.org',
|
|
30626
|
+
'@type': 'Article',
|
|
30627
|
+
headline: data.headline,
|
|
30628
|
+
description: data.description,
|
|
30629
|
+
image: data.image || DEFAULT_IMAGE,
|
|
30630
|
+
datePublished: data.datePublished,
|
|
30631
|
+
dateModified: data.dateModified || data.datePublished,
|
|
30632
|
+
author: { '@type': 'Person', ...data.author },
|
|
30633
|
+
publisher: {
|
|
30634
|
+
'@type': 'Organization',
|
|
30635
|
+
name: SITE_NAME,
|
|
30636
|
+
logo: { '@type': 'ImageObject', url: \`\${SITE_URL}/logo.png\` },
|
|
30637
|
+
},
|
|
30638
|
+
};
|
|
30639
|
+
}
|
|
30640
|
+
|
|
30641
|
+
export function productSchema(data: {
|
|
30642
|
+
name: string;
|
|
30643
|
+
description: string;
|
|
30644
|
+
image?: string;
|
|
30645
|
+
price: number;
|
|
30646
|
+
currency?: string;
|
|
30647
|
+
inStock?: boolean;
|
|
30648
|
+
}) {
|
|
30649
|
+
return {
|
|
30650
|
+
'@context': 'https://schema.org',
|
|
30651
|
+
'@type': 'Product',
|
|
30652
|
+
name: data.name,
|
|
30653
|
+
description: data.description,
|
|
30654
|
+
image: data.image || DEFAULT_IMAGE,
|
|
30655
|
+
offers: {
|
|
30656
|
+
'@type': 'Offer',
|
|
30657
|
+
price: data.price.toString(),
|
|
30658
|
+
priceCurrency: data.currency || 'USD',
|
|
30659
|
+
availability: \`https://schema.org/\${data.inStock !== false ? 'InStock' : 'OutOfStock'}\`,
|
|
30660
|
+
},
|
|
30661
|
+
};
|
|
30662
|
+
}
|
|
30663
|
+
|
|
30664
|
+
export function faqSchema(items: { question: string; answer: string }[]) {
|
|
30665
|
+
return {
|
|
30666
|
+
'@context': 'https://schema.org',
|
|
30667
|
+
'@type': 'FAQPage',
|
|
30668
|
+
mainEntity: items.map((item) => ({
|
|
30669
|
+
'@type': 'Question',
|
|
30670
|
+
name: item.question,
|
|
30671
|
+
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
|
30672
|
+
})),
|
|
30673
|
+
};
|
|
30674
|
+
}
|
|
30675
|
+
|
|
30676
|
+
export function breadcrumbSchema(items: { name: string; url: string }[]) {
|
|
30677
|
+
return {
|
|
30678
|
+
'@context': 'https://schema.org',
|
|
30679
|
+
'@type': 'BreadcrumbList',
|
|
30680
|
+
itemListElement: items.map((item, i) => ({
|
|
30681
|
+
'@type': 'ListItem',
|
|
30682
|
+
position: i + 1,
|
|
30683
|
+
name: item.name,
|
|
30684
|
+
item: item.url,
|
|
30685
|
+
})),
|
|
30686
|
+
};
|
|
30687
|
+
}
|
|
30688
|
+
|
|
30689
|
+
/**
|
|
30690
|
+
* JSON-LD Script component helper
|
|
30691
|
+
*/
|
|
30692
|
+
export function JsonLd({ schema }: { schema: Record<string, unknown> }) {
|
|
30693
|
+
return (
|
|
30694
|
+
<script
|
|
30695
|
+
type="application/ld+json"
|
|
30696
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
|
30697
|
+
/>
|
|
30698
|
+
);
|
|
30699
|
+
}`,
|
|
30700
|
+
explanation: `Remix comprehensive SEO utilities with:
|
|
30701
|
+
\u2022 Type-safe meta function helpers
|
|
30702
|
+
\u2022 Full Open Graph with article support
|
|
30703
|
+
\u2022 Twitter Cards
|
|
30704
|
+
\u2022 JSON-LD schema generators
|
|
30705
|
+
\u2022 Remix V2 meta conventions
|
|
30706
|
+
|
|
30707
|
+
Usage in route:
|
|
30708
|
+
export const meta: V2_MetaFunction = () => generateMeta({ title: "Page" });`,
|
|
30709
|
+
additionalFiles: [
|
|
30710
|
+
{
|
|
30711
|
+
file: "app/root.tsx",
|
|
30712
|
+
code: `import {
|
|
30713
|
+
Links,
|
|
30714
|
+
LiveReload,
|
|
30715
|
+
Meta,
|
|
30716
|
+
Outlet,
|
|
30717
|
+
Scripts,
|
|
30718
|
+
ScrollRestoration,
|
|
30719
|
+
} from '@remix-run/react';
|
|
30720
|
+
import { JsonLd, websiteSchema } from '~/utils/seo';
|
|
30721
|
+
|
|
30722
|
+
export default function App() {
|
|
30723
|
+
return (
|
|
30724
|
+
<html lang="en">
|
|
30725
|
+
<head>
|
|
30726
|
+
<meta charSet="utf-8" />
|
|
30727
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
30728
|
+
<Meta />
|
|
30729
|
+
<Links />
|
|
30730
|
+
<JsonLd schema={websiteSchema()} />
|
|
30731
|
+
</head>
|
|
30732
|
+
<body>
|
|
30733
|
+
<Outlet />
|
|
30734
|
+
<ScrollRestoration />
|
|
30735
|
+
<Scripts />
|
|
30736
|
+
<LiveReload />
|
|
30737
|
+
</body>
|
|
30738
|
+
</html>
|
|
30739
|
+
);
|
|
30740
|
+
}`,
|
|
30741
|
+
explanation: "Remix root with SEO integration."
|
|
30742
|
+
},
|
|
30743
|
+
{
|
|
30744
|
+
file: "public/robots.txt",
|
|
30745
|
+
code: `User-agent: *
|
|
30746
|
+
Allow: /
|
|
30747
|
+
Disallow: /admin/
|
|
30748
|
+
Disallow: /api/
|
|
30749
|
+
|
|
30750
|
+
User-agent: GPTBot
|
|
30751
|
+
Allow: /
|
|
30752
|
+
|
|
30753
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
30754
|
+
explanation: "Robots.txt with AI crawler support."
|
|
30755
|
+
}
|
|
30756
|
+
]
|
|
30757
|
+
};
|
|
30758
|
+
}
|
|
30759
|
+
function getFrameworkSpecificFixExtended(framework, options) {
|
|
30760
|
+
const name = framework.name.toLowerCase();
|
|
30761
|
+
if (name.includes("next")) {
|
|
30762
|
+
return framework.router === "app" ? generateNextJsAppRouterMetadata(options) : generateNextJsPagesRouterHead(options);
|
|
30763
|
+
}
|
|
30764
|
+
if (name.includes("nuxt")) return generateNuxtSEOHead(options);
|
|
30765
|
+
if (name.includes("vue")) return generateVueSEOHead(options);
|
|
30766
|
+
if (name.includes("astro")) return generateAstroBaseHead(options);
|
|
30767
|
+
if (name.includes("svelte")) return generateSvelteKitSEOHead(options);
|
|
30768
|
+
if (name.includes("angular")) return generateAngularSEOService(options);
|
|
30769
|
+
if (name.includes("remix")) return generateRemixSEO(options);
|
|
30770
|
+
if (name.includes("rails") || name.includes("ruby")) return generateRailsSEOHelper(options);
|
|
30771
|
+
if (name.includes("django")) return generateDjangoSEOHelper(options);
|
|
30772
|
+
if (name.includes("laravel") || name.includes("php")) return generateLaravelSEOHelper(options);
|
|
30773
|
+
if (name.includes("spring") || name.includes("java")) return generateSpringBootSEO(options);
|
|
30774
|
+
if (name.includes("phoenix") || name.includes("elixir")) return generatePhoenixSEO(options);
|
|
30775
|
+
if (name.includes("asp") || name.includes(".net") || name.includes("csharp") || name.includes("c#")) {
|
|
30776
|
+
return generateAspNetCoreSEO(options);
|
|
30777
|
+
}
|
|
30778
|
+
if (name.includes("go") || name.includes("gin") || name.includes("echo") || name.includes("fiber")) {
|
|
30779
|
+
return generateGoSEO(options);
|
|
30780
|
+
}
|
|
30781
|
+
if (name.includes("htmx") || name.includes("hotwire") || name.includes("turbo")) {
|
|
30782
|
+
return generateHTMXSEO(options);
|
|
30783
|
+
}
|
|
30784
|
+
if (name.includes("hugo")) return generateHugoSEO(options);
|
|
30785
|
+
if (name.includes("jekyll")) return generateJekyllSEO(options);
|
|
30786
|
+
if (name.includes("eleventy") || name.includes("11ty")) return generateEleventySEO(options);
|
|
30787
|
+
return generateReactSEOHead(options);
|
|
30788
|
+
}
|
|
30789
|
+
|
|
30790
|
+
// src/fixer.ts
|
|
30791
|
+
async function generateFixes(issues, options) {
|
|
30792
|
+
const { cwd, url } = options;
|
|
30793
|
+
let framework = options.framework;
|
|
30794
|
+
if (!framework) {
|
|
30795
|
+
const result = await detectFramework3({ cwd });
|
|
30796
|
+
framework = result.success ? result.data : { name: "Unknown", metaPattern: "html-head" };
|
|
30797
|
+
}
|
|
30798
|
+
const fixes = [];
|
|
30799
|
+
const htmlEntry = await findHtmlEntry({ cwd });
|
|
30800
|
+
const htmlPath = htmlEntry.success ? htmlEntry.data.path : "index.html";
|
|
30801
|
+
const htmlContent = htmlEntry.success ? htmlEntry.data.content : "";
|
|
30802
|
+
for (const issue of issues) {
|
|
30803
|
+
const fix = await generateFixForIssue(issue, {
|
|
30804
|
+
cwd,
|
|
30805
|
+
url,
|
|
30806
|
+
framework,
|
|
30807
|
+
htmlPath,
|
|
30808
|
+
htmlContent
|
|
30809
|
+
});
|
|
30810
|
+
if (fix) {
|
|
30811
|
+
fixes.push(fix);
|
|
30812
|
+
}
|
|
30813
|
+
}
|
|
30814
|
+
return fixes;
|
|
30815
|
+
}
|
|
30816
|
+
async function generateFixForIssue(issue, context) {
|
|
30817
|
+
const { cwd, url, framework, htmlPath, htmlContent } = context;
|
|
30818
|
+
const fullUrl = url || "https://example.com";
|
|
30819
|
+
const siteName = new URL(fullUrl).hostname.replace("www.", "");
|
|
30820
|
+
switch (issue.code) {
|
|
30821
|
+
// Title issues
|
|
30822
|
+
case "MISSING_TITLE":
|
|
30823
|
+
case "TITLE_MISSING":
|
|
30824
|
+
// Full audit code
|
|
30825
|
+
case "TITLE_KEYWORD_MISMATCH":
|
|
30826
|
+
case "TITLE_H1_KEYWORD_MISMATCH":
|
|
30827
|
+
case "OUTDATED_YEAR_IN_TITLE":
|
|
30828
|
+
return generateTitleFix(context, siteName);
|
|
30829
|
+
// Meta description issues
|
|
30830
|
+
case "MISSING_META_DESC":
|
|
30831
|
+
case "META_DESC_MISSING":
|
|
30832
|
+
return generateMetaDescFix(context, siteName);
|
|
30833
|
+
// Canonical issues
|
|
30834
|
+
case "MISSING_CANONICAL":
|
|
30835
|
+
case "CANONICAL_MISSING":
|
|
30836
|
+
// Full audit code
|
|
30837
|
+
case "CANONICAL_NO_HTTPS_REDIRECT":
|
|
30838
|
+
return generateCanonicalFix(context, fullUrl);
|
|
30839
|
+
// Viewport issues
|
|
30840
|
+
case "MISSING_VIEWPORT":
|
|
30841
|
+
case "HTML_NO_VIEWPORT":
|
|
30842
|
+
case "RESPONSIVE_NO_VIEWPORT":
|
|
30843
|
+
case "VIEWPORT_MISSING":
|
|
30844
|
+
return generateViewportFix(context);
|
|
30845
|
+
// Open Graph issues
|
|
30846
|
+
case "MISSING_OG_TAGS":
|
|
30847
|
+
case "OG_TITLE_MISSING":
|
|
30848
|
+
case "OG_DESC_MISSING":
|
|
30849
|
+
case "OG_DESCRIPTION_MISSING":
|
|
30850
|
+
// Full audit code
|
|
30851
|
+
case "OG_IMAGE_MISSING":
|
|
30852
|
+
case "OG_URL_MISSING":
|
|
30853
|
+
case "OG_TYPE_MISSING":
|
|
30854
|
+
return generateOGFix(context, siteName, fullUrl);
|
|
30855
|
+
// Twitter Card issues
|
|
30856
|
+
case "MISSING_TWITTER_CARD":
|
|
30857
|
+
case "TWITTER_CARD_MISSING":
|
|
30858
|
+
case "TWITTER_TITLE_MISSING":
|
|
30859
|
+
case "TWITTER_DESC_MISSING":
|
|
30860
|
+
case "TWITTER_DESCRIPTION_MISSING":
|
|
30861
|
+
// Full audit code
|
|
30862
|
+
case "TWITTER_IMAGE_MISSING":
|
|
30863
|
+
return generateTwitterFix(context, siteName);
|
|
30864
|
+
// Schema/structured data issues
|
|
30865
|
+
case "MISSING_SCHEMA":
|
|
30866
|
+
case "SCHEMA_MISSING":
|
|
30867
|
+
// Full audit code
|
|
30868
|
+
case "SCHEMA_ORG_MISSING":
|
|
30869
|
+
case "NO_ORGANIZATION_SCHEMA":
|
|
30870
|
+
case "NO_ENTITY_SCHEMA":
|
|
30871
|
+
case "FAQ_SCHEMA_MISSING":
|
|
30872
|
+
return generateSchemaFix(context, siteName, fullUrl);
|
|
30873
|
+
// Robots.txt issues
|
|
30874
|
+
case "MISSING_ROBOTS":
|
|
30875
|
+
case "ROBOTS_TXT_MISSING":
|
|
30876
|
+
// Full audit code
|
|
30877
|
+
case "ROBOTS_TXT_WARNINGS":
|
|
30878
|
+
case "ROBOTS_TXT_INVALID_SYNTAX":
|
|
30879
|
+
return generateRobotsFix(context, fullUrl);
|
|
30880
|
+
// Sitemap issues
|
|
30881
|
+
case "MISSING_SITEMAP":
|
|
30882
|
+
case "SITEMAP_MISSING":
|
|
30883
|
+
// Full audit code
|
|
30884
|
+
case "BING_SITEMAP_MISSING":
|
|
30885
|
+
return generateSitemapFix(context, fullUrl);
|
|
30886
|
+
// H1 issues
|
|
30887
|
+
case "MISSING_H1":
|
|
30888
|
+
case "H1_MISSING":
|
|
30889
|
+
// Full audit code
|
|
30890
|
+
case "NO_VISIBLE_HEADLINE":
|
|
30891
|
+
case "H1_MISSING_KEYWORD":
|
|
30892
|
+
return await generateH1Fix({ cwd });
|
|
30893
|
+
// SPA-specific: add meta management library recommendation
|
|
30894
|
+
case "SPA_NO_META_MANAGEMENT":
|
|
30895
|
+
case "SPA_WITHOUT_SSR":
|
|
30896
|
+
case "CLIENT_SIDE_RENDERING":
|
|
30897
|
+
return generateSPAMetaFix(context, framework);
|
|
30898
|
+
// Internal links - suggest adding links
|
|
30899
|
+
case "NO_INTERNAL_LINKS":
|
|
30900
|
+
case "LINKS_NO_INTERNAL":
|
|
30901
|
+
case "NO_CONTENT_INTERNAL_LINKS":
|
|
30902
|
+
return {
|
|
30903
|
+
issue: { code: issue.code, message: "No internal links found", severity: "warning" },
|
|
30904
|
+
file: "src/components/Footer.tsx",
|
|
30905
|
+
before: null,
|
|
30906
|
+
after: `// Add internal navigation links to your footer or content
|
|
30907
|
+
// Example: <Link to="/about">About</Link>`,
|
|
30908
|
+
explanation: "Add internal links to help search engines discover your content",
|
|
30909
|
+
skipped: true,
|
|
30910
|
+
skipReason: "Requires manual content updates - add links to your navigation and content"
|
|
30911
|
+
};
|
|
30912
|
+
// Preconnect issues
|
|
30913
|
+
case "GOOGLE_FONTS_NO_PRECONNECT":
|
|
30914
|
+
case "MISSING_PRECONNECT":
|
|
30915
|
+
return generatePreconnectFix(context);
|
|
30916
|
+
// Favicon/icons
|
|
30917
|
+
case "HTML_NO_FAVICON":
|
|
30918
|
+
case "HTML_NO_APPLE_TOUCH_ICON":
|
|
30919
|
+
case "FAVICON_MISSING":
|
|
30920
|
+
return generateFaviconFix(context);
|
|
30921
|
+
// Charset/lang
|
|
30922
|
+
case "HTML_NO_CHARSET":
|
|
30923
|
+
case "HTML_NOT_UTF8":
|
|
30924
|
+
return generateCharsetFix(context);
|
|
30925
|
+
case "HTML_NO_LANG":
|
|
30926
|
+
case "LANG_ATTR_MISSING":
|
|
30927
|
+
return generateLangFix(context);
|
|
30928
|
+
// AI/LLMs.txt
|
|
30929
|
+
case "AI_NO_LLMS_TXT":
|
|
30930
|
+
return generateLlmsTxtFix(context, siteName, fullUrl);
|
|
30931
|
+
default:
|
|
30932
|
+
return null;
|
|
30933
|
+
}
|
|
30934
|
+
}
|
|
30935
|
+
function generateTitleFix(context, siteName) {
|
|
30936
|
+
const { htmlPath, htmlContent, framework } = context;
|
|
30937
|
+
if (framework.metaPattern === "html-head") {
|
|
30938
|
+
const titleTag = `<title>${siteName} - Your Product Tagline</title>`;
|
|
30939
|
+
if (htmlContent.includes("<title>")) {
|
|
30940
|
+
const before = htmlContent.match(/<title>.*?<\/title>/)?.[0] || "";
|
|
30941
|
+
return {
|
|
30942
|
+
issue: { code: "MISSING_TITLE", message: "Missing or empty title tag", severity: "critical" },
|
|
30943
|
+
file: htmlPath,
|
|
30944
|
+
before,
|
|
30945
|
+
after: titleTag,
|
|
30946
|
+
explanation: "Updated the title tag with a descriptive title"
|
|
30947
|
+
};
|
|
30948
|
+
} else {
|
|
30949
|
+
return {
|
|
30950
|
+
issue: { code: "MISSING_TITLE", message: "Missing title tag", severity: "critical" },
|
|
30951
|
+
file: htmlPath,
|
|
30952
|
+
before: "<head>",
|
|
30953
|
+
after: `<head>
|
|
30954
|
+
${titleTag}`,
|
|
30955
|
+
explanation: "Added a title tag to the document head"
|
|
30956
|
+
};
|
|
30957
|
+
}
|
|
30958
|
+
}
|
|
30959
|
+
return {
|
|
30960
|
+
issue: { code: "MISSING_TITLE", message: "Missing title tag", severity: "critical" },
|
|
30961
|
+
file: htmlPath,
|
|
30962
|
+
before: null,
|
|
30963
|
+
after: `<title>${siteName} - Your Product Tagline</title>`,
|
|
30964
|
+
explanation: `Add title using ${framework.name} patterns`
|
|
30965
|
+
};
|
|
30966
|
+
}
|
|
30967
|
+
function generateMetaDescFix(context, siteName) {
|
|
30968
|
+
const { htmlPath, htmlContent, framework } = context;
|
|
30969
|
+
const description = `${siteName} - A brief, compelling description of your product or service. Include key features and benefits.`;
|
|
30970
|
+
if (framework.metaPattern === "html-head") {
|
|
30971
|
+
const metaTag = `<meta name="description" content="${description}" />`;
|
|
30972
|
+
if (htmlContent.includes("<title>")) {
|
|
30973
|
+
return {
|
|
30974
|
+
issue: { code: "MISSING_META_DESC", message: "Missing meta description", severity: "critical" },
|
|
30975
|
+
file: htmlPath,
|
|
30976
|
+
before: "</title>",
|
|
30977
|
+
after: `</title>
|
|
30978
|
+
${metaTag}`,
|
|
30979
|
+
explanation: "Added meta description after the title tag"
|
|
30980
|
+
};
|
|
30981
|
+
} else {
|
|
30982
|
+
return {
|
|
30983
|
+
issue: { code: "MISSING_META_DESC", message: "Missing meta description", severity: "critical" },
|
|
30984
|
+
file: htmlPath,
|
|
30985
|
+
before: "<head>",
|
|
30986
|
+
after: `<head>
|
|
30987
|
+
${metaTag}`,
|
|
30988
|
+
explanation: "Added meta description to the document head"
|
|
30989
|
+
};
|
|
30990
|
+
}
|
|
30991
|
+
}
|
|
30992
|
+
return {
|
|
30993
|
+
issue: { code: "MISSING_META_DESC", message: "Missing meta description", severity: "critical" },
|
|
30994
|
+
file: htmlPath,
|
|
30995
|
+
before: null,
|
|
27635
30996
|
after: `<meta name="description" content="${description}" />`,
|
|
27636
30997
|
explanation: `Add meta description using ${framework.name} patterns`
|
|
27637
30998
|
};
|
|
@@ -30394,6 +33755,121 @@ function getAIVisibilitySummary(results) {
|
|
|
30394
33755
|
};
|
|
30395
33756
|
}
|
|
30396
33757
|
|
|
33758
|
+
// src/analyzers/index.ts
|
|
33759
|
+
var analyzers_exports = {};
|
|
33760
|
+
__export(analyzers_exports, {
|
|
33761
|
+
AI_CRAWLERS_INFO: () => AI_CRAWLERS_INFO,
|
|
33762
|
+
analyzeCitationReadiness: () => analyzeCitationReadiness,
|
|
33763
|
+
analyzeComprehensive: () => analyzeComprehensive,
|
|
33764
|
+
analyzeContentStructure: () => analyzeContentStructure,
|
|
33765
|
+
analyzeCoreWebVitals: () => analyzeCoreWebVitals,
|
|
33766
|
+
analyzeEntityExtraction: () => analyzeEntityExtraction,
|
|
33767
|
+
analyzeGEO: () => analyzeGEO,
|
|
33768
|
+
analyzeImages: () => analyzeImages,
|
|
33769
|
+
analyzeInternalLinking: () => analyzeInternalLinking,
|
|
33770
|
+
analyzeMobileSEO: () => analyzeMobileSEO,
|
|
33771
|
+
analyzeRobotsTxtForAI: () => analyzeRobotsTxtForAI,
|
|
33772
|
+
analyzeSecurityHeaders: () => analyzeSecurityHeaders,
|
|
33773
|
+
analyzeStructuredData: () => analyzeStructuredData,
|
|
33774
|
+
calculateLLMSignals: () => calculateLLMSignals,
|
|
33775
|
+
detectRenderingMode: () => detectRenderingMode,
|
|
33776
|
+
generateAIFriendlyRobotsTxt: () => generateAIFriendlyRobotsTxt,
|
|
33777
|
+
generateResponsiveImage: () => generateResponsiveImage,
|
|
33778
|
+
generateSchemaTemplate: () => generateSchemaTemplate,
|
|
33779
|
+
generateSecurityHeaders: () => generateSecurityHeaders,
|
|
33780
|
+
suggestInternalLinks: () => suggestInternalLinks
|
|
33781
|
+
});
|
|
33782
|
+
async function analyzeComprehensive(html, url, options = {}) {
|
|
33783
|
+
const { robotsTxt, headers } = options;
|
|
33784
|
+
const { analyzeGEO: analyzeGEO2 } = await import("./geo-analyzer-D47LTMMA.mjs");
|
|
33785
|
+
const { analyzeCoreWebVitals: analyzeCoreWebVitals2 } = await import("./core-web-vitals-analyzer-TE6LQJMS.mjs");
|
|
33786
|
+
const { analyzeSecurityHeaders: analyzeSecurityHeaders3 } = await import("./security-headers-analyzer-3ZUQARS5.mjs");
|
|
33787
|
+
const { analyzeStructuredData: analyzeStructuredData3 } = await import("./structured-data-analyzer-2I4NQAUP.mjs");
|
|
33788
|
+
const { analyzeImages: analyzeImages3 } = await import("./image-optimization-analyzer-XP4OQGRP.mjs");
|
|
33789
|
+
const { analyzeInternalLinking: analyzeInternalLinking2 } = await import("./internal-linking-analyzer-MRMBV7NM.mjs");
|
|
33790
|
+
const { analyzeMobileSEO: analyzeMobileSEO2 } = await import("./mobile-seo-analyzer-67HNQ7IO.mjs");
|
|
33791
|
+
const geo = await analyzeGEO2(html, url, robotsTxt);
|
|
33792
|
+
const coreWebVitals = analyzeCoreWebVitals2(html, url, headers);
|
|
33793
|
+
const security = analyzeSecurityHeaders3(headers || {}, url);
|
|
33794
|
+
const structuredData = analyzeStructuredData3(html, url);
|
|
33795
|
+
const images = analyzeImages3(html, url);
|
|
33796
|
+
const internalLinking = analyzeInternalLinking2(html, url);
|
|
33797
|
+
const mobile = analyzeMobileSEO2(html, url);
|
|
33798
|
+
const allIssues = [
|
|
33799
|
+
...geo.issues,
|
|
33800
|
+
...coreWebVitals.issues,
|
|
33801
|
+
...security.issues,
|
|
33802
|
+
...structuredData.issues,
|
|
33803
|
+
...images.issues,
|
|
33804
|
+
...internalLinking.issues,
|
|
33805
|
+
...mobile.issues
|
|
33806
|
+
];
|
|
33807
|
+
const severityOrder = { critical: 0, error: 1, warning: 2, info: 3, notice: 4 };
|
|
33808
|
+
allIssues.sort((a, b) => (severityOrder[a.severity] ?? 5) - (severityOrder[b.severity] ?? 5));
|
|
33809
|
+
const weights = {
|
|
33810
|
+
geo: 0.2,
|
|
33811
|
+
// AI search is crucial
|
|
33812
|
+
cwv: 0.2,
|
|
33813
|
+
// Core Web Vitals
|
|
33814
|
+
security: 0.1,
|
|
33815
|
+
// Security headers
|
|
33816
|
+
schema: 0.15,
|
|
33817
|
+
// Structured data
|
|
33818
|
+
images: 0.1,
|
|
33819
|
+
// Image optimization
|
|
33820
|
+
links: 0.1,
|
|
33821
|
+
// Internal linking
|
|
33822
|
+
mobile: 0.15
|
|
33823
|
+
// Mobile SEO
|
|
33824
|
+
};
|
|
33825
|
+
const securityScore = security.grade === "A+" ? 100 : security.grade === "A" ? 90 : security.grade === "B" ? 75 : security.grade === "C" ? 55 : security.grade === "D" ? 35 : 20;
|
|
33826
|
+
const overallScore = Math.round(
|
|
33827
|
+
geo.score * weights.geo + coreWebVitals.overallScore * weights.cwv + securityScore * weights.security + structuredData.score * weights.schema + images.score * weights.images + internalLinking.score * weights.links + mobile.score * weights.mobile
|
|
33828
|
+
);
|
|
33829
|
+
const prioritizedRecommendations = [];
|
|
33830
|
+
if (geo.aiCrawlerAccess.blockedCrawlers.length > 0) {
|
|
33831
|
+
prioritizedRecommendations.push("\u{1F6A8} URGENT: Unblock AI crawlers (GPTBot, PerplexityBot) in robots.txt");
|
|
33832
|
+
}
|
|
33833
|
+
if (geo.aiCrawlerAccess.jsRenderingRequired && !geo.aiCrawlerAccess.hasPrerendering) {
|
|
33834
|
+
prioritizedRecommendations.push("\u{1F6A8} URGENT: Implement SSR - AI crawlers cannot see your JS-rendered content");
|
|
33835
|
+
}
|
|
33836
|
+
const criticalCWV = coreWebVitals.issues.filter((i) => i.severity === "critical");
|
|
33837
|
+
criticalCWV.forEach((i) => prioritizedRecommendations.push(`\u26A1 ${i.title}`));
|
|
33838
|
+
if (!security.https.enabled) {
|
|
33839
|
+
prioritizedRecommendations.push("\u{1F512} Enable HTTPS immediately");
|
|
33840
|
+
}
|
|
33841
|
+
if (!mobile.viewport.hasViewport) {
|
|
33842
|
+
prioritizedRecommendations.push("\u{1F4F1} Add viewport meta tag for mobile rendering");
|
|
33843
|
+
}
|
|
33844
|
+
geo.recommendations.slice(0, 2).forEach((r) => prioritizedRecommendations.push(r));
|
|
33845
|
+
structuredData.recommendations.slice(0, 2).forEach((r) => prioritizedRecommendations.push(r));
|
|
33846
|
+
images.recommendations.slice(0, 2).forEach((r) => prioritizedRecommendations.push(r));
|
|
33847
|
+
internalLinking.recommendations.slice(0, 2).forEach((r) => prioritizedRecommendations.push(r));
|
|
33848
|
+
return {
|
|
33849
|
+
url,
|
|
33850
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
33851
|
+
overallScore,
|
|
33852
|
+
grades: {
|
|
33853
|
+
geo: geo.score,
|
|
33854
|
+
coreWebVitals: coreWebVitals.overallScore,
|
|
33855
|
+
security: security.grade,
|
|
33856
|
+
structuredData: structuredData.score,
|
|
33857
|
+
images: images.score,
|
|
33858
|
+
internalLinking: internalLinking.score,
|
|
33859
|
+
mobile: mobile.score
|
|
33860
|
+
},
|
|
33861
|
+
geo,
|
|
33862
|
+
coreWebVitals,
|
|
33863
|
+
security,
|
|
33864
|
+
structuredData,
|
|
33865
|
+
images,
|
|
33866
|
+
internalLinking,
|
|
33867
|
+
mobile,
|
|
33868
|
+
allIssues,
|
|
33869
|
+
prioritizedRecommendations
|
|
33870
|
+
};
|
|
33871
|
+
}
|
|
33872
|
+
|
|
30397
33873
|
// src/frameworks/index.ts
|
|
30398
33874
|
var frameworks_exports = {};
|
|
30399
33875
|
__export(frameworks_exports, {
|
|
@@ -30417,165 +33893,266 @@ __export(frameworks_exports, {
|
|
|
30417
33893
|
|
|
30418
33894
|
// src/frameworks/detector.ts
|
|
30419
33895
|
var HTML_SIGNATURES = [
|
|
33896
|
+
// ============ JavaScript/TypeScript Frameworks ============
|
|
30420
33897
|
// Next.js
|
|
30421
33898
|
{
|
|
30422
33899
|
framework: "nextjs",
|
|
30423
|
-
|
|
30424
|
-
|
|
30425
|
-
/_next\//,
|
|
30426
|
-
/data-nscript/,
|
|
30427
|
-
/__NEXT_DATA__/,
|
|
30428
|
-
/next\/script/
|
|
30429
|
-
],
|
|
33900
|
+
language: "typescript",
|
|
33901
|
+
patterns: [/__next/, /_next\//, /data-nscript/, /__NEXT_DATA__/, /next\/script/],
|
|
30430
33902
|
confidence: "high"
|
|
30431
33903
|
},
|
|
30432
33904
|
// Nuxt
|
|
30433
33905
|
{
|
|
30434
33906
|
framework: "nuxt",
|
|
30435
|
-
|
|
30436
|
-
|
|
30437
|
-
/_nuxt\//,
|
|
30438
|
-
/data-n-head/,
|
|
30439
|
-
/nuxt-link/,
|
|
30440
|
-
/__NUXT__/
|
|
30441
|
-
],
|
|
33907
|
+
language: "typescript",
|
|
33908
|
+
patterns: [/__nuxt/, /_nuxt\//, /data-n-head/, /nuxt-link/, /__NUXT__/],
|
|
30442
33909
|
confidence: "high"
|
|
30443
33910
|
},
|
|
30444
33911
|
// Gatsby
|
|
30445
33912
|
{
|
|
30446
33913
|
framework: "gatsby",
|
|
30447
|
-
|
|
30448
|
-
|
|
30449
|
-
/gatsby-/,
|
|
30450
|
-
/gatsby-image/,
|
|
30451
|
-
/data-gatsby/
|
|
30452
|
-
],
|
|
33914
|
+
language: "javascript",
|
|
33915
|
+
patterns: [/___gatsby/, /gatsby-/, /gatsby-image/, /data-gatsby/],
|
|
30453
33916
|
confidence: "high"
|
|
30454
33917
|
},
|
|
30455
33918
|
// Astro
|
|
30456
33919
|
{
|
|
30457
33920
|
framework: "astro",
|
|
30458
|
-
|
|
30459
|
-
|
|
30460
|
-
/data-astro/,
|
|
30461
|
-
/_astro\//
|
|
30462
|
-
],
|
|
33921
|
+
language: "typescript",
|
|
33922
|
+
patterns: [/astro-/, /data-astro/, /_astro\//],
|
|
30463
33923
|
confidence: "high"
|
|
30464
33924
|
},
|
|
30465
33925
|
// SvelteKit
|
|
30466
33926
|
{
|
|
30467
33927
|
framework: "sveltekit",
|
|
30468
|
-
|
|
30469
|
-
|
|
30470
|
-
/_app\/immutable/,
|
|
30471
|
-
/svelte-kit/
|
|
30472
|
-
],
|
|
33928
|
+
language: "typescript",
|
|
33929
|
+
patterns: [/__sveltekit/, /_app\/immutable/, /svelte-kit/],
|
|
30473
33930
|
confidence: "high"
|
|
30474
33931
|
},
|
|
30475
33932
|
// Svelte (standalone)
|
|
30476
33933
|
{
|
|
30477
33934
|
framework: "svelte",
|
|
30478
|
-
|
|
30479
|
-
|
|
30480
|
-
/__svelte/
|
|
30481
|
-
],
|
|
33935
|
+
language: "javascript",
|
|
33936
|
+
patterns: [/svelte-\w+/, /__svelte/],
|
|
30482
33937
|
confidence: "medium"
|
|
30483
33938
|
},
|
|
30484
33939
|
// Remix
|
|
30485
33940
|
{
|
|
30486
33941
|
framework: "remix",
|
|
30487
|
-
|
|
30488
|
-
|
|
30489
|
-
|
|
30490
|
-
|
|
30491
|
-
|
|
33942
|
+
language: "typescript",
|
|
33943
|
+
patterns: [/__remix/, /remix-/, /data-remix/],
|
|
33944
|
+
confidence: "high"
|
|
33945
|
+
},
|
|
33946
|
+
// Solid.js
|
|
33947
|
+
{
|
|
33948
|
+
framework: "solidjs",
|
|
33949
|
+
language: "typescript",
|
|
33950
|
+
patterns: [/solid-/, /data-solid/, /_solid/],
|
|
33951
|
+
confidence: "high"
|
|
33952
|
+
},
|
|
33953
|
+
// Qwik
|
|
33954
|
+
{
|
|
33955
|
+
framework: "qwik",
|
|
33956
|
+
language: "typescript",
|
|
33957
|
+
patterns: [/q:/, /qwik/, /q-/],
|
|
30492
33958
|
confidence: "high"
|
|
30493
33959
|
},
|
|
30494
|
-
//
|
|
33960
|
+
// Fresh (Deno)
|
|
33961
|
+
{
|
|
33962
|
+
framework: "fresh",
|
|
33963
|
+
language: "typescript",
|
|
33964
|
+
patterns: [/fresh-/, /_frsh\//, /data-fresh/],
|
|
33965
|
+
confidence: "high"
|
|
33966
|
+
},
|
|
33967
|
+
// React (generic - lower priority)
|
|
30495
33968
|
{
|
|
30496
33969
|
framework: "react",
|
|
30497
|
-
|
|
30498
|
-
|
|
30499
|
-
/data-react-helmet/,
|
|
30500
|
-
/_react/,
|
|
30501
|
-
/react-app/
|
|
30502
|
-
],
|
|
33970
|
+
language: "javascript",
|
|
33971
|
+
patterns: [/data-reactroot/, /data-react-helmet/, /_react/, /react-app/],
|
|
30503
33972
|
confidence: "medium"
|
|
30504
33973
|
},
|
|
30505
|
-
// Vue (generic - lower priority
|
|
33974
|
+
// Vue (generic - lower priority)
|
|
30506
33975
|
{
|
|
30507
33976
|
framework: "vue",
|
|
30508
|
-
|
|
30509
|
-
|
|
30510
|
-
/v-cloak/,
|
|
30511
|
-
/__vue/
|
|
30512
|
-
],
|
|
33977
|
+
language: "javascript",
|
|
33978
|
+
patterns: [/data-v-[a-f0-9]+/, /v-cloak/, /__vue/],
|
|
30513
33979
|
confidence: "medium"
|
|
30514
33980
|
},
|
|
30515
33981
|
// Angular
|
|
30516
33982
|
{
|
|
30517
33983
|
framework: "angular",
|
|
30518
|
-
|
|
30519
|
-
|
|
30520
|
-
|
|
30521
|
-
|
|
30522
|
-
|
|
30523
|
-
|
|
33984
|
+
language: "typescript",
|
|
33985
|
+
patterns: [/ng-version/, /_ngcontent/, /ng-\w+=/, /\[ng/],
|
|
33986
|
+
confidence: "high"
|
|
33987
|
+
},
|
|
33988
|
+
// ============ Hypermedia Frameworks ============
|
|
33989
|
+
// HTMX
|
|
33990
|
+
{
|
|
33991
|
+
framework: "htmx",
|
|
33992
|
+
language: "unknown",
|
|
33993
|
+
patterns: [/hx-get/, /hx-post/, /hx-trigger/, /hx-swap/, /htmx\.org/],
|
|
33994
|
+
confidence: "high"
|
|
33995
|
+
},
|
|
33996
|
+
// Hotwire/Turbo
|
|
33997
|
+
{
|
|
33998
|
+
framework: "hotwire",
|
|
33999
|
+
language: "ruby",
|
|
34000
|
+
patterns: [/turbo-frame/, /turbo-stream/, /data-turbo/, /stimulus/],
|
|
30524
34001
|
confidence: "high"
|
|
30525
34002
|
},
|
|
34003
|
+
// ============ PHP Frameworks ============
|
|
30526
34004
|
// WordPress
|
|
30527
34005
|
{
|
|
30528
34006
|
framework: "wordpress",
|
|
30529
|
-
|
|
30530
|
-
|
|
30531
|
-
/wp-includes/,
|
|
30532
|
-
/wp-json/,
|
|
30533
|
-
/wordpress/i
|
|
30534
|
-
],
|
|
34007
|
+
language: "php",
|
|
34008
|
+
patterns: [/wp-content/, /wp-includes/, /wp-json/, /wordpress/i],
|
|
30535
34009
|
confidence: "high"
|
|
30536
34010
|
},
|
|
30537
34011
|
// Laravel
|
|
30538
34012
|
{
|
|
30539
34013
|
framework: "laravel",
|
|
30540
|
-
|
|
30541
|
-
|
|
30542
|
-
|
|
30543
|
-
|
|
30544
|
-
|
|
34014
|
+
language: "php",
|
|
34015
|
+
patterns: [/laravel_session/, /XSRF-TOKEN/, /@csrf/, /laravel/i],
|
|
34016
|
+
confidence: "medium"
|
|
34017
|
+
},
|
|
34018
|
+
// Symfony
|
|
34019
|
+
{
|
|
34020
|
+
framework: "symfony",
|
|
34021
|
+
language: "php",
|
|
34022
|
+
patterns: [/symfony/, /sf2/, /_sf_/, /profiler\.css/],
|
|
30545
34023
|
confidence: "medium"
|
|
30546
34024
|
},
|
|
34025
|
+
// ============ Ruby Frameworks ============
|
|
30547
34026
|
// Rails
|
|
30548
34027
|
{
|
|
30549
34028
|
framework: "rails",
|
|
30550
|
-
|
|
30551
|
-
|
|
30552
|
-
/turbolinks/,
|
|
30553
|
-
/data-turbo/,
|
|
30554
|
-
/rails-ujs/
|
|
30555
|
-
],
|
|
34029
|
+
language: "ruby",
|
|
34030
|
+
patterns: [/csrf-token/, /turbolinks/, /data-turbo/, /rails-ujs/, /action-cable/],
|
|
30556
34031
|
confidence: "medium"
|
|
30557
34032
|
},
|
|
34033
|
+
// ============ Python Frameworks ============
|
|
30558
34034
|
// Django
|
|
30559
34035
|
{
|
|
30560
34036
|
framework: "django",
|
|
30561
|
-
|
|
30562
|
-
|
|
30563
|
-
|
|
30564
|
-
|
|
34037
|
+
language: "python",
|
|
34038
|
+
patterns: [/csrfmiddlewaretoken/, /django/, /__debug__\//],
|
|
34039
|
+
confidence: "medium"
|
|
34040
|
+
},
|
|
34041
|
+
// Flask
|
|
34042
|
+
{
|
|
34043
|
+
framework: "flask",
|
|
34044
|
+
language: "python",
|
|
34045
|
+
patterns: [/flask/, /werkzeug/i],
|
|
34046
|
+
confidence: "low"
|
|
34047
|
+
},
|
|
34048
|
+
// FastAPI
|
|
34049
|
+
{
|
|
34050
|
+
framework: "fastapi",
|
|
34051
|
+
language: "python",
|
|
34052
|
+
patterns: [/fastapi/, /openapi\.json/],
|
|
34053
|
+
confidence: "low"
|
|
34054
|
+
},
|
|
34055
|
+
// ============ Java Frameworks ============
|
|
34056
|
+
// Spring Boot
|
|
34057
|
+
{
|
|
34058
|
+
framework: "spring",
|
|
34059
|
+
language: "java",
|
|
34060
|
+
patterns: [/spring/, /thymeleaf/, /th:/, /_csrf/],
|
|
34061
|
+
confidence: "medium"
|
|
34062
|
+
},
|
|
34063
|
+
// ============ C# Frameworks ============
|
|
34064
|
+
// ASP.NET Core
|
|
34065
|
+
{
|
|
34066
|
+
framework: "aspnet",
|
|
34067
|
+
language: "csharp",
|
|
34068
|
+
patterns: [/aspnetcore/, /__RequestVerificationToken/, /blazor/, /_framework\/blazor/],
|
|
34069
|
+
confidence: "medium"
|
|
34070
|
+
},
|
|
34071
|
+
// ============ Elixir Frameworks ============
|
|
34072
|
+
// Phoenix
|
|
34073
|
+
{
|
|
34074
|
+
framework: "phoenix",
|
|
34075
|
+
language: "elixir",
|
|
34076
|
+
patterns: [/phx-/, /phoenix/, /live-socket/, /data-phx/],
|
|
34077
|
+
confidence: "high"
|
|
34078
|
+
},
|
|
34079
|
+
// ============ Static Site Generators ============
|
|
34080
|
+
// Hugo
|
|
34081
|
+
{
|
|
34082
|
+
framework: "hugo",
|
|
34083
|
+
language: "go",
|
|
34084
|
+
patterns: [/hugo/, /generator.*hugo/i],
|
|
34085
|
+
confidence: "medium"
|
|
34086
|
+
},
|
|
34087
|
+
// Jekyll
|
|
34088
|
+
{
|
|
34089
|
+
framework: "jekyll",
|
|
34090
|
+
language: "ruby",
|
|
34091
|
+
patterns: [/jekyll/, /generator.*jekyll/i],
|
|
34092
|
+
confidence: "medium"
|
|
34093
|
+
},
|
|
34094
|
+
// Eleventy
|
|
34095
|
+
{
|
|
34096
|
+
framework: "eleventy",
|
|
34097
|
+
language: "javascript",
|
|
34098
|
+
patterns: [/11ty/, /eleventy/, /generator.*eleventy/i],
|
|
34099
|
+
confidence: "medium"
|
|
34100
|
+
},
|
|
34101
|
+
// Pelican
|
|
34102
|
+
{
|
|
34103
|
+
framework: "pelican",
|
|
34104
|
+
language: "python",
|
|
34105
|
+
patterns: [/pelican/, /generator.*pelican/i],
|
|
30565
34106
|
confidence: "medium"
|
|
30566
34107
|
}
|
|
30567
34108
|
];
|
|
30568
34109
|
var PACKAGE_DEPENDENCIES = [
|
|
30569
|
-
|
|
30570
|
-
{ framework: "
|
|
30571
|
-
{ framework: "
|
|
30572
|
-
{ framework: "
|
|
30573
|
-
{ framework: "
|
|
30574
|
-
{ framework: "
|
|
30575
|
-
{ framework: "
|
|
30576
|
-
{ framework: "
|
|
30577
|
-
{ framework: "
|
|
30578
|
-
{ framework: "
|
|
34110
|
+
// Meta-frameworks (check first - more specific)
|
|
34111
|
+
{ framework: "nextjs", language: "typescript", packages: ["next"], meta_framework: "react" },
|
|
34112
|
+
{ framework: "gatsby", language: "javascript", packages: ["gatsby"], meta_framework: "react" },
|
|
34113
|
+
{ framework: "remix", language: "typescript", packages: ["@remix-run/react", "@remix-run/node"], meta_framework: "react" },
|
|
34114
|
+
{ framework: "nuxt", language: "typescript", packages: ["nuxt", "nuxt3"], meta_framework: "vue" },
|
|
34115
|
+
{ framework: "sveltekit", language: "typescript", packages: ["@sveltejs/kit"], meta_framework: "svelte" },
|
|
34116
|
+
{ framework: "astro", language: "typescript", packages: ["astro"] },
|
|
34117
|
+
{ framework: "solidjs", language: "typescript", packages: ["solid-js", "solid-start"] },
|
|
34118
|
+
{ framework: "qwik", language: "typescript", packages: ["@builder.io/qwik"] },
|
|
34119
|
+
{ framework: "eleventy", language: "javascript", packages: ["@11ty/eleventy"] },
|
|
34120
|
+
// Base frameworks
|
|
34121
|
+
{ framework: "react", language: "javascript", packages: ["react", "react-dom"] },
|
|
34122
|
+
{ framework: "vue", language: "javascript", packages: ["vue"] },
|
|
34123
|
+
{ framework: "angular", language: "typescript", packages: ["@angular/core"] },
|
|
34124
|
+
{ framework: "svelte", language: "javascript", packages: ["svelte"] },
|
|
34125
|
+
// HTMX (can be used with any backend)
|
|
34126
|
+
{ framework: "htmx", language: "unknown", packages: ["htmx.org"] }
|
|
34127
|
+
];
|
|
34128
|
+
var GEMFILE_DEPENDENCIES = [
|
|
34129
|
+
{ framework: "rails", language: "ruby", packages: ["rails"] },
|
|
34130
|
+
{ framework: "hotwire", language: "ruby", packages: ["turbo-rails", "stimulus-rails"], meta_framework: "rails" },
|
|
34131
|
+
{ framework: "jekyll", language: "ruby", packages: ["jekyll"] }
|
|
34132
|
+
];
|
|
34133
|
+
var PYTHON_DEPENDENCIES = [
|
|
34134
|
+
{ framework: "django", language: "python", packages: ["django", "Django"] },
|
|
34135
|
+
{ framework: "flask", language: "python", packages: ["flask", "Flask"] },
|
|
34136
|
+
{ framework: "fastapi", language: "python", packages: ["fastapi", "FastAPI"] },
|
|
34137
|
+
{ framework: "pelican", language: "python", packages: ["pelican", "Pelican"] }
|
|
34138
|
+
];
|
|
34139
|
+
var COMPOSER_DEPENDENCIES = [
|
|
34140
|
+
{ framework: "laravel", language: "php", packages: ["laravel/framework"] },
|
|
34141
|
+
{ framework: "symfony", language: "php", packages: ["symfony/framework-bundle", "symfony/symfony"] },
|
|
34142
|
+
{ framework: "wordpress", language: "php", packages: ["johnpbloch/wordpress-core"] }
|
|
34143
|
+
];
|
|
34144
|
+
var JAVA_DEPENDENCIES = [
|
|
34145
|
+
{ framework: "spring", language: "java", packages: ["spring-boot-starter-web", "org.springframework.boot"] }
|
|
34146
|
+
];
|
|
34147
|
+
var DOTNET_DEPENDENCIES = [
|
|
34148
|
+
{ framework: "aspnet", language: "csharp", packages: ["Microsoft.AspNetCore", "Microsoft.NET.Sdk.Web"] }
|
|
34149
|
+
];
|
|
34150
|
+
var GO_DEPENDENCIES = [
|
|
34151
|
+
{ framework: "hugo", language: "go", packages: ["github.com/gohugoio/hugo"] },
|
|
34152
|
+
{ framework: "go", language: "go", packages: ["github.com/gin-gonic/gin", "github.com/labstack/echo", "github.com/gofiber/fiber"] }
|
|
34153
|
+
];
|
|
34154
|
+
var ELIXIR_DEPENDENCIES = [
|
|
34155
|
+
{ framework: "phoenix", language: "elixir", packages: [":phoenix", "phoenix"] }
|
|
30579
34156
|
];
|
|
30580
34157
|
function detectFromHtml(html) {
|
|
30581
34158
|
for (const signature of HTML_SIGNATURES) {
|
|
@@ -30583,6 +34160,7 @@ function detectFromHtml(html) {
|
|
|
30583
34160
|
if (pattern.test(html)) {
|
|
30584
34161
|
return {
|
|
30585
34162
|
framework: signature.framework,
|
|
34163
|
+
language: signature.language,
|
|
30586
34164
|
confidence: signature.confidence,
|
|
30587
34165
|
detected_from: "html"
|
|
30588
34166
|
};
|
|
@@ -30601,6 +34179,7 @@ function detectFromPackageJson(packageJson) {
|
|
|
30601
34179
|
if (allDeps[pkg]) {
|
|
30602
34180
|
return {
|
|
30603
34181
|
framework: dep.framework,
|
|
34182
|
+
language: dep.language,
|
|
30604
34183
|
version: allDeps[pkg],
|
|
30605
34184
|
confidence: "high",
|
|
30606
34185
|
detected_from: "package.json",
|
|
@@ -30611,23 +34190,169 @@ function detectFromPackageJson(packageJson) {
|
|
|
30611
34190
|
}
|
|
30612
34191
|
return null;
|
|
30613
34192
|
}
|
|
34193
|
+
function detectFromGemfile(gemfileContent) {
|
|
34194
|
+
const gemPattern = /gem\s+['"]([^'"]+)['"]/g;
|
|
34195
|
+
const gems = [];
|
|
34196
|
+
let match;
|
|
34197
|
+
while ((match = gemPattern.exec(gemfileContent)) !== null) {
|
|
34198
|
+
gems.push(match[1]);
|
|
34199
|
+
}
|
|
34200
|
+
for (const dep of GEMFILE_DEPENDENCIES) {
|
|
34201
|
+
for (const pkg of dep.packages) {
|
|
34202
|
+
if (gems.includes(pkg)) {
|
|
34203
|
+
return {
|
|
34204
|
+
framework: dep.framework,
|
|
34205
|
+
language: dep.language,
|
|
34206
|
+
confidence: "high",
|
|
34207
|
+
detected_from: "gemfile",
|
|
34208
|
+
meta_framework: dep.meta_framework
|
|
34209
|
+
};
|
|
34210
|
+
}
|
|
34211
|
+
}
|
|
34212
|
+
}
|
|
34213
|
+
return null;
|
|
34214
|
+
}
|
|
34215
|
+
function detectFromPythonDeps(content) {
|
|
34216
|
+
for (const dep of PYTHON_DEPENDENCIES) {
|
|
34217
|
+
for (const pkg of dep.packages) {
|
|
34218
|
+
const pattern = new RegExp(`(^|\\s)${pkg.toLowerCase()}[\\s=<>~!]`, "im");
|
|
34219
|
+
if (pattern.test(content.toLowerCase())) {
|
|
34220
|
+
return {
|
|
34221
|
+
framework: dep.framework,
|
|
34222
|
+
language: dep.language,
|
|
34223
|
+
confidence: "high",
|
|
34224
|
+
detected_from: "requirements"
|
|
34225
|
+
};
|
|
34226
|
+
}
|
|
34227
|
+
}
|
|
34228
|
+
}
|
|
34229
|
+
return null;
|
|
34230
|
+
}
|
|
34231
|
+
function detectFromComposerJson(composerJson) {
|
|
34232
|
+
const allDeps = {
|
|
34233
|
+
...composerJson.require,
|
|
34234
|
+
...composerJson["require-dev"]
|
|
34235
|
+
};
|
|
34236
|
+
for (const dep of COMPOSER_DEPENDENCIES) {
|
|
34237
|
+
for (const pkg of dep.packages) {
|
|
34238
|
+
if (allDeps[pkg]) {
|
|
34239
|
+
return {
|
|
34240
|
+
framework: dep.framework,
|
|
34241
|
+
language: dep.language,
|
|
34242
|
+
version: allDeps[pkg],
|
|
34243
|
+
confidence: "high",
|
|
34244
|
+
detected_from: "composer"
|
|
34245
|
+
};
|
|
34246
|
+
}
|
|
34247
|
+
}
|
|
34248
|
+
}
|
|
34249
|
+
return null;
|
|
34250
|
+
}
|
|
34251
|
+
function detectFromPomXml(pomContent) {
|
|
34252
|
+
for (const dep of JAVA_DEPENDENCIES) {
|
|
34253
|
+
for (const pkg of dep.packages) {
|
|
34254
|
+
if (pomContent.includes(pkg)) {
|
|
34255
|
+
return {
|
|
34256
|
+
framework: dep.framework,
|
|
34257
|
+
language: dep.language,
|
|
34258
|
+
confidence: "high",
|
|
34259
|
+
detected_from: "pom"
|
|
34260
|
+
};
|
|
34261
|
+
}
|
|
34262
|
+
}
|
|
34263
|
+
}
|
|
34264
|
+
return null;
|
|
34265
|
+
}
|
|
34266
|
+
function detectFromCsproj(csprojContent) {
|
|
34267
|
+
for (const dep of DOTNET_DEPENDENCIES) {
|
|
34268
|
+
for (const pkg of dep.packages) {
|
|
34269
|
+
if (csprojContent.includes(pkg)) {
|
|
34270
|
+
return {
|
|
34271
|
+
framework: dep.framework,
|
|
34272
|
+
language: dep.language,
|
|
34273
|
+
confidence: "high",
|
|
34274
|
+
detected_from: "csproj"
|
|
34275
|
+
};
|
|
34276
|
+
}
|
|
34277
|
+
}
|
|
34278
|
+
}
|
|
34279
|
+
return null;
|
|
34280
|
+
}
|
|
34281
|
+
function detectFromGoMod(goModContent) {
|
|
34282
|
+
for (const dep of GO_DEPENDENCIES) {
|
|
34283
|
+
for (const pkg of dep.packages) {
|
|
34284
|
+
if (goModContent.includes(pkg)) {
|
|
34285
|
+
return {
|
|
34286
|
+
framework: dep.framework,
|
|
34287
|
+
language: dep.language,
|
|
34288
|
+
confidence: "high",
|
|
34289
|
+
detected_from: "go.mod"
|
|
34290
|
+
};
|
|
34291
|
+
}
|
|
34292
|
+
}
|
|
34293
|
+
}
|
|
34294
|
+
return null;
|
|
34295
|
+
}
|
|
34296
|
+
function detectFromMixExs(mixContent) {
|
|
34297
|
+
for (const dep of ELIXIR_DEPENDENCIES) {
|
|
34298
|
+
for (const pkg of dep.packages) {
|
|
34299
|
+
if (mixContent.includes(pkg)) {
|
|
34300
|
+
return {
|
|
34301
|
+
framework: dep.framework,
|
|
34302
|
+
language: dep.language,
|
|
34303
|
+
confidence: "high",
|
|
34304
|
+
detected_from: "mix.exs"
|
|
34305
|
+
};
|
|
34306
|
+
}
|
|
34307
|
+
}
|
|
34308
|
+
}
|
|
34309
|
+
return null;
|
|
34310
|
+
}
|
|
30614
34311
|
function detectFromHeaders(headers) {
|
|
30615
34312
|
const poweredBy = headers["x-powered-by"]?.toLowerCase() || "";
|
|
30616
34313
|
const server = headers["server"]?.toLowerCase() || "";
|
|
34314
|
+
const generator = headers["x-generator"]?.toLowerCase() || "";
|
|
30617
34315
|
if (poweredBy.includes("next.js") || poweredBy.includes("next")) {
|
|
30618
|
-
return { framework: "nextjs", confidence: "high", detected_from: "headers" };
|
|
34316
|
+
return { framework: "nextjs", language: "typescript", confidence: "high", detected_from: "headers" };
|
|
30619
34317
|
}
|
|
30620
34318
|
if (poweredBy.includes("nuxt")) {
|
|
30621
|
-
return { framework: "nuxt", confidence: "high", detected_from: "headers" };
|
|
34319
|
+
return { framework: "nuxt", language: "typescript", confidence: "high", detected_from: "headers" };
|
|
30622
34320
|
}
|
|
30623
|
-
if (poweredBy.includes("
|
|
34321
|
+
if (poweredBy.includes("remix")) {
|
|
34322
|
+
return { framework: "remix", language: "typescript", confidence: "high", detected_from: "headers" };
|
|
34323
|
+
}
|
|
34324
|
+
if (poweredBy.includes("phusion") || poweredBy.includes("passenger") || poweredBy.includes("puma") || poweredBy.includes("unicorn")) {
|
|
34325
|
+
return { framework: "rails", language: "ruby", confidence: "medium", detected_from: "headers" };
|
|
34326
|
+
}
|
|
34327
|
+
if (poweredBy.includes("gunicorn") || poweredBy.includes("uvicorn") || poweredBy.includes("daphne")) {
|
|
30624
34328
|
return null;
|
|
30625
34329
|
}
|
|
30626
|
-
if (poweredBy.includes("php")
|
|
34330
|
+
if (poweredBy.includes("php")) {
|
|
30627
34331
|
return null;
|
|
30628
34332
|
}
|
|
30629
|
-
if (
|
|
30630
|
-
return { framework: "
|
|
34333
|
+
if (server.includes("tomcat") || server.includes("jetty") || poweredBy.includes("spring")) {
|
|
34334
|
+
return { framework: "spring", language: "java", confidence: "medium", detected_from: "headers" };
|
|
34335
|
+
}
|
|
34336
|
+
if (poweredBy.includes("asp.net") || server.includes("kestrel")) {
|
|
34337
|
+
return { framework: "aspnet", language: "csharp", confidence: "high", detected_from: "headers" };
|
|
34338
|
+
}
|
|
34339
|
+
if (poweredBy.includes("phoenix") || poweredBy.includes("cowboy")) {
|
|
34340
|
+
return { framework: "phoenix", language: "elixir", confidence: "medium", detected_from: "headers" };
|
|
34341
|
+
}
|
|
34342
|
+
if (server.includes("gin") || server.includes("echo") || server.includes("fiber")) {
|
|
34343
|
+
return { framework: "go", language: "go", confidence: "medium", detected_from: "headers" };
|
|
34344
|
+
}
|
|
34345
|
+
if (generator.includes("hugo")) {
|
|
34346
|
+
return { framework: "hugo", language: "go", confidence: "high", detected_from: "headers" };
|
|
34347
|
+
}
|
|
34348
|
+
if (generator.includes("jekyll")) {
|
|
34349
|
+
return { framework: "jekyll", language: "ruby", confidence: "high", detected_from: "headers" };
|
|
34350
|
+
}
|
|
34351
|
+
if (generator.includes("eleventy") || generator.includes("11ty")) {
|
|
34352
|
+
return { framework: "eleventy", language: "javascript", confidence: "high", detected_from: "headers" };
|
|
34353
|
+
}
|
|
34354
|
+
if (generator.includes("pelican")) {
|
|
34355
|
+
return { framework: "pelican", language: "python", confidence: "high", detected_from: "headers" };
|
|
30631
34356
|
}
|
|
30632
34357
|
return null;
|
|
30633
34358
|
}
|
|
@@ -30636,6 +34361,34 @@ function detectFramework4(options) {
|
|
|
30636
34361
|
const result = detectFromPackageJson(options.packageJson);
|
|
30637
34362
|
if (result) return result;
|
|
30638
34363
|
}
|
|
34364
|
+
if (options.gemfileContent) {
|
|
34365
|
+
const result = detectFromGemfile(options.gemfileContent);
|
|
34366
|
+
if (result) return result;
|
|
34367
|
+
}
|
|
34368
|
+
if (options.requirementsContent) {
|
|
34369
|
+
const result = detectFromPythonDeps(options.requirementsContent);
|
|
34370
|
+
if (result) return result;
|
|
34371
|
+
}
|
|
34372
|
+
if (options.composerJson) {
|
|
34373
|
+
const result = detectFromComposerJson(options.composerJson);
|
|
34374
|
+
if (result) return result;
|
|
34375
|
+
}
|
|
34376
|
+
if (options.pomXmlContent) {
|
|
34377
|
+
const result = detectFromPomXml(options.pomXmlContent);
|
|
34378
|
+
if (result) return result;
|
|
34379
|
+
}
|
|
34380
|
+
if (options.csprojContent) {
|
|
34381
|
+
const result = detectFromCsproj(options.csprojContent);
|
|
34382
|
+
if (result) return result;
|
|
34383
|
+
}
|
|
34384
|
+
if (options.goModContent) {
|
|
34385
|
+
const result = detectFromGoMod(options.goModContent);
|
|
34386
|
+
if (result) return result;
|
|
34387
|
+
}
|
|
34388
|
+
if (options.mixExsContent) {
|
|
34389
|
+
const result = detectFromMixExs(options.mixExsContent);
|
|
34390
|
+
if (result) return result;
|
|
34391
|
+
}
|
|
30639
34392
|
if (options.headers) {
|
|
30640
34393
|
const result = detectFromHeaders(options.headers);
|
|
30641
34394
|
if (result) return result;
|
|
@@ -30646,40 +34399,70 @@ function detectFramework4(options) {
|
|
|
30646
34399
|
}
|
|
30647
34400
|
if (options.files) {
|
|
30648
34401
|
if (options.files.some((f) => f.includes("next.config"))) {
|
|
30649
|
-
return { framework: "nextjs", confidence: "high", detected_from: "files" };
|
|
34402
|
+
return { framework: "nextjs", language: "typescript", confidence: "high", detected_from: "files" };
|
|
30650
34403
|
}
|
|
30651
34404
|
if (options.files.some((f) => f.includes("nuxt.config"))) {
|
|
30652
|
-
return { framework: "nuxt", confidence: "high", detected_from: "files" };
|
|
34405
|
+
return { framework: "nuxt", language: "typescript", confidence: "high", detected_from: "files" };
|
|
30653
34406
|
}
|
|
30654
34407
|
if (options.files.some((f) => f.includes("astro.config"))) {
|
|
30655
|
-
return { framework: "astro", confidence: "high", detected_from: "files" };
|
|
34408
|
+
return { framework: "astro", language: "typescript", confidence: "high", detected_from: "files" };
|
|
30656
34409
|
}
|
|
30657
34410
|
if (options.files.some((f) => f.includes("svelte.config"))) {
|
|
30658
|
-
return { framework: "sveltekit", confidence: "high", detected_from: "files" };
|
|
34411
|
+
return { framework: "sveltekit", language: "typescript", confidence: "high", detected_from: "files" };
|
|
30659
34412
|
}
|
|
30660
34413
|
if (options.files.some((f) => f.includes("angular.json"))) {
|
|
30661
|
-
return { framework: "angular", confidence: "high", detected_from: "files" };
|
|
34414
|
+
return { framework: "angular", language: "typescript", confidence: "high", detected_from: "files" };
|
|
30662
34415
|
}
|
|
30663
34416
|
if (options.files.some((f) => f.includes("gatsby-config"))) {
|
|
30664
|
-
return { framework: "gatsby", confidence: "high", detected_from: "files" };
|
|
34417
|
+
return { framework: "gatsby", language: "javascript", confidence: "high", detected_from: "files" };
|
|
34418
|
+
}
|
|
34419
|
+
if (options.files.some((f) => f.includes("remix.config"))) {
|
|
34420
|
+
return { framework: "remix", language: "typescript", confidence: "high", detected_from: "files" };
|
|
34421
|
+
}
|
|
34422
|
+
if (options.files.some((f) => f.includes(".eleventy.js") || f.includes("eleventy.config"))) {
|
|
34423
|
+
return { framework: "eleventy", language: "javascript", confidence: "high", detected_from: "files" };
|
|
30665
34424
|
}
|
|
30666
34425
|
if (options.files.some((f) => f === "wp-config.php")) {
|
|
30667
|
-
return { framework: "wordpress", confidence: "high", detected_from: "files" };
|
|
34426
|
+
return { framework: "wordpress", language: "php", confidence: "high", detected_from: "files" };
|
|
30668
34427
|
}
|
|
30669
34428
|
if (options.files.some((f) => f === "artisan")) {
|
|
30670
|
-
return { framework: "laravel", confidence: "high", detected_from: "files" };
|
|
34429
|
+
return { framework: "laravel", language: "php", confidence: "high", detected_from: "files" };
|
|
34430
|
+
}
|
|
34431
|
+
if (options.files.some((f) => f.includes("symfony.lock") || f.includes("config/bundles.php"))) {
|
|
34432
|
+
return { framework: "symfony", language: "php", confidence: "high", detected_from: "files" };
|
|
30671
34433
|
}
|
|
30672
34434
|
if (options.files.some((f) => f === "manage.py")) {
|
|
30673
|
-
return { framework: "django", confidence: "high", detected_from: "files" };
|
|
34435
|
+
return { framework: "django", language: "python", confidence: "high", detected_from: "files" };
|
|
34436
|
+
}
|
|
34437
|
+
if (options.files.some((f) => f.includes("pelicanconf.py"))) {
|
|
34438
|
+
return { framework: "pelican", language: "python", confidence: "high", detected_from: "files" };
|
|
30674
34439
|
}
|
|
30675
34440
|
if (options.files.some((f) => f === "Gemfile") && options.files.some((f) => f.includes("config/routes.rb"))) {
|
|
30676
|
-
return { framework: "rails", confidence: "high", detected_from: "files" };
|
|
34441
|
+
return { framework: "rails", language: "ruby", confidence: "high", detected_from: "files" };
|
|
34442
|
+
}
|
|
34443
|
+
if (options.files.some((f) => f === "_config.yml") && options.files.some((f) => f === "Gemfile")) {
|
|
34444
|
+
return { framework: "jekyll", language: "ruby", confidence: "high", detected_from: "files" };
|
|
34445
|
+
}
|
|
34446
|
+
if (options.files.some((f) => f.includes("hugo.toml") || f.includes("hugo.yaml") || f.includes("config.toml"))) {
|
|
34447
|
+
return { framework: "hugo", language: "go", confidence: "high", detected_from: "files" };
|
|
34448
|
+
}
|
|
34449
|
+
if (options.files.some((f) => f.includes("pom.xml") || f.includes("build.gradle"))) {
|
|
34450
|
+
if (options.files.some((f) => f.includes("src/main/resources/application"))) {
|
|
34451
|
+
return { framework: "spring", language: "java", confidence: "high", detected_from: "files" };
|
|
34452
|
+
}
|
|
34453
|
+
}
|
|
34454
|
+
if (options.files.some((f) => f.endsWith(".csproj"))) {
|
|
34455
|
+
return { framework: "aspnet", language: "csharp", confidence: "medium", detected_from: "files" };
|
|
34456
|
+
}
|
|
34457
|
+
if (options.files.some((f) => f === "mix.exs") && options.files.some((f) => f.includes("lib") && f.includes("_web"))) {
|
|
34458
|
+
return { framework: "phoenix", language: "elixir", confidence: "high", detected_from: "files" };
|
|
30677
34459
|
}
|
|
30678
34460
|
}
|
|
30679
|
-
return { framework: "unknown", confidence: "low", detected_from: "html" };
|
|
34461
|
+
return { framework: "unknown", language: "unknown", confidence: "low", detected_from: "html" };
|
|
30680
34462
|
}
|
|
30681
34463
|
function getFrameworkDisplayName(framework) {
|
|
30682
34464
|
const names = {
|
|
34465
|
+
// JavaScript/TypeScript
|
|
30683
34466
|
react: "React",
|
|
30684
34467
|
nextjs: "Next.js",
|
|
30685
34468
|
vue: "Vue.js",
|
|
@@ -30690,10 +34473,35 @@ function getFrameworkDisplayName(framework) {
|
|
|
30690
34473
|
astro: "Astro",
|
|
30691
34474
|
remix: "Remix",
|
|
30692
34475
|
gatsby: "Gatsby",
|
|
34476
|
+
solidjs: "Solid.js",
|
|
34477
|
+
qwik: "Qwik",
|
|
34478
|
+
fresh: "Fresh (Deno)",
|
|
34479
|
+
// Ruby
|
|
30693
34480
|
rails: "Ruby on Rails",
|
|
30694
|
-
|
|
34481
|
+
// Python
|
|
30695
34482
|
django: "Django",
|
|
34483
|
+
flask: "Flask",
|
|
34484
|
+
fastapi: "FastAPI",
|
|
34485
|
+
// PHP
|
|
34486
|
+
laravel: "Laravel",
|
|
34487
|
+
symfony: "Symfony",
|
|
30696
34488
|
wordpress: "WordPress",
|
|
34489
|
+
// Java
|
|
34490
|
+
spring: "Spring Boot",
|
|
34491
|
+
// C#
|
|
34492
|
+
aspnet: "ASP.NET Core",
|
|
34493
|
+
// Go
|
|
34494
|
+
go: "Go (Gin/Echo/Fiber)",
|
|
34495
|
+
// Elixir
|
|
34496
|
+
phoenix: "Phoenix",
|
|
34497
|
+
// Hypermedia
|
|
34498
|
+
htmx: "HTMX",
|
|
34499
|
+
hotwire: "Hotwire/Turbo",
|
|
34500
|
+
// Static Site Generators
|
|
34501
|
+
hugo: "Hugo",
|
|
34502
|
+
jekyll: "Jekyll",
|
|
34503
|
+
eleventy: "Eleventy (11ty)",
|
|
34504
|
+
pelican: "Pelican",
|
|
30697
34505
|
unknown: "Unknown"
|
|
30698
34506
|
};
|
|
30699
34507
|
return names[framework];
|
|
@@ -30702,6 +34510,7 @@ function getFrameworkDisplayName(framework) {
|
|
|
30702
34510
|
// src/frameworks/suggestion-engine.ts
|
|
30703
34511
|
import { parse as parseYaml2 } from "yaml";
|
|
30704
34512
|
var RECIPES = {
|
|
34513
|
+
// JavaScript/TypeScript
|
|
30705
34514
|
react: null,
|
|
30706
34515
|
nextjs: null,
|
|
30707
34516
|
vue: null,
|
|
@@ -30712,10 +34521,36 @@ var RECIPES = {
|
|
|
30712
34521
|
astro: null,
|
|
30713
34522
|
remix: null,
|
|
30714
34523
|
gatsby: null,
|
|
34524
|
+
solidjs: null,
|
|
34525
|
+
qwik: null,
|
|
34526
|
+
fresh: null,
|
|
34527
|
+
// Ruby
|
|
30715
34528
|
rails: null,
|
|
30716
|
-
|
|
34529
|
+
// Python
|
|
30717
34530
|
django: null,
|
|
34531
|
+
flask: null,
|
|
34532
|
+
fastapi: null,
|
|
34533
|
+
// PHP
|
|
34534
|
+
laravel: null,
|
|
34535
|
+
symfony: null,
|
|
30718
34536
|
wordpress: null,
|
|
34537
|
+
// Java
|
|
34538
|
+
spring: null,
|
|
34539
|
+
// C#
|
|
34540
|
+
aspnet: null,
|
|
34541
|
+
// Go
|
|
34542
|
+
go: null,
|
|
34543
|
+
// Elixir
|
|
34544
|
+
phoenix: null,
|
|
34545
|
+
// Hypermedia
|
|
34546
|
+
htmx: null,
|
|
34547
|
+
hotwire: null,
|
|
34548
|
+
// Static Site Generators
|
|
34549
|
+
hugo: null,
|
|
34550
|
+
jekyll: null,
|
|
34551
|
+
eleventy: null,
|
|
34552
|
+
pelican: null,
|
|
34553
|
+
// Unknown
|
|
30719
34554
|
unknown: null
|
|
30720
34555
|
};
|
|
30721
34556
|
var RECIPE_CONTENT = {};
|
|
@@ -30898,7 +34733,7 @@ function runSyncAudit(url, html, checksLimit) {
|
|
|
30898
34733
|
} catch (e) {
|
|
30899
34734
|
}
|
|
30900
34735
|
try {
|
|
30901
|
-
const structured =
|
|
34736
|
+
const structured = analyzeStructuredData2(html, url);
|
|
30902
34737
|
allIssues.push(...structured.issues);
|
|
30903
34738
|
} catch (e) {
|
|
30904
34739
|
}
|
|
@@ -31119,7 +34954,7 @@ export {
|
|
|
31119
34954
|
analyzeHeadings,
|
|
31120
34955
|
analyzeHeadline,
|
|
31121
34956
|
analyzeHreflang,
|
|
31122
|
-
analyzeImages,
|
|
34957
|
+
analyzeImages2 as analyzeImages,
|
|
31123
34958
|
analyzeInteractiveTools,
|
|
31124
34959
|
analyzeKeywordDensity,
|
|
31125
34960
|
analyzeKeywordPlacement,
|
|
@@ -31139,13 +34974,14 @@ export {
|
|
|
31139
34974
|
analyzeResponsiveImages,
|
|
31140
34975
|
analyzeSERPPreview,
|
|
31141
34976
|
analyzeSecurity,
|
|
31142
|
-
analyzeSecurityHeaders,
|
|
34977
|
+
analyzeSecurityHeaders2 as analyzeSecurityHeaders,
|
|
31143
34978
|
analyzeSocialMeta,
|
|
31144
|
-
analyzeStructuredData,
|
|
34979
|
+
analyzeStructuredData2 as analyzeStructuredData,
|
|
31145
34980
|
analyzeTopicalClusters,
|
|
31146
34981
|
analyzeTrackerBloat,
|
|
31147
34982
|
analyzeUrl,
|
|
31148
34983
|
analyzeUrlSafety,
|
|
34984
|
+
analyzers_exports as analyzers,
|
|
31149
34985
|
applyFixes,
|
|
31150
34986
|
buildGSCApiRequest,
|
|
31151
34987
|
buildGSCRequest,
|
|
@@ -31241,6 +35077,7 @@ export {
|
|
|
31241
35077
|
generateAICitableContent,
|
|
31242
35078
|
generateAllFixes,
|
|
31243
35079
|
generateAngularSEOService,
|
|
35080
|
+
generateAspNetCoreSEO,
|
|
31244
35081
|
generateAstroBaseHead,
|
|
31245
35082
|
generateAstroMeta,
|
|
31246
35083
|
generateBlogPost,
|
|
@@ -31249,7 +35086,9 @@ export {
|
|
|
31249
35086
|
generateCommitSummary,
|
|
31250
35087
|
generateComparisonTable,
|
|
31251
35088
|
generateCompleteSocialMetaSetup,
|
|
35089
|
+
generateDjangoSEOHelper,
|
|
31252
35090
|
generateDuplicateIssues,
|
|
35091
|
+
generateEleventySEO,
|
|
31253
35092
|
generateFAQSchema2 as generateFAQSchema,
|
|
31254
35093
|
generateFixes,
|
|
31255
35094
|
generateGA4EnvTemplate,
|
|
@@ -31259,11 +35098,16 @@ export {
|
|
|
31259
35098
|
generateGEOReport,
|
|
31260
35099
|
generateGSCVerificationTag,
|
|
31261
35100
|
generateGitHubActionSetup,
|
|
35101
|
+
generateGoSEO,
|
|
31262
35102
|
generateHTMLReport,
|
|
31263
35103
|
generateHTMLSocialMeta,
|
|
35104
|
+
generateHTMXSEO,
|
|
31264
35105
|
generateHeadlineVariations,
|
|
35106
|
+
generateHugoSEO,
|
|
35107
|
+
generateJekyllSEO,
|
|
31265
35108
|
generateJsonReport,
|
|
31266
35109
|
generateKeyFacts,
|
|
35110
|
+
generateLaravelSEOHelper,
|
|
31267
35111
|
generateMarkdownReport,
|
|
31268
35112
|
generateNextAppMetadata,
|
|
31269
35113
|
generateNextJsAppRouterMetadata,
|
|
@@ -31272,12 +35116,16 @@ export {
|
|
|
31272
35116
|
generateNuxtSEOHead,
|
|
31273
35117
|
generatePDFReport,
|
|
31274
35118
|
generatePRDescription,
|
|
35119
|
+
generatePhoenixSEO,
|
|
35120
|
+
generateRailsSEOHelper,
|
|
31275
35121
|
generateReactHelmetSocialMeta,
|
|
31276
35122
|
generateReactSEOHead,
|
|
31277
35123
|
generateRecommendationQueries,
|
|
31278
35124
|
generateRemixMeta,
|
|
35125
|
+
generateRemixSEO,
|
|
31279
35126
|
generateSecretsDoc,
|
|
31280
35127
|
generateSocialMetaFix,
|
|
35128
|
+
generateSpringBootSEO,
|
|
31281
35129
|
generateSvelteKitMeta,
|
|
31282
35130
|
generateSvelteKitSEOHead,
|
|
31283
35131
|
generateUncertaintyQuestions,
|
|
@@ -31289,6 +35137,7 @@ export {
|
|
|
31289
35137
|
getDateRange,
|
|
31290
35138
|
getExpandedSuggestions,
|
|
31291
35139
|
getFrameworkSpecificFix,
|
|
35140
|
+
getFrameworkSpecificFixExtended,
|
|
31292
35141
|
getGSCSetupInstructions,
|
|
31293
35142
|
getGitUser,
|
|
31294
35143
|
getKeywordData,
|