@xexr/blog-platform 1.0.6 → 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 (43) 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 +224 -98
  4. package/dist/app/blog/page.tsx +83 -2
  5. package/dist/components/ArticleReadingProgress.tsx +208 -0
  6. package/dist/components/ArticleTopActions.tsx +179 -0
  7. package/dist/components/AuthorByline.tsx +569 -0
  8. package/dist/components/ExpandableArticleImage.tsx +79 -0
  9. package/dist/components/Pagination.tsx +236 -0
  10. package/dist/components/TopicArchivePages.tsx +204 -0
  11. package/dist/components/ui/badge.tsx +36 -0
  12. package/dist/components/ui/button.tsx +59 -0
  13. package/dist/components/ui/card.tsx +92 -0
  14. package/dist/components/ui/pagination.tsx +127 -0
  15. package/dist/components/ui/separator.tsx +29 -0
  16. package/dist/index.d.mts +88 -71
  17. package/dist/index.d.ts +88 -71
  18. package/dist/index.js +83 -8
  19. package/dist/index.js.map +1 -1
  20. package/dist/index.mjs +83 -8
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/lib/article-ui.ts +158 -0
  23. package/dist/lib/auth.ts +354 -0
  24. package/dist/lib/cors.ts +243 -0
  25. package/dist/lib/database.ts +277 -0
  26. package/dist/lib/init.ts +186 -0
  27. package/dist/lib/mdx.ts +73 -0
  28. package/dist/lib/metadata.ts +558 -0
  29. package/dist/lib/ratelimit.ts +224 -0
  30. package/dist/lib/security-headers.ts +322 -0
  31. package/dist/lib/seo.ts +295 -0
  32. package/dist/lib/sitemap.ts +719 -0
  33. package/dist/lib/storage/drizzle.ts +400 -0
  34. package/dist/lib/structured-data.ts +608 -0
  35. package/dist/lib/topics.ts +56 -0
  36. package/dist/lib/urls.ts +29 -0
  37. package/dist/lib/utils.ts +6 -0
  38. package/dist/lib/version.ts +154 -0
  39. package/dist/types/contracts.ts +256 -0
  40. package/dist/types/index.d.mts +37 -1
  41. package/dist/types/index.d.ts +37 -1
  42. package/dist/types/index.ts +16 -0
  43. package/package.json +14 -9
@@ -0,0 +1,154 @@
1
+ // Import package.json for version info (works with resolveJsonModule)
2
+ import packageJson from "../../package.json";
3
+
4
+ /**
5
+ * Version information
6
+ */
7
+ export interface VersionInfo {
8
+ name: string;
9
+ version: string;
10
+ major: number;
11
+ minor: number;
12
+ patch: number;
13
+ }
14
+
15
+ /**
16
+ * Version compatibility result
17
+ */
18
+ export interface VersionCompatibilityResult {
19
+ compatible: boolean;
20
+ errorCode?: string;
21
+ errorMessage?: string;
22
+ warning?: string;
23
+ }
24
+
25
+ /**
26
+ * Version compatibility error codes (matches PRD error codes)
27
+ */
28
+ export const VERSION_ERROR_CODES = {
29
+ PUB009: "Major version incompatibility",
30
+ PUB010: "Minor version warning",
31
+ } as const;
32
+
33
+ /**
34
+ * Parse semantic version string into components
35
+ * @param version - Version string (e.g., "1.2.3")
36
+ * @returns Parsed version components
37
+ */
38
+ function parseVersion(version: string): { major: number; minor: number; patch: number } {
39
+ const cleaned = version.replace(/^v/, ""); // Remove 'v' prefix if present
40
+ const parts = cleaned.split(".");
41
+
42
+ if (parts.length < 3) {
43
+ throw new Error(`Invalid version format: ${version}`);
44
+ }
45
+
46
+ const major = parseInt(parts[0] ?? "0", 10);
47
+ const minor = parseInt(parts[1] ?? "0", 10);
48
+ const patch = parseInt(parts[2]?.split("-")[0] ?? "0", 10); // Handle pre-release versions
49
+
50
+ if (isNaN(major) || isNaN(minor) || isNaN(patch)) {
51
+ throw new Error(`Invalid version format: ${version}`);
52
+ }
53
+
54
+ return { major, minor, patch };
55
+ }
56
+
57
+ /**
58
+ * Get blog platform package version (current package)
59
+ * @returns Blog platform version information
60
+ */
61
+ export function getBlogPlatformVersion(): VersionInfo {
62
+ // Use imported package.json (works reliably in both CJS and ESM)
63
+ const versionComponents = parseVersion(packageJson.version);
64
+
65
+ return {
66
+ name: packageJson.name,
67
+ version: packageJson.version,
68
+ ...versionComponents,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Parse version header from incoming request
74
+ * Expected format: "bloggy/1.0.0 platform/1.0.0"
75
+ * @param headerValue - Version header value
76
+ * @returns Parsed version information or null if invalid
77
+ */
78
+ export function parseVersionHeader(headerValue: string): {
79
+ bloggyVersion?: VersionInfo;
80
+ platformVersion?: VersionInfo;
81
+ } | null {
82
+ try {
83
+ const parts = headerValue.split(" ");
84
+ const result: { bloggyVersion?: VersionInfo; platformVersion?: VersionInfo } = {};
85
+
86
+ for (const part of parts) {
87
+ const [name, version] = part.split("/");
88
+ if (!name || !version) continue;
89
+
90
+ const versionComponents = parseVersion(version);
91
+ const versionInfo: VersionInfo = {
92
+ name: name,
93
+ version: version,
94
+ ...versionComponents,
95
+ };
96
+
97
+ if (name === "bloggy") {
98
+ result.bloggyVersion = versionInfo;
99
+ } else if (name === "platform") {
100
+ result.platformVersion = versionInfo;
101
+ }
102
+ }
103
+
104
+ return result;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Check version compatibility between two versions
112
+ * Based on semantic versioning rules:
113
+ * - Major version differences are incompatible (PUB009)
114
+ * - Minor version differences are warnings (PUB010)
115
+ * - Patch version differences are acceptable
116
+ *
117
+ * @param version1 - First version
118
+ * @param version2 - Second version
119
+ * @returns Compatibility result
120
+ */
121
+ export function checkVersionCompatibility(
122
+ version1: VersionInfo,
123
+ version2: VersionInfo,
124
+ ): VersionCompatibilityResult {
125
+ try {
126
+ // Major version incompatibility
127
+ if (version1.major !== version2.major) {
128
+ return {
129
+ compatible: false,
130
+ errorCode: "PUB009",
131
+ errorMessage: `Major version incompatibility: ${version1.name} ${version1.version} is not compatible with ${version2.name} ${version2.version}. Please upgrade.`,
132
+ };
133
+ }
134
+
135
+ // Minor version warning
136
+ if (version1.minor !== version2.minor) {
137
+ return {
138
+ compatible: true,
139
+ errorCode: "PUB010",
140
+ warning: `Minor version difference: ${version1.name} ${version1.version} with ${version2.name} ${version2.version}. Some features may not be available.`,
141
+ };
142
+ }
143
+
144
+ // Compatible (same major.minor, patch differences are okay)
145
+ return {
146
+ compatible: true,
147
+ };
148
+ } catch (error) {
149
+ return {
150
+ compatible: false,
151
+ errorMessage: `Failed to check version compatibility: ${error instanceof Error ? error.message : "Unknown error"}`,
152
+ };
153
+ }
154
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Public API contracts for the @xexr/blog-platform package.
3
+ *
4
+ * These types define the public interface between Bloggy and external blog platforms.
5
+ * They must remain stable and backward-compatible to ensure smooth upgrades.
6
+ *
7
+ * IMPORTANT: These contracts should never import from the main Bloggy application
8
+ * to maintain clean separation of concerns and prevent coupling.
9
+ */
10
+
11
+ /**
12
+ * Public profile and social links for an author.
13
+ */
14
+ export interface PublishedAuthorSocialLinks {
15
+ /** Author website or profile URL */
16
+ readonly website?: string;
17
+
18
+ /** X/Twitter profile URL */
19
+ readonly x?: string;
20
+
21
+ /** LinkedIn profile URL */
22
+ readonly linkedin?: string;
23
+
24
+ /** GitHub profile URL */
25
+ readonly github?: string;
26
+
27
+ /** Public contact email address */
28
+ readonly email?: string;
29
+ }
30
+
31
+ /**
32
+ * Represents a published author with essential information for bylines and attribution.
33
+ */
34
+ export interface PublishedAuthor {
35
+ /** Unique identifier for the author */
36
+ readonly id: string;
37
+
38
+ /** Author's full name */
39
+ readonly name: string;
40
+
41
+ /** URL-friendly slug for author pages */
42
+ readonly slug: string;
43
+
44
+ /** Author's bio/description (optional) */
45
+ readonly bio?: string;
46
+
47
+ /** Author avatar image URL (optional) */
48
+ readonly avatarUrl?: string;
49
+
50
+ /** Public profile and social links (optional) */
51
+ readonly socialLinks?: PublishedAuthorSocialLinks;
52
+
53
+ /** Pronouns for proper attribution */
54
+ readonly pronouns?: string;
55
+
56
+ /** Whether this is a guest author */
57
+ readonly isGuest: boolean;
58
+
59
+ /** Tone adjectives that describe this author's writing style */
60
+ readonly toneAdjectives: readonly string[];
61
+ }
62
+
63
+ /**
64
+ * Represents a published site with essential metadata for blog rendering.
65
+ */
66
+ export interface PublishedSite {
67
+ /** Unique identifier for the site */
68
+ readonly id: string;
69
+
70
+ /** Site name/title */
71
+ readonly name: string;
72
+
73
+ /** Site tagline/subtitle */
74
+ readonly tagline: string;
75
+
76
+ /** Site description */
77
+ readonly description: string;
78
+
79
+ /** Primary domain for the site */
80
+ readonly domain: string;
81
+
82
+ /** Site-wide tone adjectives for content context */
83
+ readonly toneAdjectives: readonly string[];
84
+
85
+ /** Default locale/language code */
86
+ readonly locale: string;
87
+ }
88
+
89
+ /**
90
+ * Public topic/category taxonomy assigned to published articles.
91
+ */
92
+ export interface PublishedTopic {
93
+ /** Unique identifier for the topic */
94
+ readonly id: string;
95
+
96
+ /** Display name */
97
+ readonly name: string;
98
+
99
+ /** URL-friendly slug for topic archive routing */
100
+ readonly slug: string;
101
+
102
+ /** Optional public description */
103
+ readonly description?: string;
104
+
105
+ /** Whether this is the article's primary topic */
106
+ readonly isPrimary: boolean;
107
+ }
108
+
109
+ /**
110
+ * Represents a published article with all necessary metadata for rendering
111
+ * on external blog platforms.
112
+ */
113
+ export interface PublishedArticle {
114
+ /** Unique identifier for the article */
115
+ readonly id: string;
116
+
117
+ /** URL-friendly slug for routing */
118
+ readonly slug: string;
119
+
120
+ /** Article title/headline */
121
+ readonly title: string;
122
+
123
+ /** Brief excerpt/summary for meta descriptions and previews */
124
+ readonly excerpt: string;
125
+
126
+ /** Full article content in markdown format */
127
+ readonly content: string;
128
+
129
+ /** Estimated reading time in seconds */
130
+ readonly readingSeconds: number;
131
+
132
+ /** Word count for analytics */
133
+ readonly wordCount: number;
134
+
135
+ /** Publication date as ISO 8601 string */
136
+ readonly publishedAt: string;
137
+
138
+ /** Last updated date as ISO 8601 string */
139
+ readonly updatedAt: string;
140
+
141
+ /** Author information (can be null for anonymous posts) */
142
+ readonly author: PublishedAuthor | null;
143
+
144
+ /** Site information */
145
+ readonly site: PublishedSite;
146
+
147
+ /** Public topic taxonomy assigned to the article */
148
+ readonly topics?: readonly PublishedTopic[];
149
+
150
+ /** Featured image URL (optional) */
151
+ readonly featuredImage?: string;
152
+
153
+ /** Alt text for featured image */
154
+ readonly featuredImageAlt?: string;
155
+
156
+ /** Featured image R2 key for CDN optimization (optional) */
157
+ readonly featuredImageKey?: string;
158
+
159
+ /** SEO meta tags */
160
+ readonly meta: {
161
+ /** Meta title (defaults to article title if not provided) */
162
+ readonly title?: string;
163
+
164
+ /** Meta description (defaults to excerpt if not provided) */
165
+ readonly description?: string;
166
+
167
+ /** Canonical URL for SEO */
168
+ readonly canonicalUrl?: string;
169
+
170
+ /** Keywords for SEO */
171
+ readonly keywords: readonly string[];
172
+
173
+ /** Open Graph image URL */
174
+ readonly ogImage?: string;
175
+ };
176
+
177
+ /** Article version for optimistic locking */
178
+ readonly version: number;
179
+ }
180
+
181
+ /**
182
+ * Payload structure for publishing operations.
183
+ * Used when sending publish/unpublish requests to blog platforms.
184
+ */
185
+ export interface PublishPayload {
186
+ /** The article to publish */
187
+ readonly article: PublishedArticle;
188
+
189
+ /** Idempotency key to prevent duplicate operations */
190
+ readonly idempotencyKey: string;
191
+
192
+ /** Optional slug override (defaults to article.slug) */
193
+ readonly slug?: string;
194
+
195
+ /** Optional meta description override (defaults to article.excerpt) */
196
+ readonly metaDescription?: string;
197
+
198
+ /** Platform-specific configuration */
199
+ readonly platformConfig?: Record<string, unknown>;
200
+
201
+ /** Whether this is a dry run (validation only) */
202
+ readonly dryRun?: boolean;
203
+ }
204
+
205
+ /**
206
+ * Result structure returned from publishing operations.
207
+ */
208
+ export interface PublishResult {
209
+ /** Whether the operation was successful */
210
+ readonly success: boolean;
211
+
212
+ /** The published article URL if successful */
213
+ readonly url?: string;
214
+
215
+ /** Platform-specific post ID */
216
+ readonly platformPostId?: string;
217
+
218
+ /** Publication timestamp as ISO 8601 string */
219
+ readonly publishedAt?: string;
220
+
221
+ /** Error information if unsuccessful */
222
+ readonly error?: {
223
+ /** Error code (PUB001-PUB011) */
224
+ readonly code: string;
225
+
226
+ /** Human-readable error message */
227
+ readonly message: string;
228
+
229
+ /** Additional error details */
230
+ readonly details?: Record<string, unknown>;
231
+ };
232
+
233
+ /** Validation warnings (for dry runs or minor issues) */
234
+ readonly warnings?: readonly string[];
235
+
236
+ /** Idempotency key used for this operation */
237
+ readonly idempotencyKey: string;
238
+ }
239
+
240
+ /**
241
+ * Payload structure for unpublishing operations.
242
+ * Used when sending unpublish requests to blog platforms.
243
+ */
244
+ export interface UnpublishPayload {
245
+ /** The slug of the article to unpublish */
246
+ readonly slug: string;
247
+
248
+ /** Idempotency key to prevent duplicate operations */
249
+ readonly idempotencyKey: string;
250
+
251
+ /** Platform-specific configuration */
252
+ readonly platformConfig?: Record<string, unknown>;
253
+
254
+ /** Whether this is a dry run (validation only) */
255
+ readonly dryRun?: boolean;
256
+ }
@@ -7,6 +7,21 @@
7
7
  * IMPORTANT: These contracts should never import from the main Bloggy application
8
8
  * to maintain clean separation of concerns and prevent coupling.
9
9
  */
10
+ /**
11
+ * Public profile and social links for an author.
12
+ */
13
+ interface PublishedAuthorSocialLinks {
14
+ /** Author website or profile URL */
15
+ readonly website?: string;
16
+ /** X/Twitter profile URL */
17
+ readonly x?: string;
18
+ /** LinkedIn profile URL */
19
+ readonly linkedin?: string;
20
+ /** GitHub profile URL */
21
+ readonly github?: string;
22
+ /** Public contact email address */
23
+ readonly email?: string;
24
+ }
10
25
  /**
11
26
  * Represents a published author with essential information for bylines and attribution.
12
27
  */
@@ -19,6 +34,10 @@ interface PublishedAuthor {
19
34
  readonly slug: string;
20
35
  /** Author's bio/description (optional) */
21
36
  readonly bio?: string;
37
+ /** Author avatar image URL (optional) */
38
+ readonly avatarUrl?: string;
39
+ /** Public profile and social links (optional) */
40
+ readonly socialLinks?: PublishedAuthorSocialLinks;
22
41
  /** Pronouns for proper attribution */
23
42
  readonly pronouns?: string;
24
43
  /** Whether this is a guest author */
@@ -45,6 +64,21 @@ interface PublishedSite {
45
64
  /** Default locale/language code */
46
65
  readonly locale: string;
47
66
  }
67
+ /**
68
+ * Public topic/category taxonomy assigned to published articles.
69
+ */
70
+ interface PublishedTopic {
71
+ /** Unique identifier for the topic */
72
+ readonly id: string;
73
+ /** Display name */
74
+ readonly name: string;
75
+ /** URL-friendly slug for topic archive routing */
76
+ readonly slug: string;
77
+ /** Optional public description */
78
+ readonly description?: string;
79
+ /** Whether this is the article's primary topic */
80
+ readonly isPrimary: boolean;
81
+ }
48
82
  /**
49
83
  * Represents a published article with all necessary metadata for rendering
50
84
  * on external blog platforms.
@@ -72,6 +106,8 @@ interface PublishedArticle {
72
106
  readonly author: PublishedAuthor | null;
73
107
  /** Site information */
74
108
  readonly site: PublishedSite;
109
+ /** Public topic taxonomy assigned to the article */
110
+ readonly topics?: readonly PublishedTopic[];
75
111
  /** Featured image URL (optional) */
76
112
  readonly featuredImage?: string;
77
113
  /** Alt text for featured image */
@@ -139,4 +175,4 @@ interface PublishResult {
139
175
  readonly idempotencyKey: string;
140
176
  }
141
177
 
142
- export type { PublishPayload, PublishResult, PublishedArticle, PublishedAuthor, PublishedSite };
178
+ export type { PublishPayload, PublishResult, PublishedArticle, PublishedAuthor, PublishedAuthorSocialLinks, PublishedSite, PublishedTopic };
@@ -7,6 +7,21 @@
7
7
  * IMPORTANT: These contracts should never import from the main Bloggy application
8
8
  * to maintain clean separation of concerns and prevent coupling.
9
9
  */
10
+ /**
11
+ * Public profile and social links for an author.
12
+ */
13
+ interface PublishedAuthorSocialLinks {
14
+ /** Author website or profile URL */
15
+ readonly website?: string;
16
+ /** X/Twitter profile URL */
17
+ readonly x?: string;
18
+ /** LinkedIn profile URL */
19
+ readonly linkedin?: string;
20
+ /** GitHub profile URL */
21
+ readonly github?: string;
22
+ /** Public contact email address */
23
+ readonly email?: string;
24
+ }
10
25
  /**
11
26
  * Represents a published author with essential information for bylines and attribution.
12
27
  */
@@ -19,6 +34,10 @@ interface PublishedAuthor {
19
34
  readonly slug: string;
20
35
  /** Author's bio/description (optional) */
21
36
  readonly bio?: string;
37
+ /** Author avatar image URL (optional) */
38
+ readonly avatarUrl?: string;
39
+ /** Public profile and social links (optional) */
40
+ readonly socialLinks?: PublishedAuthorSocialLinks;
22
41
  /** Pronouns for proper attribution */
23
42
  readonly pronouns?: string;
24
43
  /** Whether this is a guest author */
@@ -45,6 +64,21 @@ interface PublishedSite {
45
64
  /** Default locale/language code */
46
65
  readonly locale: string;
47
66
  }
67
+ /**
68
+ * Public topic/category taxonomy assigned to published articles.
69
+ */
70
+ interface PublishedTopic {
71
+ /** Unique identifier for the topic */
72
+ readonly id: string;
73
+ /** Display name */
74
+ readonly name: string;
75
+ /** URL-friendly slug for topic archive routing */
76
+ readonly slug: string;
77
+ /** Optional public description */
78
+ readonly description?: string;
79
+ /** Whether this is the article's primary topic */
80
+ readonly isPrimary: boolean;
81
+ }
48
82
  /**
49
83
  * Represents a published article with all necessary metadata for rendering
50
84
  * on external blog platforms.
@@ -72,6 +106,8 @@ interface PublishedArticle {
72
106
  readonly author: PublishedAuthor | null;
73
107
  /** Site information */
74
108
  readonly site: PublishedSite;
109
+ /** Public topic taxonomy assigned to the article */
110
+ readonly topics?: readonly PublishedTopic[];
75
111
  /** Featured image URL (optional) */
76
112
  readonly featuredImage?: string;
77
113
  /** Alt text for featured image */
@@ -139,4 +175,4 @@ interface PublishResult {
139
175
  readonly idempotencyKey: string;
140
176
  }
141
177
 
142
- export type { PublishPayload, PublishResult, PublishedArticle, PublishedAuthor, PublishedSite };
178
+ export type { PublishPayload, PublishResult, PublishedArticle, PublishedAuthor, PublishedAuthorSocialLinks, PublishedSite, PublishedTopic };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Public type exports for @xexr/blog-platform
3
+ *
4
+ * This file provides clean imports for all public API contracts.
5
+ * These types define the interface between Bloggy and blog platforms.
6
+ */
7
+
8
+ export type {
9
+ PublishedArticle,
10
+ PublishedAuthor,
11
+ PublishedAuthorSocialLinks,
12
+ PublishedSite,
13
+ PublishedTopic,
14
+ PublishPayload,
15
+ PublishResult,
16
+ } from "./contracts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xexr/blog-platform",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "SEO-friendly blog platform for Next.js 15 with Bloggy integration",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -15,6 +15,11 @@
15
15
  "import": "./dist/types/index.mjs",
16
16
  "require": "./dist/types/index.js"
17
17
  },
18
+ "./app/blog/[slug]/page": {
19
+ "types": "./dist/app/blog/[slug]/page.d.ts",
20
+ "import": "./dist/app/blog/[slug]/page.tsx",
21
+ "default": "./dist/app/blog/[slug]/page.tsx"
22
+ },
18
23
  "./app/*": "./dist/app/*",
19
24
  "./styles/*": "./dist/styles/*"
20
25
  },
@@ -25,13 +30,6 @@
25
30
  "CHANGELOG.md"
26
31
  ],
27
32
  "license": "MIT",
28
- "scripts": {
29
- "build": "tsup",
30
- "test": "vitest",
31
- "lint": "eslint src --max-warnings 0",
32
- "typecheck": "tsc --noEmit",
33
- "prepublishOnly": "pnpm run build"
34
- },
35
33
  "peerDependencies": {
36
34
  "@libsql/client": "^0.15.0",
37
35
  "drizzle-orm": "^0.44.0",
@@ -49,6 +47,7 @@
49
47
  "date-fns": "^4.1.0",
50
48
  "lucide-react": "^1.11.0",
51
49
  "next-mdx-remote-client": "^2.1.10",
50
+ "react-icons": "^5.6.0",
52
51
  "rehype-slug": "^6.0.0",
53
52
  "remark-gfm": "^4.0.1",
54
53
  "remark-toc": "^9.0.0",
@@ -59,5 +58,11 @@
59
58
  "tsup": "^8.5.1",
60
59
  "typescript": "^5.9.3",
61
60
  "vitest": "^4.1.5"
61
+ },
62
+ "scripts": {
63
+ "build": "tsup && node scripts/copy-dist-assets.mjs",
64
+ "test": "vitest",
65
+ "lint": "eslint src --max-warnings 0",
66
+ "typecheck": "tsc --noEmit"
62
67
  }
63
- }
68
+ }