@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,738 @@
1
+ /**
2
+ * Next.js-compatible sitemap generation utilities for blog platform integration.
3
+ *
4
+ * These utilities generate sitemap entries that follow Next.js MetadataRoute.Sitemap
5
+ * conventions, allowing seamless integration with existing Next.js applications.
6
+ *
7
+ * @example Integration with existing Next.js sitemap:
8
+ * ```typescript
9
+ * // app/sitemap.ts
10
+ * import { generateBlogSitemapEntries } from '@xexr/blog-platform';
11
+ * import type { MetadataRoute } from 'next';
12
+ *
13
+ * export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
14
+ * // Your existing routes
15
+ * const staticRoutes = [
16
+ * { url: 'https://yoursite.com', lastModified: new Date(), priority: 1 },
17
+ * { url: 'https://yoursite.com/about', lastModified: new Date(), priority: 0.8 }
18
+ * ];
19
+ *
20
+ * // Blog platform entries
21
+ * const blogEntries = await generateBlogSitemapEntries(blogConfig);
22
+ *
23
+ * return [...staticRoutes, ...blogEntries];
24
+ * }
25
+ * ```
26
+ */
27
+
28
+ import type {
29
+ PublishedArticle,
30
+ PublishedSite,
31
+ PublishedAuthor,
32
+ } from "../types/contracts";
33
+ import {
34
+ generateCanonicalUrl,
35
+ normalizeDomain,
36
+ type CanonicalUrlOptions,
37
+ } from "./seo";
38
+ import { buildAuthorPath } from "./urls";
39
+
40
+ /**
41
+ * Configuration options for sitemap generation.
42
+ */
43
+ export interface SitemapOptions {
44
+ /** Canonical URL configuration */
45
+ canonical?: CanonicalUrlOptions;
46
+
47
+ /** Whether to include author pages in sitemap */
48
+ includeAuthorPages?: boolean;
49
+
50
+ /** Whether to include pagination pages in sitemap */
51
+ includePaginationPages?: boolean;
52
+
53
+ /** Number of articles per page for pagination calculation */
54
+ articlesPerPage?: number;
55
+
56
+ /** Maximum number of sitemap entries (defaults to 50,000 per Google guidelines) */
57
+ maxEntries?: number;
58
+
59
+ /** Change frequency for different page types */
60
+ changeFrequency?: {
61
+ /** Change frequency for blog home page */
62
+ homePage?: SitemapChangeFreq;
63
+ /** Change frequency for article pages */
64
+ articles?: SitemapChangeFreq;
65
+ /** Change frequency for author pages */
66
+ authorPages?: SitemapChangeFreq;
67
+ /** Change frequency for pagination pages */
68
+ paginationPages?: SitemapChangeFreq;
69
+ };
70
+
71
+ /** Priority values for different page types */
72
+ priority?: {
73
+ /** Priority for blog home page (0.0-1.0) */
74
+ homePage?: number;
75
+ /** Priority for article pages (0.0-1.0) */
76
+ articles?: number;
77
+ /** Priority for author pages (0.0-1.0) */
78
+ authorPages?: number;
79
+ /** Priority for pagination pages (0.0-1.0) */
80
+ paginationPages?: number;
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Valid sitemap change frequency values as per XML sitemap standard.
86
+ */
87
+ export type SitemapChangeFreq =
88
+ | "always"
89
+ | "hourly"
90
+ | "daily"
91
+ | "weekly"
92
+ | "monthly"
93
+ | "yearly"
94
+ | "never";
95
+
96
+ /**
97
+ * Next.js MetadataRoute.Sitemap compatible entry.
98
+ * This matches the expected format for Next.js sitemap generation.
99
+ */
100
+ export interface NextjsSitemapEntry {
101
+ /** The URL of the page */
102
+ url: string;
103
+
104
+ /** Last modification date */
105
+ lastModified?: string | Date;
106
+
107
+ /** Change frequency hint for search engines */
108
+ changeFrequency?: SitemapChangeFreq;
109
+
110
+ /** Priority relative to other URLs on the site (0.0-1.0) */
111
+ priority?: number;
112
+
113
+ /** Alternate language versions (for i18n) */
114
+ alternates?: {
115
+ languages?: Record<string, string>;
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Legacy XML sitemap entry format (for backward compatibility).
121
+ * @deprecated Use NextjsSitemapEntry for new implementations
122
+ */
123
+ export interface SitemapEntry {
124
+ /** The URL of the page */
125
+ loc: string;
126
+
127
+ /** Last modification date as ISO 8601 string */
128
+ lastmod?: string;
129
+
130
+ /** Change frequency hint for search engines */
131
+ changefreq?: SitemapChangeFreq;
132
+
133
+ /** Priority relative to other URLs on the site (0.0-1.0) */
134
+ priority?: number;
135
+ }
136
+
137
+ /**
138
+ * Complete sitemap data structure.
139
+ */
140
+ export interface SitemapData {
141
+ /** All URL entries in the sitemap */
142
+ entries: SitemapEntry[];
143
+
144
+ /** Total number of entries */
145
+ totalEntries: number;
146
+
147
+ /** Whether the sitemap was truncated due to size limits */
148
+ truncated: boolean;
149
+
150
+ /** Generation timestamp */
151
+ generatedAt: string;
152
+
153
+ /** Base URL used for canonical URL generation */
154
+ baseUrl: string;
155
+ }
156
+
157
+ /**
158
+ * Generates Next.js-compatible sitemap entries for blog platform integration.
159
+ *
160
+ * @param site - The published site information
161
+ * @param articles - Array of published articles
162
+ * @param authors - Array of published authors (optional)
163
+ * @param options - Sitemap generation options
164
+ * @returns Array of Next.js MetadataRoute.Sitemap compatible entries
165
+ *
166
+ * @example
167
+ * ```typescript
168
+ * // In your Next.js app/sitemap.ts file:
169
+ * import { generateBlogSitemapEntries } from '@xexr/blog-platform';
170
+ * import type { MetadataRoute } from 'next';
171
+ *
172
+ * export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
173
+ * const blogEntries = await generateBlogSitemapEntries(site, articles, authors, {
174
+ * includeAuthorPages: true,
175
+ * includePaginationPages: true,
176
+ * changeFrequency: { articles: 'monthly' }
177
+ * });
178
+ *
179
+ * return [
180
+ * // Your existing site routes
181
+ * { url: 'https://yoursite.com', lastModified: new Date(), priority: 1 },
182
+ * { url: 'https://yoursite.com/about', lastModified: new Date() },
183
+ * // Blog entries
184
+ * ...blogEntries
185
+ * ];
186
+ * }
187
+ * ```
188
+ */
189
+ export async function generateBlogSitemapEntries(
190
+ site: PublishedSite,
191
+ articles: readonly PublishedArticle[],
192
+ authors: readonly PublishedAuthor[] = [],
193
+ options: SitemapOptions = {},
194
+ ): Promise<NextjsSitemapEntry[]> {
195
+ const {
196
+ includeAuthorPages = true,
197
+ includePaginationPages = true,
198
+ articlesPerPage = 10,
199
+ maxEntries = 50000,
200
+ changeFrequency = {},
201
+ priority = {},
202
+ } = options;
203
+
204
+ const entries: NextjsSitemapEntry[] = [];
205
+ const baseUrl = normalizeDomain(
206
+ options.canonical?.domain ?? site.domain,
207
+ options.canonical?.forceHttps ?? true,
208
+ );
209
+
210
+ const pathPrefix = options.canonical?.pathPrefix ?? "/blog";
211
+
212
+ // Add blog home page
213
+ entries.push({
214
+ url: `${baseUrl}${pathPrefix}`,
215
+ lastModified: getLatestModificationDate(articles),
216
+ changeFrequency: changeFrequency.homePage ?? "daily",
217
+ priority: priority.homePage ?? 1.0,
218
+ });
219
+
220
+ // Add individual article pages
221
+ articles.forEach((article) => {
222
+ if (entries.length >= maxEntries) return;
223
+
224
+ const articleUrl = generateCanonicalUrl(article, options.canonical);
225
+ entries.push({
226
+ url: articleUrl,
227
+ lastModified: article.updatedAt,
228
+ changeFrequency: changeFrequency.articles ?? "monthly",
229
+ priority: priority.articles ?? 0.8,
230
+ });
231
+ });
232
+
233
+ // Add author pages if enabled
234
+ if (includeAuthorPages) {
235
+ authors.forEach((author) => {
236
+ if (entries.length >= maxEntries) return;
237
+
238
+ const authorUrl = `${baseUrl}${buildAuthorPath(pathPrefix, author.slug)}`;
239
+
240
+ // Find the latest article by this author for lastModified
241
+ const authorArticles = articles.filter(
242
+ (article) => article.author?.id === author.id,
243
+ );
244
+ const latestAuthorMod = getLatestModificationDate(authorArticles);
245
+
246
+ entries.push({
247
+ url: authorUrl,
248
+ lastModified: latestAuthorMod,
249
+ changeFrequency: changeFrequency.authorPages ?? "weekly",
250
+ priority: priority.authorPages ?? 0.6,
251
+ });
252
+ });
253
+ }
254
+
255
+ // Add pagination pages if enabled
256
+ if (includePaginationPages && articles.length > articlesPerPage) {
257
+ const totalPages = Math.ceil(articles.length / articlesPerPage);
258
+
259
+ for (let page = 2; page <= totalPages; page++) {
260
+ if (entries.length >= maxEntries) break;
261
+
262
+ entries.push({
263
+ url: `${baseUrl}${pathPrefix}?page=${page}`,
264
+ lastModified: getLatestModificationDate(articles),
265
+ changeFrequency: changeFrequency.paginationPages ?? "daily",
266
+ priority: priority.paginationPages ?? 0.5,
267
+ });
268
+ }
269
+ }
270
+
271
+ return entries;
272
+ }
273
+
274
+ /**
275
+ * Legacy sitemap generation for backward compatibility.
276
+ * @deprecated Use generateBlogSitemapEntries for Next.js integration
277
+ *
278
+ * @param site - The published site information
279
+ * @param articles - Array of published articles
280
+ * @param authors - Array of published authors (optional)
281
+ * @param options - Sitemap generation options
282
+ * @returns Complete sitemap data
283
+ */
284
+ export function generateSitemap(
285
+ site: PublishedSite,
286
+ articles: readonly PublishedArticle[],
287
+ authors: readonly PublishedAuthor[] = [],
288
+ options: SitemapOptions = {},
289
+ ): SitemapData {
290
+ const {
291
+ includeAuthorPages = true,
292
+ includePaginationPages = true,
293
+ articlesPerPage = 10,
294
+ maxEntries = 50000,
295
+ changeFrequency = {},
296
+ priority = {},
297
+ } = options;
298
+
299
+ const entries: SitemapEntry[] = [];
300
+ const baseUrl = normalizeDomain(
301
+ options.canonical?.domain ?? site.domain,
302
+ options.canonical?.forceHttps ?? true,
303
+ );
304
+
305
+ const pathPrefix = options.canonical?.pathPrefix ?? "/blog";
306
+ const generatedAt = new Date().toISOString();
307
+
308
+ // Calculate total potential entries to determine if truncation will occur
309
+ let potentialEntries = 1; // Home page
310
+ potentialEntries += articles.length; // Article pages
311
+ if (includeAuthorPages) {
312
+ potentialEntries += authors.length; // Author pages
313
+ }
314
+ if (includePaginationPages && articles.length > articlesPerPage) {
315
+ const totalPages = Math.ceil(articles.length / articlesPerPage);
316
+ potentialEntries += totalPages - 1; // Pagination pages (excluding page 1 which is home)
317
+ }
318
+
319
+ const willTruncate = potentialEntries > maxEntries;
320
+
321
+ // Add blog home page
322
+ entries.push({
323
+ loc: `${baseUrl}${pathPrefix}`,
324
+ lastmod: getLatestModificationDate(articles),
325
+ changefreq: changeFrequency.homePage ?? "daily",
326
+ priority: priority.homePage ?? 1.0,
327
+ });
328
+
329
+ // Add individual article pages
330
+ articles.forEach((article) => {
331
+ if (entries.length >= maxEntries) return;
332
+
333
+ const articleUrl = generateCanonicalUrl(article, options.canonical);
334
+ entries.push({
335
+ loc: articleUrl,
336
+ lastmod: article.updatedAt,
337
+ changefreq: changeFrequency.articles ?? "monthly",
338
+ priority: priority.articles ?? 0.8,
339
+ });
340
+ });
341
+
342
+ // Add author pages if enabled
343
+ if (includeAuthorPages) {
344
+ authors.forEach((author) => {
345
+ if (entries.length >= maxEntries) return;
346
+
347
+ const authorUrl = `${baseUrl}${buildAuthorPath(pathPrefix, author.slug)}`;
348
+
349
+ // Find the latest article by this author for lastmod
350
+ const authorArticles = articles.filter(
351
+ (article) => article.author?.id === author.id,
352
+ );
353
+ const latestAuthorMod = getLatestModificationDate(authorArticles);
354
+
355
+ entries.push({
356
+ loc: authorUrl,
357
+ lastmod: latestAuthorMod,
358
+ changefreq: changeFrequency.authorPages ?? "weekly",
359
+ priority: priority.authorPages ?? 0.6,
360
+ });
361
+ });
362
+ }
363
+
364
+ // Add pagination pages if enabled
365
+ if (includePaginationPages && articles.length > articlesPerPage) {
366
+ const totalPages = Math.ceil(articles.length / articlesPerPage);
367
+
368
+ for (let page = 2; page <= totalPages; page++) {
369
+ if (entries.length >= maxEntries) break;
370
+
371
+ entries.push({
372
+ loc: `${baseUrl}${pathPrefix}?page=${page}`,
373
+ lastmod: getLatestModificationDate(articles),
374
+ changefreq: changeFrequency.paginationPages ?? "daily",
375
+ priority: priority.paginationPages ?? 0.5,
376
+ });
377
+ }
378
+ }
379
+
380
+ return {
381
+ entries,
382
+ totalEntries: entries.length,
383
+ truncated: willTruncate,
384
+ generatedAt,
385
+ baseUrl,
386
+ };
387
+ }
388
+
389
+ /**
390
+ * Converts sitemap data to XML format.
391
+ *
392
+ * @param sitemapData - The sitemap data to convert
393
+ * @returns XML string in sitemap format
394
+ *
395
+ * @example
396
+ * ```typescript
397
+ * const sitemap = generateSitemap(site, articles);
398
+ * const xmlString = sitemapToXml(sitemap);
399
+ * // Use in API route response
400
+ * ```
401
+ */
402
+ export function sitemapToXml(sitemapData: SitemapData): string {
403
+ const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
404
+ const urlsetOpen =
405
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
406
+ const urlsetClose = "</urlset>";
407
+
408
+ const urlEntries = sitemapData.entries.map((entry) => {
409
+ const elements: string[] = [];
410
+
411
+ elements.push(` <url>`);
412
+ elements.push(` <loc>${escapeXml(entry.loc)}</loc>`);
413
+
414
+ if (entry.lastmod) {
415
+ elements.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
416
+ }
417
+
418
+ if (entry.changefreq) {
419
+ elements.push(
420
+ ` <changefreq>${escapeXml(entry.changefreq)}</changefreq>`,
421
+ );
422
+ }
423
+
424
+ if (entry.priority !== undefined) {
425
+ elements.push(` <priority>${entry.priority.toFixed(1)}</priority>`);
426
+ }
427
+
428
+ elements.push(` </url>`);
429
+
430
+ return elements.join("\n");
431
+ });
432
+
433
+ return [xmlHeader, urlsetOpen, ...urlEntries, urlsetClose].join("\n");
434
+ }
435
+
436
+ /**
437
+ * Generates a sitemap index for large blogs that need multiple sitemaps.
438
+ *
439
+ * @param sitemaps - Array of sitemap information
440
+ * @param baseUrl - Base URL for the site
441
+ * @returns XML string for sitemap index
442
+ *
443
+ * @example
444
+ * ```typescript
445
+ * const sitemapIndex = generateSitemapIndex([
446
+ * { url: 'https://example.com/sitemap-articles.xml', lastmod: '2023-01-01T00:00:00Z' },
447
+ * { url: 'https://example.com/sitemap-authors.xml', lastmod: '2023-01-01T00:00:00Z' }
448
+ * ], 'https://example.com');
449
+ * ```
450
+ */
451
+ export function generateSitemapIndex(
452
+ sitemaps: readonly { url: string; lastmod?: string }[],
453
+ _baseUrl: string,
454
+ ): string {
455
+ const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
456
+ const indexOpen =
457
+ '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
458
+ const indexClose = "</sitemapindex>";
459
+
460
+ const sitemapEntries = sitemaps.map((sitemap) => {
461
+ const elements: string[] = [];
462
+
463
+ elements.push(` <sitemap>`);
464
+ elements.push(` <loc>${escapeXml(sitemap.url)}</loc>`);
465
+
466
+ if (sitemap.lastmod) {
467
+ elements.push(` <lastmod>${escapeXml(sitemap.lastmod)}</lastmod>`);
468
+ }
469
+
470
+ elements.push(` </sitemap>`);
471
+
472
+ return elements.join("\n");
473
+ });
474
+
475
+ return [xmlHeader, indexOpen, ...sitemapEntries, indexClose].join("\n");
476
+ }
477
+
478
+ /**
479
+ * Validates sitemap data for compliance with search engine guidelines.
480
+ *
481
+ * @param sitemapData - The sitemap data to validate
482
+ * @returns Validation results with errors and warnings
483
+ *
484
+ * @example
485
+ * ```typescript
486
+ * const sitemap = generateSitemap(site, articles);
487
+ * const validation = validateSitemap(sitemap);
488
+ *
489
+ * if (validation.errors.length > 0) {
490
+ * console.error('Sitemap errors:', validation.errors);
491
+ * }
492
+ * ```
493
+ */
494
+ export function validateSitemap(sitemapData: SitemapData): {
495
+ valid: boolean;
496
+ errors: string[];
497
+ warnings: string[];
498
+ } {
499
+ const errors: string[] = [];
500
+ const warnings: string[] = [];
501
+
502
+ // Check entry count
503
+ if (sitemapData.entries.length === 0) {
504
+ errors.push("Sitemap must contain at least one URL");
505
+ } else if (sitemapData.entries.length > 50000) {
506
+ errors.push("Sitemap contains more than 50,000 URLs (Google limit)");
507
+ }
508
+
509
+ // Check for truncation
510
+ if (sitemapData.truncated) {
511
+ warnings.push("Sitemap was truncated due to size limits");
512
+ }
513
+
514
+ // Validate individual entries
515
+ sitemapData.entries.forEach((entry, index) => {
516
+ // Validate URL format
517
+ try {
518
+ new URL(entry.loc);
519
+ } catch {
520
+ errors.push(`Invalid URL at entry ${index + 1}: ${entry.loc}`);
521
+ }
522
+
523
+ // Validate priority range
524
+ if (
525
+ entry.priority !== undefined &&
526
+ (entry.priority < 0 || entry.priority > 1)
527
+ ) {
528
+ errors.push(
529
+ `Invalid priority at entry ${index + 1}: must be between 0.0 and 1.0`,
530
+ );
531
+ }
532
+
533
+ // Validate lastmod format
534
+ if (entry.lastmod && !isValidISODate(entry.lastmod)) {
535
+ errors.push(
536
+ `Invalid lastmod date at entry ${index + 1}: ${entry.lastmod}`,
537
+ );
538
+ }
539
+
540
+ // Check URL length (Google limit is 2048 characters)
541
+ if (entry.loc.length > 2048) {
542
+ warnings.push(`URL at entry ${index + 1} exceeds 2048 character limit`);
543
+ }
544
+ });
545
+
546
+ // Check for duplicate URLs
547
+ const urlCounts = new Map<string, number>();
548
+ sitemapData.entries.forEach((entry) => {
549
+ const count = urlCounts.get(entry.loc) ?? 0;
550
+ urlCounts.set(entry.loc, count + 1);
551
+ });
552
+
553
+ urlCounts.forEach((count, url) => {
554
+ if (count > 1) {
555
+ errors.push(`Duplicate URL found: ${url} (appears ${count} times)`);
556
+ }
557
+ });
558
+
559
+ return {
560
+ valid: errors.length === 0,
561
+ errors,
562
+ warnings,
563
+ };
564
+ }
565
+
566
+ /**
567
+ * Gets the latest modification date from an array of articles.
568
+ */
569
+ function getLatestModificationDate(
570
+ articles: readonly PublishedArticle[],
571
+ ): string {
572
+ if (articles.length === 0) {
573
+ return new Date().toISOString();
574
+ }
575
+
576
+ const latestDate = articles.reduce((latest, article) => {
577
+ const articleDate = new Date(article.updatedAt);
578
+ return articleDate > latest ? articleDate : latest;
579
+ }, new Date(articles[0]!.updatedAt));
580
+
581
+ return latestDate.toISOString();
582
+ }
583
+
584
+ /**
585
+ * Creates a separate sitemap specifically for blog content.
586
+ * Useful for large sites that need to split sitemaps by content type.
587
+ *
588
+ * @param site - The published site information
589
+ * @param articles - Array of published articles
590
+ * @param authors - Array of published authors (optional)
591
+ * @param options - Sitemap generation options
592
+ * @returns Next.js sitemap function
593
+ *
594
+ * @example
595
+ * ```typescript
596
+ * // app/blog-sitemap/route.ts - separate blog sitemap endpoint
597
+ * import { createBlogSitemapRoute } from '@xexr/blog-platform';
598
+ *
599
+ * export const GET = createBlogSitemapRoute(site, articles, authors, {
600
+ * includeAuthorPages: true,
601
+ * articlesPerPage: 20
602
+ * });
603
+ * ```
604
+ */
605
+ export function createBlogSitemapRoute(
606
+ site: PublishedSite,
607
+ articles: readonly PublishedArticle[],
608
+ authors: readonly PublishedAuthor[] = [],
609
+ options: SitemapOptions = {},
610
+ ) {
611
+ return async function GET() {
612
+ const entries = await generateBlogSitemapEntries(
613
+ site,
614
+ articles,
615
+ authors,
616
+ options,
617
+ );
618
+
619
+ // Convert to Next.js sitemap format and return as XML
620
+ const xmlEntries = entries.map((entry) => ({
621
+ loc: entry.url,
622
+ lastmod:
623
+ typeof entry.lastModified === "string"
624
+ ? entry.lastModified
625
+ : entry.lastModified?.toISOString(),
626
+ changefreq: entry.changeFrequency,
627
+ priority: entry.priority,
628
+ }));
629
+
630
+ const sitemapData = {
631
+ entries: xmlEntries,
632
+ totalEntries: xmlEntries.length,
633
+ truncated: false,
634
+ generatedAt: new Date().toISOString(),
635
+ baseUrl: normalizeDomain(
636
+ options.canonical?.domain ?? site.domain,
637
+ options.canonical?.forceHttps ?? true,
638
+ ),
639
+ };
640
+
641
+ const xml = sitemapToXml(sitemapData);
642
+
643
+ return new Response(xml, {
644
+ headers: {
645
+ "Content-Type": "application/xml",
646
+ "Cache-Control": "public, max-age=3600, s-maxage=3600",
647
+ },
648
+ });
649
+ };
650
+ }
651
+
652
+ /**
653
+ * Configuration interface for blog data fetching.
654
+ * Used to fetch blog data for sitemap generation.
655
+ */
656
+ export interface BlogSitemapConfig {
657
+ /** Function to fetch site data */
658
+ getSite: () => Promise<PublishedSite>;
659
+
660
+ /** Function to fetch published articles */
661
+ getArticles: () => Promise<readonly PublishedArticle[]>;
662
+
663
+ /** Function to fetch authors (optional) */
664
+ getAuthors?: () => Promise<readonly PublishedAuthor[]>;
665
+
666
+ /** Sitemap generation options */
667
+ options?: SitemapOptions;
668
+ }
669
+
670
+ /**
671
+ * Higher-order function that creates a Next.js sitemap function with data fetching.
672
+ * Ideal for dynamic blog content that needs to be fetched at sitemap generation time.
673
+ *
674
+ * @param config - Configuration for fetching blog data
675
+ * @returns Next.js sitemap function
676
+ *
677
+ * @example
678
+ * ```typescript
679
+ * // app/sitemap.ts
680
+ * import { createDynamicBlogSitemap } from '@xexr/blog-platform';
681
+ * import { getBlogData } from './lib/blog';
682
+ *
683
+ * export default createDynamicBlogSitemap({
684
+ * getSite: () => getBlogData().site,
685
+ * getArticles: () => getBlogData().articles,
686
+ * getAuthors: () => getBlogData().authors,
687
+ * options: {
688
+ * includeAuthorPages: true,
689
+ * changeFrequency: { articles: 'weekly' }
690
+ * }
691
+ * });
692
+ * ```
693
+ */
694
+ export function createDynamicBlogSitemap(config: BlogSitemapConfig) {
695
+ return async function sitemap(): Promise<NextjsSitemapEntry[]> {
696
+ const site = await config.getSite();
697
+ const articles = await config.getArticles();
698
+ const authors = config.getAuthors ? await config.getAuthors() : [];
699
+
700
+ return generateBlogSitemapEntries(site, articles, authors, config.options);
701
+ };
702
+ }
703
+
704
+ /**
705
+ * Escapes special XML characters for safe inclusion in XML content.
706
+ */
707
+ function escapeXml(text: string): string {
708
+ const xmlEscapes: Record<string, string> = {
709
+ "&": "&amp;",
710
+ "<": "&lt;",
711
+ ">": "&gt;",
712
+ '"': "&quot;",
713
+ "'": "&#x27;",
714
+ };
715
+
716
+ return text.replace(/[&<>"']/g, (char) => xmlEscapes[char] ?? char);
717
+ }
718
+
719
+ /**
720
+ * Validates if a string is a valid ISO 8601 date.
721
+ */
722
+ function isValidISODate(dateString: string): boolean {
723
+ try {
724
+ const date = new Date(dateString);
725
+ const isoString = date.toISOString();
726
+
727
+ // Handle both formats: with and without milliseconds
728
+ // 2023-01-01T00:00:00.000Z and 2023-01-01T00:00:00Z are both valid
729
+ const normalizedInput =
730
+ dateString.endsWith("Z") && !dateString.includes(".")
731
+ ? dateString.replace("Z", ".000Z")
732
+ : dateString;
733
+
734
+ return isoString === normalizedInput && !isNaN(date.getTime());
735
+ } catch {
736
+ return false;
737
+ }
738
+ }