@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.
- package/README.md +324 -0
- package/dist/index.cjs +1027 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +232 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.js +974 -0
- package/dist/index.js.map +1 -0
- package/dist/server.cjs +732 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +127 -0
- package/dist/server.d.ts +127 -0
- package/dist/server.js +684 -0
- package/dist/server.js.map +1 -0
- package/package.json +82 -0
- package/src/ArticleCard.tsx +63 -0
- package/src/ArticleCategoryGrid.tsx +73 -0
- package/src/ArticleSchemas.tsx +82 -0
- package/src/ArticleSearchBar.tsx +50 -0
- package/src/ArticlesHero.tsx +33 -0
- package/src/ArticlesPage.tsx +167 -0
- package/src/CategoryArticlesPage.tsx +84 -0
- package/src/CommentForm.tsx +98 -0
- package/src/CommentItem.tsx +123 -0
- package/src/CommentThread.tsx +40 -0
- package/src/CommentsSection.tsx +84 -0
- package/src/FeaturedArticle.tsx +63 -0
- package/src/LatestArticles.tsx +42 -0
- package/src/LatestArticlesSection.tsx +68 -0
- package/src/__tests__/ArticleCard.test.tsx +78 -0
- package/src/__tests__/ArticleCategoryGrid.test.tsx +98 -0
- package/src/__tests__/ArticleSchemas.test.tsx +130 -0
- package/src/__tests__/ArticleSearchBar.test.tsx +79 -0
- package/src/__tests__/ArticlesHero.test.tsx +38 -0
- package/src/__tests__/ArticlesPage.test.tsx +155 -0
- package/src/__tests__/CategoryArticlesPage.test.tsx +156 -0
- package/src/__tests__/CommentForm.test.tsx +80 -0
- package/src/__tests__/CommentItem.test.tsx +149 -0
- package/src/__tests__/CommentsSection.test.tsx +118 -0
- package/src/__tests__/FeaturedArticle.test.tsx +85 -0
- package/src/__tests__/LatestArticles.test.tsx +157 -0
- package/src/__tests__/LatestArticlesSection.test.tsx +105 -0
- package/src/__tests__/jest-dom.d.ts +1 -0
- package/src/__tests__/seoUtils.test.ts +217 -0
- package/src/__tests__/useArticles.test.ts +207 -0
- package/src/articleTypes.ts +21 -0
- package/src/articlesConfig.ts +98 -0
- package/src/commentTypes.ts +14 -0
- package/src/index.ts +35 -0
- package/src/markdown.ts +314 -0
- package/src/seoUtils.ts +155 -0
- package/src/server-articles.ts +265 -0
- package/src/server.ts +25 -0
- 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
|
+
}
|