@xexr/blog-platform 1.0.6 → 1.0.8
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/CHANGELOG.md +7 -0
- package/dist/app/blog/[slug]/page.d.ts +68 -0
- package/dist/app/blog/[slug]/page.tsx +224 -98
- package/dist/app/blog/page.tsx +83 -2
- package/dist/components/ArticleReadingProgress.tsx +208 -0
- package/dist/components/ArticleTopActions.tsx +179 -0
- package/dist/components/AuthorByline.tsx +569 -0
- package/dist/components/ExpandableArticleImage.tsx +79 -0
- package/dist/components/Pagination.tsx +236 -0
- package/dist/components/TopicArchivePages.tsx +204 -0
- package/dist/components/ui/badge.tsx +36 -0
- package/dist/components/ui/button.tsx +59 -0
- package/dist/components/ui/card.tsx +92 -0
- package/dist/components/ui/pagination.tsx +127 -0
- package/dist/components/ui/separator.tsx +29 -0
- package/dist/index.d.mts +88 -71
- package/dist/index.d.ts +88 -71
- package/dist/index.js +83 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +83 -8
- package/dist/index.mjs.map +1 -1
- package/dist/lib/article-ui.ts +158 -0
- package/dist/lib/auth.ts +354 -0
- package/dist/lib/cors.ts +243 -0
- package/dist/lib/database.ts +277 -0
- package/dist/lib/init.ts +186 -0
- package/dist/lib/mdx.ts +73 -0
- package/dist/lib/metadata.ts +558 -0
- package/dist/lib/ratelimit.ts +224 -0
- package/dist/lib/security-headers.ts +322 -0
- package/dist/lib/seo.ts +295 -0
- package/dist/lib/sitemap.ts +719 -0
- package/dist/lib/storage/drizzle.ts +400 -0
- package/dist/lib/structured-data.ts +608 -0
- package/dist/lib/topics.ts +56 -0
- package/dist/lib/urls.ts +29 -0
- package/dist/lib/utils.ts +6 -0
- package/dist/lib/version.ts +154 -0
- package/dist/types/contracts.ts +256 -0
- package/dist/types/index.d.mts +37 -1
- package/dist/types/index.d.ts +37 -1
- package/dist/types/index.ts +16 -0
- package/package.json +14 -9
package/dist/lib/mdx.ts
ADDED
|
@@ -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
|
+
'&': '&',
|
|
483
|
+
'<': '<',
|
|
484
|
+
'>': '>',
|
|
485
|
+
'"': '"',
|
|
486
|
+
"'": ''',
|
|
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
|
+
}
|