@xexr/blog-platform 1.0.8 → 1.0.10
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/README.md +110 -29
- package/dist/app/blog/[slug]/page.tsx +138 -17
- package/dist/app/blog/authors/[slug]/page.test.ts +108 -0
- package/dist/app/blog/authors/[slug]/page.tsx +387 -0
- package/dist/app/blog/page.tsx +138 -113
- package/dist/components/AuthorPages.test.ts +87 -0
- package/dist/components/AuthorPages.tsx +129 -0
- package/dist/index.d.mts +199 -70
- package/dist/index.d.ts +199 -70
- package/dist/index.js +51 -11
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +51 -11
- package/dist/index.mjs.map +1 -1
- package/dist/lib/authors.ts +100 -0
- package/dist/lib/sitemap.ts +101 -82
- package/dist/lib/storage/drizzle.ts +15 -0
- package/dist/lib/structured-data.ts +3 -2
- package/dist/lib/urls.ts +7 -0
- package/dist/types/contracts.ts +12 -0
- package/dist/types/index.d.mts +8 -0
- package/dist/types/index.d.ts +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { BlogPlatformStorage } from "./database";
|
|
2
|
+
import { getStorage } from "./database";
|
|
3
|
+
import type { PublishedArticle, PublishedAuthor } from "../types/contracts";
|
|
4
|
+
|
|
5
|
+
export interface AuthorArchive {
|
|
6
|
+
author: PublishedAuthor;
|
|
7
|
+
articles: PublishedArticle[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AuthorArchiveOptions {
|
|
11
|
+
limit?: number;
|
|
12
|
+
offset?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getAuthorsFromArticles(
|
|
16
|
+
articles: readonly PublishedArticle[],
|
|
17
|
+
): PublishedAuthor[] {
|
|
18
|
+
const authorsBySlug = new Map<string, PublishedAuthor>();
|
|
19
|
+
|
|
20
|
+
for (const article of articles) {
|
|
21
|
+
if (article.author?.slug) {
|
|
22
|
+
authorsBySlug.set(
|
|
23
|
+
article.author.slug,
|
|
24
|
+
mergeAuthorProfile(
|
|
25
|
+
authorsBySlug.get(article.author.slug),
|
|
26
|
+
article.author,
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return Array.from(authorsBySlug.values()).sort((a, b) =>
|
|
33
|
+
a.name.localeCompare(b.name),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getAuthorArchive(
|
|
38
|
+
slug: string,
|
|
39
|
+
articles: readonly PublishedArticle[],
|
|
40
|
+
): AuthorArchive | null {
|
|
41
|
+
const authorArticles = articles
|
|
42
|
+
.filter((article) => article.author?.slug === slug)
|
|
43
|
+
.sort(
|
|
44
|
+
(a, b) =>
|
|
45
|
+
new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const author = authorArticles.reduce<PublishedAuthor | null>(
|
|
49
|
+
(preferredAuthor, article) =>
|
|
50
|
+
article.author
|
|
51
|
+
? mergeAuthorProfile(preferredAuthor, article.author)
|
|
52
|
+
: preferredAuthor,
|
|
53
|
+
null,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (!author) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
author,
|
|
62
|
+
articles: authorArticles,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function loadAuthorArchive(
|
|
67
|
+
slug: string,
|
|
68
|
+
options: AuthorArchiveOptions = {},
|
|
69
|
+
storage: BlogPlatformStorage = getStorage(),
|
|
70
|
+
): Promise<AuthorArchive | null> {
|
|
71
|
+
const { articles } = await storage.listArticles({
|
|
72
|
+
limit: options.limit ?? 1000,
|
|
73
|
+
offset: options.offset ?? 0,
|
|
74
|
+
sortBy: "publishedAt",
|
|
75
|
+
order: "desc",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return getAuthorArchive(slug, articles);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function mergeAuthorProfile(
|
|
82
|
+
current: PublishedAuthor | null | undefined,
|
|
83
|
+
next: PublishedAuthor,
|
|
84
|
+
): PublishedAuthor {
|
|
85
|
+
if (!current) {
|
|
86
|
+
return next;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
...current,
|
|
91
|
+
bio: current.bio ?? next.bio,
|
|
92
|
+
avatarUrl: current.avatarUrl ?? next.avatarUrl,
|
|
93
|
+
pronouns: current.pronouns ?? next.pronouns,
|
|
94
|
+
socialLinks: current.socialLinks ?? next.socialLinks,
|
|
95
|
+
toneAdjectives:
|
|
96
|
+
current.toneAdjectives.length > 0
|
|
97
|
+
? current.toneAdjectives
|
|
98
|
+
: next.toneAdjectives,
|
|
99
|
+
};
|
|
100
|
+
}
|
package/dist/lib/sitemap.ts
CHANGED
|
@@ -25,8 +25,17 @@
|
|
|
25
25
|
* ```
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import type {
|
|
29
|
-
|
|
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";
|
|
30
39
|
|
|
31
40
|
/**
|
|
32
41
|
* Configuration options for sitemap generation.
|
|
@@ -76,13 +85,13 @@ export interface SitemapOptions {
|
|
|
76
85
|
* Valid sitemap change frequency values as per XML sitemap standard.
|
|
77
86
|
*/
|
|
78
87
|
export type SitemapChangeFreq =
|
|
79
|
-
|
|
|
80
|
-
|
|
|
81
|
-
|
|
|
82
|
-
|
|
|
83
|
-
|
|
|
84
|
-
|
|
|
85
|
-
|
|
|
88
|
+
| "always"
|
|
89
|
+
| "hourly"
|
|
90
|
+
| "daily"
|
|
91
|
+
| "weekly"
|
|
92
|
+
| "monthly"
|
|
93
|
+
| "yearly"
|
|
94
|
+
| "never";
|
|
86
95
|
|
|
87
96
|
/**
|
|
88
97
|
* Next.js MetadataRoute.Sitemap compatible entry.
|
|
@@ -181,7 +190,7 @@ export async function generateBlogSitemapEntries(
|
|
|
181
190
|
site: PublishedSite,
|
|
182
191
|
articles: readonly PublishedArticle[],
|
|
183
192
|
authors: readonly PublishedAuthor[] = [],
|
|
184
|
-
options: SitemapOptions = {}
|
|
193
|
+
options: SitemapOptions = {},
|
|
185
194
|
): Promise<NextjsSitemapEntry[]> {
|
|
186
195
|
const {
|
|
187
196
|
includeAuthorPages = true,
|
|
@@ -195,49 +204,49 @@ export async function generateBlogSitemapEntries(
|
|
|
195
204
|
const entries: NextjsSitemapEntry[] = [];
|
|
196
205
|
const baseUrl = normalizeDomain(
|
|
197
206
|
options.canonical?.domain ?? site.domain,
|
|
198
|
-
options.canonical?.forceHttps ?? true
|
|
207
|
+
options.canonical?.forceHttps ?? true,
|
|
199
208
|
);
|
|
200
209
|
|
|
201
|
-
const pathPrefix = options.canonical?.pathPrefix ??
|
|
210
|
+
const pathPrefix = options.canonical?.pathPrefix ?? "/blog";
|
|
202
211
|
|
|
203
212
|
// Add blog home page
|
|
204
213
|
entries.push({
|
|
205
214
|
url: `${baseUrl}${pathPrefix}`,
|
|
206
215
|
lastModified: getLatestModificationDate(articles),
|
|
207
|
-
changeFrequency: changeFrequency.homePage ??
|
|
216
|
+
changeFrequency: changeFrequency.homePage ?? "daily",
|
|
208
217
|
priority: priority.homePage ?? 1.0,
|
|
209
218
|
});
|
|
210
219
|
|
|
211
220
|
// Add individual article pages
|
|
212
|
-
articles.forEach(article => {
|
|
221
|
+
articles.forEach((article) => {
|
|
213
222
|
if (entries.length >= maxEntries) return;
|
|
214
223
|
|
|
215
224
|
const articleUrl = generateCanonicalUrl(article, options.canonical);
|
|
216
225
|
entries.push({
|
|
217
226
|
url: articleUrl,
|
|
218
227
|
lastModified: article.updatedAt,
|
|
219
|
-
changeFrequency: changeFrequency.articles ??
|
|
228
|
+
changeFrequency: changeFrequency.articles ?? "monthly",
|
|
220
229
|
priority: priority.articles ?? 0.8,
|
|
221
230
|
});
|
|
222
231
|
});
|
|
223
232
|
|
|
224
233
|
// Add author pages if enabled
|
|
225
234
|
if (includeAuthorPages) {
|
|
226
|
-
authors.forEach(author => {
|
|
235
|
+
authors.forEach((author) => {
|
|
227
236
|
if (entries.length >= maxEntries) return;
|
|
228
237
|
|
|
229
|
-
const authorUrl = `${baseUrl}${pathPrefix
|
|
238
|
+
const authorUrl = `${baseUrl}${buildAuthorPath(pathPrefix, author.slug)}`;
|
|
230
239
|
|
|
231
240
|
// Find the latest article by this author for lastModified
|
|
232
|
-
const authorArticles = articles.filter(
|
|
233
|
-
article.author?.id === author.id
|
|
241
|
+
const authorArticles = articles.filter(
|
|
242
|
+
(article) => article.author?.id === author.id,
|
|
234
243
|
);
|
|
235
244
|
const latestAuthorMod = getLatestModificationDate(authorArticles);
|
|
236
245
|
|
|
237
246
|
entries.push({
|
|
238
247
|
url: authorUrl,
|
|
239
248
|
lastModified: latestAuthorMod,
|
|
240
|
-
changeFrequency: changeFrequency.authorPages ??
|
|
249
|
+
changeFrequency: changeFrequency.authorPages ?? "weekly",
|
|
241
250
|
priority: priority.authorPages ?? 0.6,
|
|
242
251
|
});
|
|
243
252
|
});
|
|
@@ -253,7 +262,7 @@ export async function generateBlogSitemapEntries(
|
|
|
253
262
|
entries.push({
|
|
254
263
|
url: `${baseUrl}${pathPrefix}?page=${page}`,
|
|
255
264
|
lastModified: getLatestModificationDate(articles),
|
|
256
|
-
changeFrequency: changeFrequency.paginationPages ??
|
|
265
|
+
changeFrequency: changeFrequency.paginationPages ?? "daily",
|
|
257
266
|
priority: priority.paginationPages ?? 0.5,
|
|
258
267
|
});
|
|
259
268
|
}
|
|
@@ -276,7 +285,7 @@ export function generateSitemap(
|
|
|
276
285
|
site: PublishedSite,
|
|
277
286
|
articles: readonly PublishedArticle[],
|
|
278
287
|
authors: readonly PublishedAuthor[] = [],
|
|
279
|
-
options: SitemapOptions = {}
|
|
288
|
+
options: SitemapOptions = {},
|
|
280
289
|
): SitemapData {
|
|
281
290
|
const {
|
|
282
291
|
includeAuthorPages = true,
|
|
@@ -290,10 +299,10 @@ export function generateSitemap(
|
|
|
290
299
|
const entries: SitemapEntry[] = [];
|
|
291
300
|
const baseUrl = normalizeDomain(
|
|
292
301
|
options.canonical?.domain ?? site.domain,
|
|
293
|
-
options.canonical?.forceHttps ?? true
|
|
302
|
+
options.canonical?.forceHttps ?? true,
|
|
294
303
|
);
|
|
295
304
|
|
|
296
|
-
const pathPrefix = options.canonical?.pathPrefix ??
|
|
305
|
+
const pathPrefix = options.canonical?.pathPrefix ?? "/blog";
|
|
297
306
|
const generatedAt = new Date().toISOString();
|
|
298
307
|
|
|
299
308
|
// Calculate total potential entries to determine if truncation will occur
|
|
@@ -313,40 +322,40 @@ export function generateSitemap(
|
|
|
313
322
|
entries.push({
|
|
314
323
|
loc: `${baseUrl}${pathPrefix}`,
|
|
315
324
|
lastmod: getLatestModificationDate(articles),
|
|
316
|
-
changefreq: changeFrequency.homePage ??
|
|
325
|
+
changefreq: changeFrequency.homePage ?? "daily",
|
|
317
326
|
priority: priority.homePage ?? 1.0,
|
|
318
327
|
});
|
|
319
328
|
|
|
320
329
|
// Add individual article pages
|
|
321
|
-
articles.forEach(article => {
|
|
330
|
+
articles.forEach((article) => {
|
|
322
331
|
if (entries.length >= maxEntries) return;
|
|
323
332
|
|
|
324
333
|
const articleUrl = generateCanonicalUrl(article, options.canonical);
|
|
325
334
|
entries.push({
|
|
326
335
|
loc: articleUrl,
|
|
327
336
|
lastmod: article.updatedAt,
|
|
328
|
-
changefreq: changeFrequency.articles ??
|
|
337
|
+
changefreq: changeFrequency.articles ?? "monthly",
|
|
329
338
|
priority: priority.articles ?? 0.8,
|
|
330
339
|
});
|
|
331
340
|
});
|
|
332
341
|
|
|
333
342
|
// Add author pages if enabled
|
|
334
343
|
if (includeAuthorPages) {
|
|
335
|
-
authors.forEach(author => {
|
|
344
|
+
authors.forEach((author) => {
|
|
336
345
|
if (entries.length >= maxEntries) return;
|
|
337
346
|
|
|
338
|
-
const authorUrl = `${baseUrl}${pathPrefix
|
|
347
|
+
const authorUrl = `${baseUrl}${buildAuthorPath(pathPrefix, author.slug)}`;
|
|
339
348
|
|
|
340
349
|
// Find the latest article by this author for lastmod
|
|
341
|
-
const authorArticles = articles.filter(
|
|
342
|
-
article.author?.id === author.id
|
|
350
|
+
const authorArticles = articles.filter(
|
|
351
|
+
(article) => article.author?.id === author.id,
|
|
343
352
|
);
|
|
344
353
|
const latestAuthorMod = getLatestModificationDate(authorArticles);
|
|
345
354
|
|
|
346
355
|
entries.push({
|
|
347
356
|
loc: authorUrl,
|
|
348
357
|
lastmod: latestAuthorMod,
|
|
349
|
-
changefreq: changeFrequency.authorPages ??
|
|
358
|
+
changefreq: changeFrequency.authorPages ?? "weekly",
|
|
350
359
|
priority: priority.authorPages ?? 0.6,
|
|
351
360
|
});
|
|
352
361
|
});
|
|
@@ -362,7 +371,7 @@ export function generateSitemap(
|
|
|
362
371
|
entries.push({
|
|
363
372
|
loc: `${baseUrl}${pathPrefix}?page=${page}`,
|
|
364
373
|
lastmod: getLatestModificationDate(articles),
|
|
365
|
-
changefreq: changeFrequency.paginationPages ??
|
|
374
|
+
changefreq: changeFrequency.paginationPages ?? "daily",
|
|
366
375
|
priority: priority.paginationPages ?? 0.5,
|
|
367
376
|
});
|
|
368
377
|
}
|
|
@@ -392,10 +401,11 @@ export function generateSitemap(
|
|
|
392
401
|
*/
|
|
393
402
|
export function sitemapToXml(sitemapData: SitemapData): string {
|
|
394
403
|
const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
|
|
395
|
-
const urlsetOpen =
|
|
396
|
-
|
|
404
|
+
const urlsetOpen =
|
|
405
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
|
|
406
|
+
const urlsetClose = "</urlset>";
|
|
397
407
|
|
|
398
|
-
const urlEntries = sitemapData.entries.map(entry => {
|
|
408
|
+
const urlEntries = sitemapData.entries.map((entry) => {
|
|
399
409
|
const elements: string[] = [];
|
|
400
410
|
|
|
401
411
|
elements.push(` <url>`);
|
|
@@ -406,7 +416,9 @@ export function sitemapToXml(sitemapData: SitemapData): string {
|
|
|
406
416
|
}
|
|
407
417
|
|
|
408
418
|
if (entry.changefreq) {
|
|
409
|
-
elements.push(
|
|
419
|
+
elements.push(
|
|
420
|
+
` <changefreq>${escapeXml(entry.changefreq)}</changefreq>`,
|
|
421
|
+
);
|
|
410
422
|
}
|
|
411
423
|
|
|
412
424
|
if (entry.priority !== undefined) {
|
|
@@ -415,15 +427,10 @@ export function sitemapToXml(sitemapData: SitemapData): string {
|
|
|
415
427
|
|
|
416
428
|
elements.push(` </url>`);
|
|
417
429
|
|
|
418
|
-
return elements.join(
|
|
430
|
+
return elements.join("\n");
|
|
419
431
|
});
|
|
420
432
|
|
|
421
|
-
return [
|
|
422
|
-
xmlHeader,
|
|
423
|
-
urlsetOpen,
|
|
424
|
-
...urlEntries,
|
|
425
|
-
urlsetClose,
|
|
426
|
-
].join('\n');
|
|
433
|
+
return [xmlHeader, urlsetOpen, ...urlEntries, urlsetClose].join("\n");
|
|
427
434
|
}
|
|
428
435
|
|
|
429
436
|
/**
|
|
@@ -443,13 +450,14 @@ export function sitemapToXml(sitemapData: SitemapData): string {
|
|
|
443
450
|
*/
|
|
444
451
|
export function generateSitemapIndex(
|
|
445
452
|
sitemaps: readonly { url: string; lastmod?: string }[],
|
|
446
|
-
_baseUrl: string
|
|
453
|
+
_baseUrl: string,
|
|
447
454
|
): string {
|
|
448
455
|
const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
|
|
449
|
-
const indexOpen =
|
|
450
|
-
|
|
456
|
+
const indexOpen =
|
|
457
|
+
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
|
|
458
|
+
const indexClose = "</sitemapindex>";
|
|
451
459
|
|
|
452
|
-
const sitemapEntries = sitemaps.map(sitemap => {
|
|
460
|
+
const sitemapEntries = sitemaps.map((sitemap) => {
|
|
453
461
|
const elements: string[] = [];
|
|
454
462
|
|
|
455
463
|
elements.push(` <sitemap>`);
|
|
@@ -461,15 +469,10 @@ export function generateSitemapIndex(
|
|
|
461
469
|
|
|
462
470
|
elements.push(` </sitemap>`);
|
|
463
471
|
|
|
464
|
-
return elements.join(
|
|
472
|
+
return elements.join("\n");
|
|
465
473
|
});
|
|
466
474
|
|
|
467
|
-
return [
|
|
468
|
-
xmlHeader,
|
|
469
|
-
indexOpen,
|
|
470
|
-
...sitemapEntries,
|
|
471
|
-
indexClose,
|
|
472
|
-
].join('\n');
|
|
475
|
+
return [xmlHeader, indexOpen, ...sitemapEntries, indexClose].join("\n");
|
|
473
476
|
}
|
|
474
477
|
|
|
475
478
|
/**
|
|
@@ -498,14 +501,14 @@ export function validateSitemap(sitemapData: SitemapData): {
|
|
|
498
501
|
|
|
499
502
|
// Check entry count
|
|
500
503
|
if (sitemapData.entries.length === 0) {
|
|
501
|
-
errors.push(
|
|
504
|
+
errors.push("Sitemap must contain at least one URL");
|
|
502
505
|
} else if (sitemapData.entries.length > 50000) {
|
|
503
|
-
errors.push(
|
|
506
|
+
errors.push("Sitemap contains more than 50,000 URLs (Google limit)");
|
|
504
507
|
}
|
|
505
508
|
|
|
506
509
|
// Check for truncation
|
|
507
510
|
if (sitemapData.truncated) {
|
|
508
|
-
warnings.push(
|
|
511
|
+
warnings.push("Sitemap was truncated due to size limits");
|
|
509
512
|
}
|
|
510
513
|
|
|
511
514
|
// Validate individual entries
|
|
@@ -518,13 +521,20 @@ export function validateSitemap(sitemapData: SitemapData): {
|
|
|
518
521
|
}
|
|
519
522
|
|
|
520
523
|
// Validate priority range
|
|
521
|
-
if (
|
|
522
|
-
|
|
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
|
+
);
|
|
523
531
|
}
|
|
524
532
|
|
|
525
533
|
// Validate lastmod format
|
|
526
534
|
if (entry.lastmod && !isValidISODate(entry.lastmod)) {
|
|
527
|
-
errors.push(
|
|
535
|
+
errors.push(
|
|
536
|
+
`Invalid lastmod date at entry ${index + 1}: ${entry.lastmod}`,
|
|
537
|
+
);
|
|
528
538
|
}
|
|
529
539
|
|
|
530
540
|
// Check URL length (Google limit is 2048 characters)
|
|
@@ -535,7 +545,7 @@ export function validateSitemap(sitemapData: SitemapData): {
|
|
|
535
545
|
|
|
536
546
|
// Check for duplicate URLs
|
|
537
547
|
const urlCounts = new Map<string, number>();
|
|
538
|
-
sitemapData.entries.forEach(entry => {
|
|
548
|
+
sitemapData.entries.forEach((entry) => {
|
|
539
549
|
const count = urlCounts.get(entry.loc) ?? 0;
|
|
540
550
|
urlCounts.set(entry.loc, count + 1);
|
|
541
551
|
});
|
|
@@ -556,7 +566,9 @@ export function validateSitemap(sitemapData: SitemapData): {
|
|
|
556
566
|
/**
|
|
557
567
|
* Gets the latest modification date from an array of articles.
|
|
558
568
|
*/
|
|
559
|
-
function getLatestModificationDate(
|
|
569
|
+
function getLatestModificationDate(
|
|
570
|
+
articles: readonly PublishedArticle[],
|
|
571
|
+
): string {
|
|
560
572
|
if (articles.length === 0) {
|
|
561
573
|
return new Date().toISOString();
|
|
562
574
|
}
|
|
@@ -594,17 +606,23 @@ export function createBlogSitemapRoute(
|
|
|
594
606
|
site: PublishedSite,
|
|
595
607
|
articles: readonly PublishedArticle[],
|
|
596
608
|
authors: readonly PublishedAuthor[] = [],
|
|
597
|
-
options: SitemapOptions = {}
|
|
609
|
+
options: SitemapOptions = {},
|
|
598
610
|
) {
|
|
599
611
|
return async function GET() {
|
|
600
|
-
const entries = await generateBlogSitemapEntries(
|
|
612
|
+
const entries = await generateBlogSitemapEntries(
|
|
613
|
+
site,
|
|
614
|
+
articles,
|
|
615
|
+
authors,
|
|
616
|
+
options,
|
|
617
|
+
);
|
|
601
618
|
|
|
602
619
|
// Convert to Next.js sitemap format and return as XML
|
|
603
|
-
const xmlEntries = entries.map(entry => ({
|
|
620
|
+
const xmlEntries = entries.map((entry) => ({
|
|
604
621
|
loc: entry.url,
|
|
605
|
-
lastmod:
|
|
606
|
-
|
|
607
|
-
|
|
622
|
+
lastmod:
|
|
623
|
+
typeof entry.lastModified === "string"
|
|
624
|
+
? entry.lastModified
|
|
625
|
+
: entry.lastModified?.toISOString(),
|
|
608
626
|
changefreq: entry.changeFrequency,
|
|
609
627
|
priority: entry.priority,
|
|
610
628
|
}));
|
|
@@ -616,7 +634,7 @@ export function createBlogSitemapRoute(
|
|
|
616
634
|
generatedAt: new Date().toISOString(),
|
|
617
635
|
baseUrl: normalizeDomain(
|
|
618
636
|
options.canonical?.domain ?? site.domain,
|
|
619
|
-
options.canonical?.forceHttps ?? true
|
|
637
|
+
options.canonical?.forceHttps ?? true,
|
|
620
638
|
),
|
|
621
639
|
};
|
|
622
640
|
|
|
@@ -624,8 +642,8 @@ export function createBlogSitemapRoute(
|
|
|
624
642
|
|
|
625
643
|
return new Response(xml, {
|
|
626
644
|
headers: {
|
|
627
|
-
|
|
628
|
-
|
|
645
|
+
"Content-Type": "application/xml",
|
|
646
|
+
"Cache-Control": "public, max-age=3600, s-maxage=3600",
|
|
629
647
|
},
|
|
630
648
|
});
|
|
631
649
|
};
|
|
@@ -688,14 +706,14 @@ export function createDynamicBlogSitemap(config: BlogSitemapConfig) {
|
|
|
688
706
|
*/
|
|
689
707
|
function escapeXml(text: string): string {
|
|
690
708
|
const xmlEscapes: Record<string, string> = {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
'"':
|
|
695
|
-
"'":
|
|
709
|
+
"&": "&",
|
|
710
|
+
"<": "<",
|
|
711
|
+
">": ">",
|
|
712
|
+
'"': """,
|
|
713
|
+
"'": "'",
|
|
696
714
|
};
|
|
697
715
|
|
|
698
|
-
return text.replace(/[&<>"']/g, char => xmlEscapes[char] ?? char);
|
|
716
|
+
return text.replace(/[&<>"']/g, (char) => xmlEscapes[char] ?? char);
|
|
699
717
|
}
|
|
700
718
|
|
|
701
719
|
/**
|
|
@@ -708,12 +726,13 @@ function isValidISODate(dateString: string): boolean {
|
|
|
708
726
|
|
|
709
727
|
// Handle both formats: with and without milliseconds
|
|
710
728
|
// 2023-01-01T00:00:00.000Z and 2023-01-01T00:00:00Z are both valid
|
|
711
|
-
const normalizedInput =
|
|
712
|
-
|
|
713
|
-
|
|
729
|
+
const normalizedInput =
|
|
730
|
+
dateString.endsWith("Z") && !dateString.includes(".")
|
|
731
|
+
? dateString.replace("Z", ".000Z")
|
|
732
|
+
: dateString;
|
|
714
733
|
|
|
715
734
|
return isoString === normalizedInput && !isNaN(date.getTime());
|
|
716
735
|
} catch {
|
|
717
736
|
return false;
|
|
718
737
|
}
|
|
719
|
-
}
|
|
738
|
+
}
|
|
@@ -30,6 +30,9 @@ export const bpArticles = sqliteTable(
|
|
|
30
30
|
updatedAt: text("updated_at").notNull(),
|
|
31
31
|
featuredImage: text("featured_image"),
|
|
32
32
|
featuredImageAlt: text("featured_image_alt"),
|
|
33
|
+
featuredImageCaption: text("featured_image_caption"),
|
|
34
|
+
featuredImageCreditText: text("featured_image_credit_text"),
|
|
35
|
+
featuredImageCreditUrl: text("featured_image_credit_url"),
|
|
33
36
|
// Store complex objects as JSON strings
|
|
34
37
|
authorJson: text("author_json"),
|
|
35
38
|
siteJson: text("site_json").notNull(),
|
|
@@ -65,6 +68,9 @@ function toRow(article: PublishedArticle): ArticleRow {
|
|
|
65
68
|
updatedAt: article.updatedAt,
|
|
66
69
|
featuredImage: article.featuredImage ?? null,
|
|
67
70
|
featuredImageAlt: article.featuredImageAlt ?? null,
|
|
71
|
+
featuredImageCaption: article.featuredImageCaption ?? null,
|
|
72
|
+
featuredImageCreditText: article.featuredImageCreditText ?? null,
|
|
73
|
+
featuredImageCreditUrl: article.featuredImageCreditUrl ?? null,
|
|
68
74
|
authorJson: article.author ? JSON.stringify(article.author) : null,
|
|
69
75
|
siteJson: JSON.stringify(article.site),
|
|
70
76
|
metaJson: JSON.stringify({
|
|
@@ -88,6 +94,9 @@ function fromRow(row: ArticleRow): PublishedArticle {
|
|
|
88
94
|
updatedAt: row.updatedAt,
|
|
89
95
|
featuredImage: row.featuredImage ?? undefined,
|
|
90
96
|
featuredImageAlt: row.featuredImageAlt ?? undefined,
|
|
97
|
+
featuredImageCaption: row.featuredImageCaption ?? undefined,
|
|
98
|
+
featuredImageCreditText: row.featuredImageCreditText ?? undefined,
|
|
99
|
+
featuredImageCreditUrl: row.featuredImageCreditUrl ?? undefined,
|
|
91
100
|
author: row.authorJson
|
|
92
101
|
? (JSON.parse(row.authorJson) as PublishedAuthor)
|
|
93
102
|
: null,
|
|
@@ -204,6 +213,9 @@ export class DrizzleStorage implements BlogPlatformStorage {
|
|
|
204
213
|
updatedAt: row.updatedAt,
|
|
205
214
|
featuredImage: row.featuredImage,
|
|
206
215
|
featuredImageAlt: row.featuredImageAlt,
|
|
216
|
+
featuredImageCaption: row.featuredImageCaption,
|
|
217
|
+
featuredImageCreditText: row.featuredImageCreditText,
|
|
218
|
+
featuredImageCreditUrl: row.featuredImageCreditUrl,
|
|
207
219
|
authorJson: row.authorJson,
|
|
208
220
|
siteJson: row.siteJson,
|
|
209
221
|
metaJson: row.metaJson,
|
|
@@ -385,6 +397,9 @@ export function getDrizzleAdapterSql(): {
|
|
|
385
397
|
updated_at TEXT NOT NULL,
|
|
386
398
|
featured_image TEXT,
|
|
387
399
|
featured_image_alt TEXT,
|
|
400
|
+
featured_image_caption TEXT,
|
|
401
|
+
featured_image_credit_text TEXT,
|
|
402
|
+
featured_image_credit_url TEXT,
|
|
388
403
|
author_json TEXT,
|
|
389
404
|
site_json TEXT NOT NULL,
|
|
390
405
|
meta_json TEXT NOT NULL,
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
PublishedAuthor,
|
|
15
15
|
PublishedSite,
|
|
16
16
|
} from "../types/contracts";
|
|
17
|
+
import { buildAuthorPath } from "./urls";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Base schema.org context and type
|
|
@@ -277,8 +278,8 @@ export function generateAuthorStructuredData(
|
|
|
277
278
|
|
|
278
279
|
if (author.slug?.trim()) {
|
|
279
280
|
const authorUrl = customDomain
|
|
280
|
-
? `https://${customDomain}${pathPrefix
|
|
281
|
-
:
|
|
281
|
+
? `https://${customDomain}${buildAuthorPath(pathPrefix, author.slug)}`
|
|
282
|
+
: buildAuthorPath(pathPrefix, author.slug);
|
|
282
283
|
schema.url = authorUrl;
|
|
283
284
|
}
|
|
284
285
|
|
package/dist/lib/urls.ts
CHANGED
|
@@ -23,6 +23,13 @@ export function buildBlogPath(
|
|
|
23
23
|
return `${normalizedBasePath}/${cleanSlug}`.replace(/\/+/g, "/");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export function buildAuthorPath(
|
|
27
|
+
basePath: string | undefined,
|
|
28
|
+
authorSlug: string,
|
|
29
|
+
): string {
|
|
30
|
+
return buildBlogPath(basePath, `authors/${authorSlug}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
export function getPathPrefixFromBlogUrl(blogUrl: string): string {
|
|
27
34
|
const parsed = new URL(blogUrl);
|
|
28
35
|
return normalizeBlogBasePath(parsed.pathname);
|
package/dist/types/contracts.ts
CHANGED
|
@@ -18,6 +18,9 @@ export interface PublishedAuthorSocialLinks {
|
|
|
18
18
|
/** X/Twitter profile URL */
|
|
19
19
|
readonly x?: string;
|
|
20
20
|
|
|
21
|
+
/** Legacy Twitter profile URL */
|
|
22
|
+
readonly twitter?: string;
|
|
23
|
+
|
|
21
24
|
/** LinkedIn profile URL */
|
|
22
25
|
readonly linkedin?: string;
|
|
23
26
|
|
|
@@ -153,6 +156,15 @@ export interface PublishedArticle {
|
|
|
153
156
|
/** Alt text for featured image */
|
|
154
157
|
readonly featuredImageAlt?: string;
|
|
155
158
|
|
|
159
|
+
/** Caption displayed with the featured image */
|
|
160
|
+
readonly featuredImageCaption?: string;
|
|
161
|
+
|
|
162
|
+
/** Credit text displayed with the featured image */
|
|
163
|
+
readonly featuredImageCreditText?: string;
|
|
164
|
+
|
|
165
|
+
/** Optional URL for the featured image credit */
|
|
166
|
+
readonly featuredImageCreditUrl?: string;
|
|
167
|
+
|
|
156
168
|
/** Featured image R2 key for CDN optimization (optional) */
|
|
157
169
|
readonly featuredImageKey?: string;
|
|
158
170
|
|
package/dist/types/index.d.mts
CHANGED
|
@@ -15,6 +15,8 @@ interface PublishedAuthorSocialLinks {
|
|
|
15
15
|
readonly website?: string;
|
|
16
16
|
/** X/Twitter profile URL */
|
|
17
17
|
readonly x?: string;
|
|
18
|
+
/** Legacy Twitter profile URL */
|
|
19
|
+
readonly twitter?: string;
|
|
18
20
|
/** LinkedIn profile URL */
|
|
19
21
|
readonly linkedin?: string;
|
|
20
22
|
/** GitHub profile URL */
|
|
@@ -112,6 +114,12 @@ interface PublishedArticle {
|
|
|
112
114
|
readonly featuredImage?: string;
|
|
113
115
|
/** Alt text for featured image */
|
|
114
116
|
readonly featuredImageAlt?: string;
|
|
117
|
+
/** Caption displayed with the featured image */
|
|
118
|
+
readonly featuredImageCaption?: string;
|
|
119
|
+
/** Credit text displayed with the featured image */
|
|
120
|
+
readonly featuredImageCreditText?: string;
|
|
121
|
+
/** Optional URL for the featured image credit */
|
|
122
|
+
readonly featuredImageCreditUrl?: string;
|
|
115
123
|
/** Featured image R2 key for CDN optimization (optional) */
|
|
116
124
|
readonly featuredImageKey?: string;
|
|
117
125
|
/** SEO meta tags */
|
package/dist/types/index.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ interface PublishedAuthorSocialLinks {
|
|
|
15
15
|
readonly website?: string;
|
|
16
16
|
/** X/Twitter profile URL */
|
|
17
17
|
readonly x?: string;
|
|
18
|
+
/** Legacy Twitter profile URL */
|
|
19
|
+
readonly twitter?: string;
|
|
18
20
|
/** LinkedIn profile URL */
|
|
19
21
|
readonly linkedin?: string;
|
|
20
22
|
/** GitHub profile URL */
|
|
@@ -112,6 +114,12 @@ interface PublishedArticle {
|
|
|
112
114
|
readonly featuredImage?: string;
|
|
113
115
|
/** Alt text for featured image */
|
|
114
116
|
readonly featuredImageAlt?: string;
|
|
117
|
+
/** Caption displayed with the featured image */
|
|
118
|
+
readonly featuredImageCaption?: string;
|
|
119
|
+
/** Credit text displayed with the featured image */
|
|
120
|
+
readonly featuredImageCreditText?: string;
|
|
121
|
+
/** Optional URL for the featured image credit */
|
|
122
|
+
readonly featuredImageCreditUrl?: string;
|
|
115
123
|
/** Featured image R2 key for CDN optimization (optional) */
|
|
116
124
|
readonly featuredImageKey?: string;
|
|
117
125
|
/** SEO meta tags */
|