@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
|
@@ -1867,3 +1867,3421 @@ export function SEOHead({
|
|
|
1867
1867
|
Usage: <SEOHead title="Page" description="..." />`,
|
|
1868
1868
|
};
|
|
1869
1869
|
}
|
|
1870
|
+
|
|
1871
|
+
// ============================================================================
|
|
1872
|
+
// RUBY ON RAILS - Comprehensive meta-tags gem integration
|
|
1873
|
+
// ============================================================================
|
|
1874
|
+
|
|
1875
|
+
export function generateRailsSEOHelper(options: MetaFixOptions): GeneratedCode {
|
|
1876
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
1877
|
+
|
|
1878
|
+
return {
|
|
1879
|
+
file: 'app/helpers/seo_helper.rb',
|
|
1880
|
+
code: `# frozen_string_literal: true
|
|
1881
|
+
|
|
1882
|
+
# Comprehensive SEO Helper for Rails
|
|
1883
|
+
#
|
|
1884
|
+
# Features:
|
|
1885
|
+
# - Full Open Graph support
|
|
1886
|
+
# - Twitter Cards
|
|
1887
|
+
# - JSON-LD structured data
|
|
1888
|
+
# - Canonical URLs
|
|
1889
|
+
# - Dynamic meta tags
|
|
1890
|
+
#
|
|
1891
|
+
# Installation: gem 'meta-tags'
|
|
1892
|
+
# Include in ApplicationHelper or use directly
|
|
1893
|
+
module SeoHelper
|
|
1894
|
+
SITE_NAME = '${siteName}'.freeze
|
|
1895
|
+
SITE_URL = '${siteUrl}'.freeze
|
|
1896
|
+
DEFAULT_IMAGE = '${image || `${siteUrl}/og-image.png`}'.freeze
|
|
1897
|
+
DEFAULT_DESCRIPTION = '${description || `${siteName} - A compelling description.`}'.freeze
|
|
1898
|
+
TWITTER_HANDLE = '${twitterHandle || ''}'.freeze
|
|
1899
|
+
|
|
1900
|
+
# Set comprehensive page SEO
|
|
1901
|
+
#
|
|
1902
|
+
# @param title [String] Page title
|
|
1903
|
+
# @param description [String] Meta description
|
|
1904
|
+
# @param image [String] OG/Twitter image URL
|
|
1905
|
+
# @param type [Symbol] :website, :article, :product
|
|
1906
|
+
# @param options [Hash] Additional options
|
|
1907
|
+
#
|
|
1908
|
+
# @example
|
|
1909
|
+
# set_page_seo(
|
|
1910
|
+
# title: 'Product Name',
|
|
1911
|
+
# description: 'Product description',
|
|
1912
|
+
# type: :product,
|
|
1913
|
+
# schema: product_schema(@product)
|
|
1914
|
+
# )
|
|
1915
|
+
def set_page_seo(title: nil, description: nil, image: nil, type: :website, **options)
|
|
1916
|
+
page_title = title || SITE_NAME
|
|
1917
|
+
page_description = description || DEFAULT_DESCRIPTION
|
|
1918
|
+
page_image = image || DEFAULT_IMAGE
|
|
1919
|
+
page_url = request.original_url
|
|
1920
|
+
|
|
1921
|
+
set_meta_tags(
|
|
1922
|
+
title: page_title,
|
|
1923
|
+
site: SITE_NAME,
|
|
1924
|
+
description: page_description,
|
|
1925
|
+
canonical: page_url,
|
|
1926
|
+
robots: options[:noindex] ? 'noindex, nofollow' : 'index, follow',
|
|
1927
|
+
|
|
1928
|
+
# Open Graph
|
|
1929
|
+
og: {
|
|
1930
|
+
title: page_title,
|
|
1931
|
+
description: page_description,
|
|
1932
|
+
type: type.to_s,
|
|
1933
|
+
url: page_url,
|
|
1934
|
+
image: {
|
|
1935
|
+
_: page_image,
|
|
1936
|
+
width: 1200,
|
|
1937
|
+
height: 630,
|
|
1938
|
+
alt: page_title
|
|
1939
|
+
},
|
|
1940
|
+
site_name: SITE_NAME,
|
|
1941
|
+
locale: options[:locale] || 'en_US'
|
|
1942
|
+
},
|
|
1943
|
+
|
|
1944
|
+
# Twitter Cards
|
|
1945
|
+
twitter: {
|
|
1946
|
+
card: 'summary_large_image',
|
|
1947
|
+
title: page_title,
|
|
1948
|
+
description: page_description,
|
|
1949
|
+
image: page_image,
|
|
1950
|
+
site: TWITTER_HANDLE.presence,
|
|
1951
|
+
creator: TWITTER_HANDLE.presence
|
|
1952
|
+
}.compact,
|
|
1953
|
+
|
|
1954
|
+
# Article-specific
|
|
1955
|
+
article: if type == :article
|
|
1956
|
+
{
|
|
1957
|
+
published_time: options[:published_at]&.iso8601,
|
|
1958
|
+
modified_time: options[:updated_at]&.iso8601,
|
|
1959
|
+
author: options[:author],
|
|
1960
|
+
section: options[:section],
|
|
1961
|
+
tag: options[:tags]
|
|
1962
|
+
}.compact
|
|
1963
|
+
end
|
|
1964
|
+
)
|
|
1965
|
+
|
|
1966
|
+
# Store schema for JSON-LD rendering
|
|
1967
|
+
@json_ld_schemas = [default_website_schema]
|
|
1968
|
+
@json_ld_schemas << options[:schema] if options[:schema]
|
|
1969
|
+
end
|
|
1970
|
+
|
|
1971
|
+
# Render JSON-LD structured data
|
|
1972
|
+
def json_ld_tags
|
|
1973
|
+
return unless @json_ld_schemas&.any?
|
|
1974
|
+
|
|
1975
|
+
safe_join(@json_ld_schemas.map do |schema|
|
|
1976
|
+
content_tag(:script, schema.to_json.html_safe, type: 'application/ld+json')
|
|
1977
|
+
end)
|
|
1978
|
+
end
|
|
1979
|
+
|
|
1980
|
+
# Schema generators
|
|
1981
|
+
def default_website_schema
|
|
1982
|
+
{
|
|
1983
|
+
'@context': 'https://schema.org',
|
|
1984
|
+
'@type': 'WebSite',
|
|
1985
|
+
name: SITE_NAME,
|
|
1986
|
+
url: SITE_URL
|
|
1987
|
+
}
|
|
1988
|
+
end
|
|
1989
|
+
|
|
1990
|
+
def organization_schema(name: SITE_NAME, url: SITE_URL, logo: nil, social: [])
|
|
1991
|
+
{
|
|
1992
|
+
'@context': 'https://schema.org',
|
|
1993
|
+
'@type': 'Organization',
|
|
1994
|
+
name: name,
|
|
1995
|
+
url: url,
|
|
1996
|
+
logo: logo,
|
|
1997
|
+
sameAs: social
|
|
1998
|
+
}.compact
|
|
1999
|
+
end
|
|
2000
|
+
|
|
2001
|
+
def article_schema(article)
|
|
2002
|
+
{
|
|
2003
|
+
'@context': 'https://schema.org',
|
|
2004
|
+
'@type': 'Article',
|
|
2005
|
+
headline: article.title,
|
|
2006
|
+
description: article.excerpt || truncate(strip_tags(article.content), length: 160),
|
|
2007
|
+
image: article.image_url || DEFAULT_IMAGE,
|
|
2008
|
+
datePublished: article.published_at&.iso8601,
|
|
2009
|
+
dateModified: article.updated_at&.iso8601,
|
|
2010
|
+
author: {
|
|
2011
|
+
'@type': 'Person',
|
|
2012
|
+
name: article.author_name
|
|
2013
|
+
},
|
|
2014
|
+
publisher: {
|
|
2015
|
+
'@type': 'Organization',
|
|
2016
|
+
name: SITE_NAME,
|
|
2017
|
+
logo: { '@type': 'ImageObject', url: "\#{SITE_URL}/logo.png" }
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
end
|
|
2021
|
+
|
|
2022
|
+
def product_schema(product)
|
|
2023
|
+
{
|
|
2024
|
+
'@context': 'https://schema.org',
|
|
2025
|
+
'@type': 'Product',
|
|
2026
|
+
name: product.name,
|
|
2027
|
+
description: product.description,
|
|
2028
|
+
image: product.image_url,
|
|
2029
|
+
brand: product.brand.present? ? { '@type': 'Brand', name: product.brand } : nil,
|
|
2030
|
+
sku: product.sku,
|
|
2031
|
+
offers: {
|
|
2032
|
+
'@type': 'Offer',
|
|
2033
|
+
price: product.price.to_s,
|
|
2034
|
+
priceCurrency: product.currency || 'USD',
|
|
2035
|
+
availability: "https://schema.org/\#{product.in_stock? ? 'InStock' : 'OutOfStock'}"
|
|
2036
|
+
},
|
|
2037
|
+
aggregateRating: product.reviews_count.positive? ? {
|
|
2038
|
+
'@type': 'AggregateRating',
|
|
2039
|
+
ratingValue: product.average_rating,
|
|
2040
|
+
reviewCount: product.reviews_count
|
|
2041
|
+
} : nil
|
|
2042
|
+
}.compact
|
|
2043
|
+
end
|
|
2044
|
+
|
|
2045
|
+
def faq_schema(items)
|
|
2046
|
+
{
|
|
2047
|
+
'@context': 'https://schema.org',
|
|
2048
|
+
'@type': 'FAQPage',
|
|
2049
|
+
mainEntity: items.map do |item|
|
|
2050
|
+
{
|
|
2051
|
+
'@type': 'Question',
|
|
2052
|
+
name: item[:question],
|
|
2053
|
+
acceptedAnswer: { '@type': 'Answer', text: item[:answer] }
|
|
2054
|
+
}
|
|
2055
|
+
end
|
|
2056
|
+
}
|
|
2057
|
+
end
|
|
2058
|
+
|
|
2059
|
+
def breadcrumb_schema(items)
|
|
2060
|
+
{
|
|
2061
|
+
'@context': 'https://schema.org',
|
|
2062
|
+
'@type': 'BreadcrumbList',
|
|
2063
|
+
itemListElement: items.each_with_index.map do |item, index|
|
|
2064
|
+
{
|
|
2065
|
+
'@type': 'ListItem',
|
|
2066
|
+
position: index + 1,
|
|
2067
|
+
name: item[:name],
|
|
2068
|
+
item: item[:url]
|
|
2069
|
+
}
|
|
2070
|
+
end
|
|
2071
|
+
}
|
|
2072
|
+
end
|
|
2073
|
+
|
|
2074
|
+
def local_business_schema(business)
|
|
2075
|
+
{
|
|
2076
|
+
'@context': 'https://schema.org',
|
|
2077
|
+
'@type': 'LocalBusiness',
|
|
2078
|
+
name: business[:name],
|
|
2079
|
+
description: business[:description],
|
|
2080
|
+
url: business[:url],
|
|
2081
|
+
telephone: business[:phone],
|
|
2082
|
+
address: {
|
|
2083
|
+
'@type': 'PostalAddress',
|
|
2084
|
+
streetAddress: business.dig(:address, :street),
|
|
2085
|
+
addressLocality: business.dig(:address, :city),
|
|
2086
|
+
addressRegion: business.dig(:address, :state),
|
|
2087
|
+
postalCode: business.dig(:address, :zip),
|
|
2088
|
+
addressCountry: business.dig(:address, :country)
|
|
2089
|
+
},
|
|
2090
|
+
geo: business[:coordinates].present? ? {
|
|
2091
|
+
'@type': 'GeoCoordinates',
|
|
2092
|
+
latitude: business.dig(:coordinates, :lat),
|
|
2093
|
+
longitude: business.dig(:coordinates, :lng)
|
|
2094
|
+
} : nil,
|
|
2095
|
+
openingHours: business[:hours],
|
|
2096
|
+
priceRange: business[:price_range]
|
|
2097
|
+
}.compact
|
|
2098
|
+
end
|
|
2099
|
+
end`,
|
|
2100
|
+
explanation: `Ruby on Rails comprehensive SEO helper with:
|
|
2101
|
+
• meta-tags gem integration
|
|
2102
|
+
• Full Open Graph with article support
|
|
2103
|
+
• Twitter Cards
|
|
2104
|
+
• JSON-LD schema generators for all common types
|
|
2105
|
+
• Canonical URLs and robots directives
|
|
2106
|
+
|
|
2107
|
+
Setup:
|
|
2108
|
+
1. Add to Gemfile: gem 'meta-tags'
|
|
2109
|
+
2. Include helper in ApplicationHelper
|
|
2110
|
+
3. Call set_page_seo in controllers
|
|
2111
|
+
4. Add <%= display_meta_tags %> and <%= json_ld_tags %> to layout`,
|
|
2112
|
+
installCommands: ['bundle add meta-tags'],
|
|
2113
|
+
additionalFiles: [
|
|
2114
|
+
{
|
|
2115
|
+
file: 'app/views/layouts/application.html.erb',
|
|
2116
|
+
code: `<!DOCTYPE html>
|
|
2117
|
+
<html lang="<%= I18n.locale %>">
|
|
2118
|
+
<head>
|
|
2119
|
+
<meta charset="utf-8">
|
|
2120
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2121
|
+
|
|
2122
|
+
<%= display_meta_tags %>
|
|
2123
|
+
<%= csrf_meta_tags %>
|
|
2124
|
+
<%= csp_meta_tag %>
|
|
2125
|
+
|
|
2126
|
+
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
|
2127
|
+
<%= javascript_importmap_tags %>
|
|
2128
|
+
|
|
2129
|
+
<!-- Favicon -->
|
|
2130
|
+
<%= favicon_link_tag 'favicon.ico' %>
|
|
2131
|
+
<%= favicon_link_tag 'apple-touch-icon.png', rel: 'apple-touch-icon', type: 'image/png', sizes: '180x180' %>
|
|
2132
|
+
|
|
2133
|
+
<!-- JSON-LD Structured Data -->
|
|
2134
|
+
<%= json_ld_tags %>
|
|
2135
|
+
</head>
|
|
2136
|
+
<body>
|
|
2137
|
+
<%= yield %>
|
|
2138
|
+
</body>
|
|
2139
|
+
</html>`,
|
|
2140
|
+
explanation: 'Application layout with SEO meta tags and JSON-LD.',
|
|
2141
|
+
},
|
|
2142
|
+
{
|
|
2143
|
+
file: 'config/initializers/meta_tags.rb',
|
|
2144
|
+
code: `# frozen_string_literal: true
|
|
2145
|
+
|
|
2146
|
+
MetaTags.configure do |config|
|
|
2147
|
+
# Use title template
|
|
2148
|
+
config.title_limit = 70
|
|
2149
|
+
config.description_limit = 160
|
|
2150
|
+
config.keywords_limit = 255
|
|
2151
|
+
|
|
2152
|
+
# Truncate long strings
|
|
2153
|
+
config.truncate_on_bytesize = true
|
|
2154
|
+
|
|
2155
|
+
# Separate site name with |
|
|
2156
|
+
config.title_separator = ' | '
|
|
2157
|
+
end`,
|
|
2158
|
+
explanation: 'meta-tags gem configuration.',
|
|
2159
|
+
},
|
|
2160
|
+
{
|
|
2161
|
+
file: 'public/robots.txt',
|
|
2162
|
+
code: `User-agent: *
|
|
2163
|
+
Allow: /
|
|
2164
|
+
Disallow: /admin/
|
|
2165
|
+
Disallow: /api/
|
|
2166
|
+
|
|
2167
|
+
User-agent: GPTBot
|
|
2168
|
+
Allow: /
|
|
2169
|
+
|
|
2170
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
2171
|
+
explanation: 'Robots.txt with AI crawler support.',
|
|
2172
|
+
},
|
|
2173
|
+
],
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// ============================================================================
|
|
2178
|
+
// DJANGO - Comprehensive SEO with django-meta
|
|
2179
|
+
// ============================================================================
|
|
2180
|
+
|
|
2181
|
+
export function generateDjangoSEOHelper(options: MetaFixOptions): GeneratedCode {
|
|
2182
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
2183
|
+
|
|
2184
|
+
return {
|
|
2185
|
+
file: 'core/seo.py',
|
|
2186
|
+
code: `"""
|
|
2187
|
+
Comprehensive SEO utilities for Django
|
|
2188
|
+
|
|
2189
|
+
Features:
|
|
2190
|
+
- Full Open Graph support
|
|
2191
|
+
- Twitter Cards
|
|
2192
|
+
- JSON-LD structured data
|
|
2193
|
+
- Canonical URLs
|
|
2194
|
+
- Template tags and context processors
|
|
2195
|
+
|
|
2196
|
+
Installation: pip install django-meta
|
|
2197
|
+
"""
|
|
2198
|
+
|
|
2199
|
+
from django.conf import settings
|
|
2200
|
+
from django.utils.safestring import mark_safe
|
|
2201
|
+
import json
|
|
2202
|
+
from typing import Any, Optional
|
|
2203
|
+
from dataclasses import dataclass, field
|
|
2204
|
+
|
|
2205
|
+
|
|
2206
|
+
# Configuration
|
|
2207
|
+
SITE_NAME = '${siteName}'
|
|
2208
|
+
SITE_URL = '${siteUrl}'
|
|
2209
|
+
DEFAULT_IMAGE = '${image || `${siteUrl}/og-image.png`}'
|
|
2210
|
+
DEFAULT_DESCRIPTION = '${description || `${siteName} - A compelling description.`}'
|
|
2211
|
+
TWITTER_HANDLE = '${twitterHandle || ''}'
|
|
2212
|
+
|
|
2213
|
+
|
|
2214
|
+
@dataclass
|
|
2215
|
+
class SEOMeta:
|
|
2216
|
+
"""SEO metadata container"""
|
|
2217
|
+
title: Optional[str] = None
|
|
2218
|
+
description: str = DEFAULT_DESCRIPTION
|
|
2219
|
+
image: str = DEFAULT_IMAGE
|
|
2220
|
+
url: Optional[str] = None
|
|
2221
|
+
type: str = 'website'
|
|
2222
|
+
|
|
2223
|
+
# Article specific
|
|
2224
|
+
published_time: Optional[str] = None
|
|
2225
|
+
modified_time: Optional[str] = None
|
|
2226
|
+
author: Optional[str] = None
|
|
2227
|
+
tags: list = field(default_factory=list)
|
|
2228
|
+
|
|
2229
|
+
# Robots
|
|
2230
|
+
noindex: bool = False
|
|
2231
|
+
nofollow: bool = False
|
|
2232
|
+
|
|
2233
|
+
# Structured data
|
|
2234
|
+
schema: Optional[dict] = None
|
|
2235
|
+
|
|
2236
|
+
def get_full_title(self) -> str:
|
|
2237
|
+
if not self.title:
|
|
2238
|
+
return SITE_NAME
|
|
2239
|
+
if SITE_NAME in self.title:
|
|
2240
|
+
return self.title
|
|
2241
|
+
return f"{self.title} | {SITE_NAME}"
|
|
2242
|
+
|
|
2243
|
+
def get_image_url(self) -> str:
|
|
2244
|
+
if self.image.startswith('http'):
|
|
2245
|
+
return self.image
|
|
2246
|
+
return f"{SITE_URL}{self.image}"
|
|
2247
|
+
|
|
2248
|
+
def get_robots(self) -> str:
|
|
2249
|
+
index = 'noindex' if self.noindex else 'index'
|
|
2250
|
+
follow = 'nofollow' if self.nofollow else 'follow'
|
|
2251
|
+
return f"{index}, {follow}"
|
|
2252
|
+
|
|
2253
|
+
|
|
2254
|
+
class SEOContextMixin:
|
|
2255
|
+
"""
|
|
2256
|
+
Mixin for class-based views to add SEO context
|
|
2257
|
+
|
|
2258
|
+
Usage:
|
|
2259
|
+
class ArticleDetailView(SEOContextMixin, DetailView):
|
|
2260
|
+
model = Article
|
|
2261
|
+
|
|
2262
|
+
def get_seo_meta(self):
|
|
2263
|
+
return SEOMeta(
|
|
2264
|
+
title=self.object.title,
|
|
2265
|
+
description=self.object.excerpt,
|
|
2266
|
+
image=self.object.image.url if self.object.image else None,
|
|
2267
|
+
type='article',
|
|
2268
|
+
published_time=self.object.published_at.isoformat(),
|
|
2269
|
+
schema=article_schema(self.object)
|
|
2270
|
+
)
|
|
2271
|
+
"""
|
|
2272
|
+
|
|
2273
|
+
def get_seo_meta(self) -> SEOMeta:
|
|
2274
|
+
return SEOMeta()
|
|
2275
|
+
|
|
2276
|
+
def get_context_data(self, **kwargs):
|
|
2277
|
+
context = super().get_context_data(**kwargs)
|
|
2278
|
+
context['seo'] = self.get_seo_meta()
|
|
2279
|
+
return context
|
|
2280
|
+
|
|
2281
|
+
|
|
2282
|
+
def seo_context_processor(request):
|
|
2283
|
+
"""
|
|
2284
|
+
Context processor to add default SEO values
|
|
2285
|
+
|
|
2286
|
+
Add to settings.TEMPLATES[0]['OPTIONS']['context_processors']
|
|
2287
|
+
"""
|
|
2288
|
+
return {
|
|
2289
|
+
'site_name': SITE_NAME,
|
|
2290
|
+
'site_url': SITE_URL,
|
|
2291
|
+
'default_og_image': DEFAULT_IMAGE,
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
|
|
2295
|
+
# JSON-LD Schema Generators
|
|
2296
|
+
|
|
2297
|
+
def website_schema() -> dict:
|
|
2298
|
+
return {
|
|
2299
|
+
'@context': 'https://schema.org',
|
|
2300
|
+
'@type': 'WebSite',
|
|
2301
|
+
'name': SITE_NAME,
|
|
2302
|
+
'url': SITE_URL,
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
|
|
2306
|
+
def organization_schema(
|
|
2307
|
+
name: str = SITE_NAME,
|
|
2308
|
+
url: str = SITE_URL,
|
|
2309
|
+
logo: Optional[str] = None,
|
|
2310
|
+
social_profiles: list = None
|
|
2311
|
+
) -> dict:
|
|
2312
|
+
schema = {
|
|
2313
|
+
'@context': 'https://schema.org',
|
|
2314
|
+
'@type': 'Organization',
|
|
2315
|
+
'name': name,
|
|
2316
|
+
'url': url,
|
|
2317
|
+
}
|
|
2318
|
+
if logo:
|
|
2319
|
+
schema['logo'] = logo
|
|
2320
|
+
if social_profiles:
|
|
2321
|
+
schema['sameAs'] = social_profiles
|
|
2322
|
+
return schema
|
|
2323
|
+
|
|
2324
|
+
|
|
2325
|
+
def article_schema(article) -> dict:
|
|
2326
|
+
"""Generate Article schema from model instance"""
|
|
2327
|
+
return {
|
|
2328
|
+
'@context': 'https://schema.org',
|
|
2329
|
+
'@type': 'Article',
|
|
2330
|
+
'headline': article.title,
|
|
2331
|
+
'description': getattr(article, 'excerpt', '') or article.content[:160],
|
|
2332
|
+
'image': article.image.url if hasattr(article, 'image') and article.image else DEFAULT_IMAGE,
|
|
2333
|
+
'datePublished': article.published_at.isoformat() if hasattr(article, 'published_at') else None,
|
|
2334
|
+
'dateModified': article.updated_at.isoformat() if hasattr(article, 'updated_at') else None,
|
|
2335
|
+
'author': {
|
|
2336
|
+
'@type': 'Person',
|
|
2337
|
+
'name': str(article.author) if hasattr(article, 'author') else 'Unknown',
|
|
2338
|
+
},
|
|
2339
|
+
'publisher': {
|
|
2340
|
+
'@type': 'Organization',
|
|
2341
|
+
'name': SITE_NAME,
|
|
2342
|
+
'logo': {'@type': 'ImageObject', 'url': f'{SITE_URL}/static/logo.png'},
|
|
2343
|
+
},
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
|
|
2347
|
+
def product_schema(product) -> dict:
|
|
2348
|
+
"""Generate Product schema from model instance"""
|
|
2349
|
+
schema = {
|
|
2350
|
+
'@context': 'https://schema.org',
|
|
2351
|
+
'@type': 'Product',
|
|
2352
|
+
'name': product.name,
|
|
2353
|
+
'description': product.description,
|
|
2354
|
+
'image': product.image.url if hasattr(product, 'image') and product.image else DEFAULT_IMAGE,
|
|
2355
|
+
'offers': {
|
|
2356
|
+
'@type': 'Offer',
|
|
2357
|
+
'price': str(product.price),
|
|
2358
|
+
'priceCurrency': getattr(product, 'currency', 'USD'),
|
|
2359
|
+
'availability': f"https://schema.org/{'InStock' if product.in_stock else 'OutOfStock'}",
|
|
2360
|
+
},
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
if hasattr(product, 'brand') and product.brand:
|
|
2364
|
+
schema['brand'] = {'@type': 'Brand', 'name': product.brand}
|
|
2365
|
+
|
|
2366
|
+
if hasattr(product, 'sku') and product.sku:
|
|
2367
|
+
schema['sku'] = product.sku
|
|
2368
|
+
|
|
2369
|
+
if hasattr(product, 'average_rating') and hasattr(product, 'review_count'):
|
|
2370
|
+
if product.review_count > 0:
|
|
2371
|
+
schema['aggregateRating'] = {
|
|
2372
|
+
'@type': 'AggregateRating',
|
|
2373
|
+
'ratingValue': product.average_rating,
|
|
2374
|
+
'reviewCount': product.review_count,
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
return schema
|
|
2378
|
+
|
|
2379
|
+
|
|
2380
|
+
def faq_schema(items: list[dict]) -> dict:
|
|
2381
|
+
"""Generate FAQ schema from list of {question, answer} dicts"""
|
|
2382
|
+
return {
|
|
2383
|
+
'@context': 'https://schema.org',
|
|
2384
|
+
'@type': 'FAQPage',
|
|
2385
|
+
'mainEntity': [
|
|
2386
|
+
{
|
|
2387
|
+
'@type': 'Question',
|
|
2388
|
+
'name': item['question'],
|
|
2389
|
+
'acceptedAnswer': {'@type': 'Answer', 'text': item['answer']},
|
|
2390
|
+
}
|
|
2391
|
+
for item in items
|
|
2392
|
+
],
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
|
|
2396
|
+
def breadcrumb_schema(items: list[dict]) -> dict:
|
|
2397
|
+
"""Generate Breadcrumb schema from list of {name, url} dicts"""
|
|
2398
|
+
return {
|
|
2399
|
+
'@context': 'https://schema.org',
|
|
2400
|
+
'@type': 'BreadcrumbList',
|
|
2401
|
+
'itemListElement': [
|
|
2402
|
+
{
|
|
2403
|
+
'@type': 'ListItem',
|
|
2404
|
+
'position': i + 1,
|
|
2405
|
+
'name': item['name'],
|
|
2406
|
+
'item': item['url'],
|
|
2407
|
+
}
|
|
2408
|
+
for i, item in enumerate(items)
|
|
2409
|
+
],
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
|
|
2413
|
+
def local_business_schema(business: dict) -> dict:
|
|
2414
|
+
"""Generate LocalBusiness schema"""
|
|
2415
|
+
schema = {
|
|
2416
|
+
'@context': 'https://schema.org',
|
|
2417
|
+
'@type': 'LocalBusiness',
|
|
2418
|
+
'name': business['name'],
|
|
2419
|
+
'description': business.get('description', ''),
|
|
2420
|
+
'url': business.get('url', SITE_URL),
|
|
2421
|
+
'telephone': business.get('phone'),
|
|
2422
|
+
'address': {
|
|
2423
|
+
'@type': 'PostalAddress',
|
|
2424
|
+
'streetAddress': business.get('address', {}).get('street'),
|
|
2425
|
+
'addressLocality': business.get('address', {}).get('city'),
|
|
2426
|
+
'addressRegion': business.get('address', {}).get('state'),
|
|
2427
|
+
'postalCode': business.get('address', {}).get('zip'),
|
|
2428
|
+
'addressCountry': business.get('address', {}).get('country'),
|
|
2429
|
+
},
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
if business.get('coordinates'):
|
|
2433
|
+
schema['geo'] = {
|
|
2434
|
+
'@type': 'GeoCoordinates',
|
|
2435
|
+
'latitude': business['coordinates']['lat'],
|
|
2436
|
+
'longitude': business['coordinates']['lng'],
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
if business.get('hours'):
|
|
2440
|
+
schema['openingHours'] = business['hours']
|
|
2441
|
+
|
|
2442
|
+
if business.get('price_range'):
|
|
2443
|
+
schema['priceRange'] = business['price_range']
|
|
2444
|
+
|
|
2445
|
+
return schema
|
|
2446
|
+
|
|
2447
|
+
|
|
2448
|
+
def render_json_ld(*schemas) -> str:
|
|
2449
|
+
"""Render JSON-LD script tags for templates"""
|
|
2450
|
+
all_schemas = [website_schema()] + list(schemas)
|
|
2451
|
+
scripts = '\\n'.join(
|
|
2452
|
+
f'<script type="application/ld+json">{json.dumps(s)}</script>'
|
|
2453
|
+
for s in all_schemas if s
|
|
2454
|
+
)
|
|
2455
|
+
return mark_safe(scripts)`,
|
|
2456
|
+
explanation: `Django comprehensive SEO module with:
|
|
2457
|
+
• SEOMeta dataclass for structured metadata
|
|
2458
|
+
• SEOContextMixin for class-based views
|
|
2459
|
+
• Context processor for global SEO values
|
|
2460
|
+
• JSON-LD schema generators
|
|
2461
|
+
• Template-ready helpers
|
|
2462
|
+
|
|
2463
|
+
Setup:
|
|
2464
|
+
1. pip install django-meta
|
|
2465
|
+
2. Add context processor to settings
|
|
2466
|
+
3. Use SEOContextMixin in views
|
|
2467
|
+
4. Include seo template tags in base.html`,
|
|
2468
|
+
installCommands: ['pip install django-meta'],
|
|
2469
|
+
additionalFiles: [
|
|
2470
|
+
{
|
|
2471
|
+
file: 'templates/base.html',
|
|
2472
|
+
code: `<!DOCTYPE html>
|
|
2473
|
+
<html lang="{{ LANGUAGE_CODE }}">
|
|
2474
|
+
<head>
|
|
2475
|
+
<meta charset="utf-8">
|
|
2476
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2477
|
+
|
|
2478
|
+
{% if seo %}
|
|
2479
|
+
<!-- Primary Meta Tags -->
|
|
2480
|
+
<title>{{ seo.get_full_title }}</title>
|
|
2481
|
+
<meta name="title" content="{{ seo.get_full_title }}">
|
|
2482
|
+
<meta name="description" content="{{ seo.description }}">
|
|
2483
|
+
<meta name="robots" content="{{ seo.get_robots }}">
|
|
2484
|
+
<link rel="canonical" href="{{ seo.url|default:request.build_absolute_uri }}">
|
|
2485
|
+
|
|
2486
|
+
<!-- Open Graph / Facebook -->
|
|
2487
|
+
<meta property="og:type" content="{{ seo.type }}">
|
|
2488
|
+
<meta property="og:url" content="{{ seo.url|default:request.build_absolute_uri }}">
|
|
2489
|
+
<meta property="og:title" content="{{ seo.get_full_title }}">
|
|
2490
|
+
<meta property="og:description" content="{{ seo.description }}">
|
|
2491
|
+
<meta property="og:image" content="{{ seo.get_image_url }}">
|
|
2492
|
+
<meta property="og:image:width" content="1200">
|
|
2493
|
+
<meta property="og:image:height" content="630">
|
|
2494
|
+
<meta property="og:site_name" content="{{ site_name }}">
|
|
2495
|
+
|
|
2496
|
+
{% if seo.type == 'article' %}
|
|
2497
|
+
{% if seo.published_time %}<meta property="article:published_time" content="{{ seo.published_time }}">{% endif %}
|
|
2498
|
+
{% if seo.modified_time %}<meta property="article:modified_time" content="{{ seo.modified_time }}">{% endif %}
|
|
2499
|
+
{% if seo.author %}<meta property="article:author" content="{{ seo.author }}">{% endif %}
|
|
2500
|
+
{% for tag in seo.tags %}<meta property="article:tag" content="{{ tag }}">{% endfor %}
|
|
2501
|
+
{% endif %}
|
|
2502
|
+
|
|
2503
|
+
<!-- Twitter -->
|
|
2504
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
2505
|
+
<meta name="twitter:title" content="{{ seo.get_full_title }}">
|
|
2506
|
+
<meta name="twitter:description" content="{{ seo.description }}">
|
|
2507
|
+
<meta name="twitter:image" content="{{ seo.get_image_url }}">
|
|
2508
|
+
{% else %}
|
|
2509
|
+
<title>{{ site_name }}</title>
|
|
2510
|
+
{% endif %}
|
|
2511
|
+
|
|
2512
|
+
{% load static %}
|
|
2513
|
+
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
|
|
2514
|
+
|
|
2515
|
+
<!-- JSON-LD Structured Data -->
|
|
2516
|
+
{% block json_ld %}{% endblock %}
|
|
2517
|
+
</head>
|
|
2518
|
+
<body>
|
|
2519
|
+
{% block content %}{% endblock %}
|
|
2520
|
+
</body>
|
|
2521
|
+
</html>`,
|
|
2522
|
+
explanation: 'Django base template with comprehensive SEO meta tags.',
|
|
2523
|
+
},
|
|
2524
|
+
{
|
|
2525
|
+
file: 'robots.txt',
|
|
2526
|
+
code: `User-agent: *
|
|
2527
|
+
Allow: /
|
|
2528
|
+
Disallow: /admin/
|
|
2529
|
+
Disallow: /api/
|
|
2530
|
+
|
|
2531
|
+
User-agent: GPTBot
|
|
2532
|
+
Allow: /
|
|
2533
|
+
|
|
2534
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
2535
|
+
explanation: 'Robots.txt with AI crawler support.',
|
|
2536
|
+
},
|
|
2537
|
+
],
|
|
2538
|
+
};
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
// ============================================================================
|
|
2542
|
+
// LARAVEL - Comprehensive SEO with Blade components
|
|
2543
|
+
// ============================================================================
|
|
2544
|
+
|
|
2545
|
+
export function generateLaravelSEOHelper(options: MetaFixOptions): GeneratedCode {
|
|
2546
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
2547
|
+
|
|
2548
|
+
return {
|
|
2549
|
+
file: 'app/Services/SEOService.php',
|
|
2550
|
+
code: `<?php
|
|
2551
|
+
|
|
2552
|
+
namespace App\\Services;
|
|
2553
|
+
|
|
2554
|
+
use Illuminate\\Support\\Facades\\View;
|
|
2555
|
+
use Illuminate\\Support\\HtmlString;
|
|
2556
|
+
|
|
2557
|
+
/**
|
|
2558
|
+
* Comprehensive SEO Service for Laravel
|
|
2559
|
+
*
|
|
2560
|
+
* Features:
|
|
2561
|
+
* - Full Open Graph support
|
|
2562
|
+
* - Twitter Cards
|
|
2563
|
+
* - JSON-LD structured data
|
|
2564
|
+
* - Canonical URLs
|
|
2565
|
+
* - Blade integration
|
|
2566
|
+
*/
|
|
2567
|
+
class SEOService
|
|
2568
|
+
{
|
|
2569
|
+
public const SITE_NAME = '${siteName}';
|
|
2570
|
+
public const SITE_URL = '${siteUrl}';
|
|
2571
|
+
public const DEFAULT_IMAGE = '${image || `${siteUrl}/og-image.png`}';
|
|
2572
|
+
public const DEFAULT_DESCRIPTION = '${description || `${siteName} - A compelling description.`}';
|
|
2573
|
+
public const TWITTER_HANDLE = '${twitterHandle || ''}';
|
|
2574
|
+
|
|
2575
|
+
protected array $meta = [];
|
|
2576
|
+
protected array $schemas = [];
|
|
2577
|
+
|
|
2578
|
+
/**
|
|
2579
|
+
* Set page SEO metadata
|
|
2580
|
+
*/
|
|
2581
|
+
public function setMeta(array $options): self
|
|
2582
|
+
{
|
|
2583
|
+
$this->meta = array_merge([
|
|
2584
|
+
'title' => null,
|
|
2585
|
+
'description' => self::DEFAULT_DESCRIPTION,
|
|
2586
|
+
'image' => self::DEFAULT_IMAGE,
|
|
2587
|
+
'url' => request()->url(),
|
|
2588
|
+
'type' => 'website',
|
|
2589
|
+
'published_time' => null,
|
|
2590
|
+
'modified_time' => null,
|
|
2591
|
+
'author' => null,
|
|
2592
|
+
'tags' => [],
|
|
2593
|
+
'noindex' => false,
|
|
2594
|
+
'nofollow' => false,
|
|
2595
|
+
], $options);
|
|
2596
|
+
|
|
2597
|
+
return $this;
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
/**
|
|
2601
|
+
* Add JSON-LD schema
|
|
2602
|
+
*/
|
|
2603
|
+
public function addSchema(array $schema): self
|
|
2604
|
+
{
|
|
2605
|
+
$this->schemas[] = $schema;
|
|
2606
|
+
return $this;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
/**
|
|
2610
|
+
* Get full page title
|
|
2611
|
+
*/
|
|
2612
|
+
public function getFullTitle(): string
|
|
2613
|
+
{
|
|
2614
|
+
$title = $this->meta['title'] ?? null;
|
|
2615
|
+
|
|
2616
|
+
if (!$title) {
|
|
2617
|
+
return self::SITE_NAME;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
if (str_contains($title, self::SITE_NAME)) {
|
|
2621
|
+
return $title;
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
return "{$title} | " . self::SITE_NAME;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
/**
|
|
2628
|
+
* Get absolute image URL
|
|
2629
|
+
*/
|
|
2630
|
+
public function getImageUrl(): string
|
|
2631
|
+
{
|
|
2632
|
+
$image = $this->meta['image'] ?? self::DEFAULT_IMAGE;
|
|
2633
|
+
|
|
2634
|
+
if (str_starts_with($image, 'http')) {
|
|
2635
|
+
return $image;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
return self::SITE_URL . $image;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
/**
|
|
2642
|
+
* Get robots directive
|
|
2643
|
+
*/
|
|
2644
|
+
public function getRobots(): string
|
|
2645
|
+
{
|
|
2646
|
+
$index = ($this->meta['noindex'] ?? false) ? 'noindex' : 'index';
|
|
2647
|
+
$follow = ($this->meta['nofollow'] ?? false) ? 'nofollow' : 'follow';
|
|
2648
|
+
return "{$index}, {$follow}";
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
/**
|
|
2652
|
+
* Render all meta tags
|
|
2653
|
+
*/
|
|
2654
|
+
public function render(): HtmlString
|
|
2655
|
+
{
|
|
2656
|
+
$html = view('components.seo-meta', [
|
|
2657
|
+
'seo' => $this,
|
|
2658
|
+
'meta' => $this->meta,
|
|
2659
|
+
])->render();
|
|
2660
|
+
|
|
2661
|
+
return new HtmlString($html);
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
/**
|
|
2665
|
+
* Render JSON-LD scripts
|
|
2666
|
+
*/
|
|
2667
|
+
public function renderJsonLd(): HtmlString
|
|
2668
|
+
{
|
|
2669
|
+
$allSchemas = array_merge(
|
|
2670
|
+
[$this->websiteSchema()],
|
|
2671
|
+
$this->schemas
|
|
2672
|
+
);
|
|
2673
|
+
|
|
2674
|
+
$scripts = collect($allSchemas)
|
|
2675
|
+
->filter()
|
|
2676
|
+
->map(fn($schema) => '<script type="application/ld+json">' . json_encode($schema) . '</script>')
|
|
2677
|
+
->implode("\\n");
|
|
2678
|
+
|
|
2679
|
+
return new HtmlString($scripts);
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
// Schema Generators
|
|
2683
|
+
|
|
2684
|
+
public function websiteSchema(): array
|
|
2685
|
+
{
|
|
2686
|
+
return [
|
|
2687
|
+
'@context' => 'https://schema.org',
|
|
2688
|
+
'@type' => 'WebSite',
|
|
2689
|
+
'name' => self::SITE_NAME,
|
|
2690
|
+
'url' => self::SITE_URL,
|
|
2691
|
+
];
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
public function organizationSchema(
|
|
2695
|
+
?string $name = null,
|
|
2696
|
+
?string $url = null,
|
|
2697
|
+
?string $logo = null,
|
|
2698
|
+
array $socialProfiles = []
|
|
2699
|
+
): array {
|
|
2700
|
+
return array_filter([
|
|
2701
|
+
'@context' => 'https://schema.org',
|
|
2702
|
+
'@type' => 'Organization',
|
|
2703
|
+
'name' => $name ?? self::SITE_NAME,
|
|
2704
|
+
'url' => $url ?? self::SITE_URL,
|
|
2705
|
+
'logo' => $logo,
|
|
2706
|
+
'sameAs' => $socialProfiles ?: null,
|
|
2707
|
+
]);
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
public function articleSchema($article): array
|
|
2711
|
+
{
|
|
2712
|
+
return [
|
|
2713
|
+
'@context' => 'https://schema.org',
|
|
2714
|
+
'@type' => 'Article',
|
|
2715
|
+
'headline' => $article->title,
|
|
2716
|
+
'description' => $article->excerpt ?? substr(strip_tags($article->content), 0, 160),
|
|
2717
|
+
'image' => $article->image_url ?? self::DEFAULT_IMAGE,
|
|
2718
|
+
'datePublished' => optional($article->published_at)->toIso8601String(),
|
|
2719
|
+
'dateModified' => optional($article->updated_at)->toIso8601String(),
|
|
2720
|
+
'author' => [
|
|
2721
|
+
'@type' => 'Person',
|
|
2722
|
+
'name' => $article->author?->name ?? 'Unknown',
|
|
2723
|
+
],
|
|
2724
|
+
'publisher' => [
|
|
2725
|
+
'@type' => 'Organization',
|
|
2726
|
+
'name' => self::SITE_NAME,
|
|
2727
|
+
'logo' => [
|
|
2728
|
+
'@type' => 'ImageObject',
|
|
2729
|
+
'url' => self::SITE_URL . '/logo.png',
|
|
2730
|
+
],
|
|
2731
|
+
],
|
|
2732
|
+
];
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
public function productSchema($product): array
|
|
2736
|
+
{
|
|
2737
|
+
$schema = [
|
|
2738
|
+
'@context' => 'https://schema.org',
|
|
2739
|
+
'@type' => 'Product',
|
|
2740
|
+
'name' => $product->name,
|
|
2741
|
+
'description' => $product->description,
|
|
2742
|
+
'image' => $product->image_url ?? self::DEFAULT_IMAGE,
|
|
2743
|
+
'offers' => [
|
|
2744
|
+
'@type' => 'Offer',
|
|
2745
|
+
'price' => (string) $product->price,
|
|
2746
|
+
'priceCurrency' => $product->currency ?? 'USD',
|
|
2747
|
+
'availability' => 'https://schema.org/' . ($product->in_stock ? 'InStock' : 'OutOfStock'),
|
|
2748
|
+
],
|
|
2749
|
+
];
|
|
2750
|
+
|
|
2751
|
+
if ($product->brand) {
|
|
2752
|
+
$schema['brand'] = ['@type' => 'Brand', 'name' => $product->brand];
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
if ($product->sku) {
|
|
2756
|
+
$schema['sku'] = $product->sku;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
if ($product->reviews_count > 0) {
|
|
2760
|
+
$schema['aggregateRating'] = [
|
|
2761
|
+
'@type' => 'AggregateRating',
|
|
2762
|
+
'ratingValue' => $product->average_rating,
|
|
2763
|
+
'reviewCount' => $product->reviews_count,
|
|
2764
|
+
];
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
return $schema;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
public function faqSchema(array $items): array
|
|
2771
|
+
{
|
|
2772
|
+
return [
|
|
2773
|
+
'@context' => 'https://schema.org',
|
|
2774
|
+
'@type' => 'FAQPage',
|
|
2775
|
+
'mainEntity' => collect($items)->map(fn($item) => [
|
|
2776
|
+
'@type' => 'Question',
|
|
2777
|
+
'name' => $item['question'],
|
|
2778
|
+
'acceptedAnswer' => ['@type' => 'Answer', 'text' => $item['answer']],
|
|
2779
|
+
])->all(),
|
|
2780
|
+
];
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
public function breadcrumbSchema(array $items): array
|
|
2784
|
+
{
|
|
2785
|
+
return [
|
|
2786
|
+
'@context' => 'https://schema.org',
|
|
2787
|
+
'@type' => 'BreadcrumbList',
|
|
2788
|
+
'itemListElement' => collect($items)->map(fn($item, $index) => [
|
|
2789
|
+
'@type' => 'ListItem',
|
|
2790
|
+
'position' => $index + 1,
|
|
2791
|
+
'name' => $item['name'],
|
|
2792
|
+
'item' => $item['url'],
|
|
2793
|
+
])->all(),
|
|
2794
|
+
];
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
public function localBusinessSchema(array $business): array
|
|
2798
|
+
{
|
|
2799
|
+
return array_filter([
|
|
2800
|
+
'@context' => 'https://schema.org',
|
|
2801
|
+
'@type' => 'LocalBusiness',
|
|
2802
|
+
'name' => $business['name'],
|
|
2803
|
+
'description' => $business['description'] ?? null,
|
|
2804
|
+
'url' => $business['url'] ?? self::SITE_URL,
|
|
2805
|
+
'telephone' => $business['phone'] ?? null,
|
|
2806
|
+
'address' => [
|
|
2807
|
+
'@type' => 'PostalAddress',
|
|
2808
|
+
'streetAddress' => $business['address']['street'] ?? null,
|
|
2809
|
+
'addressLocality' => $business['address']['city'] ?? null,
|
|
2810
|
+
'addressRegion' => $business['address']['state'] ?? null,
|
|
2811
|
+
'postalCode' => $business['address']['zip'] ?? null,
|
|
2812
|
+
'addressCountry' => $business['address']['country'] ?? null,
|
|
2813
|
+
],
|
|
2814
|
+
'geo' => isset($business['coordinates']) ? [
|
|
2815
|
+
'@type' => 'GeoCoordinates',
|
|
2816
|
+
'latitude' => $business['coordinates']['lat'],
|
|
2817
|
+
'longitude' => $business['coordinates']['lng'],
|
|
2818
|
+
] : null,
|
|
2819
|
+
'openingHours' => $business['hours'] ?? null,
|
|
2820
|
+
'priceRange' => $business['price_range'] ?? null,
|
|
2821
|
+
]);
|
|
2822
|
+
}
|
|
2823
|
+
}`,
|
|
2824
|
+
explanation: `Laravel comprehensive SEO service with:
|
|
2825
|
+
• Fluent API for setting metadata
|
|
2826
|
+
• Full Open Graph with article support
|
|
2827
|
+
• Twitter Cards
|
|
2828
|
+
• JSON-LD schema generators
|
|
2829
|
+
• Blade component integration
|
|
2830
|
+
|
|
2831
|
+
Setup:
|
|
2832
|
+
1. Register SEOService in AppServiceProvider
|
|
2833
|
+
2. Use @seo directive in Blade templates
|
|
2834
|
+
3. Call SEO()->setMeta() in controllers`,
|
|
2835
|
+
additionalFiles: [
|
|
2836
|
+
{
|
|
2837
|
+
file: 'resources/views/components/seo-meta.blade.php',
|
|
2838
|
+
code: `{{-- Primary Meta Tags --}}
|
|
2839
|
+
<title>{{ $seo->getFullTitle() }}</title>
|
|
2840
|
+
<meta name="title" content="{{ $seo->getFullTitle() }}">
|
|
2841
|
+
<meta name="description" content="{{ $meta['description'] }}">
|
|
2842
|
+
<meta name="robots" content="{{ $seo->getRobots() }}">
|
|
2843
|
+
<link rel="canonical" href="{{ $meta['url'] }}">
|
|
2844
|
+
|
|
2845
|
+
{{-- Open Graph / Facebook --}}
|
|
2846
|
+
<meta property="og:type" content="{{ $meta['type'] }}">
|
|
2847
|
+
<meta property="og:url" content="{{ $meta['url'] }}">
|
|
2848
|
+
<meta property="og:title" content="{{ $seo->getFullTitle() }}">
|
|
2849
|
+
<meta property="og:description" content="{{ $meta['description'] }}">
|
|
2850
|
+
<meta property="og:image" content="{{ $seo->getImageUrl() }}">
|
|
2851
|
+
<meta property="og:image:width" content="1200">
|
|
2852
|
+
<meta property="og:image:height" content="630">
|
|
2853
|
+
<meta property="og:site_name" content="{{ $seo::SITE_NAME }}">
|
|
2854
|
+
|
|
2855
|
+
@if($meta['type'] === 'article')
|
|
2856
|
+
@if($meta['published_time'])
|
|
2857
|
+
<meta property="article:published_time" content="{{ $meta['published_time'] }}">
|
|
2858
|
+
@endif
|
|
2859
|
+
@if($meta['modified_time'])
|
|
2860
|
+
<meta property="article:modified_time" content="{{ $meta['modified_time'] }}">
|
|
2861
|
+
@endif
|
|
2862
|
+
@if($meta['author'])
|
|
2863
|
+
<meta property="article:author" content="{{ $meta['author'] }}">
|
|
2864
|
+
@endif
|
|
2865
|
+
@foreach($meta['tags'] as $tag)
|
|
2866
|
+
<meta property="article:tag" content="{{ $tag }}">
|
|
2867
|
+
@endforeach
|
|
2868
|
+
@endif
|
|
2869
|
+
|
|
2870
|
+
{{-- Twitter --}}
|
|
2871
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
2872
|
+
<meta name="twitter:title" content="{{ $seo->getFullTitle() }}">
|
|
2873
|
+
<meta name="twitter:description" content="{{ $meta['description'] }}">
|
|
2874
|
+
<meta name="twitter:image" content="{{ $seo->getImageUrl() }}">
|
|
2875
|
+
@if($seo::TWITTER_HANDLE)
|
|
2876
|
+
<meta name="twitter:site" content="{{ $seo::TWITTER_HANDLE }}">
|
|
2877
|
+
<meta name="twitter:creator" content="{{ $seo::TWITTER_HANDLE }}">
|
|
2878
|
+
@endif`,
|
|
2879
|
+
explanation: 'Blade component for rendering SEO meta tags.',
|
|
2880
|
+
},
|
|
2881
|
+
{
|
|
2882
|
+
file: 'resources/views/layouts/app.blade.php',
|
|
2883
|
+
code: `<!DOCTYPE html>
|
|
2884
|
+
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
|
2885
|
+
<head>
|
|
2886
|
+
<meta charset="utf-8">
|
|
2887
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2888
|
+
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
2889
|
+
|
|
2890
|
+
{{-- SEO Meta Tags --}}
|
|
2891
|
+
{!! SEO()->render() !!}
|
|
2892
|
+
|
|
2893
|
+
{{-- Favicon --}}
|
|
2894
|
+
<link rel="icon" href="/favicon.ico">
|
|
2895
|
+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
2896
|
+
|
|
2897
|
+
{{-- Styles --}}
|
|
2898
|
+
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
|
2899
|
+
|
|
2900
|
+
{{-- JSON-LD --}}
|
|
2901
|
+
{!! SEO()->renderJsonLd() !!}
|
|
2902
|
+
</head>
|
|
2903
|
+
<body>
|
|
2904
|
+
@yield('content')
|
|
2905
|
+
</body>
|
|
2906
|
+
</html>`,
|
|
2907
|
+
explanation: 'Laravel layout with SEO integration.',
|
|
2908
|
+
},
|
|
2909
|
+
{
|
|
2910
|
+
file: 'app/Providers/AppServiceProvider.php',
|
|
2911
|
+
code: `<?php
|
|
2912
|
+
|
|
2913
|
+
namespace App\\Providers;
|
|
2914
|
+
|
|
2915
|
+
use App\\Services\\SEOService;
|
|
2916
|
+
use Illuminate\\Support\\ServiceProvider;
|
|
2917
|
+
|
|
2918
|
+
class AppServiceProvider extends ServiceProvider
|
|
2919
|
+
{
|
|
2920
|
+
public function register(): void
|
|
2921
|
+
{
|
|
2922
|
+
$this->app->singleton(SEOService::class);
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
public function boot(): void
|
|
2926
|
+
{
|
|
2927
|
+
// Global SEO helper
|
|
2928
|
+
if (!function_exists('SEO')) {
|
|
2929
|
+
function SEO(): SEOService {
|
|
2930
|
+
return app(SEOService::class);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
}`,
|
|
2935
|
+
explanation: 'Service provider registration.',
|
|
2936
|
+
},
|
|
2937
|
+
{
|
|
2938
|
+
file: 'public/robots.txt',
|
|
2939
|
+
code: `User-agent: *
|
|
2940
|
+
Allow: /
|
|
2941
|
+
Disallow: /admin/
|
|
2942
|
+
Disallow: /api/
|
|
2943
|
+
|
|
2944
|
+
User-agent: GPTBot
|
|
2945
|
+
Allow: /
|
|
2946
|
+
|
|
2947
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
2948
|
+
explanation: 'Robots.txt with AI crawler support.',
|
|
2949
|
+
},
|
|
2950
|
+
],
|
|
2951
|
+
};
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
// ============================================================================
|
|
2955
|
+
// SPRING BOOT (Java) - Thymeleaf SEO
|
|
2956
|
+
// ============================================================================
|
|
2957
|
+
|
|
2958
|
+
export function generateSpringBootSEO(options: MetaFixOptions): GeneratedCode {
|
|
2959
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
2960
|
+
|
|
2961
|
+
return {
|
|
2962
|
+
file: 'src/main/java/com/example/seo/SEOService.java',
|
|
2963
|
+
code: `package com.example.seo;
|
|
2964
|
+
|
|
2965
|
+
import org.springframework.stereotype.Service;
|
|
2966
|
+
import java.util.*;
|
|
2967
|
+
|
|
2968
|
+
/**
|
|
2969
|
+
* Comprehensive SEO Service for Spring Boot
|
|
2970
|
+
*
|
|
2971
|
+
* Features:
|
|
2972
|
+
* - Full Open Graph support
|
|
2973
|
+
* - Twitter Cards
|
|
2974
|
+
* - JSON-LD structured data
|
|
2975
|
+
* - Thymeleaf integration
|
|
2976
|
+
*/
|
|
2977
|
+
@Service
|
|
2978
|
+
public class SEOService {
|
|
2979
|
+
|
|
2980
|
+
public static final String SITE_NAME = "${siteName}";
|
|
2981
|
+
public static final String SITE_URL = "${siteUrl}";
|
|
2982
|
+
public static final String DEFAULT_IMAGE = "${image || `${siteUrl}/og-image.png`}";
|
|
2983
|
+
public static final String DEFAULT_DESCRIPTION = "${description || `${siteName} - A compelling description.`}";
|
|
2984
|
+
public static final String TWITTER_HANDLE = "${twitterHandle || ''}";
|
|
2985
|
+
|
|
2986
|
+
/**
|
|
2987
|
+
* Build SEO metadata for a page
|
|
2988
|
+
*/
|
|
2989
|
+
public SEOMeta buildMeta(String title, String description, String image, String type) {
|
|
2990
|
+
return SEOMeta.builder()
|
|
2991
|
+
.title(title)
|
|
2992
|
+
.description(description != null ? description : DEFAULT_DESCRIPTION)
|
|
2993
|
+
.image(image != null ? image : DEFAULT_IMAGE)
|
|
2994
|
+
.type(type != null ? type : "website")
|
|
2995
|
+
.build();
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
/**
|
|
2999
|
+
* Get full page title with site name
|
|
3000
|
+
*/
|
|
3001
|
+
public String getFullTitle(String title) {
|
|
3002
|
+
if (title == null || title.isEmpty()) {
|
|
3003
|
+
return SITE_NAME;
|
|
3004
|
+
}
|
|
3005
|
+
if (title.contains(SITE_NAME)) {
|
|
3006
|
+
return title;
|
|
3007
|
+
}
|
|
3008
|
+
return title + " | " + SITE_NAME;
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
/**
|
|
3012
|
+
* Get absolute image URL
|
|
3013
|
+
*/
|
|
3014
|
+
public String getAbsoluteImageUrl(String image) {
|
|
3015
|
+
if (image == null) {
|
|
3016
|
+
return DEFAULT_IMAGE;
|
|
3017
|
+
}
|
|
3018
|
+
if (image.startsWith("http")) {
|
|
3019
|
+
return image;
|
|
3020
|
+
}
|
|
3021
|
+
return SITE_URL + image;
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
// JSON-LD Schema Generators
|
|
3025
|
+
|
|
3026
|
+
public Map<String, Object> websiteSchema() {
|
|
3027
|
+
Map<String, Object> schema = new LinkedHashMap<>();
|
|
3028
|
+
schema.put("@context", "https://schema.org");
|
|
3029
|
+
schema.put("@type", "WebSite");
|
|
3030
|
+
schema.put("name", SITE_NAME);
|
|
3031
|
+
schema.put("url", SITE_URL);
|
|
3032
|
+
return schema;
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
public Map<String, Object> organizationSchema(String name, String url, String logo, List<String> socialProfiles) {
|
|
3036
|
+
Map<String, Object> schema = new LinkedHashMap<>();
|
|
3037
|
+
schema.put("@context", "https://schema.org");
|
|
3038
|
+
schema.put("@type", "Organization");
|
|
3039
|
+
schema.put("name", name != null ? name : SITE_NAME);
|
|
3040
|
+
schema.put("url", url != null ? url : SITE_URL);
|
|
3041
|
+
if (logo != null) schema.put("logo", logo);
|
|
3042
|
+
if (socialProfiles != null && !socialProfiles.isEmpty()) {
|
|
3043
|
+
schema.put("sameAs", socialProfiles);
|
|
3044
|
+
}
|
|
3045
|
+
return schema;
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
public Map<String, Object> articleSchema(
|
|
3049
|
+
String headline,
|
|
3050
|
+
String description,
|
|
3051
|
+
String image,
|
|
3052
|
+
String datePublished,
|
|
3053
|
+
String dateModified,
|
|
3054
|
+
String authorName
|
|
3055
|
+
) {
|
|
3056
|
+
Map<String, Object> schema = new LinkedHashMap<>();
|
|
3057
|
+
schema.put("@context", "https://schema.org");
|
|
3058
|
+
schema.put("@type", "Article");
|
|
3059
|
+
schema.put("headline", headline);
|
|
3060
|
+
schema.put("description", description);
|
|
3061
|
+
schema.put("image", image != null ? image : DEFAULT_IMAGE);
|
|
3062
|
+
schema.put("datePublished", datePublished);
|
|
3063
|
+
schema.put("dateModified", dateModified != null ? dateModified : datePublished);
|
|
3064
|
+
|
|
3065
|
+
Map<String, Object> author = new LinkedHashMap<>();
|
|
3066
|
+
author.put("@type", "Person");
|
|
3067
|
+
author.put("name", authorName);
|
|
3068
|
+
schema.put("author", author);
|
|
3069
|
+
|
|
3070
|
+
Map<String, Object> publisher = new LinkedHashMap<>();
|
|
3071
|
+
publisher.put("@type", "Organization");
|
|
3072
|
+
publisher.put("name", SITE_NAME);
|
|
3073
|
+
Map<String, Object> logo = new LinkedHashMap<>();
|
|
3074
|
+
logo.put("@type", "ImageObject");
|
|
3075
|
+
logo.put("url", SITE_URL + "/logo.png");
|
|
3076
|
+
publisher.put("logo", logo);
|
|
3077
|
+
schema.put("publisher", publisher);
|
|
3078
|
+
|
|
3079
|
+
return schema;
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
public Map<String, Object> productSchema(
|
|
3083
|
+
String name,
|
|
3084
|
+
String description,
|
|
3085
|
+
String image,
|
|
3086
|
+
String price,
|
|
3087
|
+
String currency,
|
|
3088
|
+
boolean inStock
|
|
3089
|
+
) {
|
|
3090
|
+
Map<String, Object> schema = new LinkedHashMap<>();
|
|
3091
|
+
schema.put("@context", "https://schema.org");
|
|
3092
|
+
schema.put("@type", "Product");
|
|
3093
|
+
schema.put("name", name);
|
|
3094
|
+
schema.put("description", description);
|
|
3095
|
+
schema.put("image", image);
|
|
3096
|
+
|
|
3097
|
+
Map<String, Object> offers = new LinkedHashMap<>();
|
|
3098
|
+
offers.put("@type", "Offer");
|
|
3099
|
+
offers.put("price", price);
|
|
3100
|
+
offers.put("priceCurrency", currency != null ? currency : "USD");
|
|
3101
|
+
offers.put("availability", "https://schema.org/" + (inStock ? "InStock" : "OutOfStock"));
|
|
3102
|
+
schema.put("offers", offers);
|
|
3103
|
+
|
|
3104
|
+
return schema;
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
public Map<String, Object> faqSchema(List<Map<String, String>> items) {
|
|
3108
|
+
Map<String, Object> schema = new LinkedHashMap<>();
|
|
3109
|
+
schema.put("@context", "https://schema.org");
|
|
3110
|
+
schema.put("@type", "FAQPage");
|
|
3111
|
+
|
|
3112
|
+
List<Map<String, Object>> mainEntity = new ArrayList<>();
|
|
3113
|
+
for (Map<String, String> item : items) {
|
|
3114
|
+
Map<String, Object> question = new LinkedHashMap<>();
|
|
3115
|
+
question.put("@type", "Question");
|
|
3116
|
+
question.put("name", item.get("question"));
|
|
3117
|
+
|
|
3118
|
+
Map<String, Object> answer = new LinkedHashMap<>();
|
|
3119
|
+
answer.put("@type", "Answer");
|
|
3120
|
+
answer.put("text", item.get("answer"));
|
|
3121
|
+
question.put("acceptedAnswer", answer);
|
|
3122
|
+
|
|
3123
|
+
mainEntity.add(question);
|
|
3124
|
+
}
|
|
3125
|
+
schema.put("mainEntity", mainEntity);
|
|
3126
|
+
|
|
3127
|
+
return schema;
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
public Map<String, Object> breadcrumbSchema(List<Map<String, String>> items) {
|
|
3131
|
+
Map<String, Object> schema = new LinkedHashMap<>();
|
|
3132
|
+
schema.put("@context", "https://schema.org");
|
|
3133
|
+
schema.put("@type", "BreadcrumbList");
|
|
3134
|
+
|
|
3135
|
+
List<Map<String, Object>> itemList = new ArrayList<>();
|
|
3136
|
+
for (int i = 0; i < items.size(); i++) {
|
|
3137
|
+
Map<String, String> item = items.get(i);
|
|
3138
|
+
Map<String, Object> listItem = new LinkedHashMap<>();
|
|
3139
|
+
listItem.put("@type", "ListItem");
|
|
3140
|
+
listItem.put("position", i + 1);
|
|
3141
|
+
listItem.put("name", item.get("name"));
|
|
3142
|
+
listItem.put("item", item.get("url"));
|
|
3143
|
+
itemList.add(listItem);
|
|
3144
|
+
}
|
|
3145
|
+
schema.put("itemListElement", itemList);
|
|
3146
|
+
|
|
3147
|
+
return schema;
|
|
3148
|
+
}
|
|
3149
|
+
}`,
|
|
3150
|
+
explanation: `Spring Boot comprehensive SEO service with:
|
|
3151
|
+
• Full Open Graph support
|
|
3152
|
+
• Twitter Cards
|
|
3153
|
+
• JSON-LD schema generators
|
|
3154
|
+
• Thymeleaf integration
|
|
3155
|
+
|
|
3156
|
+
Setup:
|
|
3157
|
+
1. Add SEOService to your controllers
|
|
3158
|
+
2. Pass SEOMeta to model
|
|
3159
|
+
3. Use Thymeleaf fragments in templates`,
|
|
3160
|
+
additionalFiles: [
|
|
3161
|
+
{
|
|
3162
|
+
file: 'src/main/java/com/example/seo/SEOMeta.java',
|
|
3163
|
+
code: `package com.example.seo;
|
|
3164
|
+
|
|
3165
|
+
import lombok.Builder;
|
|
3166
|
+
import lombok.Data;
|
|
3167
|
+
import java.util.List;
|
|
3168
|
+
|
|
3169
|
+
@Data
|
|
3170
|
+
@Builder
|
|
3171
|
+
public class SEOMeta {
|
|
3172
|
+
private String title;
|
|
3173
|
+
private String description;
|
|
3174
|
+
private String image;
|
|
3175
|
+
private String url;
|
|
3176
|
+
private String type;
|
|
3177
|
+
|
|
3178
|
+
// Article specific
|
|
3179
|
+
private String publishedTime;
|
|
3180
|
+
private String modifiedTime;
|
|
3181
|
+
private String author;
|
|
3182
|
+
private List<String> tags;
|
|
3183
|
+
|
|
3184
|
+
// Robots
|
|
3185
|
+
@Builder.Default
|
|
3186
|
+
private boolean noindex = false;
|
|
3187
|
+
@Builder.Default
|
|
3188
|
+
private boolean nofollow = false;
|
|
3189
|
+
|
|
3190
|
+
public String getFullTitle() {
|
|
3191
|
+
if (title == null || title.isEmpty()) {
|
|
3192
|
+
return SEOService.SITE_NAME;
|
|
3193
|
+
}
|
|
3194
|
+
if (title.contains(SEOService.SITE_NAME)) {
|
|
3195
|
+
return title;
|
|
3196
|
+
}
|
|
3197
|
+
return title + " | " + SEOService.SITE_NAME;
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
public String getImageUrl() {
|
|
3201
|
+
if (image == null) {
|
|
3202
|
+
return SEOService.DEFAULT_IMAGE;
|
|
3203
|
+
}
|
|
3204
|
+
if (image.startsWith("http")) {
|
|
3205
|
+
return image;
|
|
3206
|
+
}
|
|
3207
|
+
return SEOService.SITE_URL + image;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
public String getRobots() {
|
|
3211
|
+
String index = noindex ? "noindex" : "index";
|
|
3212
|
+
String follow = nofollow ? "nofollow" : "follow";
|
|
3213
|
+
return index + ", " + follow;
|
|
3214
|
+
}
|
|
3215
|
+
}`,
|
|
3216
|
+
explanation: 'SEO metadata DTO with Lombok.',
|
|
3217
|
+
},
|
|
3218
|
+
{
|
|
3219
|
+
file: 'src/main/resources/templates/fragments/seo.html',
|
|
3220
|
+
code: `<!DOCTYPE html>
|
|
3221
|
+
<html xmlns:th="http://www.thymeleaf.org">
|
|
3222
|
+
<head th:fragment="meta(seo)">
|
|
3223
|
+
<!-- Primary Meta Tags -->
|
|
3224
|
+
<title th:text="\${seo.getFullTitle()}">Page Title</title>
|
|
3225
|
+
<meta name="title" th:content="\${seo.getFullTitle()}">
|
|
3226
|
+
<meta name="description" th:content="\${seo.description}">
|
|
3227
|
+
<meta name="robots" th:content="\${seo.getRobots()}">
|
|
3228
|
+
<link rel="canonical" th:href="\${seo.url}">
|
|
3229
|
+
|
|
3230
|
+
<!-- Open Graph / Facebook -->
|
|
3231
|
+
<meta property="og:type" th:content="\${seo.type}">
|
|
3232
|
+
<meta property="og:url" th:content="\${seo.url}">
|
|
3233
|
+
<meta property="og:title" th:content="\${seo.getFullTitle()}">
|
|
3234
|
+
<meta property="og:description" th:content="\${seo.description}">
|
|
3235
|
+
<meta property="og:image" th:content="\${seo.getImageUrl()}">
|
|
3236
|
+
<meta property="og:image:width" content="1200">
|
|
3237
|
+
<meta property="og:image:height" content="630">
|
|
3238
|
+
<meta property="og:site_name" th:content="@{${siteName}}">
|
|
3239
|
+
|
|
3240
|
+
<th:block th:if="\${seo.type == 'article'}">
|
|
3241
|
+
<meta th:if="\${seo.publishedTime}" property="article:published_time" th:content="\${seo.publishedTime}">
|
|
3242
|
+
<meta th:if="\${seo.modifiedTime}" property="article:modified_time" th:content="\${seo.modifiedTime}">
|
|
3243
|
+
<meta th:if="\${seo.author}" property="article:author" th:content="\${seo.author}">
|
|
3244
|
+
<meta th:each="tag : \${seo.tags}" property="article:tag" th:content="\${tag}">
|
|
3245
|
+
</th:block>
|
|
3246
|
+
|
|
3247
|
+
<!-- Twitter -->
|
|
3248
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
3249
|
+
<meta name="twitter:title" th:content="\${seo.getFullTitle()}">
|
|
3250
|
+
<meta name="twitter:description" th:content="\${seo.description}">
|
|
3251
|
+
<meta name="twitter:image" th:content="\${seo.getImageUrl()}">
|
|
3252
|
+
<meta th:if="${twitterHandle}" name="twitter:site" content="${twitterHandle}">
|
|
3253
|
+
</head>
|
|
3254
|
+
</html>`,
|
|
3255
|
+
explanation: 'Thymeleaf fragment for SEO meta tags.',
|
|
3256
|
+
},
|
|
3257
|
+
{
|
|
3258
|
+
file: 'src/main/resources/templates/layout/base.html',
|
|
3259
|
+
code: `<!DOCTYPE html>
|
|
3260
|
+
<html xmlns:th="http://www.thymeleaf.org" th:lang="\${#locale.language}">
|
|
3261
|
+
<head>
|
|
3262
|
+
<meta charset="UTF-8">
|
|
3263
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3264
|
+
|
|
3265
|
+
<!-- SEO Meta Tags -->
|
|
3266
|
+
<th:block th:replace="~{fragments/seo :: meta(seo=\${seo})}"></th:block>
|
|
3267
|
+
|
|
3268
|
+
<!-- Favicon -->
|
|
3269
|
+
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
3270
|
+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
3271
|
+
|
|
3272
|
+
<!-- Styles -->
|
|
3273
|
+
<link rel="stylesheet" th:href="@{/css/styles.css}">
|
|
3274
|
+
|
|
3275
|
+
<!-- JSON-LD -->
|
|
3276
|
+
<script th:if="\${jsonLd}" type="application/ld+json" th:utext="\${jsonLd}"></script>
|
|
3277
|
+
</head>
|
|
3278
|
+
<body>
|
|
3279
|
+
<div th:replace="~{:: content}"></div>
|
|
3280
|
+
</body>
|
|
3281
|
+
</html>`,
|
|
3282
|
+
explanation: 'Base layout with SEO integration.',
|
|
3283
|
+
},
|
|
3284
|
+
{
|
|
3285
|
+
file: 'src/main/resources/static/robots.txt',
|
|
3286
|
+
code: `User-agent: *
|
|
3287
|
+
Allow: /
|
|
3288
|
+
Disallow: /admin/
|
|
3289
|
+
Disallow: /api/
|
|
3290
|
+
|
|
3291
|
+
User-agent: GPTBot
|
|
3292
|
+
Allow: /
|
|
3293
|
+
|
|
3294
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
3295
|
+
explanation: 'Robots.txt with AI crawler support.',
|
|
3296
|
+
},
|
|
3297
|
+
],
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
// ============================================================================
|
|
3302
|
+
// PHOENIX (Elixir) - HEEx SEO
|
|
3303
|
+
// ============================================================================
|
|
3304
|
+
|
|
3305
|
+
export function generatePhoenixSEO(options: MetaFixOptions): GeneratedCode {
|
|
3306
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
3307
|
+
|
|
3308
|
+
return {
|
|
3309
|
+
file: 'lib/my_app_web/components/seo.ex',
|
|
3310
|
+
code: `defmodule MyAppWeb.Components.SEO do
|
|
3311
|
+
@moduledoc """
|
|
3312
|
+
Comprehensive SEO components for Phoenix
|
|
3313
|
+
|
|
3314
|
+
Features:
|
|
3315
|
+
- Full Open Graph support
|
|
3316
|
+
- Twitter Cards
|
|
3317
|
+
- JSON-LD structured data
|
|
3318
|
+
- HEEx integration
|
|
3319
|
+
"""
|
|
3320
|
+
|
|
3321
|
+
use Phoenix.Component
|
|
3322
|
+
|
|
3323
|
+
@site_name "${siteName}"
|
|
3324
|
+
@site_url "${siteUrl}"
|
|
3325
|
+
@default_image "${image || `${siteUrl}/og-image.png`}"
|
|
3326
|
+
@default_description "${description || `${siteName} - A compelling description.`}"
|
|
3327
|
+
@twitter_handle "${twitterHandle || ''}"
|
|
3328
|
+
|
|
3329
|
+
def site_name, do: @site_name
|
|
3330
|
+
def site_url, do: @site_url
|
|
3331
|
+
|
|
3332
|
+
@doc """
|
|
3333
|
+
SEO meta tags component
|
|
3334
|
+
|
|
3335
|
+
## Examples
|
|
3336
|
+
|
|
3337
|
+
<.seo_meta
|
|
3338
|
+
title="Page Title"
|
|
3339
|
+
description="Page description"
|
|
3340
|
+
type="article"
|
|
3341
|
+
published_time={@article.published_at}
|
|
3342
|
+
/>
|
|
3343
|
+
"""
|
|
3344
|
+
attr :title, :string, default: nil
|
|
3345
|
+
attr :description, :string, default: @default_description
|
|
3346
|
+
attr :image, :string, default: @default_image
|
|
3347
|
+
attr :url, :string, default: nil
|
|
3348
|
+
attr :type, :string, default: "website"
|
|
3349
|
+
attr :published_time, :any, default: nil
|
|
3350
|
+
attr :modified_time, :any, default: nil
|
|
3351
|
+
attr :author, :string, default: nil
|
|
3352
|
+
attr :tags, :list, default: []
|
|
3353
|
+
attr :noindex, :boolean, default: false
|
|
3354
|
+
attr :nofollow, :boolean, default: false
|
|
3355
|
+
|
|
3356
|
+
def seo_meta(assigns) do
|
|
3357
|
+
assigns =
|
|
3358
|
+
assigns
|
|
3359
|
+
|> assign(:full_title, get_full_title(assigns.title))
|
|
3360
|
+
|> assign(:image_url, get_absolute_url(assigns.image))
|
|
3361
|
+
|> assign(:canonical_url, assigns.url || @site_url)
|
|
3362
|
+
|> assign(:robots, get_robots(assigns.noindex, assigns.nofollow))
|
|
3363
|
+
|
|
3364
|
+
~H\"\"\"
|
|
3365
|
+
<!-- Primary Meta Tags -->
|
|
3366
|
+
<title><%= @full_title %></title>
|
|
3367
|
+
<meta name="title" content={@full_title}>
|
|
3368
|
+
<meta name="description" content={@description}>
|
|
3369
|
+
<meta name="robots" content={@robots}>
|
|
3370
|
+
<link rel="canonical" href={@canonical_url}>
|
|
3371
|
+
|
|
3372
|
+
<!-- Open Graph / Facebook -->
|
|
3373
|
+
<meta property="og:type" content={@type}>
|
|
3374
|
+
<meta property="og:url" content={@canonical_url}>
|
|
3375
|
+
<meta property="og:title" content={@full_title}>
|
|
3376
|
+
<meta property="og:description" content={@description}>
|
|
3377
|
+
<meta property="og:image" content={@image_url}>
|
|
3378
|
+
<meta property="og:image:width" content="1200">
|
|
3379
|
+
<meta property="og:image:height" content="630">
|
|
3380
|
+
<meta property="og:site_name" content={@site_name}>
|
|
3381
|
+
|
|
3382
|
+
<%= if @type == "article" do %>
|
|
3383
|
+
<%= if @published_time do %>
|
|
3384
|
+
<meta property="article:published_time" content={format_datetime(@published_time)}>
|
|
3385
|
+
<% end %>
|
|
3386
|
+
<%= if @modified_time do %>
|
|
3387
|
+
<meta property="article:modified_time" content={format_datetime(@modified_time)}>
|
|
3388
|
+
<% end %>
|
|
3389
|
+
<%= if @author do %>
|
|
3390
|
+
<meta property="article:author" content={@author}>
|
|
3391
|
+
<% end %>
|
|
3392
|
+
<%= for tag <- @tags do %>
|
|
3393
|
+
<meta property="article:tag" content={tag}>
|
|
3394
|
+
<% end %>
|
|
3395
|
+
<% end %>
|
|
3396
|
+
|
|
3397
|
+
<!-- Twitter -->
|
|
3398
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
3399
|
+
<meta name="twitter:title" content={@full_title}>
|
|
3400
|
+
<meta name="twitter:description" content={@description}>
|
|
3401
|
+
<meta name="twitter:image" content={@image_url}>
|
|
3402
|
+
<%= if @twitter_handle != "" do %>
|
|
3403
|
+
<meta name="twitter:site" content={@twitter_handle}>
|
|
3404
|
+
<meta name="twitter:creator" content={@twitter_handle}>
|
|
3405
|
+
<% end %>
|
|
3406
|
+
\"\"\"
|
|
3407
|
+
end
|
|
3408
|
+
|
|
3409
|
+
@doc """
|
|
3410
|
+
JSON-LD structured data component
|
|
3411
|
+
"""
|
|
3412
|
+
attr :schemas, :list, required: true
|
|
3413
|
+
|
|
3414
|
+
def json_ld(assigns) do
|
|
3415
|
+
all_schemas = [website_schema() | assigns.schemas]
|
|
3416
|
+
|
|
3417
|
+
~H\"\"\"
|
|
3418
|
+
<%= for schema <- @all_schemas do %>
|
|
3419
|
+
<script type="application/ld+json">
|
|
3420
|
+
<%= raw(Jason.encode!(schema)) %>
|
|
3421
|
+
</script>
|
|
3422
|
+
<% end %>
|
|
3423
|
+
\"\"\"
|
|
3424
|
+
end
|
|
3425
|
+
|
|
3426
|
+
# Schema generators
|
|
3427
|
+
|
|
3428
|
+
def website_schema do
|
|
3429
|
+
%{
|
|
3430
|
+
"@context" => "https://schema.org",
|
|
3431
|
+
"@type" => "WebSite",
|
|
3432
|
+
"name" => @site_name,
|
|
3433
|
+
"url" => @site_url
|
|
3434
|
+
}
|
|
3435
|
+
end
|
|
3436
|
+
|
|
3437
|
+
def organization_schema(opts \\\\ []) do
|
|
3438
|
+
%{
|
|
3439
|
+
"@context" => "https://schema.org",
|
|
3440
|
+
"@type" => "Organization",
|
|
3441
|
+
"name" => opts[:name] || @site_name,
|
|
3442
|
+
"url" => opts[:url] || @site_url
|
|
3443
|
+
}
|
|
3444
|
+
|> maybe_put("logo", opts[:logo])
|
|
3445
|
+
|> maybe_put("sameAs", opts[:social_profiles])
|
|
3446
|
+
end
|
|
3447
|
+
|
|
3448
|
+
def article_schema(article) do
|
|
3449
|
+
%{
|
|
3450
|
+
"@context" => "https://schema.org",
|
|
3451
|
+
"@type" => "Article",
|
|
3452
|
+
"headline" => article.title,
|
|
3453
|
+
"description" => article.excerpt || String.slice(article.content, 0, 160),
|
|
3454
|
+
"image" => article.image_url || @default_image,
|
|
3455
|
+
"datePublished" => format_datetime(article.published_at),
|
|
3456
|
+
"dateModified" => format_datetime(article.updated_at || article.published_at),
|
|
3457
|
+
"author" => %{
|
|
3458
|
+
"@type" => "Person",
|
|
3459
|
+
"name" => article.author_name || "Unknown"
|
|
3460
|
+
},
|
|
3461
|
+
"publisher" => %{
|
|
3462
|
+
"@type" => "Organization",
|
|
3463
|
+
"name" => @site_name,
|
|
3464
|
+
"logo" => %{"@type" => "ImageObject", "url" => "\#{@site_url}/logo.png"}
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
end
|
|
3468
|
+
|
|
3469
|
+
def product_schema(product) do
|
|
3470
|
+
%{
|
|
3471
|
+
"@context" => "https://schema.org",
|
|
3472
|
+
"@type" => "Product",
|
|
3473
|
+
"name" => product.name,
|
|
3474
|
+
"description" => product.description,
|
|
3475
|
+
"image" => product.image_url || @default_image,
|
|
3476
|
+
"offers" => %{
|
|
3477
|
+
"@type" => "Offer",
|
|
3478
|
+
"price" => to_string(product.price),
|
|
3479
|
+
"priceCurrency" => product.currency || "USD",
|
|
3480
|
+
"availability" => "https://schema.org/\#{if product.in_stock, do: "InStock", else: "OutOfStock"}"
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
|> maybe_put("brand", product.brand && %{"@type" => "Brand", "name" => product.brand})
|
|
3484
|
+
|> maybe_put("sku", product.sku)
|
|
3485
|
+
end
|
|
3486
|
+
|
|
3487
|
+
def faq_schema(items) do
|
|
3488
|
+
%{
|
|
3489
|
+
"@context" => "https://schema.org",
|
|
3490
|
+
"@type" => "FAQPage",
|
|
3491
|
+
"mainEntity" =>
|
|
3492
|
+
Enum.map(items, fn item ->
|
|
3493
|
+
%{
|
|
3494
|
+
"@type" => "Question",
|
|
3495
|
+
"name" => item.question,
|
|
3496
|
+
"acceptedAnswer" => %{"@type" => "Answer", "text" => item.answer}
|
|
3497
|
+
}
|
|
3498
|
+
end)
|
|
3499
|
+
}
|
|
3500
|
+
end
|
|
3501
|
+
|
|
3502
|
+
def breadcrumb_schema(items) do
|
|
3503
|
+
%{
|
|
3504
|
+
"@context" => "https://schema.org",
|
|
3505
|
+
"@type" => "BreadcrumbList",
|
|
3506
|
+
"itemListElement" =>
|
|
3507
|
+
items
|
|
3508
|
+
|> Enum.with_index(1)
|
|
3509
|
+
|> Enum.map(fn {item, position} ->
|
|
3510
|
+
%{
|
|
3511
|
+
"@type" => "ListItem",
|
|
3512
|
+
"position" => position,
|
|
3513
|
+
"name" => item.name,
|
|
3514
|
+
"item" => item.url
|
|
3515
|
+
}
|
|
3516
|
+
end)
|
|
3517
|
+
}
|
|
3518
|
+
end
|
|
3519
|
+
|
|
3520
|
+
# Helpers
|
|
3521
|
+
|
|
3522
|
+
defp get_full_title(nil), do: @site_name
|
|
3523
|
+
defp get_full_title(title) when is_binary(title) do
|
|
3524
|
+
if String.contains?(title, @site_name), do: title, else: "\#{title} | \#{@site_name}"
|
|
3525
|
+
end
|
|
3526
|
+
|
|
3527
|
+
defp get_absolute_url(nil), do: @default_image
|
|
3528
|
+
defp get_absolute_url(url) when is_binary(url) do
|
|
3529
|
+
if String.starts_with?(url, "http"), do: url, else: @site_url <> url
|
|
3530
|
+
end
|
|
3531
|
+
|
|
3532
|
+
defp get_robots(noindex, nofollow) do
|
|
3533
|
+
index = if noindex, do: "noindex", else: "index"
|
|
3534
|
+
follow = if nofollow, do: "nofollow", else: "follow"
|
|
3535
|
+
"\#{index}, \#{follow}"
|
|
3536
|
+
end
|
|
3537
|
+
|
|
3538
|
+
defp format_datetime(nil), do: nil
|
|
3539
|
+
defp format_datetime(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
|
3540
|
+
defp format_datetime(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
|
3541
|
+
defp format_datetime(dt) when is_binary(dt), do: dt
|
|
3542
|
+
|
|
3543
|
+
defp maybe_put(map, _key, nil), do: map
|
|
3544
|
+
defp maybe_put(map, _key, []), do: map
|
|
3545
|
+
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
|
3546
|
+
end`,
|
|
3547
|
+
explanation: `Phoenix/Elixir comprehensive SEO module with:
|
|
3548
|
+
• HEEx function components
|
|
3549
|
+
• Full Open Graph with article support
|
|
3550
|
+
• Twitter Cards
|
|
3551
|
+
• JSON-LD schema generators
|
|
3552
|
+
• Elixir-idiomatic API
|
|
3553
|
+
|
|
3554
|
+
Setup:
|
|
3555
|
+
1. Import in your components module
|
|
3556
|
+
2. Use <.seo_meta> in layouts
|
|
3557
|
+
3. Add <.json_ld schemas={[@article_schema]} />`,
|
|
3558
|
+
additionalFiles: [
|
|
3559
|
+
{
|
|
3560
|
+
file: 'lib/my_app_web/components/layouts/root.html.heex',
|
|
3561
|
+
code: `<!DOCTYPE html>
|
|
3562
|
+
<html lang={@locale || "en"}>
|
|
3563
|
+
<head>
|
|
3564
|
+
<meta charset="utf-8">
|
|
3565
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3566
|
+
<meta name="csrf-token" content={get_csrf_token()}>
|
|
3567
|
+
|
|
3568
|
+
<.seo_meta
|
|
3569
|
+
title={assigns[:page_title]}
|
|
3570
|
+
description={assigns[:page_description]}
|
|
3571
|
+
image={assigns[:page_image]}
|
|
3572
|
+
type={assigns[:page_type] || "website"}
|
|
3573
|
+
/>
|
|
3574
|
+
|
|
3575
|
+
<link rel="icon" href="/favicon.ico">
|
|
3576
|
+
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"}>
|
|
3577
|
+
<script defer phx-track-static src={~p"/assets/app.js"}></script>
|
|
3578
|
+
|
|
3579
|
+
<%= if assigns[:json_ld_schemas] do %>
|
|
3580
|
+
<.json_ld schemas={@json_ld_schemas} />
|
|
3581
|
+
<% end %>
|
|
3582
|
+
</head>
|
|
3583
|
+
<body>
|
|
3584
|
+
<%= @inner_content %>
|
|
3585
|
+
</body>
|
|
3586
|
+
</html>`,
|
|
3587
|
+
explanation: 'Phoenix root layout with SEO components.',
|
|
3588
|
+
},
|
|
3589
|
+
{
|
|
3590
|
+
file: 'priv/static/robots.txt',
|
|
3591
|
+
code: `User-agent: *
|
|
3592
|
+
Allow: /
|
|
3593
|
+
Disallow: /admin/
|
|
3594
|
+
Disallow: /api/
|
|
3595
|
+
|
|
3596
|
+
User-agent: GPTBot
|
|
3597
|
+
Allow: /
|
|
3598
|
+
|
|
3599
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
3600
|
+
explanation: 'Robots.txt with AI crawler support.',
|
|
3601
|
+
},
|
|
3602
|
+
],
|
|
3603
|
+
};
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
// ============================================================================
|
|
3607
|
+
// GO (Gin/Echo/Fiber) - Template SEO
|
|
3608
|
+
// ============================================================================
|
|
3609
|
+
|
|
3610
|
+
export function generateGoSEO(options: MetaFixOptions): GeneratedCode {
|
|
3611
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
3612
|
+
|
|
3613
|
+
return {
|
|
3614
|
+
file: 'internal/seo/seo.go',
|
|
3615
|
+
code: `package seo
|
|
3616
|
+
|
|
3617
|
+
import (
|
|
3618
|
+
"encoding/json"
|
|
3619
|
+
"html/template"
|
|
3620
|
+
"strings"
|
|
3621
|
+
"time"
|
|
3622
|
+
)
|
|
3623
|
+
|
|
3624
|
+
// Configuration
|
|
3625
|
+
const (
|
|
3626
|
+
SiteName = "${siteName}"
|
|
3627
|
+
SiteURL = "${siteUrl}"
|
|
3628
|
+
DefaultImage = "${image || `${siteUrl}/og-image.png`}"
|
|
3629
|
+
DefaultDescription = "${description || `${siteName} - A compelling description.`}"
|
|
3630
|
+
TwitterHandle = "${twitterHandle || ''}"
|
|
3631
|
+
)
|
|
3632
|
+
|
|
3633
|
+
// Meta contains SEO metadata for a page
|
|
3634
|
+
type Meta struct {
|
|
3635
|
+
Title string
|
|
3636
|
+
Description string
|
|
3637
|
+
Image string
|
|
3638
|
+
URL string
|
|
3639
|
+
Type string // website, article, product
|
|
3640
|
+
PublishedTime *time.Time
|
|
3641
|
+
ModifiedTime *time.Time
|
|
3642
|
+
Author string
|
|
3643
|
+
Tags []string
|
|
3644
|
+
NoIndex bool
|
|
3645
|
+
NoFollow bool
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
// NewMeta creates a Meta with defaults
|
|
3649
|
+
func NewMeta() *Meta {
|
|
3650
|
+
return &Meta{
|
|
3651
|
+
Description: DefaultDescription,
|
|
3652
|
+
Image: DefaultImage,
|
|
3653
|
+
Type: "website",
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
// FullTitle returns the title with site name
|
|
3658
|
+
func (m *Meta) FullTitle() string {
|
|
3659
|
+
if m.Title == "" {
|
|
3660
|
+
return SiteName
|
|
3661
|
+
}
|
|
3662
|
+
if strings.Contains(m.Title, SiteName) {
|
|
3663
|
+
return m.Title
|
|
3664
|
+
}
|
|
3665
|
+
return m.Title + " | " + SiteName
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
// AbsoluteImageURL returns the absolute image URL
|
|
3669
|
+
func (m *Meta) AbsoluteImageURL() string {
|
|
3670
|
+
if m.Image == "" {
|
|
3671
|
+
return DefaultImage
|
|
3672
|
+
}
|
|
3673
|
+
if strings.HasPrefix(m.Image, "http") {
|
|
3674
|
+
return m.Image
|
|
3675
|
+
}
|
|
3676
|
+
return SiteURL + m.Image
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
// Robots returns the robots meta content
|
|
3680
|
+
func (m *Meta) Robots() string {
|
|
3681
|
+
index := "index"
|
|
3682
|
+
if m.NoIndex {
|
|
3683
|
+
index = "noindex"
|
|
3684
|
+
}
|
|
3685
|
+
follow := "follow"
|
|
3686
|
+
if m.NoFollow {
|
|
3687
|
+
follow = "nofollow"
|
|
3688
|
+
}
|
|
3689
|
+
return index + ", " + follow
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
// Schema types for JSON-LD
|
|
3693
|
+
|
|
3694
|
+
type Schema map[string]interface{}
|
|
3695
|
+
|
|
3696
|
+
// WebsiteSchema returns the default website schema
|
|
3697
|
+
func WebsiteSchema() Schema {
|
|
3698
|
+
return Schema{
|
|
3699
|
+
"@context": "https://schema.org",
|
|
3700
|
+
"@type": "WebSite",
|
|
3701
|
+
"name": SiteName,
|
|
3702
|
+
"url": SiteURL,
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
// OrganizationSchema creates an organization schema
|
|
3707
|
+
func OrganizationSchema(name, url, logo string, socialProfiles []string) Schema {
|
|
3708
|
+
schema := Schema{
|
|
3709
|
+
"@context": "https://schema.org",
|
|
3710
|
+
"@type": "Organization",
|
|
3711
|
+
"name": name,
|
|
3712
|
+
"url": url,
|
|
3713
|
+
}
|
|
3714
|
+
if logo != "" {
|
|
3715
|
+
schema["logo"] = logo
|
|
3716
|
+
}
|
|
3717
|
+
if len(socialProfiles) > 0 {
|
|
3718
|
+
schema["sameAs"] = socialProfiles
|
|
3719
|
+
}
|
|
3720
|
+
return schema
|
|
3721
|
+
}
|
|
3722
|
+
|
|
3723
|
+
// ArticleSchema creates an article schema
|
|
3724
|
+
func ArticleSchema(headline, description, image string, datePublished, dateModified time.Time, authorName string) Schema {
|
|
3725
|
+
return Schema{
|
|
3726
|
+
"@context": "https://schema.org",
|
|
3727
|
+
"@type": "Article",
|
|
3728
|
+
"headline": headline,
|
|
3729
|
+
"description": description,
|
|
3730
|
+
"image": image,
|
|
3731
|
+
"datePublished": datePublished.Format(time.RFC3339),
|
|
3732
|
+
"dateModified": dateModified.Format(time.RFC3339),
|
|
3733
|
+
"author": Schema{
|
|
3734
|
+
"@type": "Person",
|
|
3735
|
+
"name": authorName,
|
|
3736
|
+
},
|
|
3737
|
+
"publisher": Schema{
|
|
3738
|
+
"@type": "Organization",
|
|
3739
|
+
"name": SiteName,
|
|
3740
|
+
"logo": Schema{
|
|
3741
|
+
"@type": "ImageObject",
|
|
3742
|
+
"url": SiteURL + "/logo.png",
|
|
3743
|
+
},
|
|
3744
|
+
},
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
// ProductSchema creates a product schema
|
|
3749
|
+
func ProductSchema(name, description, image, price, currency string, inStock bool) Schema {
|
|
3750
|
+
availability := "https://schema.org/InStock"
|
|
3751
|
+
if !inStock {
|
|
3752
|
+
availability = "https://schema.org/OutOfStock"
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
return Schema{
|
|
3756
|
+
"@context": "https://schema.org",
|
|
3757
|
+
"@type": "Product",
|
|
3758
|
+
"name": name,
|
|
3759
|
+
"description": description,
|
|
3760
|
+
"image": image,
|
|
3761
|
+
"offers": Schema{
|
|
3762
|
+
"@type": "Offer",
|
|
3763
|
+
"price": price,
|
|
3764
|
+
"priceCurrency": currency,
|
|
3765
|
+
"availability": availability,
|
|
3766
|
+
},
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
// FAQItem represents a single FAQ entry
|
|
3771
|
+
type FAQItem struct {
|
|
3772
|
+
Question string
|
|
3773
|
+
Answer string
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
// FAQSchema creates a FAQ schema
|
|
3777
|
+
func FAQSchema(items []FAQItem) Schema {
|
|
3778
|
+
mainEntity := make([]Schema, len(items))
|
|
3779
|
+
for i, item := range items {
|
|
3780
|
+
mainEntity[i] = Schema{
|
|
3781
|
+
"@type": "Question",
|
|
3782
|
+
"name": item.Question,
|
|
3783
|
+
"acceptedAnswer": Schema{
|
|
3784
|
+
"@type": "Answer",
|
|
3785
|
+
"text": item.Answer,
|
|
3786
|
+
},
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
return Schema{
|
|
3790
|
+
"@context": "https://schema.org",
|
|
3791
|
+
"@type": "FAQPage",
|
|
3792
|
+
"mainEntity": mainEntity,
|
|
3793
|
+
}
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
// BreadcrumbItem represents a breadcrumb entry
|
|
3797
|
+
type BreadcrumbItem struct {
|
|
3798
|
+
Name string
|
|
3799
|
+
URL string
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
// BreadcrumbSchema creates a breadcrumb schema
|
|
3803
|
+
func BreadcrumbSchema(items []BreadcrumbItem) Schema {
|
|
3804
|
+
itemList := make([]Schema, len(items))
|
|
3805
|
+
for i, item := range items {
|
|
3806
|
+
itemList[i] = Schema{
|
|
3807
|
+
"@type": "ListItem",
|
|
3808
|
+
"position": i + 1,
|
|
3809
|
+
"name": item.Name,
|
|
3810
|
+
"item": item.URL,
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
return Schema{
|
|
3814
|
+
"@context": "https://schema.org",
|
|
3815
|
+
"@type": "BreadcrumbList",
|
|
3816
|
+
"itemListElement": itemList,
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
// ToJSON converts a schema to JSON string
|
|
3821
|
+
func (s Schema) ToJSON() template.JS {
|
|
3822
|
+
b, err := json.Marshal(s)
|
|
3823
|
+
if err != nil {
|
|
3824
|
+
return ""
|
|
3825
|
+
}
|
|
3826
|
+
return template.JS(b)
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
// RenderJSONLD renders multiple schemas as script tags
|
|
3830
|
+
func RenderJSONLD(schemas ...Schema) template.HTML {
|
|
3831
|
+
all := append([]Schema{WebsiteSchema()}, schemas...)
|
|
3832
|
+
var sb strings.Builder
|
|
3833
|
+
for _, schema := range all {
|
|
3834
|
+
b, err := json.Marshal(schema)
|
|
3835
|
+
if err != nil {
|
|
3836
|
+
continue
|
|
3837
|
+
}
|
|
3838
|
+
sb.WriteString(\`<script type="application/ld+json">\`)
|
|
3839
|
+
sb.Write(b)
|
|
3840
|
+
sb.WriteString(\`</script>\\n\`)
|
|
3841
|
+
}
|
|
3842
|
+
return template.HTML(sb.String())
|
|
3843
|
+
}`,
|
|
3844
|
+
explanation: `Go comprehensive SEO package with:
|
|
3845
|
+
• Meta struct for page metadata
|
|
3846
|
+
• JSON-LD schema generators
|
|
3847
|
+
• Template-friendly helpers
|
|
3848
|
+
• Works with Gin, Echo, Fiber, or stdlib
|
|
3849
|
+
|
|
3850
|
+
Setup:
|
|
3851
|
+
1. Import in your handlers
|
|
3852
|
+
2. Pass Meta to templates
|
|
3853
|
+
3. Use schema helpers for JSON-LD`,
|
|
3854
|
+
additionalFiles: [
|
|
3855
|
+
{
|
|
3856
|
+
file: 'templates/partials/seo.html',
|
|
3857
|
+
code: `{{define "seo"}}
|
|
3858
|
+
<!-- Primary Meta Tags -->
|
|
3859
|
+
<title>{{.SEO.FullTitle}}</title>
|
|
3860
|
+
<meta name="title" content="{{.SEO.FullTitle}}">
|
|
3861
|
+
<meta name="description" content="{{.SEO.Description}}">
|
|
3862
|
+
<meta name="robots" content="{{.SEO.Robots}}">
|
|
3863
|
+
<link rel="canonical" href="{{.SEO.URL}}">
|
|
3864
|
+
|
|
3865
|
+
<!-- Open Graph / Facebook -->
|
|
3866
|
+
<meta property="og:type" content="{{.SEO.Type}}">
|
|
3867
|
+
<meta property="og:url" content="{{.SEO.URL}}">
|
|
3868
|
+
<meta property="og:title" content="{{.SEO.FullTitle}}">
|
|
3869
|
+
<meta property="og:description" content="{{.SEO.Description}}">
|
|
3870
|
+
<meta property="og:image" content="{{.SEO.AbsoluteImageURL}}">
|
|
3871
|
+
<meta property="og:image:width" content="1200">
|
|
3872
|
+
<meta property="og:image:height" content="630">
|
|
3873
|
+
<meta property="og:site_name" content="${siteName}">
|
|
3874
|
+
|
|
3875
|
+
{{if eq .SEO.Type "article"}}
|
|
3876
|
+
{{if .SEO.PublishedTime}}
|
|
3877
|
+
<meta property="article:published_time" content="{{.SEO.PublishedTime.Format "2006-01-02T15:04:05Z07:00"}}">
|
|
3878
|
+
{{end}}
|
|
3879
|
+
{{if .SEO.ModifiedTime}}
|
|
3880
|
+
<meta property="article:modified_time" content="{{.SEO.ModifiedTime.Format "2006-01-02T15:04:05Z07:00"}}">
|
|
3881
|
+
{{end}}
|
|
3882
|
+
{{if .SEO.Author}}
|
|
3883
|
+
<meta property="article:author" content="{{.SEO.Author}}">
|
|
3884
|
+
{{end}}
|
|
3885
|
+
{{range .SEO.Tags}}
|
|
3886
|
+
<meta property="article:tag" content="{{.}}">
|
|
3887
|
+
{{end}}
|
|
3888
|
+
{{end}}
|
|
3889
|
+
|
|
3890
|
+
<!-- Twitter -->
|
|
3891
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
3892
|
+
<meta name="twitter:title" content="{{.SEO.FullTitle}}">
|
|
3893
|
+
<meta name="twitter:description" content="{{.SEO.Description}}">
|
|
3894
|
+
<meta name="twitter:image" content="{{.SEO.AbsoluteImageURL}}">
|
|
3895
|
+
{{if ne "${twitterHandle}" ""}}
|
|
3896
|
+
<meta name="twitter:site" content="${twitterHandle}">
|
|
3897
|
+
<meta name="twitter:creator" content="${twitterHandle}">
|
|
3898
|
+
{{end}}
|
|
3899
|
+
{{end}}`,
|
|
3900
|
+
explanation: 'Go html/template partial for SEO meta tags.',
|
|
3901
|
+
},
|
|
3902
|
+
{
|
|
3903
|
+
file: 'templates/layouts/base.html',
|
|
3904
|
+
code: `<!DOCTYPE html>
|
|
3905
|
+
<html lang="{{.Lang}}">
|
|
3906
|
+
<head>
|
|
3907
|
+
<meta charset="utf-8">
|
|
3908
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3909
|
+
|
|
3910
|
+
{{template "seo" .}}
|
|
3911
|
+
|
|
3912
|
+
<link rel="icon" href="/static/favicon.ico">
|
|
3913
|
+
<link rel="stylesheet" href="/static/css/styles.css">
|
|
3914
|
+
|
|
3915
|
+
{{if .JSONLDSchemas}}
|
|
3916
|
+
{{.JSONLDSchemas}}
|
|
3917
|
+
{{end}}
|
|
3918
|
+
</head>
|
|
3919
|
+
<body>
|
|
3920
|
+
{{template "content" .}}
|
|
3921
|
+
</body>
|
|
3922
|
+
</html>`,
|
|
3923
|
+
explanation: 'Go base template layout.',
|
|
3924
|
+
},
|
|
3925
|
+
{
|
|
3926
|
+
file: 'static/robots.txt',
|
|
3927
|
+
code: `User-agent: *
|
|
3928
|
+
Allow: /
|
|
3929
|
+
Disallow: /admin/
|
|
3930
|
+
Disallow: /api/
|
|
3931
|
+
|
|
3932
|
+
User-agent: GPTBot
|
|
3933
|
+
Allow: /
|
|
3934
|
+
|
|
3935
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
3936
|
+
explanation: 'Robots.txt with AI crawler support.',
|
|
3937
|
+
},
|
|
3938
|
+
],
|
|
3939
|
+
};
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
// ============================================================================
|
|
3943
|
+
// ASP.NET CORE (C#) - Razor SEO
|
|
3944
|
+
// ============================================================================
|
|
3945
|
+
|
|
3946
|
+
export function generateAspNetCoreSEO(options: MetaFixOptions): GeneratedCode {
|
|
3947
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
3948
|
+
|
|
3949
|
+
return {
|
|
3950
|
+
file: 'Services/SEOService.cs',
|
|
3951
|
+
code: `using System.Text.Json;
|
|
3952
|
+
|
|
3953
|
+
namespace MyApp.Services;
|
|
3954
|
+
|
|
3955
|
+
/// <summary>
|
|
3956
|
+
/// Comprehensive SEO Service for ASP.NET Core
|
|
3957
|
+
///
|
|
3958
|
+
/// Features:
|
|
3959
|
+
/// - Full Open Graph support
|
|
3960
|
+
/// - Twitter Cards
|
|
3961
|
+
/// - JSON-LD structured data
|
|
3962
|
+
/// - Razor integration
|
|
3963
|
+
/// </summary>
|
|
3964
|
+
public class SEOService
|
|
3965
|
+
{
|
|
3966
|
+
public const string SiteName = "${siteName}";
|
|
3967
|
+
public const string SiteUrl = "${siteUrl}";
|
|
3968
|
+
public const string DefaultImage = "${image || `${siteUrl}/og-image.png`}";
|
|
3969
|
+
public const string DefaultDescription = "${description || `${siteName} - A compelling description.`}";
|
|
3970
|
+
public const string TwitterHandle = "${twitterHandle || ''}";
|
|
3971
|
+
|
|
3972
|
+
/// <summary>
|
|
3973
|
+
/// Create SEO metadata for a page
|
|
3974
|
+
/// </summary>
|
|
3975
|
+
public SEOMeta CreateMeta(
|
|
3976
|
+
string? title = null,
|
|
3977
|
+
string? description = null,
|
|
3978
|
+
string? image = null,
|
|
3979
|
+
string? type = "website")
|
|
3980
|
+
{
|
|
3981
|
+
return new SEOMeta
|
|
3982
|
+
{
|
|
3983
|
+
Title = title,
|
|
3984
|
+
Description = description ?? DefaultDescription,
|
|
3985
|
+
Image = image ?? DefaultImage,
|
|
3986
|
+
Type = type ?? "website"
|
|
3987
|
+
};
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
// JSON-LD Schema Generators
|
|
3991
|
+
|
|
3992
|
+
public object WebsiteSchema() => new
|
|
3993
|
+
{
|
|
3994
|
+
@context = "https://schema.org",
|
|
3995
|
+
@type = "WebSite",
|
|
3996
|
+
name = SiteName,
|
|
3997
|
+
url = SiteUrl
|
|
3998
|
+
};
|
|
3999
|
+
|
|
4000
|
+
public object OrganizationSchema(
|
|
4001
|
+
string? name = null,
|
|
4002
|
+
string? url = null,
|
|
4003
|
+
string? logo = null,
|
|
4004
|
+
string[]? socialProfiles = null) => new
|
|
4005
|
+
{
|
|
4006
|
+
@context = "https://schema.org",
|
|
4007
|
+
@type = "Organization",
|
|
4008
|
+
name = name ?? SiteName,
|
|
4009
|
+
url = url ?? SiteUrl,
|
|
4010
|
+
logo,
|
|
4011
|
+
sameAs = socialProfiles
|
|
4012
|
+
};
|
|
4013
|
+
|
|
4014
|
+
public object ArticleSchema(
|
|
4015
|
+
string headline,
|
|
4016
|
+
string description,
|
|
4017
|
+
string? image,
|
|
4018
|
+
DateTime datePublished,
|
|
4019
|
+
DateTime? dateModified,
|
|
4020
|
+
string authorName) => new
|
|
4021
|
+
{
|
|
4022
|
+
@context = "https://schema.org",
|
|
4023
|
+
@type = "Article",
|
|
4024
|
+
headline,
|
|
4025
|
+
description,
|
|
4026
|
+
image = image ?? DefaultImage,
|
|
4027
|
+
datePublished = datePublished.ToString("O"),
|
|
4028
|
+
dateModified = (dateModified ?? datePublished).ToString("O"),
|
|
4029
|
+
author = new { @type = "Person", name = authorName },
|
|
4030
|
+
publisher = new
|
|
4031
|
+
{
|
|
4032
|
+
@type = "Organization",
|
|
4033
|
+
name = SiteName,
|
|
4034
|
+
logo = new { @type = "ImageObject", url = SiteUrl + "/logo.png" }
|
|
4035
|
+
}
|
|
4036
|
+
};
|
|
4037
|
+
|
|
4038
|
+
public object ProductSchema(
|
|
4039
|
+
string name,
|
|
4040
|
+
string description,
|
|
4041
|
+
string? image,
|
|
4042
|
+
decimal price,
|
|
4043
|
+
string currency = "USD",
|
|
4044
|
+
bool inStock = true) => new
|
|
4045
|
+
{
|
|
4046
|
+
@context = "https://schema.org",
|
|
4047
|
+
@type = "Product",
|
|
4048
|
+
name,
|
|
4049
|
+
description,
|
|
4050
|
+
image = image ?? DefaultImage,
|
|
4051
|
+
offers = new
|
|
4052
|
+
{
|
|
4053
|
+
@type = "Offer",
|
|
4054
|
+
price = price.ToString("F2"),
|
|
4055
|
+
priceCurrency = currency,
|
|
4056
|
+
availability = $"https://schema.org/{(inStock ? "InStock" : "OutOfStock")}"
|
|
4057
|
+
}
|
|
4058
|
+
};
|
|
4059
|
+
|
|
4060
|
+
public object FAQSchema(IEnumerable<(string Question, string Answer)> items) => new
|
|
4061
|
+
{
|
|
4062
|
+
@context = "https://schema.org",
|
|
4063
|
+
@type = "FAQPage",
|
|
4064
|
+
mainEntity = items.Select(item => new
|
|
4065
|
+
{
|
|
4066
|
+
@type = "Question",
|
|
4067
|
+
name = item.Question,
|
|
4068
|
+
acceptedAnswer = new { @type = "Answer", text = item.Answer }
|
|
4069
|
+
})
|
|
4070
|
+
};
|
|
4071
|
+
|
|
4072
|
+
public object BreadcrumbSchema(IEnumerable<(string Name, string Url)> items) => new
|
|
4073
|
+
{
|
|
4074
|
+
@context = "https://schema.org",
|
|
4075
|
+
@type = "BreadcrumbList",
|
|
4076
|
+
itemListElement = items.Select((item, index) => new
|
|
4077
|
+
{
|
|
4078
|
+
@type = "ListItem",
|
|
4079
|
+
position = index + 1,
|
|
4080
|
+
name = item.Name,
|
|
4081
|
+
item = item.Url
|
|
4082
|
+
})
|
|
4083
|
+
};
|
|
4084
|
+
|
|
4085
|
+
/// <summary>
|
|
4086
|
+
/// Serialize schema to JSON for embedding in HTML
|
|
4087
|
+
/// </summary>
|
|
4088
|
+
public string ToJson(object schema)
|
|
4089
|
+
{
|
|
4090
|
+
return JsonSerializer.Serialize(schema, new JsonSerializerOptions
|
|
4091
|
+
{
|
|
4092
|
+
WriteIndented = false,
|
|
4093
|
+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
4094
|
+
});
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
public class SEOMeta
|
|
4099
|
+
{
|
|
4100
|
+
public string? Title { get; set; }
|
|
4101
|
+
public string Description { get; set; } = SEOService.DefaultDescription;
|
|
4102
|
+
public string Image { get; set; } = SEOService.DefaultImage;
|
|
4103
|
+
public string? Url { get; set; }
|
|
4104
|
+
public string Type { get; set; } = "website";
|
|
4105
|
+
public DateTime? PublishedTime { get; set; }
|
|
4106
|
+
public DateTime? ModifiedTime { get; set; }
|
|
4107
|
+
public string? Author { get; set; }
|
|
4108
|
+
public List<string> Tags { get; set; } = new();
|
|
4109
|
+
public bool NoIndex { get; set; }
|
|
4110
|
+
public bool NoFollow { get; set; }
|
|
4111
|
+
|
|
4112
|
+
public string FullTitle => string.IsNullOrEmpty(Title)
|
|
4113
|
+
? SEOService.SiteName
|
|
4114
|
+
: Title.Contains(SEOService.SiteName)
|
|
4115
|
+
? Title
|
|
4116
|
+
: $"{Title} | {SEOService.SiteName}";
|
|
4117
|
+
|
|
4118
|
+
public string AbsoluteImageUrl => Image.StartsWith("http")
|
|
4119
|
+
? Image
|
|
4120
|
+
: SEOService.SiteUrl + Image;
|
|
4121
|
+
|
|
4122
|
+
public string Robots
|
|
4123
|
+
{
|
|
4124
|
+
get
|
|
4125
|
+
{
|
|
4126
|
+
var index = NoIndex ? "noindex" : "index";
|
|
4127
|
+
var follow = NoFollow ? "nofollow" : "follow";
|
|
4128
|
+
return $"{index}, {follow}";
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
}`,
|
|
4132
|
+
explanation: `ASP.NET Core comprehensive SEO service with:
|
|
4133
|
+
• SEOMeta class for page metadata
|
|
4134
|
+
• JSON-LD schema generators
|
|
4135
|
+
• Razor integration
|
|
4136
|
+
• Fluent API
|
|
4137
|
+
|
|
4138
|
+
Setup:
|
|
4139
|
+
1. Register SEOService as singleton
|
|
4140
|
+
2. Inject in controllers/pages
|
|
4141
|
+
3. Use in Razor views`,
|
|
4142
|
+
additionalFiles: [
|
|
4143
|
+
{
|
|
4144
|
+
file: 'Pages/Shared/_SEOMeta.cshtml',
|
|
4145
|
+
code: `@model SEOMeta
|
|
4146
|
+
|
|
4147
|
+
<!-- Primary Meta Tags -->
|
|
4148
|
+
<title>@Model.FullTitle</title>
|
|
4149
|
+
<meta name="title" content="@Model.FullTitle">
|
|
4150
|
+
<meta name="description" content="@Model.Description">
|
|
4151
|
+
<meta name="robots" content="@Model.Robots">
|
|
4152
|
+
<link rel="canonical" href="@Model.Url">
|
|
4153
|
+
|
|
4154
|
+
<!-- Open Graph / Facebook -->
|
|
4155
|
+
<meta property="og:type" content="@Model.Type">
|
|
4156
|
+
<meta property="og:url" content="@Model.Url">
|
|
4157
|
+
<meta property="og:title" content="@Model.FullTitle">
|
|
4158
|
+
<meta property="og:description" content="@Model.Description">
|
|
4159
|
+
<meta property="og:image" content="@Model.AbsoluteImageUrl">
|
|
4160
|
+
<meta property="og:image:width" content="1200">
|
|
4161
|
+
<meta property="og:image:height" content="630">
|
|
4162
|
+
<meta property="og:site_name" content="${siteName}">
|
|
4163
|
+
|
|
4164
|
+
@if (Model.Type == "article")
|
|
4165
|
+
{
|
|
4166
|
+
if (Model.PublishedTime.HasValue)
|
|
4167
|
+
{
|
|
4168
|
+
<meta property="article:published_time" content="@Model.PublishedTime.Value.ToString("O")">
|
|
4169
|
+
}
|
|
4170
|
+
if (Model.ModifiedTime.HasValue)
|
|
4171
|
+
{
|
|
4172
|
+
<meta property="article:modified_time" content="@Model.ModifiedTime.Value.ToString("O")">
|
|
4173
|
+
}
|
|
4174
|
+
if (!string.IsNullOrEmpty(Model.Author))
|
|
4175
|
+
{
|
|
4176
|
+
<meta property="article:author" content="@Model.Author">
|
|
4177
|
+
}
|
|
4178
|
+
foreach (var tag in Model.Tags)
|
|
4179
|
+
{
|
|
4180
|
+
<meta property="article:tag" content="@tag">
|
|
4181
|
+
}
|
|
4182
|
+
}
|
|
4183
|
+
|
|
4184
|
+
<!-- Twitter -->
|
|
4185
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
4186
|
+
<meta name="twitter:title" content="@Model.FullTitle">
|
|
4187
|
+
<meta name="twitter:description" content="@Model.Description">
|
|
4188
|
+
<meta name="twitter:image" content="@Model.AbsoluteImageUrl">
|
|
4189
|
+
@if (!string.IsNullOrEmpty("${twitterHandle}"))
|
|
4190
|
+
{
|
|
4191
|
+
<meta name="twitter:site" content="${twitterHandle}">
|
|
4192
|
+
<meta name="twitter:creator" content="${twitterHandle}">
|
|
4193
|
+
}`,
|
|
4194
|
+
explanation: 'Razor partial view for SEO meta tags.',
|
|
4195
|
+
},
|
|
4196
|
+
{
|
|
4197
|
+
file: 'Pages/Shared/_Layout.cshtml',
|
|
4198
|
+
code: `<!DOCTYPE html>
|
|
4199
|
+
<html lang="en">
|
|
4200
|
+
<head>
|
|
4201
|
+
<meta charset="utf-8">
|
|
4202
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
4203
|
+
|
|
4204
|
+
@if (ViewData["SEO"] is SEOMeta seo)
|
|
4205
|
+
{
|
|
4206
|
+
<partial name="_SEOMeta" model="seo" />
|
|
4207
|
+
}
|
|
4208
|
+
else
|
|
4209
|
+
{
|
|
4210
|
+
<title>${siteName}</title>
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
<link rel="icon" type="image/x-icon" href="~/favicon.ico">
|
|
4214
|
+
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true">
|
|
4215
|
+
|
|
4216
|
+
@if (ViewData["JsonLd"] is string jsonLd)
|
|
4217
|
+
{
|
|
4218
|
+
<script type="application/ld+json">@Html.Raw(jsonLd)</script>
|
|
4219
|
+
}
|
|
4220
|
+
</head>
|
|
4221
|
+
<body>
|
|
4222
|
+
@RenderBody()
|
|
4223
|
+
|
|
4224
|
+
<script src="~/js/site.js" asp-append-version="true"></script>
|
|
4225
|
+
@await RenderSectionAsync("Scripts", required: false)
|
|
4226
|
+
</body>
|
|
4227
|
+
</html>`,
|
|
4228
|
+
explanation: 'Razor layout with SEO integration.',
|
|
4229
|
+
},
|
|
4230
|
+
{
|
|
4231
|
+
file: 'wwwroot/robots.txt',
|
|
4232
|
+
code: `User-agent: *
|
|
4233
|
+
Allow: /
|
|
4234
|
+
Disallow: /admin/
|
|
4235
|
+
Disallow: /api/
|
|
4236
|
+
|
|
4237
|
+
User-agent: GPTBot
|
|
4238
|
+
Allow: /
|
|
4239
|
+
|
|
4240
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
4241
|
+
explanation: 'Robots.txt with AI crawler support.',
|
|
4242
|
+
},
|
|
4243
|
+
],
|
|
4244
|
+
};
|
|
4245
|
+
}
|
|
4246
|
+
|
|
4247
|
+
// ============================================================================
|
|
4248
|
+
// HTMX - Server-rendered SEO (framework-agnostic)
|
|
4249
|
+
// ============================================================================
|
|
4250
|
+
|
|
4251
|
+
export function generateHTMXSEO(options: MetaFixOptions): GeneratedCode {
|
|
4252
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
4253
|
+
|
|
4254
|
+
return {
|
|
4255
|
+
file: 'templates/seo_head.html',
|
|
4256
|
+
code: `<!--
|
|
4257
|
+
HTMX SEO Template
|
|
4258
|
+
|
|
4259
|
+
This template provides comprehensive SEO for HTMX applications.
|
|
4260
|
+
HTMX apps are server-rendered, so SEO is straightforward - just
|
|
4261
|
+
include proper meta tags in your initial HTML response.
|
|
4262
|
+
|
|
4263
|
+
Features:
|
|
4264
|
+
- Full Open Graph support
|
|
4265
|
+
- Twitter Cards
|
|
4266
|
+
- JSON-LD structured data
|
|
4267
|
+
- Works with any backend (Python, Ruby, Go, PHP, etc.)
|
|
4268
|
+
|
|
4269
|
+
Usage:
|
|
4270
|
+
Include this template in your base layout, passing the required variables.
|
|
4271
|
+
-->
|
|
4272
|
+
|
|
4273
|
+
<!-- Primary Meta Tags -->
|
|
4274
|
+
<title>{{ full_title or '${siteName}' }}</title>
|
|
4275
|
+
<meta name="title" content="{{ full_title or '${siteName}' }}">
|
|
4276
|
+
<meta name="description" content="{{ description or '${description || `${siteName} - A compelling description.`}' }}">
|
|
4277
|
+
<meta name="robots" content="{{ 'noindex, nofollow' if noindex else 'index, follow' }}">
|
|
4278
|
+
<link rel="canonical" href="{{ canonical_url }}">
|
|
4279
|
+
|
|
4280
|
+
<!-- Open Graph / Facebook -->
|
|
4281
|
+
<meta property="og:type" content="{{ og_type or 'website' }}">
|
|
4282
|
+
<meta property="og:url" content="{{ canonical_url }}">
|
|
4283
|
+
<meta property="og:title" content="{{ full_title or '${siteName}' }}">
|
|
4284
|
+
<meta property="og:description" content="{{ description or '${description || `${siteName} - A compelling description.`}' }}">
|
|
4285
|
+
<meta property="og:image" content="{{ image_url or '${image || `${siteUrl}/og-image.png`}' }}">
|
|
4286
|
+
<meta property="og:image:width" content="1200">
|
|
4287
|
+
<meta property="og:image:height" content="630">
|
|
4288
|
+
<meta property="og:site_name" content="${siteName}">
|
|
4289
|
+
<meta property="og:locale" content="{{ locale or 'en_US' }}">
|
|
4290
|
+
|
|
4291
|
+
{% if og_type == 'article' %}
|
|
4292
|
+
{% if published_time %}
|
|
4293
|
+
<meta property="article:published_time" content="{{ published_time }}">
|
|
4294
|
+
{% endif %}
|
|
4295
|
+
{% if modified_time %}
|
|
4296
|
+
<meta property="article:modified_time" content="{{ modified_time }}">
|
|
4297
|
+
{% endif %}
|
|
4298
|
+
{% if author %}
|
|
4299
|
+
<meta property="article:author" content="{{ author }}">
|
|
4300
|
+
{% endif %}
|
|
4301
|
+
{% for tag in tags or [] %}
|
|
4302
|
+
<meta property="article:tag" content="{{ tag }}">
|
|
4303
|
+
{% endfor %}
|
|
4304
|
+
{% endif %}
|
|
4305
|
+
|
|
4306
|
+
<!-- Twitter -->
|
|
4307
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
4308
|
+
<meta name="twitter:title" content="{{ full_title or '${siteName}' }}">
|
|
4309
|
+
<meta name="twitter:description" content="{{ description or '${description || `${siteName} - A compelling description.`}' }}">
|
|
4310
|
+
<meta name="twitter:image" content="{{ image_url or '${image || `${siteUrl}/og-image.png`}' }}">
|
|
4311
|
+
{% if '${twitterHandle}' %}
|
|
4312
|
+
<meta name="twitter:site" content="${twitterHandle}">
|
|
4313
|
+
<meta name="twitter:creator" content="${twitterHandle}">
|
|
4314
|
+
{% endif %}
|
|
4315
|
+
|
|
4316
|
+
<!-- JSON-LD Structured Data -->
|
|
4317
|
+
<script type="application/ld+json">
|
|
4318
|
+
{
|
|
4319
|
+
"@context": "https://schema.org",
|
|
4320
|
+
"@type": "WebSite",
|
|
4321
|
+
"name": "${siteName}",
|
|
4322
|
+
"url": "${siteUrl}"
|
|
4323
|
+
}
|
|
4324
|
+
</script>
|
|
4325
|
+
{% if schema_json %}
|
|
4326
|
+
<script type="application/ld+json">
|
|
4327
|
+
{{ schema_json | safe }}
|
|
4328
|
+
</script>
|
|
4329
|
+
{% endif %}`,
|
|
4330
|
+
explanation: `HTMX SEO template (framework-agnostic) with:
|
|
4331
|
+
• Full Open Graph support
|
|
4332
|
+
• Twitter Cards
|
|
4333
|
+
• JSON-LD structured data
|
|
4334
|
+
• Works with any template engine (Jinja2, Liquid, Go templates, etc.)
|
|
4335
|
+
|
|
4336
|
+
HTMX apps are server-rendered, so SEO is built-in!
|
|
4337
|
+
Just include proper meta tags in your initial HTML response.
|
|
4338
|
+
|
|
4339
|
+
Usage:
|
|
4340
|
+
1. Include in your base layout
|
|
4341
|
+
2. Pass SEO variables from your backend
|
|
4342
|
+
3. JSON-LD schemas can be generated server-side`,
|
|
4343
|
+
additionalFiles: [
|
|
4344
|
+
{
|
|
4345
|
+
file: 'templates/base.html',
|
|
4346
|
+
code: `<!DOCTYPE html>
|
|
4347
|
+
<html lang="{{ lang or 'en' }}">
|
|
4348
|
+
<head>
|
|
4349
|
+
<meta charset="utf-8">
|
|
4350
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4351
|
+
|
|
4352
|
+
{% include 'seo_head.html' %}
|
|
4353
|
+
|
|
4354
|
+
<!-- HTMX -->
|
|
4355
|
+
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
4356
|
+
|
|
4357
|
+
<!-- Styles -->
|
|
4358
|
+
<link rel="stylesheet" href="/static/css/styles.css">
|
|
4359
|
+
|
|
4360
|
+
<!-- Favicon -->
|
|
4361
|
+
<link rel="icon" href="/static/favicon.ico">
|
|
4362
|
+
</head>
|
|
4363
|
+
<body hx-boost="true">
|
|
4364
|
+
<main id="content">
|
|
4365
|
+
{% block content %}{% endblock %}
|
|
4366
|
+
</main>
|
|
4367
|
+
</body>
|
|
4368
|
+
</html>`,
|
|
4369
|
+
explanation: 'Base HTMX layout with SEO integration.',
|
|
4370
|
+
},
|
|
4371
|
+
{
|
|
4372
|
+
file: 'static/robots.txt',
|
|
4373
|
+
code: `User-agent: *
|
|
4374
|
+
Allow: /
|
|
4375
|
+
Disallow: /admin/
|
|
4376
|
+
Disallow: /api/
|
|
4377
|
+
|
|
4378
|
+
User-agent: GPTBot
|
|
4379
|
+
Allow: /
|
|
4380
|
+
|
|
4381
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
4382
|
+
explanation: 'Robots.txt with AI crawler support.',
|
|
4383
|
+
},
|
|
4384
|
+
],
|
|
4385
|
+
};
|
|
4386
|
+
}
|
|
4387
|
+
|
|
4388
|
+
// ============================================================================
|
|
4389
|
+
// HUGO (Static Site Generator)
|
|
4390
|
+
// ============================================================================
|
|
4391
|
+
|
|
4392
|
+
export function generateHugoSEO(options: MetaFixOptions): GeneratedCode {
|
|
4393
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
4394
|
+
|
|
4395
|
+
return {
|
|
4396
|
+
file: 'layouts/partials/seo.html',
|
|
4397
|
+
code: `{{- /* Comprehensive SEO partial for Hugo */ -}}
|
|
4398
|
+
{{- $title := .Title | default site.Title -}}
|
|
4399
|
+
{{- $description := .Description | default .Summary | default site.Params.description | default "${description || `${siteName} - A compelling description.`}" -}}
|
|
4400
|
+
{{- $image := .Params.image | default site.Params.defaultImage | default "${image || '/og-image.png'}" -}}
|
|
4401
|
+
{{- $imageURL := $image | absURL -}}
|
|
4402
|
+
{{- $canonical := .Permalink -}}
|
|
4403
|
+
{{- $type := cond (eq .Kind "page") "article" "website" -}}
|
|
4404
|
+
|
|
4405
|
+
{{- /* Full title with site name */ -}}
|
|
4406
|
+
{{- $fullTitle := $title -}}
|
|
4407
|
+
{{- if and (ne $title site.Title) (not (in $title site.Title)) -}}
|
|
4408
|
+
{{- $fullTitle = printf "%s | %s" $title site.Title -}}
|
|
4409
|
+
{{- end -}}
|
|
4410
|
+
|
|
4411
|
+
<!-- Primary Meta Tags -->
|
|
4412
|
+
<title>{{ $fullTitle }}</title>
|
|
4413
|
+
<meta name="title" content="{{ $fullTitle }}">
|
|
4414
|
+
<meta name="description" content="{{ $description | truncate 160 }}">
|
|
4415
|
+
<meta name="robots" content="{{ if .Params.noindex }}noindex, nofollow{{ else }}index, follow{{ end }}">
|
|
4416
|
+
<link rel="canonical" href="{{ $canonical }}">
|
|
4417
|
+
|
|
4418
|
+
<!-- Open Graph / Facebook -->
|
|
4419
|
+
<meta property="og:type" content="{{ $type }}">
|
|
4420
|
+
<meta property="og:url" content="{{ $canonical }}">
|
|
4421
|
+
<meta property="og:title" content="{{ $fullTitle }}">
|
|
4422
|
+
<meta property="og:description" content="{{ $description | truncate 160 }}">
|
|
4423
|
+
<meta property="og:image" content="{{ $imageURL }}">
|
|
4424
|
+
<meta property="og:image:width" content="1200">
|
|
4425
|
+
<meta property="og:image:height" content="630">
|
|
4426
|
+
<meta property="og:site_name" content="{{ site.Title }}">
|
|
4427
|
+
<meta property="og:locale" content="{{ site.Language.Lang | default "en" }}_{{ site.Language.Lang | default "US" | upper }}">
|
|
4428
|
+
|
|
4429
|
+
{{- if eq $type "article" }}
|
|
4430
|
+
{{- with .PublishDate }}
|
|
4431
|
+
<meta property="article:published_time" content="{{ .Format "2006-01-02T15:04:05Z07:00" }}">
|
|
4432
|
+
{{- end }}
|
|
4433
|
+
{{- with .Lastmod }}
|
|
4434
|
+
<meta property="article:modified_time" content="{{ .Format "2006-01-02T15:04:05Z07:00" }}">
|
|
4435
|
+
{{- end }}
|
|
4436
|
+
{{- with .Params.author }}
|
|
4437
|
+
<meta property="article:author" content="{{ . }}">
|
|
4438
|
+
{{- end }}
|
|
4439
|
+
{{- range .Params.tags }}
|
|
4440
|
+
<meta property="article:tag" content="{{ . }}">
|
|
4441
|
+
{{- end }}
|
|
4442
|
+
{{- end }}
|
|
4443
|
+
|
|
4444
|
+
<!-- Twitter -->
|
|
4445
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
4446
|
+
<meta name="twitter:title" content="{{ $fullTitle }}">
|
|
4447
|
+
<meta name="twitter:description" content="{{ $description | truncate 160 }}">
|
|
4448
|
+
<meta name="twitter:image" content="{{ $imageURL }}">
|
|
4449
|
+
{{- with site.Params.twitterHandle }}
|
|
4450
|
+
<meta name="twitter:site" content="{{ . }}">
|
|
4451
|
+
<meta name="twitter:creator" content="{{ . }}">
|
|
4452
|
+
{{- end }}
|
|
4453
|
+
|
|
4454
|
+
<!-- JSON-LD: WebSite -->
|
|
4455
|
+
<script type="application/ld+json">
|
|
4456
|
+
{
|
|
4457
|
+
"@context": "https://schema.org",
|
|
4458
|
+
"@type": "WebSite",
|
|
4459
|
+
"name": "{{ site.Title }}",
|
|
4460
|
+
"url": "{{ site.BaseURL }}"
|
|
4461
|
+
}
|
|
4462
|
+
</script>
|
|
4463
|
+
|
|
4464
|
+
{{- /* Article Schema */ -}}
|
|
4465
|
+
{{- if eq $type "article" }}
|
|
4466
|
+
<script type="application/ld+json">
|
|
4467
|
+
{
|
|
4468
|
+
"@context": "https://schema.org",
|
|
4469
|
+
"@type": "Article",
|
|
4470
|
+
"headline": "{{ $title }}",
|
|
4471
|
+
"description": "{{ $description | truncate 160 }}",
|
|
4472
|
+
"image": "{{ $imageURL }}",
|
|
4473
|
+
"datePublished": "{{ .PublishDate.Format "2006-01-02T15:04:05Z07:00" }}",
|
|
4474
|
+
"dateModified": "{{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" }}",
|
|
4475
|
+
"author": {
|
|
4476
|
+
"@type": "Person",
|
|
4477
|
+
"name": "{{ .Params.author | default site.Params.author | default "Author" }}"
|
|
4478
|
+
},
|
|
4479
|
+
"publisher": {
|
|
4480
|
+
"@type": "Organization",
|
|
4481
|
+
"name": "{{ site.Title }}",
|
|
4482
|
+
"logo": {
|
|
4483
|
+
"@type": "ImageObject",
|
|
4484
|
+
"url": "{{ "logo.png" | absURL }}"
|
|
4485
|
+
}
|
|
4486
|
+
}
|
|
4487
|
+
}
|
|
4488
|
+
</script>
|
|
4489
|
+
{{- end }}
|
|
4490
|
+
|
|
4491
|
+
{{- /* Breadcrumbs */ -}}
|
|
4492
|
+
{{- if .Parent }}
|
|
4493
|
+
<script type="application/ld+json">
|
|
4494
|
+
{
|
|
4495
|
+
"@context": "https://schema.org",
|
|
4496
|
+
"@type": "BreadcrumbList",
|
|
4497
|
+
"itemListElement": [
|
|
4498
|
+
{{- $breadcrumbs := slice -}}
|
|
4499
|
+
{{- range .Ancestors.Reverse }}
|
|
4500
|
+
{{- $breadcrumbs = $breadcrumbs | append (dict "name" .Title "url" .Permalink) -}}
|
|
4501
|
+
{{- end }}
|
|
4502
|
+
{{- $breadcrumbs = $breadcrumbs | append (dict "name" .Title "url" .Permalink) -}}
|
|
4503
|
+
{{- range $i, $item := $breadcrumbs }}
|
|
4504
|
+
{{- if $i }},{{ end }}
|
|
4505
|
+
{
|
|
4506
|
+
"@type": "ListItem",
|
|
4507
|
+
"position": {{ add $i 1 }},
|
|
4508
|
+
"name": "{{ $item.name }}",
|
|
4509
|
+
"item": "{{ $item.url }}"
|
|
4510
|
+
}
|
|
4511
|
+
{{- end }}
|
|
4512
|
+
]
|
|
4513
|
+
}
|
|
4514
|
+
</script>
|
|
4515
|
+
{{- end }}`,
|
|
4516
|
+
explanation: `Hugo comprehensive SEO partial with:
|
|
4517
|
+
• Automatic title handling
|
|
4518
|
+
• Full Open Graph with article support
|
|
4519
|
+
• Twitter Cards
|
|
4520
|
+
• JSON-LD schemas (WebSite, Article, Breadcrumbs)
|
|
4521
|
+
• Content summaries for descriptions
|
|
4522
|
+
|
|
4523
|
+
Usage: {{ partial "seo.html" . }}`,
|
|
4524
|
+
additionalFiles: [
|
|
4525
|
+
{
|
|
4526
|
+
file: 'layouts/_default/baseof.html',
|
|
4527
|
+
code: `<!DOCTYPE html>
|
|
4528
|
+
<html lang="{{ site.Language.Lang | default "en" }}">
|
|
4529
|
+
<head>
|
|
4530
|
+
<meta charset="utf-8">
|
|
4531
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4532
|
+
|
|
4533
|
+
{{ partial "seo.html" . }}
|
|
4534
|
+
|
|
4535
|
+
<!-- Favicon -->
|
|
4536
|
+
<link rel="icon" href="{{ "favicon.ico" | relURL }}">
|
|
4537
|
+
<link rel="apple-touch-icon" sizes="180x180" href="{{ "apple-touch-icon.png" | relURL }}">
|
|
4538
|
+
|
|
4539
|
+
<!-- Styles -->
|
|
4540
|
+
{{ $styles := resources.Get "css/styles.css" | minify | fingerprint }}
|
|
4541
|
+
<link rel="stylesheet" href="{{ $styles.Permalink }}">
|
|
4542
|
+
</head>
|
|
4543
|
+
<body>
|
|
4544
|
+
{{ block "main" . }}{{ end }}
|
|
4545
|
+
</body>
|
|
4546
|
+
</html>`,
|
|
4547
|
+
explanation: 'Hugo base template with SEO partial.',
|
|
4548
|
+
},
|
|
4549
|
+
{
|
|
4550
|
+
file: 'config.toml',
|
|
4551
|
+
code: `baseURL = '${siteUrl}/'
|
|
4552
|
+
languageCode = 'en-us'
|
|
4553
|
+
title = '${siteName}'
|
|
4554
|
+
|
|
4555
|
+
[params]
|
|
4556
|
+
description = '${description || `${siteName} - A compelling description.`}'
|
|
4557
|
+
defaultImage = '${image || '/og-image.png'}'
|
|
4558
|
+
twitterHandle = '${twitterHandle || ''}'
|
|
4559
|
+
author = 'Your Name'
|
|
4560
|
+
|
|
4561
|
+
[sitemap]
|
|
4562
|
+
changefreq = 'weekly'
|
|
4563
|
+
filename = 'sitemap.xml'
|
|
4564
|
+
priority = 0.5
|
|
4565
|
+
|
|
4566
|
+
[outputs]
|
|
4567
|
+
home = ['HTML', 'RSS', 'JSON']
|
|
4568
|
+
|
|
4569
|
+
# Robots.txt
|
|
4570
|
+
enableRobotsTXT = true`,
|
|
4571
|
+
explanation: 'Hugo configuration with SEO settings.',
|
|
4572
|
+
},
|
|
4573
|
+
{
|
|
4574
|
+
file: 'layouts/robots.txt',
|
|
4575
|
+
code: `User-agent: *
|
|
4576
|
+
Allow: /
|
|
4577
|
+
Disallow: /admin/
|
|
4578
|
+
|
|
4579
|
+
User-agent: GPTBot
|
|
4580
|
+
Allow: /
|
|
4581
|
+
|
|
4582
|
+
Sitemap: {{ .Site.BaseURL }}sitemap.xml`,
|
|
4583
|
+
explanation: 'Hugo robots.txt template.',
|
|
4584
|
+
},
|
|
4585
|
+
],
|
|
4586
|
+
};
|
|
4587
|
+
}
|
|
4588
|
+
|
|
4589
|
+
// ============================================================================
|
|
4590
|
+
// JEKYLL (Ruby Static Site Generator)
|
|
4591
|
+
// ============================================================================
|
|
4592
|
+
|
|
4593
|
+
export function generateJekyllSEO(options: MetaFixOptions): GeneratedCode {
|
|
4594
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
4595
|
+
|
|
4596
|
+
return {
|
|
4597
|
+
file: '_includes/seo.html',
|
|
4598
|
+
code: `{% comment %}
|
|
4599
|
+
Comprehensive SEO include for Jekyll
|
|
4600
|
+
|
|
4601
|
+
Features:
|
|
4602
|
+
- Full Open Graph support
|
|
4603
|
+
- Twitter Cards
|
|
4604
|
+
- JSON-LD structured data
|
|
4605
|
+
- jekyll-seo-tag compatible
|
|
4606
|
+
|
|
4607
|
+
Usage: {% include seo.html %}
|
|
4608
|
+
{% endcomment %}
|
|
4609
|
+
|
|
4610
|
+
{%- assign seo_title = page.title | default: site.title -%}
|
|
4611
|
+
{%- if page.title and site.title and page.title != site.title -%}
|
|
4612
|
+
{%- assign seo_title = page.title | append: " | " | append: site.title -%}
|
|
4613
|
+
{%- endif -%}
|
|
4614
|
+
|
|
4615
|
+
{%- assign seo_description = page.description | default: page.excerpt | default: site.description | default: "${description || `${siteName} - A compelling description.`}" | strip_html | truncate: 160 -%}
|
|
4616
|
+
|
|
4617
|
+
{%- assign seo_image = page.image | default: site.og_image | default: "${image || '/og-image.png'}" -%}
|
|
4618
|
+
{%- unless seo_image contains "://" -%}
|
|
4619
|
+
{%- assign seo_image = seo_image | absolute_url -%}
|
|
4620
|
+
{%- endunless -%}
|
|
4621
|
+
|
|
4622
|
+
{%- assign seo_type = "website" -%}
|
|
4623
|
+
{%- if page.layout == "post" -%}
|
|
4624
|
+
{%- assign seo_type = "article" -%}
|
|
4625
|
+
{%- endif -%}
|
|
4626
|
+
|
|
4627
|
+
<!-- Primary Meta Tags -->
|
|
4628
|
+
<title>{{ seo_title }}</title>
|
|
4629
|
+
<meta name="title" content="{{ seo_title }}">
|
|
4630
|
+
<meta name="description" content="{{ seo_description }}">
|
|
4631
|
+
<meta name="robots" content="{% if page.noindex %}noindex, nofollow{% else %}index, follow{% endif %}">
|
|
4632
|
+
<link rel="canonical" href="{{ page.url | absolute_url }}">
|
|
4633
|
+
|
|
4634
|
+
<!-- Open Graph / Facebook -->
|
|
4635
|
+
<meta property="og:type" content="{{ seo_type }}">
|
|
4636
|
+
<meta property="og:url" content="{{ page.url | absolute_url }}">
|
|
4637
|
+
<meta property="og:title" content="{{ seo_title }}">
|
|
4638
|
+
<meta property="og:description" content="{{ seo_description }}">
|
|
4639
|
+
<meta property="og:image" content="{{ seo_image }}">
|
|
4640
|
+
<meta property="og:image:width" content="1200">
|
|
4641
|
+
<meta property="og:image:height" content="630">
|
|
4642
|
+
<meta property="og:site_name" content="{{ site.title }}">
|
|
4643
|
+
<meta property="og:locale" content="{{ site.locale | default: "en_US" }}">
|
|
4644
|
+
|
|
4645
|
+
{% if seo_type == "article" %}
|
|
4646
|
+
{% if page.date %}
|
|
4647
|
+
<meta property="article:published_time" content="{{ page.date | date_to_xmlschema }}">
|
|
4648
|
+
{% endif %}
|
|
4649
|
+
{% if page.last_modified_at %}
|
|
4650
|
+
<meta property="article:modified_time" content="{{ page.last_modified_at | date_to_xmlschema }}">
|
|
4651
|
+
{% endif %}
|
|
4652
|
+
{% if page.author %}
|
|
4653
|
+
<meta property="article:author" content="{{ page.author }}">
|
|
4654
|
+
{% endif %}
|
|
4655
|
+
{% for tag in page.tags %}
|
|
4656
|
+
<meta property="article:tag" content="{{ tag }}">
|
|
4657
|
+
{% endfor %}
|
|
4658
|
+
{% endif %}
|
|
4659
|
+
|
|
4660
|
+
<!-- Twitter -->
|
|
4661
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
4662
|
+
<meta name="twitter:title" content="{{ seo_title }}">
|
|
4663
|
+
<meta name="twitter:description" content="{{ seo_description }}">
|
|
4664
|
+
<meta name="twitter:image" content="{{ seo_image }}">
|
|
4665
|
+
{% if site.twitter_handle %}
|
|
4666
|
+
<meta name="twitter:site" content="{{ site.twitter_handle }}">
|
|
4667
|
+
<meta name="twitter:creator" content="{{ site.twitter_handle }}">
|
|
4668
|
+
{% endif %}
|
|
4669
|
+
|
|
4670
|
+
<!-- JSON-LD: WebSite -->
|
|
4671
|
+
<script type="application/ld+json">
|
|
4672
|
+
{
|
|
4673
|
+
"@context": "https://schema.org",
|
|
4674
|
+
"@type": "WebSite",
|
|
4675
|
+
"name": "{{ site.title }}",
|
|
4676
|
+
"url": "{{ site.url }}"
|
|
4677
|
+
}
|
|
4678
|
+
</script>
|
|
4679
|
+
|
|
4680
|
+
{% if seo_type == "article" %}
|
|
4681
|
+
<!-- JSON-LD: Article -->
|
|
4682
|
+
<script type="application/ld+json">
|
|
4683
|
+
{
|
|
4684
|
+
"@context": "https://schema.org",
|
|
4685
|
+
"@type": "Article",
|
|
4686
|
+
"headline": "{{ page.title | escape }}",
|
|
4687
|
+
"description": "{{ seo_description | escape }}",
|
|
4688
|
+
"image": "{{ seo_image }}",
|
|
4689
|
+
"datePublished": "{{ page.date | date_to_xmlschema }}",
|
|
4690
|
+
"dateModified": "{{ page.last_modified_at | default: page.date | date_to_xmlschema }}",
|
|
4691
|
+
"author": {
|
|
4692
|
+
"@type": "Person",
|
|
4693
|
+
"name": "{{ page.author | default: site.author | default: "Author" }}"
|
|
4694
|
+
},
|
|
4695
|
+
"publisher": {
|
|
4696
|
+
"@type": "Organization",
|
|
4697
|
+
"name": "{{ site.title }}",
|
|
4698
|
+
"logo": {
|
|
4699
|
+
"@type": "ImageObject",
|
|
4700
|
+
"url": "{{ '/logo.png' | absolute_url }}"
|
|
4701
|
+
}
|
|
4702
|
+
}
|
|
4703
|
+
}
|
|
4704
|
+
</script>
|
|
4705
|
+
{% endif %}`,
|
|
4706
|
+
explanation: `Jekyll comprehensive SEO include with:
|
|
4707
|
+
• Full Open Graph with article support
|
|
4708
|
+
• Twitter Cards
|
|
4709
|
+
• JSON-LD schemas
|
|
4710
|
+
• Liquid template integration
|
|
4711
|
+
|
|
4712
|
+
Usage: {% include seo.html %}`,
|
|
4713
|
+
additionalFiles: [
|
|
4714
|
+
{
|
|
4715
|
+
file: '_layouts/default.html',
|
|
4716
|
+
code: `<!DOCTYPE html>
|
|
4717
|
+
<html lang="{{ site.locale | default: "en" | slice: 0, 2 }}">
|
|
4718
|
+
<head>
|
|
4719
|
+
<meta charset="utf-8">
|
|
4720
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4721
|
+
|
|
4722
|
+
{% include seo.html %}
|
|
4723
|
+
|
|
4724
|
+
<link rel="icon" href="{{ '/favicon.ico' | relative_url }}">
|
|
4725
|
+
<link rel="apple-touch-icon" sizes="180x180" href="{{ '/apple-touch-icon.png' | relative_url }}">
|
|
4726
|
+
|
|
4727
|
+
<link rel="stylesheet" href="{{ '/assets/css/styles.css' | relative_url }}">
|
|
4728
|
+
</head>
|
|
4729
|
+
<body>
|
|
4730
|
+
{{ content }}
|
|
4731
|
+
</body>
|
|
4732
|
+
</html>`,
|
|
4733
|
+
explanation: 'Jekyll default layout with SEO include.',
|
|
4734
|
+
},
|
|
4735
|
+
{
|
|
4736
|
+
file: '_config.yml',
|
|
4737
|
+
code: `title: ${siteName}
|
|
4738
|
+
description: "${description || `${siteName} - A compelling description.`}"
|
|
4739
|
+
url: "${siteUrl}"
|
|
4740
|
+
baseurl: ""
|
|
4741
|
+
|
|
4742
|
+
# SEO
|
|
4743
|
+
og_image: "${image || '/og-image.png'}"
|
|
4744
|
+
twitter_handle: "${twitterHandle || ''}"
|
|
4745
|
+
locale: "en_US"
|
|
4746
|
+
author: "Your Name"
|
|
4747
|
+
|
|
4748
|
+
# Plugins
|
|
4749
|
+
plugins:
|
|
4750
|
+
- jekyll-sitemap
|
|
4751
|
+
- jekyll-feed
|
|
4752
|
+
|
|
4753
|
+
# Defaults
|
|
4754
|
+
defaults:
|
|
4755
|
+
- scope:
|
|
4756
|
+
path: ""
|
|
4757
|
+
type: "posts"
|
|
4758
|
+
values:
|
|
4759
|
+
layout: "post"
|
|
4760
|
+
- scope:
|
|
4761
|
+
path: ""
|
|
4762
|
+
values:
|
|
4763
|
+
layout: "default"`,
|
|
4764
|
+
explanation: 'Jekyll configuration with SEO settings.',
|
|
4765
|
+
},
|
|
4766
|
+
{
|
|
4767
|
+
file: 'robots.txt',
|
|
4768
|
+
code: `---
|
|
4769
|
+
---
|
|
4770
|
+
User-agent: *
|
|
4771
|
+
Allow: /
|
|
4772
|
+
Disallow: /admin/
|
|
4773
|
+
|
|
4774
|
+
User-agent: GPTBot
|
|
4775
|
+
Allow: /
|
|
4776
|
+
|
|
4777
|
+
Sitemap: {{ site.url }}/sitemap.xml`,
|
|
4778
|
+
explanation: 'Jekyll robots.txt with front matter.',
|
|
4779
|
+
},
|
|
4780
|
+
],
|
|
4781
|
+
};
|
|
4782
|
+
}
|
|
4783
|
+
|
|
4784
|
+
// ============================================================================
|
|
4785
|
+
// ELEVENTY (11ty)
|
|
4786
|
+
// ============================================================================
|
|
4787
|
+
|
|
4788
|
+
export function generateEleventySEO(options: MetaFixOptions): GeneratedCode {
|
|
4789
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
4790
|
+
|
|
4791
|
+
return {
|
|
4792
|
+
file: 'src/_includes/seo.njk',
|
|
4793
|
+
code: `{#
|
|
4794
|
+
Comprehensive SEO partial for Eleventy (11ty)
|
|
4795
|
+
|
|
4796
|
+
Features:
|
|
4797
|
+
- Full Open Graph support
|
|
4798
|
+
- Twitter Cards
|
|
4799
|
+
- JSON-LD structured data
|
|
4800
|
+
- Nunjucks template
|
|
4801
|
+
|
|
4802
|
+
Usage: {% include "seo.njk" %}
|
|
4803
|
+
#}
|
|
4804
|
+
|
|
4805
|
+
{%- set seoTitle = title or metadata.title -%}
|
|
4806
|
+
{%- set fullTitle = seoTitle -%}
|
|
4807
|
+
{%- if seoTitle and metadata.title and seoTitle != metadata.title -%}
|
|
4808
|
+
{%- set fullTitle = seoTitle + " | " + metadata.title -%}
|
|
4809
|
+
{%- endif -%}
|
|
4810
|
+
|
|
4811
|
+
{%- set seoDescription = description or excerpt or metadata.description or "${description || `${siteName} - A compelling description.`}" -%}
|
|
4812
|
+
{%- set seoImage = image or metadata.defaultImage or "${image || '/og-image.png'}" -%}
|
|
4813
|
+
{%- if not seoImage.startsWith("http") -%}
|
|
4814
|
+
{%- set seoImage = metadata.url + seoImage -%}
|
|
4815
|
+
{%- endif -%}
|
|
4816
|
+
|
|
4817
|
+
{%- set pageUrl = page.url | url | absoluteUrl(metadata.url) -%}
|
|
4818
|
+
{%- set seoType = "article" if layout == "post" else "website" -%}
|
|
4819
|
+
|
|
4820
|
+
<!-- Primary Meta Tags -->
|
|
4821
|
+
<title>{{ fullTitle }}</title>
|
|
4822
|
+
<meta name="title" content="{{ fullTitle }}">
|
|
4823
|
+
<meta name="description" content="{{ seoDescription | truncate(160) }}">
|
|
4824
|
+
<meta name="robots" content="{{ 'noindex, nofollow' if noindex else 'index, follow' }}">
|
|
4825
|
+
<link rel="canonical" href="{{ pageUrl }}">
|
|
4826
|
+
|
|
4827
|
+
<!-- Open Graph / Facebook -->
|
|
4828
|
+
<meta property="og:type" content="{{ seoType }}">
|
|
4829
|
+
<meta property="og:url" content="{{ pageUrl }}">
|
|
4830
|
+
<meta property="og:title" content="{{ fullTitle }}">
|
|
4831
|
+
<meta property="og:description" content="{{ seoDescription | truncate(160) }}">
|
|
4832
|
+
<meta property="og:image" content="{{ seoImage }}">
|
|
4833
|
+
<meta property="og:image:width" content="1200">
|
|
4834
|
+
<meta property="og:image:height" content="630">
|
|
4835
|
+
<meta property="og:site_name" content="{{ metadata.title }}">
|
|
4836
|
+
<meta property="og:locale" content="{{ metadata.locale or 'en_US' }}">
|
|
4837
|
+
|
|
4838
|
+
{% if seoType == "article" %}
|
|
4839
|
+
{% if date %}
|
|
4840
|
+
<meta property="article:published_time" content="{{ date | dateToISO }}">
|
|
4841
|
+
{% endif %}
|
|
4842
|
+
{% if lastModified %}
|
|
4843
|
+
<meta property="article:modified_time" content="{{ lastModified | dateToISO }}">
|
|
4844
|
+
{% endif %}
|
|
4845
|
+
{% if author %}
|
|
4846
|
+
<meta property="article:author" content="{{ author }}">
|
|
4847
|
+
{% endif %}
|
|
4848
|
+
{% for tag in tags %}
|
|
4849
|
+
{% if tag != "posts" and tag != "all" %}
|
|
4850
|
+
<meta property="article:tag" content="{{ tag }}">
|
|
4851
|
+
{% endif %}
|
|
4852
|
+
{% endfor %}
|
|
4853
|
+
{% endif %}
|
|
4854
|
+
|
|
4855
|
+
<!-- Twitter -->
|
|
4856
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
4857
|
+
<meta name="twitter:title" content="{{ fullTitle }}">
|
|
4858
|
+
<meta name="twitter:description" content="{{ seoDescription | truncate(160) }}">
|
|
4859
|
+
<meta name="twitter:image" content="{{ seoImage }}">
|
|
4860
|
+
{% if metadata.twitterHandle %}
|
|
4861
|
+
<meta name="twitter:site" content="{{ metadata.twitterHandle }}">
|
|
4862
|
+
<meta name="twitter:creator" content="{{ metadata.twitterHandle }}">
|
|
4863
|
+
{% endif %}
|
|
4864
|
+
|
|
4865
|
+
<!-- JSON-LD: WebSite -->
|
|
4866
|
+
<script type="application/ld+json">
|
|
4867
|
+
{
|
|
4868
|
+
"@context": "https://schema.org",
|
|
4869
|
+
"@type": "WebSite",
|
|
4870
|
+
"name": "{{ metadata.title }}",
|
|
4871
|
+
"url": "{{ metadata.url }}"
|
|
4872
|
+
}
|
|
4873
|
+
</script>
|
|
4874
|
+
|
|
4875
|
+
{% if seoType == "article" %}
|
|
4876
|
+
<!-- JSON-LD: Article -->
|
|
4877
|
+
<script type="application/ld+json">
|
|
4878
|
+
{
|
|
4879
|
+
"@context": "https://schema.org",
|
|
4880
|
+
"@type": "Article",
|
|
4881
|
+
"headline": "{{ title }}",
|
|
4882
|
+
"description": "{{ seoDescription | truncate(160) | escape }}",
|
|
4883
|
+
"image": "{{ seoImage }}",
|
|
4884
|
+
"datePublished": "{{ date | dateToISO }}",
|
|
4885
|
+
"dateModified": "{{ (lastModified or date) | dateToISO }}",
|
|
4886
|
+
"author": {
|
|
4887
|
+
"@type": "Person",
|
|
4888
|
+
"name": "{{ author or metadata.author or 'Author' }}"
|
|
4889
|
+
},
|
|
4890
|
+
"publisher": {
|
|
4891
|
+
"@type": "Organization",
|
|
4892
|
+
"name": "{{ metadata.title }}",
|
|
4893
|
+
"logo": {
|
|
4894
|
+
"@type": "ImageObject",
|
|
4895
|
+
"url": "{{ metadata.url }}/logo.png"
|
|
4896
|
+
}
|
|
4897
|
+
}
|
|
4898
|
+
}
|
|
4899
|
+
</script>
|
|
4900
|
+
{% endif %}`,
|
|
4901
|
+
explanation: `Eleventy comprehensive SEO partial with:
|
|
4902
|
+
• Full Open Graph with article support
|
|
4903
|
+
• Twitter Cards
|
|
4904
|
+
• JSON-LD schemas
|
|
4905
|
+
• Nunjucks templating
|
|
4906
|
+
|
|
4907
|
+
Usage: {% include "seo.njk" %}`,
|
|
4908
|
+
additionalFiles: [
|
|
4909
|
+
{
|
|
4910
|
+
file: 'src/_includes/base.njk',
|
|
4911
|
+
code: `<!DOCTYPE html>
|
|
4912
|
+
<html lang="{{ metadata.locale | default('en') | truncate(2, true, '') }}">
|
|
4913
|
+
<head>
|
|
4914
|
+
<meta charset="utf-8">
|
|
4915
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4916
|
+
|
|
4917
|
+
{% include "seo.njk" %}
|
|
4918
|
+
|
|
4919
|
+
<link rel="icon" href="/favicon.ico">
|
|
4920
|
+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
4921
|
+
|
|
4922
|
+
<link rel="stylesheet" href="/css/styles.css">
|
|
4923
|
+
</head>
|
|
4924
|
+
<body>
|
|
4925
|
+
{{ content | safe }}
|
|
4926
|
+
</body>
|
|
4927
|
+
</html>`,
|
|
4928
|
+
explanation: 'Eleventy base layout with SEO include.',
|
|
4929
|
+
},
|
|
4930
|
+
{
|
|
4931
|
+
file: 'src/_data/metadata.json',
|
|
4932
|
+
code: `{
|
|
4933
|
+
"title": "${siteName}",
|
|
4934
|
+
"url": "${siteUrl}",
|
|
4935
|
+
"description": "${description || `${siteName} - A compelling description.`}",
|
|
4936
|
+
"defaultImage": "${image || '/og-image.png'}",
|
|
4937
|
+
"twitterHandle": "${twitterHandle || ''}",
|
|
4938
|
+
"locale": "en_US",
|
|
4939
|
+
"author": "Your Name"
|
|
4940
|
+
}`,
|
|
4941
|
+
explanation: 'Eleventy global data file with SEO metadata.',
|
|
4942
|
+
},
|
|
4943
|
+
{
|
|
4944
|
+
file: 'src/robots.njk',
|
|
4945
|
+
code: `---
|
|
4946
|
+
permalink: /robots.txt
|
|
4947
|
+
eleventyExcludeFromCollections: true
|
|
4948
|
+
---
|
|
4949
|
+
User-agent: *
|
|
4950
|
+
Allow: /
|
|
4951
|
+
Disallow: /admin/
|
|
4952
|
+
|
|
4953
|
+
User-agent: GPTBot
|
|
4954
|
+
Allow: /
|
|
4955
|
+
|
|
4956
|
+
Sitemap: {{ metadata.url }}/sitemap.xml`,
|
|
4957
|
+
explanation: 'Eleventy robots.txt template.',
|
|
4958
|
+
},
|
|
4959
|
+
],
|
|
4960
|
+
};
|
|
4961
|
+
}
|
|
4962
|
+
|
|
4963
|
+
// ============================================================================
|
|
4964
|
+
// REMIX
|
|
4965
|
+
// ============================================================================
|
|
4966
|
+
|
|
4967
|
+
export function generateRemixSEO(options: MetaFixOptions): GeneratedCode {
|
|
4968
|
+
const { siteName, siteUrl, description, image, twitterHandle } = options;
|
|
4969
|
+
|
|
4970
|
+
return {
|
|
4971
|
+
file: 'app/utils/seo.ts',
|
|
4972
|
+
code: `import type { V2_MetaFunction, V2_MetaDescriptor } from '@remix-run/node';
|
|
4973
|
+
|
|
4974
|
+
/**
|
|
4975
|
+
* Comprehensive SEO utilities for Remix
|
|
4976
|
+
*
|
|
4977
|
+
* Features:
|
|
4978
|
+
* - Full Open Graph support
|
|
4979
|
+
* - Twitter Cards
|
|
4980
|
+
* - JSON-LD structured data
|
|
4981
|
+
* - Type-safe meta function helpers
|
|
4982
|
+
*/
|
|
4983
|
+
|
|
4984
|
+
const SITE_NAME = '${siteName}';
|
|
4985
|
+
const SITE_URL = '${siteUrl}';
|
|
4986
|
+
const DEFAULT_IMAGE = '${image || `${siteUrl}/og-image.png`}';
|
|
4987
|
+
const DEFAULT_DESCRIPTION = '${description || `${siteName} - A compelling description.`}';
|
|
4988
|
+
const TWITTER_HANDLE = '${twitterHandle || ''}';
|
|
4989
|
+
|
|
4990
|
+
interface SEOConfig {
|
|
4991
|
+
title?: string;
|
|
4992
|
+
description?: string;
|
|
4993
|
+
image?: string;
|
|
4994
|
+
url?: string;
|
|
4995
|
+
type?: 'website' | 'article';
|
|
4996
|
+
publishedTime?: string;
|
|
4997
|
+
modifiedTime?: string;
|
|
4998
|
+
author?: string;
|
|
4999
|
+
tags?: string[];
|
|
5000
|
+
noIndex?: boolean;
|
|
5001
|
+
}
|
|
5002
|
+
|
|
5003
|
+
/**
|
|
5004
|
+
* Generate meta tags for a page
|
|
5005
|
+
*/
|
|
5006
|
+
export function generateMeta(config: SEOConfig = {}): V2_MetaDescriptor[] {
|
|
5007
|
+
const {
|
|
5008
|
+
title,
|
|
5009
|
+
description = DEFAULT_DESCRIPTION,
|
|
5010
|
+
image = DEFAULT_IMAGE,
|
|
5011
|
+
url = SITE_URL,
|
|
5012
|
+
type = 'website',
|
|
5013
|
+
publishedTime,
|
|
5014
|
+
modifiedTime,
|
|
5015
|
+
author,
|
|
5016
|
+
tags = [],
|
|
5017
|
+
noIndex = false,
|
|
5018
|
+
} = config;
|
|
5019
|
+
|
|
5020
|
+
const fullTitle = title
|
|
5021
|
+
? title.includes(SITE_NAME)
|
|
5022
|
+
? title
|
|
5023
|
+
: \`\${title} | \${SITE_NAME}\`
|
|
5024
|
+
: SITE_NAME;
|
|
5025
|
+
|
|
5026
|
+
const imageUrl = image.startsWith('http') ? image : \`\${SITE_URL}\${image}\`;
|
|
5027
|
+
const robotsContent = noIndex ? 'noindex, nofollow' : 'index, follow';
|
|
5028
|
+
|
|
5029
|
+
const meta: V2_MetaDescriptor[] = [
|
|
5030
|
+
{ title: fullTitle },
|
|
5031
|
+
{ name: 'description', content: description },
|
|
5032
|
+
{ name: 'robots', content: robotsContent },
|
|
5033
|
+
|
|
5034
|
+
// Open Graph
|
|
5035
|
+
{ property: 'og:type', content: type },
|
|
5036
|
+
{ property: 'og:url', content: url },
|
|
5037
|
+
{ property: 'og:title', content: fullTitle },
|
|
5038
|
+
{ property: 'og:description', content: description },
|
|
5039
|
+
{ property: 'og:image', content: imageUrl },
|
|
5040
|
+
{ property: 'og:image:width', content: '1200' },
|
|
5041
|
+
{ property: 'og:image:height', content: '630' },
|
|
5042
|
+
{ property: 'og:site_name', content: SITE_NAME },
|
|
5043
|
+
|
|
5044
|
+
// Twitter
|
|
5045
|
+
{ name: 'twitter:card', content: 'summary_large_image' },
|
|
5046
|
+
{ name: 'twitter:title', content: fullTitle },
|
|
5047
|
+
{ name: 'twitter:description', content: description },
|
|
5048
|
+
{ name: 'twitter:image', content: imageUrl },
|
|
5049
|
+
];
|
|
5050
|
+
|
|
5051
|
+
if (TWITTER_HANDLE) {
|
|
5052
|
+
meta.push(
|
|
5053
|
+
{ name: 'twitter:site', content: TWITTER_HANDLE },
|
|
5054
|
+
{ name: 'twitter:creator', content: TWITTER_HANDLE }
|
|
5055
|
+
);
|
|
5056
|
+
}
|
|
5057
|
+
|
|
5058
|
+
// Article-specific meta
|
|
5059
|
+
if (type === 'article') {
|
|
5060
|
+
if (publishedTime) {
|
|
5061
|
+
meta.push({ property: 'article:published_time', content: publishedTime });
|
|
5062
|
+
}
|
|
5063
|
+
if (modifiedTime) {
|
|
5064
|
+
meta.push({ property: 'article:modified_time', content: modifiedTime });
|
|
5065
|
+
}
|
|
5066
|
+
if (author) {
|
|
5067
|
+
meta.push({ property: 'article:author', content: author });
|
|
5068
|
+
}
|
|
5069
|
+
tags.forEach((tag) => {
|
|
5070
|
+
meta.push({ property: 'article:tag', content: tag });
|
|
5071
|
+
});
|
|
5072
|
+
}
|
|
5073
|
+
|
|
5074
|
+
return meta;
|
|
5075
|
+
}
|
|
5076
|
+
|
|
5077
|
+
/**
|
|
5078
|
+
* Generate canonical link
|
|
5079
|
+
*/
|
|
5080
|
+
export function generateLinks(url: string) {
|
|
5081
|
+
return [{ rel: 'canonical', href: url }];
|
|
5082
|
+
}
|
|
5083
|
+
|
|
5084
|
+
// JSON-LD Schema Generators
|
|
5085
|
+
|
|
5086
|
+
export function websiteSchema() {
|
|
5087
|
+
return {
|
|
5088
|
+
'@context': 'https://schema.org',
|
|
5089
|
+
'@type': 'WebSite',
|
|
5090
|
+
name: SITE_NAME,
|
|
5091
|
+
url: SITE_URL,
|
|
5092
|
+
};
|
|
5093
|
+
}
|
|
5094
|
+
|
|
5095
|
+
export function articleSchema(data: {
|
|
5096
|
+
headline: string;
|
|
5097
|
+
description: string;
|
|
5098
|
+
image?: string;
|
|
5099
|
+
datePublished: string;
|
|
5100
|
+
dateModified?: string;
|
|
5101
|
+
author: { name: string; url?: string };
|
|
5102
|
+
}) {
|
|
5103
|
+
return {
|
|
5104
|
+
'@context': 'https://schema.org',
|
|
5105
|
+
'@type': 'Article',
|
|
5106
|
+
headline: data.headline,
|
|
5107
|
+
description: data.description,
|
|
5108
|
+
image: data.image || DEFAULT_IMAGE,
|
|
5109
|
+
datePublished: data.datePublished,
|
|
5110
|
+
dateModified: data.dateModified || data.datePublished,
|
|
5111
|
+
author: { '@type': 'Person', ...data.author },
|
|
5112
|
+
publisher: {
|
|
5113
|
+
'@type': 'Organization',
|
|
5114
|
+
name: SITE_NAME,
|
|
5115
|
+
logo: { '@type': 'ImageObject', url: \`\${SITE_URL}/logo.png\` },
|
|
5116
|
+
},
|
|
5117
|
+
};
|
|
5118
|
+
}
|
|
5119
|
+
|
|
5120
|
+
export function productSchema(data: {
|
|
5121
|
+
name: string;
|
|
5122
|
+
description: string;
|
|
5123
|
+
image?: string;
|
|
5124
|
+
price: number;
|
|
5125
|
+
currency?: string;
|
|
5126
|
+
inStock?: boolean;
|
|
5127
|
+
}) {
|
|
5128
|
+
return {
|
|
5129
|
+
'@context': 'https://schema.org',
|
|
5130
|
+
'@type': 'Product',
|
|
5131
|
+
name: data.name,
|
|
5132
|
+
description: data.description,
|
|
5133
|
+
image: data.image || DEFAULT_IMAGE,
|
|
5134
|
+
offers: {
|
|
5135
|
+
'@type': 'Offer',
|
|
5136
|
+
price: data.price.toString(),
|
|
5137
|
+
priceCurrency: data.currency || 'USD',
|
|
5138
|
+
availability: \`https://schema.org/\${data.inStock !== false ? 'InStock' : 'OutOfStock'}\`,
|
|
5139
|
+
},
|
|
5140
|
+
};
|
|
5141
|
+
}
|
|
5142
|
+
|
|
5143
|
+
export function faqSchema(items: { question: string; answer: string }[]) {
|
|
5144
|
+
return {
|
|
5145
|
+
'@context': 'https://schema.org',
|
|
5146
|
+
'@type': 'FAQPage',
|
|
5147
|
+
mainEntity: items.map((item) => ({
|
|
5148
|
+
'@type': 'Question',
|
|
5149
|
+
name: item.question,
|
|
5150
|
+
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
|
5151
|
+
})),
|
|
5152
|
+
};
|
|
5153
|
+
}
|
|
5154
|
+
|
|
5155
|
+
export function breadcrumbSchema(items: { name: string; url: string }[]) {
|
|
5156
|
+
return {
|
|
5157
|
+
'@context': 'https://schema.org',
|
|
5158
|
+
'@type': 'BreadcrumbList',
|
|
5159
|
+
itemListElement: items.map((item, i) => ({
|
|
5160
|
+
'@type': 'ListItem',
|
|
5161
|
+
position: i + 1,
|
|
5162
|
+
name: item.name,
|
|
5163
|
+
item: item.url,
|
|
5164
|
+
})),
|
|
5165
|
+
};
|
|
5166
|
+
}
|
|
5167
|
+
|
|
5168
|
+
/**
|
|
5169
|
+
* JSON-LD Script component helper
|
|
5170
|
+
*/
|
|
5171
|
+
export function JsonLd({ schema }: { schema: Record<string, unknown> }) {
|
|
5172
|
+
return (
|
|
5173
|
+
<script
|
|
5174
|
+
type="application/ld+json"
|
|
5175
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
|
5176
|
+
/>
|
|
5177
|
+
);
|
|
5178
|
+
}`,
|
|
5179
|
+
explanation: `Remix comprehensive SEO utilities with:
|
|
5180
|
+
• Type-safe meta function helpers
|
|
5181
|
+
• Full Open Graph with article support
|
|
5182
|
+
• Twitter Cards
|
|
5183
|
+
• JSON-LD schema generators
|
|
5184
|
+
• Remix V2 meta conventions
|
|
5185
|
+
|
|
5186
|
+
Usage in route:
|
|
5187
|
+
export const meta: V2_MetaFunction = () => generateMeta({ title: "Page" });`,
|
|
5188
|
+
additionalFiles: [
|
|
5189
|
+
{
|
|
5190
|
+
file: 'app/root.tsx',
|
|
5191
|
+
code: `import {
|
|
5192
|
+
Links,
|
|
5193
|
+
LiveReload,
|
|
5194
|
+
Meta,
|
|
5195
|
+
Outlet,
|
|
5196
|
+
Scripts,
|
|
5197
|
+
ScrollRestoration,
|
|
5198
|
+
} from '@remix-run/react';
|
|
5199
|
+
import { JsonLd, websiteSchema } from '~/utils/seo';
|
|
5200
|
+
|
|
5201
|
+
export default function App() {
|
|
5202
|
+
return (
|
|
5203
|
+
<html lang="en">
|
|
5204
|
+
<head>
|
|
5205
|
+
<meta charSet="utf-8" />
|
|
5206
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5207
|
+
<Meta />
|
|
5208
|
+
<Links />
|
|
5209
|
+
<JsonLd schema={websiteSchema()} />
|
|
5210
|
+
</head>
|
|
5211
|
+
<body>
|
|
5212
|
+
<Outlet />
|
|
5213
|
+
<ScrollRestoration />
|
|
5214
|
+
<Scripts />
|
|
5215
|
+
<LiveReload />
|
|
5216
|
+
</body>
|
|
5217
|
+
</html>
|
|
5218
|
+
);
|
|
5219
|
+
}`,
|
|
5220
|
+
explanation: 'Remix root with SEO integration.',
|
|
5221
|
+
},
|
|
5222
|
+
{
|
|
5223
|
+
file: 'public/robots.txt',
|
|
5224
|
+
code: `User-agent: *
|
|
5225
|
+
Allow: /
|
|
5226
|
+
Disallow: /admin/
|
|
5227
|
+
Disallow: /api/
|
|
5228
|
+
|
|
5229
|
+
User-agent: GPTBot
|
|
5230
|
+
Allow: /
|
|
5231
|
+
|
|
5232
|
+
Sitemap: ${siteUrl}/sitemap.xml`,
|
|
5233
|
+
explanation: 'Robots.txt with AI crawler support.',
|
|
5234
|
+
},
|
|
5235
|
+
],
|
|
5236
|
+
};
|
|
5237
|
+
}
|
|
5238
|
+
|
|
5239
|
+
// ============================================================================
|
|
5240
|
+
// Updated getFrameworkSpecificFix to include all new frameworks
|
|
5241
|
+
// ============================================================================
|
|
5242
|
+
|
|
5243
|
+
export function getFrameworkSpecificFixExtended(
|
|
5244
|
+
framework: FrameworkInfo,
|
|
5245
|
+
options: MetaFixOptions
|
|
5246
|
+
): GeneratedCode {
|
|
5247
|
+
const name = framework.name.toLowerCase();
|
|
5248
|
+
|
|
5249
|
+
// JavaScript frameworks (existing)
|
|
5250
|
+
if (name.includes('next')) {
|
|
5251
|
+
return framework.router === 'app'
|
|
5252
|
+
? generateNextJsAppRouterMetadata(options)
|
|
5253
|
+
: generateNextJsPagesRouterHead(options);
|
|
5254
|
+
}
|
|
5255
|
+
if (name.includes('nuxt')) return generateNuxtSEOHead(options);
|
|
5256
|
+
if (name.includes('vue')) return generateVueSEOHead(options);
|
|
5257
|
+
if (name.includes('astro')) return generateAstroBaseHead(options);
|
|
5258
|
+
if (name.includes('svelte')) return generateSvelteKitSEOHead(options);
|
|
5259
|
+
if (name.includes('angular')) return generateAngularSEOService(options);
|
|
5260
|
+
if (name.includes('remix')) return generateRemixSEO(options);
|
|
5261
|
+
|
|
5262
|
+
// Backend frameworks (new)
|
|
5263
|
+
if (name.includes('rails') || name.includes('ruby')) return generateRailsSEOHelper(options);
|
|
5264
|
+
if (name.includes('django')) return generateDjangoSEOHelper(options);
|
|
5265
|
+
if (name.includes('laravel') || name.includes('php')) return generateLaravelSEOHelper(options);
|
|
5266
|
+
if (name.includes('spring') || name.includes('java')) return generateSpringBootSEO(options);
|
|
5267
|
+
if (name.includes('phoenix') || name.includes('elixir')) return generatePhoenixSEO(options);
|
|
5268
|
+
if (name.includes('asp') || name.includes('.net') || name.includes('csharp') || name.includes('c#')) {
|
|
5269
|
+
return generateAspNetCoreSEO(options);
|
|
5270
|
+
}
|
|
5271
|
+
if (name.includes('go') || name.includes('gin') || name.includes('echo') || name.includes('fiber')) {
|
|
5272
|
+
return generateGoSEO(options);
|
|
5273
|
+
}
|
|
5274
|
+
|
|
5275
|
+
// HTMX and hypermedia
|
|
5276
|
+
if (name.includes('htmx') || name.includes('hotwire') || name.includes('turbo')) {
|
|
5277
|
+
return generateHTMXSEO(options);
|
|
5278
|
+
}
|
|
5279
|
+
|
|
5280
|
+
// Static site generators
|
|
5281
|
+
if (name.includes('hugo')) return generateHugoSEO(options);
|
|
5282
|
+
if (name.includes('jekyll')) return generateJekyllSEO(options);
|
|
5283
|
+
if (name.includes('eleventy') || name.includes('11ty')) return generateEleventySEO(options);
|
|
5284
|
+
|
|
5285
|
+
// Default: React with react-helmet-async
|
|
5286
|
+
return generateReactSEOHead(options);
|
|
5287
|
+
}
|