@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,609 @@
1
+ /**
2
+ * Structured Data (JSON-LD) utilities for SEO optimization
3
+ *
4
+ * This module provides comprehensive schema.org structured data generation
5
+ * for blog articles, authors, and organizations. It follows Google's
6
+ * recommended guidelines for structured data markup.
7
+ *
8
+ * @see https://developers.google.com/search/docs/appearance/structured-data
9
+ * @see https://schema.org/
10
+ */
11
+
12
+ import type {
13
+ PublishedArticle,
14
+ PublishedAuthor,
15
+ PublishedSite,
16
+ } from "../types/contracts";
17
+ import { buildAuthorPath } from "./urls";
18
+
19
+ /**
20
+ * Base schema.org context and type
21
+ */
22
+ interface BaseStructuredData {
23
+ "@context": "https://schema.org";
24
+ "@type": string;
25
+ "@id"?: string;
26
+ }
27
+
28
+ /**
29
+ * Person schema for authors
30
+ * @see https://schema.org/Person
31
+ */
32
+ export interface PersonSchema extends BaseStructuredData {
33
+ "@type": "Person";
34
+ name: string;
35
+ description?: string;
36
+ identifier?: string;
37
+ image?: string;
38
+ url?: string;
39
+ sameAs?: string[];
40
+ jobTitle?: string;
41
+ affiliation?: OrganizationSchema;
42
+ knowsAbout?: string[];
43
+ additionalType?: string;
44
+ }
45
+
46
+ /**
47
+ * Organization schema for blog sites
48
+ * @see https://schema.org/Organization
49
+ */
50
+ export interface OrganizationSchema extends BaseStructuredData {
51
+ "@type": "Organization";
52
+ name: string;
53
+ description?: string;
54
+ url?: string;
55
+ logo?: string;
56
+ sameAs?: string[];
57
+ foundingDate?: string;
58
+ location?: string;
59
+ contactPoint?: {
60
+ "@type": "ContactPoint";
61
+ contactType: string;
62
+ url?: string;
63
+ email?: string;
64
+ };
65
+ }
66
+
67
+ /**
68
+ * BlogPosting schema for articles
69
+ * @see https://schema.org/BlogPosting
70
+ */
71
+ export interface BlogPostingSchema extends BaseStructuredData {
72
+ "@type": "BlogPosting";
73
+ headline: string;
74
+ description?: string;
75
+ image?: string | string[];
76
+ datePublished: string;
77
+ dateModified?: string;
78
+ author?: PersonSchema | PersonSchema[];
79
+ publisher: OrganizationSchema;
80
+ mainEntityOfPage: {
81
+ "@type": "WebPage";
82
+ "@id": string;
83
+ };
84
+ keywords?: string | string[];
85
+ wordCount?: number;
86
+ timeRequired?: string; // ISO 8601 duration format (PT5M = 5 minutes)
87
+ inLanguage?: string;
88
+ isAccessibleForFree?: boolean;
89
+ genre?: string | string[];
90
+ about?: {
91
+ "@type": "Thing";
92
+ name: string;
93
+ }[];
94
+ mentions?: {
95
+ "@type": "Thing";
96
+ name: string;
97
+ }[];
98
+ }
99
+
100
+ /**
101
+ * WebSite schema for the blog home page
102
+ * @see https://schema.org/WebSite
103
+ */
104
+ export interface WebSiteSchema extends BaseStructuredData {
105
+ "@type": "WebSite";
106
+ name: string;
107
+ description?: string;
108
+ url: string;
109
+ publisher: OrganizationSchema;
110
+ inLanguage?: string;
111
+ potentialAction?: {
112
+ "@type": "SearchAction";
113
+ target: {
114
+ "@type": "EntryPoint";
115
+ urlTemplate: string;
116
+ };
117
+ "query-input": string;
118
+ };
119
+ }
120
+
121
+ /**
122
+ * BreadcrumbList schema for navigation
123
+ * @see https://schema.org/BreadcrumbList
124
+ */
125
+ export interface BreadcrumbListSchema extends BaseStructuredData {
126
+ "@type": "BreadcrumbList";
127
+ itemListElement: {
128
+ "@type": "ListItem";
129
+ position: number;
130
+ name: string;
131
+ item: string;
132
+ }[];
133
+ }
134
+
135
+ /**
136
+ * Options for customizing structured data generation
137
+ */
138
+ export interface StructuredDataOptions {
139
+ /** Include optional properties like keywords, mentions, etc. */
140
+ includeOptionalProperties?: boolean;
141
+ /** Include search action in WebSite schema */
142
+ includeSearchAction?: boolean;
143
+ /** Custom domain override for URLs */
144
+ customDomain?: string;
145
+ /** Path prefix for blog URLs (e.g., '/blog', '/articles') */
146
+ pathPrefix?: string;
147
+ /** Language code (e.g., 'en-US') */
148
+ language?: string;
149
+ /** Whether content is free to access */
150
+ isAccessibleForFree?: boolean;
151
+ }
152
+
153
+ /**
154
+ * Generate comprehensive structured data for a blog article
155
+ */
156
+ export function generateArticleStructuredData(
157
+ article: PublishedArticle,
158
+ options: StructuredDataOptions = {},
159
+ ): BlogPostingSchema {
160
+ const {
161
+ customDomain,
162
+ pathPrefix = "/blog",
163
+ language,
164
+ isAccessibleForFree = true,
165
+ includeOptionalProperties = true,
166
+ } = options;
167
+
168
+ const domain = customDomain ?? article.site.domain;
169
+ const canonicalUrl = customDomain
170
+ ? `https://${domain}${pathPrefix}/${article.slug}`
171
+ : (article.meta.canonicalUrl ??
172
+ `https://${domain}${pathPrefix}/${article.slug}`);
173
+
174
+ // Generate author schema
175
+ const authorSchema = article.author
176
+ ? generateAuthorStructuredData(article.author, { customDomain, pathPrefix })
177
+ : undefined;
178
+
179
+ // Generate publisher schema
180
+ const publisherSchema = generateOrganizationStructuredData(article.site, {
181
+ customDomain,
182
+ pathPrefix,
183
+ });
184
+
185
+ // Convert reading seconds to ISO 8601 duration
186
+ const readingMinutes = Math.max(1, Math.ceil(article.readingSeconds / 60));
187
+ const timeRequired = `PT${readingMinutes}M`;
188
+
189
+ const schema: BlogPostingSchema = {
190
+ "@context": "https://schema.org",
191
+ "@type": "BlogPosting",
192
+ "@id": `${canonicalUrl}#article`,
193
+ headline: article.title,
194
+ description: article.excerpt,
195
+ datePublished: article.publishedAt,
196
+ dateModified: article.updatedAt,
197
+ author: authorSchema,
198
+ publisher: publisherSchema,
199
+ mainEntityOfPage: {
200
+ "@type": "WebPage",
201
+ "@id": canonicalUrl,
202
+ },
203
+ wordCount: article.wordCount,
204
+ timeRequired,
205
+ isAccessibleForFree,
206
+ };
207
+
208
+ // Add optional properties if enabled
209
+ if (includeOptionalProperties) {
210
+ // Featured image
211
+ if (article.featuredImage) {
212
+ schema.image = article.featuredImage;
213
+ }
214
+
215
+ // Keywords
216
+ if (article.meta.keywords.length > 0) {
217
+ schema.keywords = [...article.meta.keywords];
218
+ }
219
+
220
+ // Language
221
+ if (language ?? article.site.locale) {
222
+ schema.inLanguage = language ?? article.site.locale;
223
+ }
224
+
225
+ // Extract topics from tone adjectives or keywords for 'about' property
226
+ if (article.site.toneAdjectives.length > 0) {
227
+ schema.about = article.site.toneAdjectives.map((topic) => ({
228
+ "@type": "Thing" as const,
229
+ name: topic,
230
+ }));
231
+ }
232
+
233
+ // Genre based on site description or tone
234
+ if (article.site.toneAdjectives.length > 0) {
235
+ schema.genre = [...article.site.toneAdjectives];
236
+ }
237
+ }
238
+
239
+ return schema;
240
+ }
241
+
242
+ /**
243
+ * Generate structured data for an author
244
+ */
245
+ export function generateAuthorStructuredData(
246
+ author: PublishedAuthor,
247
+ options: StructuredDataOptions = {},
248
+ ): PersonSchema {
249
+ const { customDomain, pathPrefix = "/blog" } = options;
250
+
251
+ const schema: PersonSchema = {
252
+ "@context": "https://schema.org",
253
+ "@type": "Person",
254
+ "@id": `#author-${author.id}`,
255
+ name: author.name,
256
+ identifier: author.id,
257
+ };
258
+
259
+ // Add optional properties
260
+ if (author.bio) {
261
+ schema.description = author.bio;
262
+ }
263
+
264
+ if (author.avatarUrl) {
265
+ schema.image = author.avatarUrl;
266
+ }
267
+
268
+ const sameAs = [
269
+ author.socialLinks?.website,
270
+ author.socialLinks?.x,
271
+ author.socialLinks?.linkedin,
272
+ author.socialLinks?.github,
273
+ ].filter((url): url is string => Boolean(url));
274
+
275
+ if (sameAs.length > 0) {
276
+ schema.sameAs = sameAs;
277
+ }
278
+
279
+ if (author.slug?.trim()) {
280
+ const authorUrl = customDomain
281
+ ? `https://${customDomain}${buildAuthorPath(pathPrefix, author.slug)}`
282
+ : buildAuthorPath(pathPrefix, author.slug);
283
+ schema.url = authorUrl;
284
+ }
285
+
286
+ // Additional type for guest authors
287
+ if (author.isGuest) {
288
+ schema.additionalType = "GuestAuthor";
289
+ }
290
+
291
+ // Add knowledge areas from tone adjectives
292
+ if (author.toneAdjectives && author.toneAdjectives.length > 0) {
293
+ schema.knowsAbout = [...author.toneAdjectives];
294
+ }
295
+
296
+ return schema;
297
+ }
298
+
299
+ /**
300
+ * Generate structured data for a blog site organization
301
+ */
302
+ export function generateOrganizationStructuredData(
303
+ site: PublishedSite,
304
+ options: StructuredDataOptions = {},
305
+ ): OrganizationSchema {
306
+ const { customDomain, pathPrefix = "/blog" } = options;
307
+
308
+ const domain = customDomain ?? site.domain;
309
+ const siteUrl = `https://${domain}${pathPrefix}`;
310
+
311
+ const schema: OrganizationSchema = {
312
+ "@context": "https://schema.org",
313
+ "@type": "Organization",
314
+ "@id": `${siteUrl}#organization`,
315
+ name: site.name,
316
+ url: siteUrl,
317
+ };
318
+
319
+ // Add optional properties
320
+ if (site.description?.trim()) {
321
+ schema.description = site.description;
322
+ }
323
+
324
+ if (site.tagline?.trim()) {
325
+ schema.description = schema.description
326
+ ? `${schema.description}. ${site.tagline}`
327
+ : site.tagline;
328
+ }
329
+
330
+ return schema;
331
+ }
332
+
333
+ /**
334
+ * Generate structured data for the blog website
335
+ */
336
+ export function generateWebSiteStructuredData(
337
+ site: PublishedSite,
338
+ options: StructuredDataOptions = {},
339
+ ): WebSiteSchema {
340
+ const {
341
+ customDomain,
342
+ pathPrefix = "/blog",
343
+ language,
344
+ includeSearchAction = true,
345
+ } = options;
346
+
347
+ const domain = customDomain ?? site.domain;
348
+ const siteUrl = `https://${domain}${pathPrefix}`;
349
+
350
+ const publisherSchema = generateOrganizationStructuredData(site, options);
351
+
352
+ const schema: WebSiteSchema = {
353
+ "@context": "https://schema.org",
354
+ "@type": "WebSite",
355
+ "@id": `${siteUrl}#website`,
356
+ name: site.name,
357
+ url: siteUrl,
358
+ publisher: publisherSchema,
359
+ };
360
+
361
+ // Add optional properties
362
+ if (site.description?.trim()) {
363
+ schema.description = site.description;
364
+ }
365
+
366
+ if (language ?? site.locale) {
367
+ schema.inLanguage = language ?? site.locale;
368
+ }
369
+
370
+ // Add search action for better search engine integration
371
+ if (includeSearchAction) {
372
+ schema.potentialAction = {
373
+ "@type": "SearchAction",
374
+ target: {
375
+ "@type": "EntryPoint",
376
+ urlTemplate: `${siteUrl}?search={search_term_string}`,
377
+ },
378
+ "query-input": "required name=search_term_string",
379
+ };
380
+ }
381
+
382
+ return schema;
383
+ }
384
+
385
+ /**
386
+ * Generate breadcrumb structured data for navigation
387
+ */
388
+ export function generateBreadcrumbStructuredData(
389
+ breadcrumbs: Array<{ name: string; url: string }>,
390
+ options: StructuredDataOptions = {},
391
+ ): BreadcrumbListSchema {
392
+ const { customDomain } = options;
393
+
394
+ return {
395
+ "@context": "https://schema.org",
396
+ "@type": "BreadcrumbList",
397
+ itemListElement: breadcrumbs.map((crumb, index) => ({
398
+ "@type": "ListItem" as const,
399
+ position: index + 1,
400
+ name: crumb.name,
401
+ item:
402
+ customDomain && !crumb.url.startsWith("http")
403
+ ? `https://${customDomain}${crumb.url}`
404
+ : crumb.url,
405
+ })),
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Generate combined structured data for a complete article page
411
+ */
412
+ export function generateCompleteArticleStructuredData(
413
+ article: PublishedArticle,
414
+ options: StructuredDataOptions = {},
415
+ ): Array<BlogPostingSchema | WebSiteSchema | BreadcrumbListSchema> {
416
+ const schemas: Array<
417
+ BlogPostingSchema | WebSiteSchema | BreadcrumbListSchema
418
+ > = [];
419
+
420
+ // Article schema
421
+ schemas.push(generateArticleStructuredData(article, options));
422
+
423
+ // Website schema
424
+ schemas.push(generateWebSiteStructuredData(article.site, options));
425
+
426
+ // Breadcrumb schema for article pages
427
+ const pathPrefix = options.pathPrefix ?? "/blog";
428
+ const breadcrumbs = [
429
+ { name: "Home", url: "/" },
430
+ { name: article.site.name, url: pathPrefix },
431
+ { name: article.title, url: `${pathPrefix}/${article.slug}` },
432
+ ];
433
+ schemas.push(generateBreadcrumbStructuredData(breadcrumbs, options));
434
+
435
+ return schemas;
436
+ }
437
+
438
+ /**
439
+ * Generate structured data for the blog home page
440
+ */
441
+ export function generateBlogHomeStructuredData(
442
+ site: PublishedSite,
443
+ featuredArticles: PublishedArticle[] = [],
444
+ options: StructuredDataOptions = {},
445
+ ): Array<WebSiteSchema | BlogPostingSchema> {
446
+ const schemas: Array<WebSiteSchema | BlogPostingSchema> = [];
447
+
448
+ // Website schema for the home page
449
+ schemas.push(generateWebSiteStructuredData(site, options));
450
+
451
+ // Featured articles schemas (limit to first 3-5 for performance)
452
+ if (featuredArticles.length > 0) {
453
+ const articleSchemas = featuredArticles
454
+ .slice(0, 5)
455
+ .map((article) => generateArticleStructuredData(article, options));
456
+ schemas.push(...articleSchemas);
457
+ }
458
+
459
+ return schemas;
460
+ }
461
+
462
+ /**
463
+ * Validate structured data schema
464
+ */
465
+ export function validateStructuredData(schema: BaseStructuredData): {
466
+ isValid: boolean;
467
+ errors: string[];
468
+ } {
469
+ const errors: string[] = [];
470
+
471
+ // Check required fields
472
+ if (!schema["@context"]) {
473
+ errors.push("Missing required @context property");
474
+ } else if (schema["@context"] !== "https://schema.org") {
475
+ errors.push("@context must be 'https://schema.org'");
476
+ }
477
+
478
+ if (!schema["@type"]) {
479
+ errors.push("Missing required @type property");
480
+ }
481
+
482
+ // Type-specific validations
483
+ if (schema["@type"] === "BlogPosting") {
484
+ const blogSchema = schema as BlogPostingSchema;
485
+ if (!blogSchema.headline) {
486
+ errors.push("BlogPosting: Missing required headline");
487
+ }
488
+ if (!blogSchema.datePublished) {
489
+ errors.push("BlogPosting: Missing required datePublished");
490
+ }
491
+ if (!blogSchema.author) {
492
+ errors.push("BlogPosting: Missing required author");
493
+ }
494
+ if (!blogSchema.publisher) {
495
+ errors.push("BlogPosting: Missing required publisher");
496
+ }
497
+ }
498
+
499
+ if (schema["@type"] === "Person") {
500
+ const personSchema = schema as PersonSchema;
501
+ if (!personSchema.name) {
502
+ errors.push("Person: Missing required name");
503
+ }
504
+ }
505
+
506
+ if (schema["@type"] === "Organization") {
507
+ const orgSchema = schema as OrganizationSchema;
508
+ if (!orgSchema.name) {
509
+ errors.push("Organization: Missing required name");
510
+ }
511
+ }
512
+
513
+ return {
514
+ isValid: errors.length === 0,
515
+ errors,
516
+ };
517
+ }
518
+
519
+ /**
520
+ * Convert structured data to JSON-LD string for HTML embedding
521
+ */
522
+ export function structuredDataToJsonLd(
523
+ schema: BaseStructuredData | BaseStructuredData[],
524
+ ): string {
525
+ return JSON.stringify(schema, null, 0);
526
+ }
527
+
528
+ /**
529
+ * Create a React script element for JSON-LD structured data
530
+ */
531
+ export function createStructuredDataScript(
532
+ schema: BaseStructuredData | BaseStructuredData[],
533
+ ) {
534
+ return {
535
+ type: "application/ld+json",
536
+ dangerouslySetInnerHTML: {
537
+ __html: structuredDataToJsonLd(schema),
538
+ },
539
+ };
540
+ }
541
+
542
+ /**
543
+ * Extract keywords from article content for structured data
544
+ */
545
+ export function extractKeywordsFromArticle(
546
+ article: PublishedArticle,
547
+ ): string[] {
548
+ const keywords = new Set<string>();
549
+
550
+ // Add meta keywords
551
+ article.meta.keywords.forEach((keyword) => keywords.add(keyword));
552
+
553
+ // Add site tone adjectives as topical keywords
554
+ article.site.toneAdjectives.forEach((tone) => keywords.add(tone));
555
+
556
+ // Add author tone adjectives
557
+ if (article.author) {
558
+ article.author.toneAdjectives.forEach((tone) => keywords.add(tone));
559
+ }
560
+
561
+ return Array.from(keywords);
562
+ }
563
+
564
+ /**
565
+ * Error types for structured data operations
566
+ */
567
+ export class StructuredDataError extends Error {
568
+ constructor(
569
+ message: string,
570
+ public readonly code: string,
571
+ public readonly details?: unknown,
572
+ ) {
573
+ super(message);
574
+ this.name = "StructuredDataError";
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Generate structured data with error handling
580
+ */
581
+ export function safeGenerateArticleStructuredData(
582
+ article: PublishedArticle,
583
+ options: StructuredDataOptions = {},
584
+ ): { schema?: BlogPostingSchema; error?: StructuredDataError } {
585
+ try {
586
+ const schema = generateArticleStructuredData(article, options);
587
+ const validation = validateStructuredData(schema);
588
+
589
+ if (!validation.isValid) {
590
+ return {
591
+ error: new StructuredDataError(
592
+ "Generated schema validation failed",
593
+ "VALIDATION_FAILED",
594
+ validation.errors,
595
+ ),
596
+ };
597
+ }
598
+
599
+ return { schema };
600
+ } catch (error) {
601
+ return {
602
+ error: new StructuredDataError(
603
+ "Failed to generate structured data",
604
+ "GENERATION_FAILED",
605
+ error,
606
+ ),
607
+ };
608
+ }
609
+ }
@@ -0,0 +1,56 @@
1
+ import type { PublishedArticle, PublishedTopic } from "../types/contracts";
2
+
3
+ export interface TopicArchive {
4
+ topic: PublishedTopic;
5
+ articles: PublishedArticle[];
6
+ }
7
+
8
+ export function getArticlePrimaryTopic(
9
+ article: PublishedArticle,
10
+ ): PublishedTopic | null {
11
+ const topics = article.topics ?? [];
12
+ return topics.find((topic) => topic.isPrimary) ?? topics[0] ?? null;
13
+ }
14
+
15
+ export function getAllTopics(articles: PublishedArticle[]): PublishedTopic[] {
16
+ const topicsBySlug = new Map<string, PublishedTopic>();
17
+
18
+ for (const article of articles) {
19
+ for (const topic of article.topics ?? []) {
20
+ if (!topicsBySlug.has(topic.slug)) {
21
+ topicsBySlug.set(topic.slug, topic);
22
+ }
23
+ }
24
+ }
25
+
26
+ return Array.from(topicsBySlug.values()).sort((a, b) =>
27
+ a.name.localeCompare(b.name),
28
+ );
29
+ }
30
+
31
+ export function getTopicArchive(
32
+ articles: PublishedArticle[],
33
+ topicSlug: string,
34
+ ): TopicArchive | null {
35
+ const matchingArticles = articles.filter((article) =>
36
+ (article.topics ?? []).some((topic) => topic.slug === topicSlug),
37
+ );
38
+
39
+ if (matchingArticles.length === 0) {
40
+ return null;
41
+ }
42
+
43
+ const topic =
44
+ matchingArticles
45
+ .flatMap((article) => article.topics ?? [])
46
+ .find((candidate) => candidate.slug === topicSlug) ?? null;
47
+
48
+ if (!topic) {
49
+ return null;
50
+ }
51
+
52
+ return {
53
+ topic,
54
+ articles: matchingArticles,
55
+ };
56
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * URL helpers shared by package UI, metadata, and sitemap integrations.
3
+ */
4
+
5
+ export function normalizeBlogBasePath(basePath = "/blog"): string {
6
+ const trimmed = basePath.trim();
7
+ if (!trimmed || trimmed === "/") return "";
8
+
9
+ return trimmed.replace(/^(?!\/)/, "/").replace(/\/+$/, "");
10
+ }
11
+
12
+ export function buildBlogPath(
13
+ basePath: string | undefined,
14
+ slug?: string,
15
+ ): string {
16
+ const normalizedBasePath = normalizeBlogBasePath(basePath);
17
+ const cleanSlug = slug?.replace(/^\/+/, "").replace(/\/+$/, "");
18
+
19
+ if (!cleanSlug) {
20
+ return normalizedBasePath || "/";
21
+ }
22
+
23
+ return `${normalizedBasePath}/${cleanSlug}`.replace(/\/+/g, "/");
24
+ }
25
+
26
+ export function buildAuthorPath(
27
+ basePath: string | undefined,
28
+ authorSlug: string,
29
+ ): string {
30
+ return buildBlogPath(basePath, `authors/${authorSlug}`);
31
+ }
32
+
33
+ export function getPathPrefixFromBlogUrl(blogUrl: string): string {
34
+ const parsed = new URL(blogUrl);
35
+ return normalizeBlogBasePath(parsed.pathname);
36
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }