@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.
Files changed (56) hide show
  1. package/README.md +586 -0
  2. package/dist/client-next.d.mts +46 -0
  3. package/dist/client-next.d.mts.map +1 -0
  4. package/dist/client-next.mjs +92 -0
  5. package/dist/client-next.mjs.map +1 -0
  6. package/dist/client.d.mts +16 -0
  7. package/dist/client.d.mts.map +1 -0
  8. package/dist/client.mjs +47 -0
  9. package/dist/client.mjs.map +1 -0
  10. package/dist/env.d.mts +31 -0
  11. package/dist/env.d.mts.map +1 -0
  12. package/dist/env.mjs +125 -0
  13. package/dist/env.mjs.map +1 -0
  14. package/dist/index.d.mts +30 -0
  15. package/dist/index.d.mts.map +1 -0
  16. package/dist/index.mjs +129 -0
  17. package/dist/index.mjs.map +1 -0
  18. package/dist/server-next.d.mts +230 -0
  19. package/dist/server-next.d.mts.map +1 -0
  20. package/dist/server-next.mjs +541 -0
  21. package/dist/server-next.mjs.map +1 -0
  22. package/dist/server.d.mts +3 -0
  23. package/dist/server.mjs +3 -0
  24. package/dist/structured-data-builders-ByJ4KCEf.mjs +176 -0
  25. package/dist/structured-data-builders-ByJ4KCEf.mjs.map +1 -0
  26. package/dist/structured-data-builders-CAgdYvmz.d.mts +74 -0
  27. package/dist/structured-data-builders-CAgdYvmz.d.mts.map +1 -0
  28. package/dist/structured-data.d.mts +16 -0
  29. package/dist/structured-data.d.mts.map +1 -0
  30. package/dist/structured-data.mjs +62 -0
  31. package/dist/structured-data.mjs.map +1 -0
  32. package/dist/validation.d.mts +20 -0
  33. package/dist/validation.d.mts.map +1 -0
  34. package/dist/validation.mjs +233 -0
  35. package/dist/validation.mjs.map +1 -0
  36. package/package.json +110 -0
  37. package/src/client-next.tsx +134 -0
  38. package/src/client.tsx +96 -0
  39. package/src/components/json-ld.tsx +74 -0
  40. package/src/components/structured-data.tsx +91 -0
  41. package/src/examples/app-router-sitemap.ts +109 -0
  42. package/src/examples/metadata-patterns.ts +528 -0
  43. package/src/examples/next-sitemap-config.ts +92 -0
  44. package/src/examples/nextjs-15-features.tsx +383 -0
  45. package/src/examples/nextjs-15-integration.ts +241 -0
  46. package/src/index.ts +87 -0
  47. package/src/server-next.ts +958 -0
  48. package/src/server.ts +27 -0
  49. package/src/types/metadata.ts +85 -0
  50. package/src/types/seo.ts +60 -0
  51. package/src/types/structured-data.ts +94 -0
  52. package/src/utils/i18n-enhanced.ts +148 -0
  53. package/src/utils/metadata-enhanced.ts +238 -0
  54. package/src/utils/metadata.ts +169 -0
  55. package/src/utils/structured-data-builders.ts +322 -0
  56. package/src/utils/validation.ts +284 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @fileoverview Metadata Utilities
3
+ *
4
+ * Core utilities for generating Next.js metadata with validation and error handling.
5
+ * Provides type-safe metadata creation with OpenGraph and Twitter Card support.
6
+ *
7
+ * @module @repo/seo/utils/metadata
8
+ */
9
+
10
+ import { type Metadata } 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
+ /**
20
+ * Open Graph image dimensions recommended by OG specification
21
+ * Aspect ratio: ~1.91:1 (1200x630)
22
+ * @see https://ogp.me/#structured
23
+ */
24
+ const OG_IMAGE_WIDTH = 1200;
25
+ const OG_IMAGE_HEIGHT = 630;
26
+
27
+ /**
28
+ * Safely creates a URL object with comprehensive error handling
29
+ *
30
+ * This function handles URL creation with validation and graceful degradation:
31
+ * - Returns undefined for empty/null input
32
+ * - Validates URL format before creating URL object
33
+ * - Adds protocol if missing
34
+ * - Logs warnings for invalid URLs but doesn't throw
35
+ *
36
+ * @param url - URL string to parse (with or without protocol)
37
+ * @param protocol - Protocol to use if URL doesn't have one ('http' or 'https')
38
+ * @returns Parsed URL object or undefined if invalid
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * const url = safeCreateUrl('example.com', 'https');
43
+ * // Returns: URL { href: 'https://example.com', ... }
44
+ *
45
+ * const invalid = safeCreateUrl('not a url', 'https');
46
+ * // Logs warning, returns: undefined
47
+ * ```
48
+ */
49
+ function safeCreateUrl(url: string | undefined, protocol: string): URL | undefined {
50
+ if (!url) return undefined;
51
+
52
+ try {
53
+ const fullUrl = url.startsWith('http') ? url : `${protocol}://${url}`;
54
+ if (!validateUrls([fullUrl])) {
55
+ logWarn(`[SEO] Invalid URL provided: ${url}, skipping metadataBase`, { url });
56
+ return undefined;
57
+ }
58
+ return new URL(fullUrl);
59
+ } catch (error) {
60
+ logWarn(`[SEO] Failed to create URL from ${url}`, { error, url });
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Metadata generator options
67
+ */
68
+ type MetadataGenerator = Omit<Metadata, 'description' | 'title'> & {
69
+ /** Page description (required) */
70
+ description: string;
71
+ /** Page title (required, will be combined with applicationName) */
72
+ title: string;
73
+ /** Optional Open Graph image URL */
74
+ image?: string;
75
+ /** Application name (defaults to 'Application') */
76
+ applicationName?: string;
77
+ /** Author information */
78
+ author?: Metadata['authors'];
79
+ /** Publisher name */
80
+ publisher?: string;
81
+ /** Twitter handle (e.g., '@username') */
82
+ twitterHandle?: string;
83
+ };
84
+
85
+ /**
86
+ * Create comprehensive Next.js metadata with sensible defaults
87
+ *
88
+ * Generates a complete metadata object with:
89
+ * - Title and description
90
+ * - Open Graph tags for social media
91
+ * - Twitter Card metadata
92
+ * - Apple Web App configuration
93
+ * - Format detection settings
94
+ *
95
+ * @param options - Metadata configuration
96
+ * @returns Complete Next.js Metadata object
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * export const metadata = createMetadata({
101
+ * title: 'Home Page',
102
+ * description: 'Welcome to our site',
103
+ * applicationName: 'My App',
104
+ * image: '/og-image.png',
105
+ * author: { name: 'John Doe', url: 'https://johndoe.com' },
106
+ * publisher: 'My Company',
107
+ * twitterHandle: '@myapp'
108
+ * });
109
+ * ```
110
+ */
111
+ export const createMetadata = ({
112
+ description,
113
+ image,
114
+ title,
115
+ applicationName = 'Application',
116
+ author = { name: 'Author', url: 'https://example.com' },
117
+ publisher = 'Publisher',
118
+ twitterHandle = '@site',
119
+ ...properties
120
+ }: MetadataGenerator): Metadata => {
121
+ const protocol = getProtocol();
122
+ const envVars = safeEnv();
123
+ const productionUrl = envVars.VERCEL_PROJECT_PRODUCTION_URL ?? envVars.NEXT_PUBLIC_URL;
124
+ const parsedTitle = `${title} | ${applicationName}`;
125
+ const defaultMetadata: Metadata = {
126
+ appleWebApp: {
127
+ capable: true,
128
+ statusBarStyle: 'default',
129
+ title: parsedTitle,
130
+ },
131
+ applicationName,
132
+ authors: author ? (Array.isArray(author) ? author : [author]) : undefined,
133
+ creator: author ? (Array.isArray(author) ? author[0]?.name : author.name) : undefined,
134
+ description,
135
+ formatDetection: {
136
+ telephone: false,
137
+ },
138
+ metadataBase: safeCreateUrl(productionUrl, protocol),
139
+ openGraph: {
140
+ description,
141
+ locale: 'en_US',
142
+ siteName: applicationName,
143
+ title: parsedTitle,
144
+ type: 'website',
145
+ },
146
+ publisher,
147
+ title: parsedTitle,
148
+ twitter: {
149
+ card: 'summary_large_image',
150
+ creator: twitterHandle,
151
+ site: twitterHandle,
152
+ },
153
+ };
154
+
155
+ const metadata: Metadata = merge(defaultMetadata, properties);
156
+
157
+ if (image && metadata.openGraph) {
158
+ metadata.openGraph.images = [
159
+ {
160
+ alt: title,
161
+ height: OG_IMAGE_HEIGHT,
162
+ url: image,
163
+ width: OG_IMAGE_WIDTH,
164
+ },
165
+ ];
166
+ }
167
+
168
+ return metadata;
169
+ };
@@ -0,0 +1,322 @@
1
+ /**
2
+ * @fileoverview Structured Data Builders
3
+ *
4
+ * Shared utilities for creating Schema.org structured data (JSON-LD).
5
+ * This file contains type-safe builders for common schema types.
6
+ *
7
+ * Features:
8
+ * - Type-safe structured data creation
9
+ * - Schema.org validation
10
+ * - Common schema builders (Article, Product, Organization, etc.)
11
+ *
12
+ * @module @repo/seo/utils/structured-data-builders
13
+ */
14
+
15
+ import { logWarn } from '@repo/shared/logger';
16
+ import {
17
+ type Article,
18
+ type BreadcrumbList,
19
+ type FAQPage,
20
+ type Organization,
21
+ type Product,
22
+ type Thing,
23
+ type WebSite,
24
+ type WithContext,
25
+ } from 'schema-dts';
26
+
27
+ import { isProduction } from '../../env';
28
+
29
+ import { validateSchemaOrgDetailed } from './validation';
30
+
31
+ /**
32
+ * Common structured data types with better type safety
33
+ */
34
+ export type StructuredDataType =
35
+ | 'Article'
36
+ | 'BlogPosting'
37
+ | 'BreadcrumbList'
38
+ | 'Course'
39
+ | 'Event'
40
+ | 'FAQPage'
41
+ | 'HowTo'
42
+ | 'LocalBusiness'
43
+ | 'Organization'
44
+ | 'Person'
45
+ | 'Product'
46
+ | 'Recipe'
47
+ | 'SoftwareApplication'
48
+ | 'Thing'
49
+ | 'VideoObject'
50
+ | 'WebSite';
51
+
52
+ /**
53
+ * Helper function to create structured data with proper typing and validation
54
+ *
55
+ * Creates Schema.org compliant structured data with automatic validation in
56
+ * development mode. Validation errors are logged to console but don't throw
57
+ * to avoid breaking builds.
58
+ *
59
+ * @param type - Schema.org type (e.g., 'Article', 'Product', 'Organization')
60
+ * @param data - The data object (without @context and @type)
61
+ * @param options - Optional configuration
62
+ * @param options.validate - Force validation even in production (default: false)
63
+ * @returns WithContext<T> structured data ready for JSON-LD
64
+ * @throws Never throws - validation errors are logged only
65
+ *
66
+ * @example
67
+ * ```typescript
68
+ * const article = createStructuredData('Article', {
69
+ * headline: 'My Article',
70
+ * author: { '@type': 'Person', name: 'John Doe' },
71
+ * datePublished: '2024-01-01'
72
+ * });
73
+ * ```
74
+ */
75
+ export function createStructuredData<TType extends StructuredDataType, T extends Thing>(
76
+ type: TType,
77
+ data: Omit<T, '@context' | '@type'>,
78
+ options?: { validate?: boolean },
79
+ ): WithContext<T> {
80
+ const structured = {
81
+ '@context': 'https://schema.org',
82
+ '@type': type,
83
+ ...data,
84
+ } as unknown as WithContext<T>;
85
+
86
+ // Validation in development or when explicitly requested
87
+ const shouldValidate = options?.validate ?? !isProduction();
88
+
89
+ if (shouldValidate) {
90
+ const result = validateSchemaOrgDetailed(structured, type);
91
+ if (!result.valid) {
92
+ logWarn(`[SEO] Structured data validation failed for type '${type}'`, {
93
+ errors: result.errors.join(', '),
94
+ type,
95
+ });
96
+ }
97
+ }
98
+
99
+ return structured;
100
+ }
101
+
102
+ /**
103
+ * Pre-built structured data builders for common schema types.
104
+ * Each builder provides type-safe creation of Schema.org compliant JSON-LD.
105
+ *
106
+ * @example
107
+ * ```typescript
108
+ * // Article schema
109
+ * const article = structuredData.article({
110
+ * headline: 'My Article',
111
+ * author: 'John Doe',
112
+ * datePublished: '2024-01-01',
113
+ * publisher: { name: 'My Site', logo: '/logo.png' }
114
+ * });
115
+ *
116
+ * // Product schema
117
+ * const product = structuredData.product({
118
+ * name: 'Product Name',
119
+ * image: '/product.jpg',
120
+ * offers: {
121
+ * price: '99.99',
122
+ * priceCurrency: 'USD'
123
+ * }
124
+ * });
125
+ * ```
126
+ */
127
+ export const structuredData = {
128
+ /**
129
+ * Create an Article schema
130
+ * @param data - Article data
131
+ * @returns Article structured data
132
+ */
133
+ article: (data: {
134
+ author: string | { name: string; url?: string };
135
+ dateModified?: string;
136
+ datePublished: string;
137
+ description?: string;
138
+ headline: string;
139
+ image?: string | string[];
140
+ mainEntityOfPage?: string;
141
+ publisher: {
142
+ logo?: string;
143
+ name: string;
144
+ };
145
+ }): WithContext<Article> =>
146
+ createStructuredData<'Article', Article>('Article', {
147
+ author:
148
+ typeof data.author === 'string'
149
+ ? { '@type': 'Person', name: data.author }
150
+ : { '@type': 'Person', ...data.author },
151
+ dateModified: data.dateModified ?? data.datePublished,
152
+ datePublished: data.datePublished,
153
+ description: data.description,
154
+ headline: data.headline,
155
+ image: data.image,
156
+ mainEntityOfPage: data.mainEntityOfPage && {
157
+ '@id': data.mainEntityOfPage,
158
+ '@type': 'WebPage',
159
+ },
160
+ publisher: {
161
+ '@type': 'Organization',
162
+ name: data.publisher.name,
163
+ ...(data.publisher.logo && {
164
+ logo: {
165
+ '@type': 'ImageObject',
166
+ url: data.publisher.logo,
167
+ },
168
+ }),
169
+ },
170
+ }),
171
+
172
+ /**
173
+ * Create a BreadcrumbList schema
174
+ * @param items - Array of breadcrumb items
175
+ * @returns BreadcrumbList structured data
176
+ */
177
+ breadcrumbs: (items: { name: string; url: string }[]): WithContext<BreadcrumbList> =>
178
+ createStructuredData<'BreadcrumbList', BreadcrumbList>('BreadcrumbList', {
179
+ itemListElement: items.map((item, index) => ({
180
+ '@type': 'ListItem',
181
+ item: item.url,
182
+ name: item.name,
183
+ position: index + 1,
184
+ })),
185
+ }),
186
+
187
+ /**
188
+ * Create an FAQPage schema
189
+ * @param items - Array of question/answer pairs
190
+ * @returns FAQPage structured data
191
+ */
192
+ faq: (items: { answer: string; question: string }[]): WithContext<FAQPage> =>
193
+ createStructuredData<'FAQPage', FAQPage>('FAQPage', {
194
+ mainEntity: items.map(item => ({
195
+ '@type': 'Question',
196
+ acceptedAnswer: {
197
+ '@type': 'Answer',
198
+ text: item.answer,
199
+ },
200
+ name: item.question,
201
+ })),
202
+ }),
203
+
204
+ /**
205
+ * Create an Organization schema
206
+ * @param data - Organization data
207
+ * @returns Organization structured data
208
+ */
209
+ organization: (data: {
210
+ contactPoint?: {
211
+ areaServed?: string | string[];
212
+ availableLanguage?: string | string[];
213
+ contactType: string;
214
+ telephone: string;
215
+ };
216
+ description?: string;
217
+ logo?: string;
218
+ name: string;
219
+ sameAs?: string[];
220
+ url: string;
221
+ }): WithContext<Organization> =>
222
+ createStructuredData<'Organization', Organization>('Organization', {
223
+ description: data.description,
224
+ logo: data.logo,
225
+ name: data.name,
226
+ sameAs: data.sameAs,
227
+ url: data.url,
228
+ ...(data.contactPoint && {
229
+ contactPoint: {
230
+ '@type': 'ContactPoint',
231
+ ...data.contactPoint,
232
+ },
233
+ }),
234
+ }),
235
+
236
+ /**
237
+ * Create a Product schema
238
+ * @param data - Product data
239
+ * @returns Product structured data
240
+ */
241
+ product: (data: {
242
+ aggregateRating?: {
243
+ ratingValue: number;
244
+ reviewCount: number;
245
+ };
246
+ brand?: string;
247
+ description?: string;
248
+ image?: string | string[];
249
+ name: string;
250
+ offers?: {
251
+ availability?: string;
252
+ price: string;
253
+ priceCurrency: string;
254
+ seller?: string;
255
+ };
256
+ }): WithContext<Product> =>
257
+ createStructuredData<'Product', Product>('Product', {
258
+ description: data.description,
259
+ image: data.image,
260
+ name: data.name,
261
+ ...(data.brand && {
262
+ brand: {
263
+ '@type': 'Brand',
264
+ name: data.brand,
265
+ },
266
+ }),
267
+ ...(data.offers && {
268
+ offers: {
269
+ '@type': 'Offer',
270
+ availability: (data.offers.availability ?? 'https://schema.org/InStock') as any,
271
+ price: data.offers.price,
272
+ priceCurrency: data.offers.priceCurrency,
273
+ ...(data.offers.seller && {
274
+ seller: {
275
+ '@type': 'Organization',
276
+ name: data.offers.seller,
277
+ },
278
+ }),
279
+ },
280
+ }),
281
+ ...(data.aggregateRating && {
282
+ aggregateRating: {
283
+ '@type': 'AggregateRating',
284
+ ratingValue: data.aggregateRating.ratingValue,
285
+ reviewCount: data.aggregateRating.reviewCount,
286
+ },
287
+ }),
288
+ }),
289
+
290
+ /**
291
+ * Create a WebSite schema
292
+ * @param data - Website data
293
+ * @returns WebSite structured data
294
+ */
295
+ website: (data: {
296
+ description?: string;
297
+ name: string;
298
+ potentialAction?: {
299
+ queryInput: string;
300
+ target: string;
301
+ };
302
+ url: string;
303
+ }): WithContext<WebSite> =>
304
+ createStructuredData<'WebSite', WebSite>('WebSite', {
305
+ description: data.description,
306
+ name: data.name,
307
+ url: data.url,
308
+ ...(data.potentialAction && {
309
+ potentialAction: {
310
+ '@type': 'SearchAction',
311
+ 'query-input': `required name=${data.potentialAction.queryInput}` as any,
312
+ target: {
313
+ '@type': 'EntryPoint',
314
+ urlTemplate: data.potentialAction.target,
315
+ },
316
+ } as any,
317
+ }),
318
+ }),
319
+ };
320
+
321
+ // Re-export types for convenience
322
+ export type { Thing, WithContext } from 'schema-dts';