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