@fullstackdatasolutions/articles 0.1.0

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 (53) hide show
  1. package/README.md +324 -0
  2. package/dist/index.cjs +1027 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +232 -0
  5. package/dist/index.d.ts +232 -0
  6. package/dist/index.js +974 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/server.cjs +732 -0
  9. package/dist/server.cjs.map +1 -0
  10. package/dist/server.d.cts +127 -0
  11. package/dist/server.d.ts +127 -0
  12. package/dist/server.js +684 -0
  13. package/dist/server.js.map +1 -0
  14. package/package.json +82 -0
  15. package/src/ArticleCard.tsx +63 -0
  16. package/src/ArticleCategoryGrid.tsx +73 -0
  17. package/src/ArticleSchemas.tsx +82 -0
  18. package/src/ArticleSearchBar.tsx +50 -0
  19. package/src/ArticlesHero.tsx +33 -0
  20. package/src/ArticlesPage.tsx +167 -0
  21. package/src/CategoryArticlesPage.tsx +84 -0
  22. package/src/CommentForm.tsx +98 -0
  23. package/src/CommentItem.tsx +123 -0
  24. package/src/CommentThread.tsx +40 -0
  25. package/src/CommentsSection.tsx +84 -0
  26. package/src/FeaturedArticle.tsx +63 -0
  27. package/src/LatestArticles.tsx +42 -0
  28. package/src/LatestArticlesSection.tsx +68 -0
  29. package/src/__tests__/ArticleCard.test.tsx +78 -0
  30. package/src/__tests__/ArticleCategoryGrid.test.tsx +98 -0
  31. package/src/__tests__/ArticleSchemas.test.tsx +130 -0
  32. package/src/__tests__/ArticleSearchBar.test.tsx +79 -0
  33. package/src/__tests__/ArticlesHero.test.tsx +38 -0
  34. package/src/__tests__/ArticlesPage.test.tsx +155 -0
  35. package/src/__tests__/CategoryArticlesPage.test.tsx +156 -0
  36. package/src/__tests__/CommentForm.test.tsx +80 -0
  37. package/src/__tests__/CommentItem.test.tsx +149 -0
  38. package/src/__tests__/CommentsSection.test.tsx +118 -0
  39. package/src/__tests__/FeaturedArticle.test.tsx +85 -0
  40. package/src/__tests__/LatestArticles.test.tsx +157 -0
  41. package/src/__tests__/LatestArticlesSection.test.tsx +105 -0
  42. package/src/__tests__/jest-dom.d.ts +1 -0
  43. package/src/__tests__/seoUtils.test.ts +217 -0
  44. package/src/__tests__/useArticles.test.ts +207 -0
  45. package/src/articleTypes.ts +21 -0
  46. package/src/articlesConfig.ts +98 -0
  47. package/src/commentTypes.ts +14 -0
  48. package/src/index.ts +35 -0
  49. package/src/markdown.ts +314 -0
  50. package/src/seoUtils.ts +155 -0
  51. package/src/server-articles.ts +265 -0
  52. package/src/server.ts +25 -0
  53. package/src/useArticles.ts +93 -0
@@ -0,0 +1,265 @@
1
+ import { cache } from 'react'
2
+ import matter from 'gray-matter'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import readingTime from 'reading-time'
6
+ import { markdownToHtml } from './markdown'
7
+ import type { Article, CategoryInfo } from './articleTypes'
8
+
9
+ const articlesDirectory = path.join(process.cwd(), 'public/articles')
10
+
11
+ function getReadingTime(content: string): string {
12
+ return readingTime(content).text
13
+ }
14
+
15
+ function findArticleImage(slug: string): string | null {
16
+ try {
17
+ const articleDir = path.join(articlesDirectory, slug)
18
+ const entries: (fs.Dirent | string)[] = fs.readdirSync(articleDir, { withFileTypes: true })
19
+ const names = entries
20
+ .map((entry) => {
21
+ if (typeof entry === 'string') return entry
22
+ if (entry && typeof entry.name === 'string') return entry.name
23
+ return null
24
+ })
25
+ .filter((name): name is string => Boolean(name))
26
+ const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp']
27
+ const imageFileName = names.find((name) =>
28
+ imageExtensions.some((ext) => name.toLowerCase().endsWith(ext))
29
+ )
30
+ return imageFileName ? sanitizeImagePath(imageFileName, slug) : null
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ function sanitizeImagePath(rawPath: string, articleSlug: string): string | null {
37
+ if (!rawPath || typeof rawPath !== 'string') return null
38
+ const cleanPath = rawPath.replaceAll(/[\x00-\x1f\x7f-\x9f]/g, '')
39
+ if (cleanPath.startsWith('http://') || cleanPath.startsWith('https://')) return cleanPath
40
+ if (cleanPath.includes('..') || cleanPath.includes('\\') || cleanPath.startsWith('/')) {
41
+ console.warn(`Potentially unsafe image path detected: ${cleanPath}`)
42
+ return null
43
+ }
44
+ if (!/^[a-zA-Z0-9._/-]+$/.test(cleanPath)) {
45
+ console.warn(`Invalid characters in image path: ${cleanPath}`)
46
+ return null
47
+ }
48
+ if (cleanPath.includes('/')) {
49
+ const normalizedPath = path.normalize(cleanPath)
50
+ if (normalizedPath.startsWith('..') || normalizedPath.includes('../')) {
51
+ console.warn(`Path traversal attempt detected: ${cleanPath}`)
52
+ return null
53
+ }
54
+ return `/articles/${articleSlug}/${cleanPath}`
55
+ }
56
+ return `/articles/${articleSlug}/${cleanPath}`
57
+ }
58
+
59
+ export function getAvailableArticleSlugs(): string[] {
60
+ try {
61
+ if (!fs.existsSync(articlesDirectory)) return []
62
+ const items = fs.readdirSync(articlesDirectory, { withFileTypes: true })
63
+ return items
64
+ .filter((item) => item.isDirectory())
65
+ .filter((dir) => {
66
+ const articleDir = path.join(articlesDirectory, dir.name)
67
+ try {
68
+ const entries: (fs.Dirent | string)[] = fs.readdirSync(articleDir, {
69
+ withFileTypes: true,
70
+ })
71
+ return entries.some((entry) => {
72
+ const name = typeof entry === 'string' ? entry : entry?.name
73
+ if (name !== 'article.md') return false
74
+ if (typeof entry === 'string') return true
75
+ if (typeof entry?.isFile === 'function') return entry.isFile()
76
+ return true
77
+ })
78
+ } catch {
79
+ return false
80
+ }
81
+ })
82
+ .map((dir) => dir.name)
83
+ } catch (error) {
84
+ console.error('Error reading articles directory:', error)
85
+ return []
86
+ }
87
+ }
88
+
89
+ async function getArticleSummary(slug: string): Promise<Article | null> {
90
+ try {
91
+ const articlePath = path.join(articlesDirectory, slug, 'article.md')
92
+ if (!fs.existsSync(articlePath)) return null
93
+ const fileContent = fs.readFileSync(articlePath, 'utf8')
94
+ const { data, content: markdownContent } = matter(fileContent)
95
+ const readTime = getReadingTime(markdownContent)
96
+ let featuredImage = data.featuredImage || ''
97
+ if (featuredImage && !featuredImage.startsWith('http')) {
98
+ const sanitizedPath = sanitizeImagePath(featuredImage, slug)
99
+ featuredImage = sanitizedPath || '/placeholder-logo.png'
100
+ } else if (!featuredImage) {
101
+ featuredImage = findArticleImage(slug) || '/placeholder-logo.png'
102
+ }
103
+ let articleDate: string | undefined
104
+ if (data.date) {
105
+ try {
106
+ const parsedDate = new Date(data.date)
107
+ if (!Number.isNaN(parsedDate.getTime())) {
108
+ articleDate = parsedDate.toISOString().split('T')[0]
109
+ }
110
+ } catch {
111
+ console.warn(`Invalid date for article ${slug}: ${data.date}`)
112
+ }
113
+ }
114
+ const allTags: string[] = Array.isArray(data.tags)
115
+ ? data.tags.filter((t: unknown) => typeof t === 'string' && String(t).trim())
116
+ : []
117
+ const categories: string[] =
118
+ allTags.length > 0 ? allTags.map((t: string) => t.replaceAll('-', ' ').trim()) : ['Campaigns']
119
+ return {
120
+ slug,
121
+ title: data.title || slug.replaceAll('-', ' '),
122
+ excerpt: data.excerpt || '',
123
+ date: articleDate,
124
+ author: data.author || 'Andrew Blase',
125
+ category: categories[0],
126
+ categories,
127
+ readTime,
128
+ featuredImage,
129
+ tags: data.tags || [],
130
+ }
131
+ } catch (error) {
132
+ console.error(`Error loading article ${slug}:`, error)
133
+ return null
134
+ }
135
+ }
136
+
137
+ export const getArticleMetadata = cache(async (slug: string): Promise<Article | null> => {
138
+ try {
139
+ const articlePath = path.join(articlesDirectory, slug, 'article.md')
140
+ if (!fs.existsSync(articlePath)) return null
141
+ const fileContent = fs.readFileSync(articlePath, 'utf8')
142
+ const { data, content: markdownContent } = matter(fileContent)
143
+ const htmlContent = await markdownToHtml(markdownContent, slug)
144
+ const readTime = getReadingTime(markdownContent)
145
+ let featuredImage = data.featuredImage || ''
146
+ if (featuredImage && !featuredImage.startsWith('http')) {
147
+ const sanitizedPath = sanitizeImagePath(featuredImage, slug)
148
+ featuredImage = sanitizedPath || '/placeholder-logo.png'
149
+ } else if (!featuredImage) {
150
+ featuredImage = findArticleImage(slug) || '/placeholder-logo.png'
151
+ }
152
+ let articleDate: string | undefined
153
+ if (data.date) {
154
+ try {
155
+ const parsedDate = new Date(data.date)
156
+ if (!Number.isNaN(parsedDate.getTime())) {
157
+ articleDate = parsedDate.toISOString().split('T')[0]
158
+ }
159
+ } catch {
160
+ console.warn(`Invalid date for article ${slug}: ${data.date}`)
161
+ }
162
+ }
163
+ const allTags: string[] = Array.isArray(data.tags)
164
+ ? data.tags.filter((t: unknown) => typeof t === 'string' && String(t).trim())
165
+ : []
166
+ const categories: string[] =
167
+ allTags.length > 0 ? allTags.map((t: string) => t.replaceAll('-', ' ').trim()) : ['Campaigns']
168
+ return {
169
+ slug,
170
+ title: data.title || slug.replaceAll('-', ' '),
171
+ excerpt: data.excerpt || '',
172
+ date: articleDate,
173
+ author: data.author || 'Andrew Blase',
174
+ category: categories[0],
175
+ categories,
176
+ readTime,
177
+ featuredImage,
178
+ tags: data.tags || [],
179
+ content: markdownContent,
180
+ htmlContent,
181
+ }
182
+ } catch (error) {
183
+ console.error(`Error loading article ${slug}:`, error)
184
+ return null
185
+ }
186
+ })
187
+
188
+ export const getAllArticles = cache(async (): Promise<Article[]> => {
189
+ const slugs = getAvailableArticleSlugs()
190
+ const articles = await Promise.all(slugs.map((slug) => getArticleSummary(slug)))
191
+ const currentDate = new Date().toISOString().split('T')[0]
192
+ return articles
193
+ .filter((article): article is Article => article !== null)
194
+ .filter((article) => !article.date || article.date <= currentDate)
195
+ .sort((a, b) => {
196
+ if (!a.date && !b.date) return 0
197
+ if (!a.date) return 1
198
+ if (!b.date) return -1
199
+ return new Date(b.date).getTime() - new Date(a.date).getTime()
200
+ })
201
+ })
202
+
203
+ export async function getAdjacentArticles(
204
+ currentSlug: string
205
+ ): Promise<{ previous: Article | null; next: Article | null }> {
206
+ const allArticles = await getAllArticles()
207
+ const currentIndex = allArticles.findIndex((article) => article.slug === currentSlug)
208
+ if (currentIndex === -1) return { previous: null, next: null }
209
+ const previous = currentIndex < allArticles.length - 1 ? allArticles[currentIndex + 1] : null
210
+ const next = currentIndex > 0 ? allArticles[currentIndex - 1] : null
211
+ return { previous, next }
212
+ }
213
+
214
+ export async function searchArticles(query: string): Promise<Article[]> {
215
+ if (!query?.trim()) return getAllArticles()
216
+ const articles = await getAllArticles()
217
+ const searchTerm = query.toLowerCase().trim()
218
+ return articles.filter((article) => {
219
+ const matchesTitle = article.title.toLowerCase().includes(searchTerm)
220
+ const matchesExcerpt = article.excerpt.toLowerCase().includes(searchTerm)
221
+ const matchesAuthor = article.author.toLowerCase().includes(searchTerm)
222
+ const matchesCategory = article.categories.some((cat) => cat.toLowerCase().includes(searchTerm))
223
+ const matchesTags = article.tags?.some((tag) => tag.toLowerCase().includes(searchTerm))
224
+ return (
225
+ matchesTitle || matchesExcerpt || matchesAuthor || matchesCategory || Boolean(matchesTags)
226
+ )
227
+ })
228
+ }
229
+
230
+ export function categoryToSlug(category: string): string {
231
+ return category
232
+ .toLowerCase()
233
+ .replaceAll(/\s+/g, '-')
234
+ .replaceAll(/[^a-z0-9-]/g, '')
235
+ }
236
+
237
+ export async function getAllCategories(): Promise<CategoryInfo[]> {
238
+ const articles = await getAllArticles()
239
+ const categoryMap = new Map<string, { count: number; featuredImage: string }>()
240
+ for (const article of articles) {
241
+ for (const cat of article.categories) {
242
+ if (!categoryMap.has(cat)) {
243
+ categoryMap.set(cat, { count: 0, featuredImage: article.featuredImage })
244
+ }
245
+ categoryMap.get(cat)!.count++
246
+ }
247
+ }
248
+ return Array.from(categoryMap.entries())
249
+ .map(([name, { count, featuredImage }]) => ({
250
+ name,
251
+ slug: categoryToSlug(name),
252
+ count,
253
+ featuredImage,
254
+ }))
255
+ .sort((a, b) => b.count - a.count)
256
+ }
257
+
258
+ export async function getArticlesByCategory(categorySlug: string): Promise<Article[]> {
259
+ const articles = await getAllArticles()
260
+ return articles.filter((article) =>
261
+ article.categories.some((cat) => categoryToSlug(cat) === categorySlug)
262
+ )
263
+ }
264
+
265
+ export { sanitizeImagePath }
package/src/server.ts ADDED
@@ -0,0 +1,25 @@
1
+ // Server-only exports — uses fs/path; never import this in a client bundle
2
+ export {
3
+ getAllArticles,
4
+ getArticleMetadata,
5
+ getAvailableArticleSlugs,
6
+ getAdjacentArticles,
7
+ searchArticles,
8
+ getAllCategories,
9
+ getArticlesByCategory,
10
+ categoryToSlug,
11
+ sanitizeImagePath,
12
+ } from './server-articles'
13
+
14
+ export {
15
+ generateArticleStaticParams,
16
+ generateCategoryStaticParams,
17
+ generateArticleMetadata,
18
+ generateCategoryMetadata,
19
+ getArticleSitemapEntries,
20
+ } from './seoUtils'
21
+
22
+ export { markdownToHtml } from './markdown'
23
+
24
+ export type { Article, CategoryInfo } from './articleTypes'
25
+ export type { ArticlesConfig } from './articlesConfig'
@@ -0,0 +1,93 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, useEffect, useRef } from 'react'
4
+ import { Article, CategoryInfo } from './articleTypes'
5
+
6
+ export interface UseArticlesReturn {
7
+ articles: Article[]
8
+ categories: CategoryInfo[]
9
+ searchQuery: string
10
+ loading: boolean
11
+ error: string | null
12
+ handleSearch: (query: string) => void
13
+ }
14
+
15
+ export function useArticles(): UseArticlesReturn {
16
+ const [searchQuery, setSearchQuery] = useState('')
17
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
18
+ const [articles, setArticles] = useState<Article[]>([])
19
+ const [categories, setCategories] = useState<CategoryInfo[]>([])
20
+ const [loading, setLoading] = useState(true)
21
+ const [error, setError] = useState<string | null>(null)
22
+
23
+ const fetchArticles = useCallback(async (query: string) => {
24
+ try {
25
+ setLoading(true)
26
+ const url = query ? `/api/articles?q=${encodeURIComponent(query)}` : '/api/articles'
27
+ const response = await fetch(url)
28
+
29
+ if (!response.ok) throw new Error('Failed to fetch articles')
30
+
31
+ const data = await response.json()
32
+ setArticles(data.articles)
33
+
34
+ if (!query?.trim()) {
35
+ const catMap = new Map<string, { count: number; featuredImage: string }>()
36
+ for (const article of data.articles as Article[]) {
37
+ for (const cat of article.categories) {
38
+ if (!catMap.has(cat)) {
39
+ catMap.set(cat, { count: 0, featuredImage: article.featuredImage })
40
+ }
41
+ catMap.get(cat)!.count++
42
+ }
43
+ }
44
+ setCategories(
45
+ Array.from(catMap.entries())
46
+ .map(([name, { count, featuredImage }]) => ({
47
+ name,
48
+ slug: name
49
+ .toLowerCase()
50
+ .replaceAll(/\s+/g, '-')
51
+ .replaceAll(/[^a-z0-9-]/g, ''),
52
+ count,
53
+ featuredImage,
54
+ }))
55
+ .sort((a, b) => b.count - a.count)
56
+ )
57
+ }
58
+
59
+ setError(null)
60
+ } catch (err) {
61
+ setError(err instanceof Error ? err.message : 'Failed to load articles')
62
+ setArticles([])
63
+ } finally {
64
+ setLoading(false)
65
+ }
66
+ }, [])
67
+
68
+ useEffect(() => {
69
+ fetchArticles('')
70
+ }, [fetchArticles])
71
+
72
+ const handleSearch = useCallback(
73
+ (query: string) => {
74
+ setSearchQuery(query)
75
+ if (debounceRef.current) clearTimeout(debounceRef.current)
76
+ if (query === '') {
77
+ fetchArticles('')
78
+ } else if (query.length >= 3) {
79
+ debounceRef.current = setTimeout(() => fetchArticles(query), 1000)
80
+ }
81
+ },
82
+ [fetchArticles]
83
+ )
84
+
85
+ useEffect(
86
+ () => () => {
87
+ if (debounceRef.current) clearTimeout(debounceRef.current)
88
+ },
89
+ []
90
+ )
91
+
92
+ return { articles, categories, searchQuery, loading, error, handleSearch }
93
+ }