@xexr/blog-platform 1.0.7 → 1.0.9

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 (49) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +110 -29
  3. package/dist/app/blog/[slug]/page.d.ts +68 -0
  4. package/dist/app/blog/[slug]/page.tsx +175 -116
  5. package/dist/app/blog/authors/[slug]/page.test.ts +108 -0
  6. package/dist/app/blog/authors/[slug]/page.tsx +387 -0
  7. package/dist/app/blog/page.tsx +138 -113
  8. package/dist/components/ArticleReadingProgress.tsx +208 -0
  9. package/dist/components/ArticleTopActions.tsx +179 -0
  10. package/dist/components/AuthorByline.tsx +569 -0
  11. package/dist/components/AuthorPages.test.ts +87 -0
  12. package/dist/components/AuthorPages.tsx +129 -0
  13. package/dist/components/ExpandableArticleImage.tsx +79 -0
  14. package/dist/components/Pagination.tsx +236 -0
  15. package/dist/components/TopicArchivePages.tsx +204 -0
  16. package/dist/components/ui/badge.tsx +36 -0
  17. package/dist/components/ui/button.tsx +59 -0
  18. package/dist/components/ui/card.tsx +92 -0
  19. package/dist/components/ui/pagination.tsx +127 -0
  20. package/dist/components/ui/separator.tsx +29 -0
  21. package/dist/index.d.mts +174 -112
  22. package/dist/index.d.ts +174 -112
  23. package/dist/index.js +83 -8
  24. package/dist/index.js.map +1 -1
  25. package/dist/index.mjs +83 -8
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/lib/article-ui.ts +158 -0
  28. package/dist/lib/auth.ts +354 -0
  29. package/dist/lib/authors.ts +100 -0
  30. package/dist/lib/cors.ts +243 -0
  31. package/dist/lib/database.ts +277 -0
  32. package/dist/lib/init.ts +186 -0
  33. package/dist/lib/mdx.ts +73 -0
  34. package/dist/lib/metadata.ts +558 -0
  35. package/dist/lib/ratelimit.ts +224 -0
  36. package/dist/lib/security-headers.ts +322 -0
  37. package/dist/lib/seo.ts +295 -0
  38. package/dist/lib/sitemap.ts +738 -0
  39. package/dist/lib/storage/drizzle.ts +400 -0
  40. package/dist/lib/structured-data.ts +609 -0
  41. package/dist/lib/topics.ts +56 -0
  42. package/dist/lib/urls.ts +36 -0
  43. package/dist/lib/utils.ts +6 -0
  44. package/dist/lib/version.ts +154 -0
  45. package/dist/types/contracts.ts +259 -0
  46. package/dist/types/index.d.mts +20 -1
  47. package/dist/types/index.d.ts +20 -1
  48. package/dist/types/index.ts +16 -0
  49. package/package.json +14 -9
@@ -0,0 +1,73 @@
1
+ import remarkGfm from "remark-gfm";
2
+ import remarkToc from "remark-toc";
3
+ import rehypeSlug from "rehype-slug";
4
+
5
+ interface HastNode {
6
+ type?: string;
7
+ tagName?: string;
8
+ value?: string;
9
+ children?: HastNode[];
10
+ }
11
+
12
+ export const articleMdxOptions = {
13
+ mdxOptions: {
14
+ remarkPlugins: [remarkToc, remarkGfm],
15
+ rehypePlugins: [rehypeUnwrapStandaloneImages, rehypeSlug],
16
+ },
17
+ };
18
+
19
+ export function rehypeUnwrapStandaloneImages() {
20
+ return (tree: HastNode) => {
21
+ unwrapStandaloneImages(tree);
22
+ };
23
+ }
24
+
25
+ function unwrapStandaloneImages(node: HastNode) {
26
+ if (!node.children) {
27
+ return;
28
+ }
29
+
30
+ node.children = node.children.flatMap((child) => {
31
+ unwrapStandaloneImages(child);
32
+
33
+ if (isStandaloneImageParagraph(child)) {
34
+ return child.children?.filter(isMeaningfulNode) ?? [child];
35
+ }
36
+
37
+ return [child];
38
+ });
39
+ }
40
+
41
+ function isStandaloneImageParagraph(node: HastNode): boolean {
42
+ if (node.type !== "element" || node.tagName !== "p" || !node.children) {
43
+ return false;
44
+ }
45
+
46
+ const meaningfulChildren = node.children.filter(isMeaningfulNode);
47
+ return (
48
+ meaningfulChildren.length === 1 && isImageLikeNode(meaningfulChildren[0])
49
+ );
50
+ }
51
+
52
+ function isImageLikeNode(node: HastNode | undefined): boolean {
53
+ if (node?.type !== "element") {
54
+ return false;
55
+ }
56
+
57
+ if (node.tagName === "img") {
58
+ return true;
59
+ }
60
+
61
+ if (node.tagName !== "a" || !node.children) {
62
+ return false;
63
+ }
64
+
65
+ const meaningfulChildren = node.children.filter(isMeaningfulNode);
66
+ return (
67
+ meaningfulChildren.length === 1 && meaningfulChildren[0]?.tagName === "img"
68
+ );
69
+ }
70
+
71
+ function isMeaningfulNode(node: HastNode): boolean {
72
+ return node.type !== "text" || Boolean(node.value?.trim());
73
+ }
@@ -0,0 +1,558 @@
1
+ /**
2
+ * Metadata generation utilities for SEO meta tags, OpenGraph, and Twitter Cards.
3
+ *
4
+ * These utilities create comprehensive metadata for blog articles and pages,
5
+ * optimizing for search engines and social media sharing platforms.
6
+ */
7
+
8
+ import type { PublishedArticle, PublishedSite } from '../types/contracts';
9
+ import { generateCanonicalUrl, type CanonicalUrlOptions } from './seo';
10
+
11
+ /**
12
+ * Configuration options for metadata generation.
13
+ */
14
+ export interface MetadataOptions {
15
+ /** Canonical URL generation options */
16
+ canonical?: CanonicalUrlOptions;
17
+
18
+ /** OpenGraph configuration */
19
+ openGraph?: {
20
+ /** Site name for OpenGraph */
21
+ siteName?: string;
22
+ /** Default OpenGraph image URL */
23
+ defaultImage?: string;
24
+ /** OpenGraph locale (defaults to site locale) */
25
+ locale?: string;
26
+ /** OpenGraph type (defaults to 'article') */
27
+ type?: 'article' | 'website';
28
+ };
29
+
30
+ /** Twitter Card configuration */
31
+ twitter?: {
32
+ /** Twitter site handle (e.g., '@blogname') */
33
+ site?: string;
34
+ /** Twitter creator handle (e.g., '@authorname') */
35
+ creator?: string;
36
+ /** Default Twitter card type */
37
+ cardType?: 'summary' | 'summary_large_image' | 'app' | 'player';
38
+ };
39
+
40
+ /** Additional meta tag configuration */
41
+ meta?: {
42
+ /** Robots meta tag directive */
43
+ robots?: string;
44
+ /** Viewport meta tag content */
45
+ viewport?: string;
46
+ /** Theme color for mobile browsers */
47
+ themeColor?: string;
48
+ /** Application name */
49
+ applicationName?: string;
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Generated metadata for HTML head section.
55
+ */
56
+ export interface GeneratedMetadata {
57
+ /** Basic HTML meta tags */
58
+ meta: MetaTag[];
59
+
60
+ /** OpenGraph meta tags */
61
+ openGraph: MetaTag[];
62
+
63
+ /** Twitter Card meta tags */
64
+ twitter: MetaTag[];
65
+
66
+ /** Link tags (canonical, etc.) */
67
+ links: LinkTag[];
68
+
69
+ /** Title tag content */
70
+ title: string;
71
+
72
+ /** Meta description content */
73
+ description: string;
74
+
75
+ /** Canonical URL */
76
+ canonicalUrl: string;
77
+ }
78
+
79
+ /**
80
+ * HTML meta tag representation.
81
+ */
82
+ export interface MetaTag {
83
+ name?: string;
84
+ property?: string;
85
+ content: string;
86
+ httpEquiv?: string;
87
+ }
88
+
89
+ /**
90
+ * HTML link tag representation.
91
+ */
92
+ export interface LinkTag {
93
+ rel: string;
94
+ href: string;
95
+ type?: string;
96
+ title?: string;
97
+ }
98
+
99
+ /**
100
+ * Generates comprehensive metadata for an article page.
101
+ *
102
+ * @param article - The published article
103
+ * @param options - Metadata generation options
104
+ * @returns Complete metadata for HTML head section
105
+ *
106
+ * @example
107
+ * ```typescript
108
+ * const metadata = generateArticleMetadata(article, {
109
+ * openGraph: {
110
+ * siteName: 'My Blog',
111
+ * defaultImage: 'https://example.com/og-default.jpg'
112
+ * },
113
+ * twitter: {
114
+ * site: '@myblog',
115
+ * cardType: 'summary_large_image'
116
+ * }
117
+ * });
118
+ * ```
119
+ */
120
+ export function generateArticleMetadata(
121
+ article: PublishedArticle,
122
+ options: MetadataOptions = {}
123
+ ): GeneratedMetadata {
124
+ const canonicalUrl = generateCanonicalUrl(article, options.canonical);
125
+ const title = article.meta.title ?? article.title;
126
+ const description = article.meta.description ?? article.excerpt;
127
+ const siteName = options.openGraph?.siteName ?? article.site.name;
128
+
129
+ // Basic meta tags
130
+ const meta: MetaTag[] = [
131
+ { name: 'description', content: description },
132
+ { name: 'keywords', content: article.meta.keywords.join(', ') },
133
+ { name: 'author', content: article.author?.name ?? siteName },
134
+ { name: 'published_time', content: article.publishedAt },
135
+ { name: 'modified_time', content: article.updatedAt },
136
+ { name: 'article:reading_time', content: Math.ceil(article.readingSeconds / 60).toString() },
137
+ ];
138
+
139
+ // Add robots meta if specified
140
+ if (options.meta?.robots) {
141
+ meta.push({ name: 'robots', content: options.meta.robots });
142
+ }
143
+
144
+ // Add viewport meta if specified
145
+ if (options.meta?.viewport) {
146
+ meta.push({ name: 'viewport', content: options.meta.viewport });
147
+ }
148
+
149
+ // Add theme color if specified
150
+ if (options.meta?.themeColor) {
151
+ meta.push({ name: 'theme-color', content: options.meta.themeColor });
152
+ }
153
+
154
+ // Add application name if specified
155
+ if (options.meta?.applicationName) {
156
+ meta.push({ name: 'application-name', content: options.meta.applicationName });
157
+ }
158
+
159
+ // Generate OpenGraph metadata
160
+ const openGraph = generateOpenGraphMetadata(article, canonicalUrl, {
161
+ siteName,
162
+ defaultImage: options.openGraph?.defaultImage,
163
+ locale: options.openGraph?.locale ?? article.site.locale,
164
+ type: options.openGraph?.type ?? 'article',
165
+ });
166
+
167
+ // Generate Twitter Card metadata
168
+ const twitter = generateTwitterCardMetadata(article, {
169
+ site: options.twitter?.site,
170
+ creator: options.twitter?.creator,
171
+ cardType: options.twitter?.cardType ?? 'summary_large_image',
172
+ });
173
+
174
+ // Generate link tags
175
+ const links: LinkTag[] = [
176
+ { rel: 'canonical', href: canonicalUrl },
177
+ ];
178
+
179
+ return {
180
+ meta,
181
+ openGraph,
182
+ twitter,
183
+ links,
184
+ title,
185
+ description,
186
+ canonicalUrl,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Generates metadata for a blog home page or listing page.
192
+ *
193
+ * @param site - The published site information
194
+ * @param options - Metadata generation options
195
+ * @param pageInfo - Optional page-specific information
196
+ * @returns Complete metadata for HTML head section
197
+ *
198
+ * @example
199
+ * ```typescript
200
+ * const metadata = generateSiteMetadata(site, {
201
+ * openGraph: {
202
+ * defaultImage: 'https://example.com/og-home.jpg'
203
+ * }
204
+ * }, {
205
+ * title: 'Latest Articles',
206
+ * description: 'Browse our latest blog posts',
207
+ * path: '/blog'
208
+ * });
209
+ * ```
210
+ */
211
+ export function generateSiteMetadata(
212
+ site: PublishedSite,
213
+ options: MetadataOptions = {},
214
+ pageInfo?: {
215
+ title?: string;
216
+ description?: string;
217
+ path?: string;
218
+ pageNumber?: number;
219
+ }
220
+ ): GeneratedMetadata {
221
+ const path = pageInfo?.path ?? '/blog';
222
+ const domain = options.canonical?.domain ?? site.domain;
223
+ const canonicalUrl = `https://${domain}${path}`;
224
+
225
+ const title = pageInfo?.title
226
+ ? `${pageInfo.title} | ${site.name}`
227
+ : site.name;
228
+
229
+ const description = pageInfo?.description ?? site.description;
230
+ const siteName = options.openGraph?.siteName ?? site.name;
231
+
232
+ // Basic meta tags
233
+ const meta: MetaTag[] = [
234
+ { name: 'description', content: description },
235
+ { name: 'author', content: siteName },
236
+ ];
237
+
238
+ // Add page number for pagination
239
+ if (pageInfo?.pageNumber && pageInfo.pageNumber > 1) {
240
+ meta.push({ name: 'page', content: pageInfo.pageNumber.toString() });
241
+ }
242
+
243
+ // Add robots meta if specified
244
+ if (options.meta?.robots) {
245
+ meta.push({ name: 'robots', content: options.meta.robots });
246
+ }
247
+
248
+ // Add viewport meta if specified
249
+ if (options.meta?.viewport) {
250
+ meta.push({ name: 'viewport', content: options.meta.viewport });
251
+ }
252
+
253
+ // Add theme color if specified
254
+ if (options.meta?.themeColor) {
255
+ meta.push({ name: 'theme-color', content: options.meta.themeColor });
256
+ }
257
+
258
+ // Add application name if specified
259
+ if (options.meta?.applicationName) {
260
+ meta.push({ name: 'application-name', content: options.meta.applicationName });
261
+ }
262
+
263
+ // Generate OpenGraph metadata for site
264
+ const openGraph: MetaTag[] = [
265
+ { property: 'og:title', content: title },
266
+ { property: 'og:description', content: description },
267
+ { property: 'og:url', content: canonicalUrl },
268
+ { property: 'og:site_name', content: siteName },
269
+ { property: 'og:locale', content: options.openGraph?.locale ?? site.locale },
270
+ { property: 'og:type', content: options.openGraph?.type ?? 'website' },
271
+ ];
272
+
273
+ // Add default OpenGraph image if provided
274
+ if (options.openGraph?.defaultImage) {
275
+ openGraph.push(
276
+ { property: 'og:image', content: options.openGraph.defaultImage },
277
+ { property: 'og:image:alt', content: `${siteName} - ${site.tagline}` }
278
+ );
279
+ }
280
+
281
+ // Generate Twitter Card metadata
282
+ const twitter = generateTwitterCardMetadata(
283
+ null, // No article for site pages
284
+ {
285
+ site: options.twitter?.site,
286
+ creator: options.twitter?.creator,
287
+ cardType: options.twitter?.cardType ?? 'summary',
288
+ },
289
+ {
290
+ title,
291
+ description,
292
+ image: options.openGraph?.defaultImage,
293
+ }
294
+ );
295
+
296
+ // Generate link tags
297
+ const links: LinkTag[] = [
298
+ { rel: 'canonical', href: canonicalUrl },
299
+ ];
300
+
301
+ return {
302
+ meta,
303
+ openGraph,
304
+ twitter,
305
+ links,
306
+ title,
307
+ description,
308
+ canonicalUrl,
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Generates OpenGraph metadata for an article.
314
+ */
315
+ function generateOpenGraphMetadata(
316
+ article: PublishedArticle,
317
+ canonicalUrl: string,
318
+ options: {
319
+ siteName: string;
320
+ defaultImage?: string;
321
+ locale: string;
322
+ type: string;
323
+ }
324
+ ): MetaTag[] {
325
+ const title = article.meta.title ?? article.title;
326
+ const description = article.meta.description ?? article.excerpt;
327
+ const image = article.meta.ogImage ?? article.featuredImage ?? options.defaultImage;
328
+
329
+ const openGraph: MetaTag[] = [
330
+ { property: 'og:title', content: title },
331
+ { property: 'og:description', content: description },
332
+ { property: 'og:url', content: canonicalUrl },
333
+ { property: 'og:site_name', content: options.siteName },
334
+ { property: 'og:locale', content: options.locale },
335
+ { property: 'og:type', content: options.type },
336
+ { property: 'article:published_time', content: article.publishedAt },
337
+ { property: 'article:modified_time', content: article.updatedAt },
338
+ ];
339
+
340
+ // Add image if available
341
+ if (image) {
342
+ const imageAlt = article.featuredImageAlt ?? `${article.title} - ${options.siteName}`;
343
+ openGraph.push(
344
+ { property: 'og:image', content: image },
345
+ { property: 'og:image:alt', content: imageAlt }
346
+ );
347
+ }
348
+
349
+ // Add author information
350
+ if (article.author) {
351
+ openGraph.push({ property: 'article:author', content: article.author.name });
352
+
353
+ // Add author profile URL if we can construct one
354
+ if (article.author.slug) {
355
+ const authorUrl = canonicalUrl.replace(/\/[^/]+$/, `/authors/${article.author.slug}`);
356
+ openGraph.push({ property: 'article:author:url', content: authorUrl });
357
+ }
358
+ }
359
+
360
+ // Add article tags from keywords
361
+ article.meta.keywords.forEach(keyword => {
362
+ openGraph.push({ property: 'article:tag', content: keyword });
363
+ });
364
+
365
+ // Add reading time
366
+ const readingMinutes = Math.ceil(article.readingSeconds / 60);
367
+ openGraph.push({ property: 'article:reading_time', content: readingMinutes.toString() });
368
+
369
+ return openGraph;
370
+ }
371
+
372
+ /**
373
+ * Generates Twitter Card metadata.
374
+ */
375
+ function generateTwitterCardMetadata(
376
+ article: PublishedArticle | null,
377
+ options: {
378
+ site?: string;
379
+ creator?: string;
380
+ cardType: string;
381
+ },
382
+ overrides?: {
383
+ title?: string;
384
+ description?: string;
385
+ image?: string;
386
+ }
387
+ ): MetaTag[] {
388
+ const title = overrides?.title ?? article?.meta.title ?? article?.title ?? '';
389
+ const description = overrides?.description ?? article?.meta.description ?? article?.excerpt ?? '';
390
+ const image = overrides?.image ?? article?.meta.ogImage ?? article?.featuredImage;
391
+
392
+ const twitter: MetaTag[] = [
393
+ { name: 'twitter:card', content: options.cardType },
394
+ { name: 'twitter:title', content: title },
395
+ { name: 'twitter:description', content: description },
396
+ ];
397
+
398
+ // Add site handle if provided
399
+ if (options.site) {
400
+ twitter.push({ name: 'twitter:site', content: options.site });
401
+ }
402
+
403
+ // Add creator handle if provided (for articles) or from options
404
+ if (article?.author && options.creator) {
405
+ twitter.push({ name: 'twitter:creator', content: options.creator });
406
+ } else if (options.creator) {
407
+ twitter.push({ name: 'twitter:creator', content: options.creator });
408
+ }
409
+
410
+ // Add image if available
411
+ if (image) {
412
+ twitter.push({ name: 'twitter:image', content: image });
413
+
414
+ if (article?.featuredImageAlt) {
415
+ twitter.push({ name: 'twitter:image:alt', content: article.featuredImageAlt });
416
+ }
417
+ }
418
+
419
+ return twitter;
420
+ }
421
+
422
+ /**
423
+ * Converts metadata to HTML string for server-side rendering.
424
+ *
425
+ * @param metadata - Generated metadata object
426
+ * @returns HTML string for head section
427
+ *
428
+ * @example
429
+ * ```typescript
430
+ * const metadata = generateArticleMetadata(article);
431
+ * const htmlHead = metadataToHtml(metadata);
432
+ * // Use in server-side rendering
433
+ * ```
434
+ */
435
+ export function metadataToHtml(metadata: GeneratedMetadata): string {
436
+ const elements: string[] = [];
437
+
438
+ // Title
439
+ elements.push(`<title>${escapeHtml(metadata.title)}</title>`);
440
+
441
+ // Meta tags
442
+ metadata.meta.forEach(tag => {
443
+ const attrs: string[] = [];
444
+
445
+ if (tag.name) attrs.push(`name="${escapeHtml(tag.name)}"`);
446
+ if (tag.property) attrs.push(`property="${escapeHtml(tag.property)}"`);
447
+ if (tag.httpEquiv) attrs.push(`http-equiv="${escapeHtml(tag.httpEquiv)}"`);
448
+ attrs.push(`content="${escapeHtml(tag.content)}"`);
449
+
450
+ elements.push(`<meta ${attrs.join(' ')} />`);
451
+ });
452
+
453
+ // OpenGraph tags
454
+ metadata.openGraph.forEach(tag => {
455
+ elements.push(`<meta property="${escapeHtml(tag.property!)}" content="${escapeHtml(tag.content)}" />`);
456
+ });
457
+
458
+ // Twitter tags
459
+ metadata.twitter.forEach(tag => {
460
+ elements.push(`<meta name="${escapeHtml(tag.name!)}" content="${escapeHtml(tag.content)}" />`);
461
+ });
462
+
463
+ // Link tags
464
+ metadata.links.forEach(link => {
465
+ const attrs: string[] = [];
466
+ attrs.push(`rel="${escapeHtml(link.rel)}"`);
467
+ attrs.push(`href="${escapeHtml(link.href)}"`);
468
+ if (link.type) attrs.push(`type="${escapeHtml(link.type)}"`);
469
+ if (link.title) attrs.push(`title="${escapeHtml(link.title)}"`);
470
+
471
+ elements.push(`<link ${attrs.join(' ')} />`);
472
+ });
473
+
474
+ return elements.join('\n');
475
+ }
476
+
477
+ /**
478
+ * Escapes HTML special characters for safe inclusion in HTML attributes.
479
+ */
480
+ function escapeHtml(text: string): string {
481
+ const htmlEscapes: Record<string, string> = {
482
+ '&': '&amp;',
483
+ '<': '&lt;',
484
+ '>': '&gt;',
485
+ '"': '&quot;',
486
+ "'": '&#x27;',
487
+ };
488
+
489
+ return text.replace(/[&<>"']/g, char => htmlEscapes[char] ?? char);
490
+ }
491
+
492
+ /**
493
+ * Validates that metadata meets SEO best practices.
494
+ *
495
+ * @param metadata - Generated metadata to validate
496
+ * @returns Validation results with warnings and errors
497
+ *
498
+ * @example
499
+ * ```typescript
500
+ * const metadata = generateArticleMetadata(article);
501
+ * const validation = validateMetadata(metadata);
502
+ *
503
+ * if (validation.errors.length > 0) {
504
+ * console.error('Metadata errors:', validation.errors);
505
+ * }
506
+ * ```
507
+ */
508
+ export function validateMetadata(metadata: GeneratedMetadata): {
509
+ valid: boolean;
510
+ errors: string[];
511
+ warnings: string[];
512
+ } {
513
+ const errors: string[] = [];
514
+ const warnings: string[] = [];
515
+
516
+ // Title validation
517
+ if (!metadata.title) {
518
+ errors.push('Title is required');
519
+ } else if (metadata.title.length > 60) {
520
+ warnings.push('Title longer than 60 characters may be truncated in search results');
521
+ } else if (metadata.title.length < 30) {
522
+ warnings.push('Title shorter than 30 characters may not be descriptive enough');
523
+ }
524
+
525
+ // Description validation
526
+ if (!metadata.description) {
527
+ errors.push('Meta description is required');
528
+ } else if (metadata.description.length > 160) {
529
+ warnings.push('Meta description longer than 160 characters may be truncated');
530
+ } else if (metadata.description.length < 120) {
531
+ warnings.push('Meta description shorter than 120 characters may not be descriptive enough');
532
+ }
533
+
534
+ // Canonical URL validation
535
+ if (!metadata.canonicalUrl) {
536
+ errors.push('Canonical URL is required');
537
+ } else if (!metadata.canonicalUrl.startsWith('https://')) {
538
+ warnings.push('Canonical URL should use HTTPS for better SEO');
539
+ }
540
+
541
+ // OpenGraph validation
542
+ const ogImage = metadata.openGraph.find(tag => tag.property === 'og:image');
543
+ if (!ogImage) {
544
+ warnings.push('OpenGraph image missing - social media sharing will not show image preview');
545
+ }
546
+
547
+ // Twitter Card validation
548
+ const twitterCard = metadata.twitter.find(tag => tag.name === 'twitter:card');
549
+ if (!twitterCard) {
550
+ warnings.push('Twitter Card type missing - Twitter sharing may not display properly');
551
+ }
552
+
553
+ return {
554
+ valid: errors.length === 0,
555
+ errors,
556
+ warnings,
557
+ };
558
+ }