@od-oneapp/seo 2026.1.1301
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 +586 -0
- package/dist/client-next.d.mts +46 -0
- package/dist/client-next.d.mts.map +1 -0
- package/dist/client-next.mjs +92 -0
- package/dist/client-next.mjs.map +1 -0
- package/dist/client.d.mts +16 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +47 -0
- package/dist/client.mjs.map +1 -0
- package/dist/env.d.mts +31 -0
- package/dist/env.d.mts.map +1 -0
- package/dist/env.mjs +125 -0
- package/dist/env.mjs.map +1 -0
- package/dist/index.d.mts +30 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +129 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server-next.d.mts +230 -0
- package/dist/server-next.d.mts.map +1 -0
- package/dist/server-next.mjs +541 -0
- package/dist/server-next.mjs.map +1 -0
- package/dist/server.d.mts +3 -0
- package/dist/server.mjs +3 -0
- package/dist/structured-data-builders-ByJ4KCEf.mjs +176 -0
- package/dist/structured-data-builders-ByJ4KCEf.mjs.map +1 -0
- package/dist/structured-data-builders-CAgdYvmz.d.mts +74 -0
- package/dist/structured-data-builders-CAgdYvmz.d.mts.map +1 -0
- package/dist/structured-data.d.mts +16 -0
- package/dist/structured-data.d.mts.map +1 -0
- package/dist/structured-data.mjs +62 -0
- package/dist/structured-data.mjs.map +1 -0
- package/dist/validation.d.mts +20 -0
- package/dist/validation.d.mts.map +1 -0
- package/dist/validation.mjs +233 -0
- package/dist/validation.mjs.map +1 -0
- package/package.json +110 -0
- package/src/client-next.tsx +134 -0
- package/src/client.tsx +96 -0
- package/src/components/json-ld.tsx +74 -0
- package/src/components/structured-data.tsx +91 -0
- package/src/examples/app-router-sitemap.ts +109 -0
- package/src/examples/metadata-patterns.ts +528 -0
- package/src/examples/next-sitemap-config.ts +92 -0
- package/src/examples/nextjs-15-features.tsx +383 -0
- package/src/examples/nextjs-15-integration.ts +241 -0
- package/src/index.ts +87 -0
- package/src/server-next.ts +958 -0
- package/src/server.ts +27 -0
- package/src/types/metadata.ts +85 -0
- package/src/types/seo.ts +60 -0
- package/src/types/structured-data.ts +94 -0
- package/src/utils/i18n-enhanced.ts +148 -0
- package/src/utils/metadata-enhanced.ts +238 -0
- package/src/utils/metadata.ts +169 -0
- package/src/utils/structured-data-builders.ts +322 -0
- package/src/utils/validation.ts +284 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Server-side SEO exports (non-Next.js)
|
|
3
|
+
*
|
|
4
|
+
* This file provides server-side SEO functionality for non-Next.js environments.
|
|
5
|
+
* For Next.js applications, use '@repo/seo/server/next' instead.
|
|
6
|
+
*
|
|
7
|
+
* @module @repo/seo/server
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { createStructuredData, structuredData } from '@repo/seo/server';
|
|
12
|
+
*
|
|
13
|
+
* const article = structuredData.article({
|
|
14
|
+
* headline: 'My Article',
|
|
15
|
+
* author: 'John Doe',
|
|
16
|
+
* datePublished: '2024-01-01',
|
|
17
|
+
* publisher: { name: 'Publisher', logo: '/logo.png' }
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Re-export everything from the shared builders
|
|
23
|
+
export { createStructuredData, structuredData } from './utils/structured-data-builders';
|
|
24
|
+
|
|
25
|
+
// Re-export types
|
|
26
|
+
export type { Thing, WithContext } from 'schema-dts';
|
|
27
|
+
export type { StructuredDataType } from './utils/structured-data-builders';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Metadata Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* Provides TypeScript types for metadata and OpenGraph structures.
|
|
5
|
+
* Used for Next.js metadata generation and validation.
|
|
6
|
+
*
|
|
7
|
+
* @module @repo/seo/types/metadata
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Enhanced metadata options for SEO generators
|
|
12
|
+
*/
|
|
13
|
+
export interface MetadataOptions {
|
|
14
|
+
alternates?: {
|
|
15
|
+
canonical?: string;
|
|
16
|
+
languages?: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
article?: {
|
|
19
|
+
authors?: string[];
|
|
20
|
+
expirationTime?: string;
|
|
21
|
+
modifiedTime?: string;
|
|
22
|
+
publishedTime?: string;
|
|
23
|
+
section?: string;
|
|
24
|
+
tags?: string[];
|
|
25
|
+
};
|
|
26
|
+
canonical?: string;
|
|
27
|
+
description: string;
|
|
28
|
+
image?: string | { alt?: string; height?: number; url: string; width?: number };
|
|
29
|
+
keywords?: string[];
|
|
30
|
+
noFollow?: boolean;
|
|
31
|
+
noIndex?: boolean;
|
|
32
|
+
title: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* OpenGraph data structure
|
|
37
|
+
*/
|
|
38
|
+
export interface OpenGraphData {
|
|
39
|
+
title: string;
|
|
40
|
+
description: string;
|
|
41
|
+
type: 'website' | 'article' | 'profile' | 'product';
|
|
42
|
+
url?: string;
|
|
43
|
+
siteName?: string;
|
|
44
|
+
images?: Array<{
|
|
45
|
+
url: string;
|
|
46
|
+
width?: number;
|
|
47
|
+
height?: number;
|
|
48
|
+
alt?: string;
|
|
49
|
+
}>;
|
|
50
|
+
locale?: string;
|
|
51
|
+
publishedTime?: string;
|
|
52
|
+
modifiedTime?: string;
|
|
53
|
+
authors?: string[];
|
|
54
|
+
tags?: string[];
|
|
55
|
+
section?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Twitter Card data structure
|
|
60
|
+
*/
|
|
61
|
+
export interface TwitterCardData {
|
|
62
|
+
card: 'summary' | 'summary_large_image' | 'app' | 'player';
|
|
63
|
+
site?: string;
|
|
64
|
+
creator?: string;
|
|
65
|
+
title?: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
images?: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Complete metadata structure
|
|
72
|
+
*/
|
|
73
|
+
export interface CompleteMetadata {
|
|
74
|
+
title: string;
|
|
75
|
+
description: string;
|
|
76
|
+
keywords?: string[];
|
|
77
|
+
canonical?: string;
|
|
78
|
+
openGraph?: OpenGraphData;
|
|
79
|
+
twitter?: TwitterCardData;
|
|
80
|
+
robots?: {
|
|
81
|
+
index?: boolean;
|
|
82
|
+
follow?: boolean;
|
|
83
|
+
};
|
|
84
|
+
themeColor?: string;
|
|
85
|
+
}
|
package/src/types/seo.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Core SEO Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* Provides TypeScript types for SEO functionality and configurations.
|
|
5
|
+
* Includes structured data types and SEO configuration interfaces.
|
|
6
|
+
*
|
|
7
|
+
* @module @repo/seo/types/seo
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Re-export from schema-dts for convenience
|
|
11
|
+
export type { Thing, WithContext } from 'schema-dts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Common structured data types with better type safety
|
|
15
|
+
*/
|
|
16
|
+
export type StructuredDataType =
|
|
17
|
+
| 'Article'
|
|
18
|
+
| 'BlogPosting'
|
|
19
|
+
| 'BreadcrumbList'
|
|
20
|
+
| 'Course'
|
|
21
|
+
| 'Event'
|
|
22
|
+
| 'FAQPage'
|
|
23
|
+
| 'HowTo'
|
|
24
|
+
| 'LocalBusiness'
|
|
25
|
+
| 'Organization'
|
|
26
|
+
| 'Person'
|
|
27
|
+
| 'Product'
|
|
28
|
+
| 'Recipe'
|
|
29
|
+
| 'SoftwareApplication'
|
|
30
|
+
| 'VideoObject'
|
|
31
|
+
| 'WebSite';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Configuration for SEO setup
|
|
35
|
+
*/
|
|
36
|
+
export interface SEOConfig {
|
|
37
|
+
applicationName: string;
|
|
38
|
+
author: {
|
|
39
|
+
name: string;
|
|
40
|
+
url: string;
|
|
41
|
+
};
|
|
42
|
+
keywords?: string[];
|
|
43
|
+
locale?: string;
|
|
44
|
+
publisher: string;
|
|
45
|
+
themeColor?: string;
|
|
46
|
+
twitterHandle: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Basic SEO metadata structure
|
|
51
|
+
*/
|
|
52
|
+
export interface SEOMetadata {
|
|
53
|
+
title: string;
|
|
54
|
+
description: string;
|
|
55
|
+
image?: string;
|
|
56
|
+
keywords?: string[];
|
|
57
|
+
canonical?: string;
|
|
58
|
+
noIndex?: boolean;
|
|
59
|
+
noFollow?: boolean;
|
|
60
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Structured Data Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* Provides TypeScript types for schema.org structured data.
|
|
5
|
+
* Includes input types for common structured data schemas.
|
|
6
|
+
*
|
|
7
|
+
* @module @repo/seo/types/structured-data
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Re-export from schema-dts for convenience
|
|
11
|
+
export type { Thing, WithContext } from 'schema-dts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Article structured data input
|
|
15
|
+
*/
|
|
16
|
+
export interface ArticleData {
|
|
17
|
+
author: string | { name: string; url?: string };
|
|
18
|
+
dateModified?: string;
|
|
19
|
+
datePublished: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
headline: string;
|
|
22
|
+
image?: string | string[];
|
|
23
|
+
mainEntityOfPage?: string;
|
|
24
|
+
publisher: {
|
|
25
|
+
logo?: string;
|
|
26
|
+
name: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Product structured data input
|
|
32
|
+
*/
|
|
33
|
+
export interface ProductData {
|
|
34
|
+
aggregateRating?: {
|
|
35
|
+
ratingValue: number;
|
|
36
|
+
reviewCount: number;
|
|
37
|
+
};
|
|
38
|
+
brand?: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
image?: string | string[];
|
|
41
|
+
name: string;
|
|
42
|
+
offers?: {
|
|
43
|
+
availability?: string;
|
|
44
|
+
price: string;
|
|
45
|
+
priceCurrency: string;
|
|
46
|
+
seller?: string;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Organization structured data input
|
|
52
|
+
*/
|
|
53
|
+
export interface OrganizationData {
|
|
54
|
+
contactPoint?: {
|
|
55
|
+
areaServed?: string | string[];
|
|
56
|
+
availableLanguage?: string | string[];
|
|
57
|
+
contactType: string;
|
|
58
|
+
telephone: string;
|
|
59
|
+
};
|
|
60
|
+
description?: string;
|
|
61
|
+
logo?: string;
|
|
62
|
+
name: string;
|
|
63
|
+
sameAs?: string[];
|
|
64
|
+
url: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Website structured data input
|
|
69
|
+
*/
|
|
70
|
+
export interface WebsiteData {
|
|
71
|
+
description?: string;
|
|
72
|
+
name: string;
|
|
73
|
+
potentialAction?: {
|
|
74
|
+
queryInput: string;
|
|
75
|
+
target: string;
|
|
76
|
+
};
|
|
77
|
+
url: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Breadcrumb item
|
|
82
|
+
*/
|
|
83
|
+
export interface BreadcrumbItem {
|
|
84
|
+
name: string;
|
|
85
|
+
url: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* FAQ item
|
|
90
|
+
*/
|
|
91
|
+
export interface FAQItem {
|
|
92
|
+
answer: string;
|
|
93
|
+
question: string;
|
|
94
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview i18n-enhanced SEO utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides internationalization-aware SEO metadata generation.
|
|
5
|
+
* Extends SEOManager with locale-specific metadata and translations support.
|
|
6
|
+
*
|
|
7
|
+
* @module @repo/seo/utils/i18n-enhanced
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type Metadata } from 'next';
|
|
11
|
+
|
|
12
|
+
import { SEOManager } from './metadata-enhanced';
|
|
13
|
+
|
|
14
|
+
interface I18nSEOConfig {
|
|
15
|
+
defaultLocale: string;
|
|
16
|
+
localeNames: Record<string, string>;
|
|
17
|
+
locales: string[];
|
|
18
|
+
rtlLocales?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class I18nSEOManager extends SEOManager {
|
|
22
|
+
private i18nConfig: I18nSEOConfig;
|
|
23
|
+
|
|
24
|
+
constructor(config: ConstructorParameters<typeof SEOManager>[0] & { i18n: I18nSEOConfig }) {
|
|
25
|
+
super(config);
|
|
26
|
+
this.i18nConfig = config.i18n;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
createI18nMetadata(
|
|
30
|
+
options: Parameters<SEOManager['createMetadata']>[0] & {
|
|
31
|
+
locale: string;
|
|
32
|
+
translations?: Record<string, { description: string; title: string }>;
|
|
33
|
+
},
|
|
34
|
+
): Metadata {
|
|
35
|
+
const { locale, translations, ...baseOptions } = options;
|
|
36
|
+
|
|
37
|
+
// Use translated title and description if available
|
|
38
|
+
const localizedTitle = translations?.[locale]?.title ?? baseOptions.title;
|
|
39
|
+
const localizedDescription = translations?.[locale]?.description ?? baseOptions.description;
|
|
40
|
+
|
|
41
|
+
// Generate language alternates automatically
|
|
42
|
+
const languageAlternates: Record<string, string> = {};
|
|
43
|
+
this.i18nConfig.locales.forEach(loc => {
|
|
44
|
+
if (baseOptions.alternates?.canonical) {
|
|
45
|
+
const basePath = baseOptions.alternates.canonical.replace(/^\/[a-z]{2}\//, '/');
|
|
46
|
+
languageAlternates[loc] = `/${loc}${basePath}`;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Determine OpenGraph locale format
|
|
51
|
+
const ogLocale = this.formatOpenGraphLocale(locale);
|
|
52
|
+
|
|
53
|
+
// Get alternate locales for OpenGraph
|
|
54
|
+
const alternateLocales = this.i18nConfig.locales
|
|
55
|
+
.filter(loc => loc !== locale)
|
|
56
|
+
.map(loc => this.formatOpenGraphLocale(loc));
|
|
57
|
+
|
|
58
|
+
const metadata = super.createMetadata({
|
|
59
|
+
...baseOptions,
|
|
60
|
+
alternates: {
|
|
61
|
+
...baseOptions.alternates,
|
|
62
|
+
languages: languageAlternates,
|
|
63
|
+
},
|
|
64
|
+
description: localizedDescription,
|
|
65
|
+
title: localizedTitle,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Enhance OpenGraph with locale information
|
|
69
|
+
if (metadata.openGraph) {
|
|
70
|
+
metadata.openGraph.locale = ogLocale;
|
|
71
|
+
metadata.openGraph.alternateLocale = alternateLocales;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Add language meta tag
|
|
75
|
+
const otherMetadata: Record<string, (number | string)[] | number | string> = {
|
|
76
|
+
'content-language': locale,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Handle RTL languages
|
|
80
|
+
if (this.i18nConfig.rtlLocales?.includes(locale)) {
|
|
81
|
+
otherMetadata.direction = 'rtl';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Merge with existing metadata.other
|
|
85
|
+
if (metadata.other) {
|
|
86
|
+
Object.entries(metadata.other).forEach(([key, value]) => {
|
|
87
|
+
otherMetadata[key] = value;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
metadata.other = otherMetadata;
|
|
92
|
+
|
|
93
|
+
return metadata;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Create localized structured data
|
|
97
|
+
createLocalizedStructuredData<T>(
|
|
98
|
+
type: string,
|
|
99
|
+
data: T,
|
|
100
|
+
locale: string,
|
|
101
|
+
translations?: Record<string, Partial<T>>,
|
|
102
|
+
): T {
|
|
103
|
+
const localizedData = translations?.[locale] ? { ...data, ...translations[locale] } : data;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
...localizedData,
|
|
107
|
+
'@context': 'https://schema.org',
|
|
108
|
+
'@type': type,
|
|
109
|
+
inLanguage: locale,
|
|
110
|
+
} as T;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Generate hreflang tags for international SEO
|
|
114
|
+
|
|
115
|
+
generateHreflangTags(currentPath: string, _currentLocale: string): Record<string, string> {
|
|
116
|
+
const hreflangTags: Record<string, string> = {};
|
|
117
|
+
|
|
118
|
+
// Add x-default for default locale
|
|
119
|
+
const basePath = currentPath.replace(/^\/[a-z]{2}\//, '/');
|
|
120
|
+
hreflangTags['x-default'] = `/${this.i18nConfig.defaultLocale}${basePath}`;
|
|
121
|
+
|
|
122
|
+
// Add all locale variations
|
|
123
|
+
this.i18nConfig.locales.forEach(locale => {
|
|
124
|
+
hreflangTags[locale] = `/${locale}${basePath}`;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return hreflangTags;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Format locale for OpenGraph (e.g., 'en' -> 'en_US', 'fr' -> 'fr_FR')
|
|
131
|
+
private formatOpenGraphLocale(locale: string): string {
|
|
132
|
+
const localeMap: Record<string, string> = {
|
|
133
|
+
ar: 'ar_SA',
|
|
134
|
+
de: 'de_DE',
|
|
135
|
+
en: 'en_US',
|
|
136
|
+
es: 'es_ES',
|
|
137
|
+
fr: 'fr_FR',
|
|
138
|
+
it: 'it_IT',
|
|
139
|
+
ja: 'ja_JP',
|
|
140
|
+
ko: 'ko_KR',
|
|
141
|
+
nl: 'nl_NL',
|
|
142
|
+
pt: 'pt_BR',
|
|
143
|
+
ru: 'ru_RU',
|
|
144
|
+
zh: 'zh_CN',
|
|
145
|
+
};
|
|
146
|
+
return localeMap[locale] ?? `${locale}_${locale.toUpperCase()}`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Enhanced metadata utilities for SEO
|
|
3
|
+
*
|
|
4
|
+
* Provides advanced metadata generation with enhanced features including
|
|
5
|
+
* SEO manager class, viewport configuration, and comprehensive metadata merging.
|
|
6
|
+
*
|
|
7
|
+
* @module @repo/seo/utils/metadata-enhanced
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type Metadata, type Viewport } from 'next';
|
|
11
|
+
|
|
12
|
+
import { logWarn } from '@repo/shared/logger';
|
|
13
|
+
import merge from 'lodash.merge';
|
|
14
|
+
|
|
15
|
+
import { getProtocol, safeEnv } from '../../env';
|
|
16
|
+
|
|
17
|
+
import { validateUrls } from './validation';
|
|
18
|
+
|
|
19
|
+
// Constants for Open Graph image dimensions (recommended by OG spec)
|
|
20
|
+
const OG_IMAGE_WIDTH = 1200;
|
|
21
|
+
const OG_IMAGE_HEIGHT = 630;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Safely creates a URL object with error handling
|
|
25
|
+
*/
|
|
26
|
+
function safeCreateUrl(url: string | undefined, protocol: string): URL | undefined {
|
|
27
|
+
if (!url) return undefined;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const fullUrl = url.startsWith('http') ? url : `${protocol}://${url}`;
|
|
31
|
+
if (!validateUrls([fullUrl])) {
|
|
32
|
+
logWarn(`[SEO] Invalid URL provided: ${url}, skipping metadataBase`, { url });
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return new URL(fullUrl);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
logWarn(`[SEO] Failed to create URL from ${url}:`, { error });
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface MetadataGeneratorOptions {
|
|
43
|
+
alternates?: {
|
|
44
|
+
canonical?: string;
|
|
45
|
+
languages?: Record<string, string>;
|
|
46
|
+
};
|
|
47
|
+
article?: {
|
|
48
|
+
authors?: string[];
|
|
49
|
+
expirationTime?: string;
|
|
50
|
+
modifiedTime?: string;
|
|
51
|
+
publishedTime?: string;
|
|
52
|
+
section?: string;
|
|
53
|
+
tags?: string[];
|
|
54
|
+
};
|
|
55
|
+
canonical?: string;
|
|
56
|
+
description: string;
|
|
57
|
+
image?: string | { alt?: string; height?: number; url: string; width?: number };
|
|
58
|
+
keywords?: string[];
|
|
59
|
+
noFollow?: boolean;
|
|
60
|
+
noIndex?: boolean;
|
|
61
|
+
other?: Record<string, string | number | (string | number)[]>;
|
|
62
|
+
title: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface SEOConfig {
|
|
66
|
+
applicationName: string;
|
|
67
|
+
author: {
|
|
68
|
+
name: string;
|
|
69
|
+
url: string;
|
|
70
|
+
};
|
|
71
|
+
keywords?: string[];
|
|
72
|
+
locale?: string;
|
|
73
|
+
publisher: string;
|
|
74
|
+
themeColor?: string;
|
|
75
|
+
twitterHandle: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Enhanced viewport configuration for better mobile experience
|
|
79
|
+
export const viewport: Viewport = {
|
|
80
|
+
initialScale: 1,
|
|
81
|
+
maximumScale: 5,
|
|
82
|
+
userScalable: true,
|
|
83
|
+
viewportFit: 'cover',
|
|
84
|
+
width: 'device-width',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export class SEOManager {
|
|
88
|
+
private config: SEOConfig;
|
|
89
|
+
|
|
90
|
+
constructor(config: SEOConfig) {
|
|
91
|
+
this.config = config;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Generate metadata for error pages
|
|
95
|
+
createErrorMetadata(statusCode: number): Metadata {
|
|
96
|
+
const titles: Record<number, string> = {
|
|
97
|
+
404: 'Page Not Found',
|
|
98
|
+
500: 'Internal Server Error',
|
|
99
|
+
503: 'Service Unavailable',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return this.createMetadata({
|
|
103
|
+
description: `Error ${statusCode}`,
|
|
104
|
+
noFollow: true,
|
|
105
|
+
noIndex: true,
|
|
106
|
+
title: titles[statusCode] ?? 'Error',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
createMetadata(options: MetadataGeneratorOptions): Metadata {
|
|
111
|
+
const {
|
|
112
|
+
alternates,
|
|
113
|
+
article,
|
|
114
|
+
canonical,
|
|
115
|
+
description,
|
|
116
|
+
image,
|
|
117
|
+
keywords = [],
|
|
118
|
+
noFollow = false,
|
|
119
|
+
noIndex = false,
|
|
120
|
+
title,
|
|
121
|
+
...additionalProperties
|
|
122
|
+
} = options;
|
|
123
|
+
|
|
124
|
+
const protocol = getProtocol();
|
|
125
|
+
const envVars = safeEnv();
|
|
126
|
+
const productionUrl = envVars.VERCEL_PROJECT_PRODUCTION_URL ?? envVars.NEXT_PUBLIC_URL;
|
|
127
|
+
const parsedTitle = `${title} | ${this.config.applicationName}`;
|
|
128
|
+
|
|
129
|
+
// Combine default keywords with page-specific ones
|
|
130
|
+
const allKeywords = [...(this.config.keywords ?? []), ...keywords];
|
|
131
|
+
|
|
132
|
+
const defaultMetadata: Metadata = {
|
|
133
|
+
// Alternates for i18n and canonical URLs
|
|
134
|
+
alternates: alternates ?? (canonical ? { canonical } : undefined),
|
|
135
|
+
// Apple Web App
|
|
136
|
+
appleWebApp: {
|
|
137
|
+
capable: true,
|
|
138
|
+
statusBarStyle: 'default',
|
|
139
|
+
title,
|
|
140
|
+
},
|
|
141
|
+
applicationName: this.config.applicationName,
|
|
142
|
+
// App Links for mobile deep linking
|
|
143
|
+
appLinks: {},
|
|
144
|
+
authors: [{ name: this.config.author.name, url: this.config.author.url }],
|
|
145
|
+
creator: this.config.author.name,
|
|
146
|
+
description,
|
|
147
|
+
|
|
148
|
+
// Enhanced format detection
|
|
149
|
+
formatDetection: {
|
|
150
|
+
address: false,
|
|
151
|
+
date: false,
|
|
152
|
+
email: false,
|
|
153
|
+
telephone: false,
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
keywords: allKeywords,
|
|
157
|
+
|
|
158
|
+
// Metadata base for absolute URLs
|
|
159
|
+
metadataBase: safeCreateUrl(productionUrl, protocol),
|
|
160
|
+
|
|
161
|
+
// Open Graph
|
|
162
|
+
openGraph: {
|
|
163
|
+
description,
|
|
164
|
+
locale: this.config.locale ?? 'en_US',
|
|
165
|
+
siteName: this.config.applicationName,
|
|
166
|
+
title: parsedTitle,
|
|
167
|
+
type: article ? 'article' : 'website',
|
|
168
|
+
...(article && {
|
|
169
|
+
article: {
|
|
170
|
+
...(article.authors && { authors: article.authors }),
|
|
171
|
+
...(article.expirationTime && { expirationTime: article.expirationTime }),
|
|
172
|
+
...(article.modifiedTime && { modifiedTime: article.modifiedTime }),
|
|
173
|
+
...(article.publishedTime && { publishedTime: article.publishedTime }),
|
|
174
|
+
...(article.section && { section: article.section }),
|
|
175
|
+
...(article.tags && { tags: article.tags }),
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
publisher: this.config.publisher,
|
|
181
|
+
|
|
182
|
+
// Robots directives
|
|
183
|
+
robots: {
|
|
184
|
+
follow: !noFollow,
|
|
185
|
+
googleBot: {
|
|
186
|
+
follow: !noFollow,
|
|
187
|
+
index: !noIndex,
|
|
188
|
+
'max-image-preview': 'large',
|
|
189
|
+
'max-snippet': -1,
|
|
190
|
+
'max-video-preview': -1,
|
|
191
|
+
},
|
|
192
|
+
index: !noIndex,
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
title: {
|
|
196
|
+
default: parsedTitle,
|
|
197
|
+
template: `%s | ${this.config.applicationName}`,
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
// Twitter Card
|
|
201
|
+
twitter: {
|
|
202
|
+
card: 'summary_large_image',
|
|
203
|
+
creator: this.config.twitterHandle,
|
|
204
|
+
description,
|
|
205
|
+
site: this.config.twitterHandle,
|
|
206
|
+
title: parsedTitle,
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
// Verification (can be extended)
|
|
210
|
+
verification: {},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Handle image configuration
|
|
214
|
+
if (image) {
|
|
215
|
+
const imageData =
|
|
216
|
+
typeof image === 'string'
|
|
217
|
+
? { alt: title, height: OG_IMAGE_HEIGHT, url: image, width: OG_IMAGE_WIDTH }
|
|
218
|
+
: { alt: title, height: OG_IMAGE_HEIGHT, width: OG_IMAGE_WIDTH, ...image };
|
|
219
|
+
|
|
220
|
+
if (defaultMetadata.openGraph) {
|
|
221
|
+
defaultMetadata.openGraph.images = [imageData];
|
|
222
|
+
}
|
|
223
|
+
if (defaultMetadata.twitter) {
|
|
224
|
+
defaultMetadata.twitter.images = [imageData.url];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Theme color
|
|
229
|
+
if (this.config.themeColor) {
|
|
230
|
+
defaultMetadata.themeColor = [
|
|
231
|
+
{ color: this.config.themeColor, media: '(prefers-color-scheme: light)' },
|
|
232
|
+
{ color: this.config.themeColor, media: '(prefers-color-scheme: dark)' },
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return merge(defaultMetadata, additionalProperties);
|
|
237
|
+
}
|
|
238
|
+
}
|