@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/seo.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO utilities for canonical URL generation and validation.
|
|
3
|
+
*
|
|
4
|
+
* These utilities ensure proper canonical URL structure for SEO optimization,
|
|
5
|
+
* preventing duplicate content issues and improving search engine indexing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PublishedArticle, PublishedSite } from '../types/contracts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Configuration options for canonical URL generation.
|
|
12
|
+
*/
|
|
13
|
+
export interface CanonicalUrlOptions {
|
|
14
|
+
/** Whether to force HTTPS protocol (default: true) */
|
|
15
|
+
forceHttps?: boolean;
|
|
16
|
+
|
|
17
|
+
/** Whether to remove trailing slashes (default: true) */
|
|
18
|
+
removeTrailingSlash?: boolean;
|
|
19
|
+
|
|
20
|
+
/** Custom domain override (uses site.domain if not provided) */
|
|
21
|
+
domain?: string;
|
|
22
|
+
|
|
23
|
+
/** Custom path prefix (default: '/blog') */
|
|
24
|
+
pathPrefix?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalizes a domain by ensuring proper protocol and removing trailing slashes.
|
|
29
|
+
*
|
|
30
|
+
* @param domain - The domain to normalize
|
|
31
|
+
* @param forceHttps - Whether to force HTTPS protocol
|
|
32
|
+
* @returns The normalized domain
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* normalizeDomain('example.com') // 'https://example.com'
|
|
37
|
+
* normalizeDomain('http://example.com/') // 'https://example.com'
|
|
38
|
+
* normalizeDomain('https://example.com', false) // 'https://example.com'
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeDomain(domain: string, forceHttps = true): string {
|
|
42
|
+
let normalized = domain.trim();
|
|
43
|
+
|
|
44
|
+
// Remove trailing slashes
|
|
45
|
+
normalized = normalized.replace(/\/+$/, '');
|
|
46
|
+
|
|
47
|
+
// Add protocol if missing
|
|
48
|
+
if (!/^https?:\/\//.exec(normalized)) {
|
|
49
|
+
normalized = `${forceHttps ? 'https' : 'http'}://${normalized}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Force HTTPS if requested
|
|
53
|
+
if (forceHttps && normalized.startsWith('http://')) {
|
|
54
|
+
normalized = normalized.replace('http://', 'https://');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return normalized;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validates and normalizes a slug for URL usage.
|
|
62
|
+
*
|
|
63
|
+
* @param slug - The slug to validate and normalize
|
|
64
|
+
* @returns The normalized slug
|
|
65
|
+
* @throws Error if slug is invalid
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```typescript
|
|
69
|
+
* normalizeSlug('hello-world') // 'hello-world'
|
|
70
|
+
* normalizeSlug('Hello World!') // throws Error
|
|
71
|
+
* normalizeSlug('hello--world') // 'hello-world'
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function normalizeSlug(slug: string): string {
|
|
75
|
+
const trimmed = slug.trim();
|
|
76
|
+
|
|
77
|
+
if (!trimmed) {
|
|
78
|
+
throw new Error('Slug cannot be empty');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check if slug contains only valid characters
|
|
82
|
+
if (!/^[a-z0-9-]+$/.test(trimmed)) {
|
|
83
|
+
throw new Error('Slug can only contain lowercase letters, numbers, and hyphens');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Remove multiple consecutive hyphens
|
|
87
|
+
const normalized = trimmed.replace(/-+/g, '-');
|
|
88
|
+
|
|
89
|
+
// Remove leading/trailing hyphens
|
|
90
|
+
const clean = normalized.replace(/^-+|-+$/g, '');
|
|
91
|
+
|
|
92
|
+
if (!clean) {
|
|
93
|
+
throw new Error('Slug cannot consist only of hyphens');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return clean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Generates a canonical URL for an article.
|
|
101
|
+
*
|
|
102
|
+
* @param article - The published article
|
|
103
|
+
* @param options - Configuration options for URL generation
|
|
104
|
+
* @returns The canonical URL
|
|
105
|
+
* @throws Error if article or site data is invalid
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```typescript
|
|
109
|
+
* const article = {
|
|
110
|
+
* slug: 'hello-world',
|
|
111
|
+
* site: { domain: 'example.com' },
|
|
112
|
+
* // ... other fields
|
|
113
|
+
* };
|
|
114
|
+
*
|
|
115
|
+
* generateCanonicalUrl(article)
|
|
116
|
+
* // 'https://example.com/blog/hello-world'
|
|
117
|
+
*
|
|
118
|
+
* generateCanonicalUrl(article, { pathPrefix: '/posts' })
|
|
119
|
+
* // 'https://example.com/posts/hello-world'
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export function generateCanonicalUrl(
|
|
123
|
+
article: PublishedArticle,
|
|
124
|
+
options: CanonicalUrlOptions = {}
|
|
125
|
+
): string {
|
|
126
|
+
const {
|
|
127
|
+
forceHttps = true,
|
|
128
|
+
removeTrailingSlash = true,
|
|
129
|
+
domain = article.site.domain,
|
|
130
|
+
pathPrefix = '/blog'
|
|
131
|
+
} = options;
|
|
132
|
+
|
|
133
|
+
if (!domain) {
|
|
134
|
+
throw new Error('Domain is required for canonical URL generation');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!article.slug) {
|
|
138
|
+
throw new Error('Article slug is required for canonical URL generation');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Validate and normalize inputs
|
|
142
|
+
const normalizedDomain = normalizeDomain(domain, forceHttps);
|
|
143
|
+
const normalizedSlug = normalizeSlug(article.slug);
|
|
144
|
+
|
|
145
|
+
// Ensure path prefix starts with '/' and doesn't end with '/'
|
|
146
|
+
const cleanPrefix = pathPrefix
|
|
147
|
+
.replace(/^(?!\/)/, '/') // Add leading slash if missing
|
|
148
|
+
.replace(/\/+$/, ''); // Remove trailing slashes
|
|
149
|
+
|
|
150
|
+
// Construct the canonical URL
|
|
151
|
+
let canonicalUrl = `${normalizedDomain}${cleanPrefix}/${normalizedSlug}`;
|
|
152
|
+
|
|
153
|
+
// Remove trailing slash if requested
|
|
154
|
+
if (removeTrailingSlash) {
|
|
155
|
+
canonicalUrl = canonicalUrl.replace(/\/+$/, '');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return canonicalUrl;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Generates a canonical URL for an article from site and slug data.
|
|
163
|
+
* Useful when you don't have a full PublishedArticle object.
|
|
164
|
+
*
|
|
165
|
+
* @param site - The published site
|
|
166
|
+
* @param slug - The article slug
|
|
167
|
+
* @param options - Configuration options for URL generation
|
|
168
|
+
* @returns The canonical URL
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* ```typescript
|
|
172
|
+
* const site = { domain: 'example.com', ... };
|
|
173
|
+
* generateCanonicalUrlFromSlug(site, 'hello-world')
|
|
174
|
+
* // 'https://example.com/blog/hello-world'
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
export function generateCanonicalUrlFromSlug(
|
|
178
|
+
site: PublishedSite,
|
|
179
|
+
slug: string,
|
|
180
|
+
options: CanonicalUrlOptions = {}
|
|
181
|
+
): string {
|
|
182
|
+
return generateCanonicalUrl(
|
|
183
|
+
{
|
|
184
|
+
slug,
|
|
185
|
+
site,
|
|
186
|
+
// Minimal required fields for canonical URL generation
|
|
187
|
+
id: '',
|
|
188
|
+
title: '',
|
|
189
|
+
excerpt: '',
|
|
190
|
+
content: '',
|
|
191
|
+
readingSeconds: 0,
|
|
192
|
+
wordCount: 0,
|
|
193
|
+
publishedAt: '',
|
|
194
|
+
updatedAt: '',
|
|
195
|
+
author: null,
|
|
196
|
+
meta: { keywords: [] },
|
|
197
|
+
version: 1
|
|
198
|
+
},
|
|
199
|
+
options
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Validates if a URL is a valid canonical URL format.
|
|
205
|
+
*
|
|
206
|
+
* @param url - The URL to validate
|
|
207
|
+
* @returns True if the URL is valid for canonical use
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* ```typescript
|
|
211
|
+
* isValidCanonicalUrl('https://example.com/blog/hello-world') // true
|
|
212
|
+
* isValidCanonicalUrl('http://example.com/blog/hello-world') // true
|
|
213
|
+
* isValidCanonicalUrl('example.com/blog/hello-world') // false (missing protocol)
|
|
214
|
+
* isValidCanonicalUrl('https://example.com/blog/hello world') // false (spaces)
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
export function isValidCanonicalUrl(url: string): boolean {
|
|
218
|
+
try {
|
|
219
|
+
const parsed = new URL(url);
|
|
220
|
+
|
|
221
|
+
// Must be HTTP or HTTPS
|
|
222
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Must have a valid hostname (not empty or just slashes)
|
|
227
|
+
if (!parsed.hostname || parsed.hostname.length === 0) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Check for URLs with empty hostname like 'https:///path'
|
|
232
|
+
if (/^https?:\/\/\//.exec(url) && !/^https?:\/\/[^\/]/.exec(url)) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Path should not contain unencoded spaces or other problematic characters
|
|
237
|
+
// Check the original URL string since URL constructor auto-encodes
|
|
238
|
+
if (url.includes(' ')) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return true;
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Extracts the base URL (protocol + hostname + port) from a canonical URL.
|
|
250
|
+
*
|
|
251
|
+
* @param canonicalUrl - The canonical URL
|
|
252
|
+
* @returns The base URL
|
|
253
|
+
*
|
|
254
|
+
* @example
|
|
255
|
+
* ```typescript
|
|
256
|
+
* getBaseUrl('https://example.com/blog/hello-world') // 'https://example.com'
|
|
257
|
+
* getBaseUrl('https://blog.example.com:8080/posts/test') // 'https://blog.example.com:8080'
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
export function getBaseUrl(canonicalUrl: string): string {
|
|
261
|
+
try {
|
|
262
|
+
const parsed = new URL(canonicalUrl);
|
|
263
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
264
|
+
} catch {
|
|
265
|
+
throw new Error('Invalid canonical URL provided');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Updates an article's canonical URL in its meta field.
|
|
271
|
+
*
|
|
272
|
+
* @param article - The article to update
|
|
273
|
+
* @param options - Configuration options for URL generation
|
|
274
|
+
* @returns A new article object with updated canonical URL
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* ```typescript
|
|
278
|
+
* const updatedArticle = updateArticleCanonicalUrl(article);
|
|
279
|
+
* console.log(updatedArticle.meta.canonicalUrl); // Generated canonical URL
|
|
280
|
+
* ```
|
|
281
|
+
*/
|
|
282
|
+
export function updateArticleCanonicalUrl(
|
|
283
|
+
article: PublishedArticle,
|
|
284
|
+
options: CanonicalUrlOptions = {}
|
|
285
|
+
): PublishedArticle {
|
|
286
|
+
const canonicalUrl = generateCanonicalUrl(article, options);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
...article,
|
|
290
|
+
meta: {
|
|
291
|
+
...article.meta,
|
|
292
|
+
canonicalUrl
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|