@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,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
+ }