@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
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ 'use client'
2
+
3
+ // Components
4
+ export { ArticlesPage } from './ArticlesPage'
5
+ export { ArticleCard } from './ArticleCard'
6
+ export { ArticleCategoryGrid } from './ArticleCategoryGrid'
7
+ export { ArticlesHero } from './ArticlesHero'
8
+ export { ArticleSearchBar } from './ArticleSearchBar'
9
+ export { FeaturedArticle } from './FeaturedArticle'
10
+ export { LatestArticles } from './LatestArticles'
11
+ export { LatestArticlesSection } from './LatestArticlesSection'
12
+ export { CategoryArticlesPage } from './CategoryArticlesPage'
13
+ export { ArticleSchema, BreadcrumbSchema, CollectionPageSchema } from './ArticleSchemas'
14
+
15
+ // Comments
16
+ export { CommentsSection } from './CommentsSection'
17
+ export { CommentThread } from './CommentThread'
18
+ export { CommentItem } from './CommentItem'
19
+ export { CommentForm } from './CommentForm'
20
+
21
+ // Hooks
22
+ export { useArticles } from './useArticles'
23
+ export type { UseArticlesReturn } from './useArticles'
24
+
25
+ // Config + types
26
+ export type {
27
+ ArticlesConfig,
28
+ ArticlesTheme,
29
+ ArticlesSection,
30
+ CategoryDescription,
31
+ CommentsConfig,
32
+ } from './articlesConfig'
33
+ export { DEFAULT_LAYOUT, DEFAULT_PAGE_SIZE, DEFAULT_CATEGORIES_PAGE_SIZE } from './articlesConfig'
34
+ export type { Article, CategoryInfo } from './articleTypes'
35
+ export type { ArticleComment, ArticleCommentWithReplies } from './commentTypes'
@@ -0,0 +1,314 @@
1
+ import type { Element, Root, ElementContent } from 'hast'
2
+ import rehypePrism from 'rehype-prism-plus'
3
+ import rehypeSanitize from 'rehype-sanitize'
4
+ import rehypeStringify from 'rehype-stringify'
5
+ import { remark } from 'remark'
6
+ import remarkGfm from 'remark-gfm'
7
+ import remarkParse from 'remark-parse'
8
+ import remarkRehype from 'remark-rehype'
9
+ import { Plugin } from 'unified'
10
+ import { visit } from 'unist-util-visit'
11
+
12
+ // Import the sanitizeImagePath function
13
+ function sanitizeImagePath(rawPath: string, articleSlug: string): string | null {
14
+ if (!rawPath || typeof rawPath !== 'string') {
15
+ return null
16
+ }
17
+
18
+ // Remove any null bytes or control characters
19
+ const cleanPath = rawPath.replaceAll(/[\x00-\x1f\x7f-\x9f]/g, '')
20
+
21
+ // Check for absolute URLs (http/https)
22
+ if (cleanPath.startsWith('http://') || cleanPath.startsWith('https://')) {
23
+ // For external URLs, just return as-is (they're safe)
24
+ return cleanPath
25
+ }
26
+
27
+ // For relative paths, ensure they don't contain dangerous patterns
28
+ if (cleanPath.includes('..') || cleanPath.includes('\\') || cleanPath.startsWith('/')) {
29
+ console.warn(`Potentially unsafe image path detected: ${cleanPath}`)
30
+ return null
31
+ }
32
+
33
+ // Only allow alphanumeric characters, hyphens, underscores, dots, and forward slashes
34
+ if (!/^[a-zA-Z0-9._/-]+$/.test(cleanPath)) {
35
+ console.warn(`Invalid characters in image path: ${cleanPath}`)
36
+ return null
37
+ }
38
+
39
+ // Construct safe path within articles directory
40
+ if (cleanPath.includes('/')) {
41
+ // Relative path, ensure it's within the article directory
42
+ const normalizedPath = cleanPath.replaceAll('\\', '/')
43
+ if (normalizedPath.startsWith('..') || normalizedPath.includes('../')) {
44
+ console.warn(`Path traversal attempt detected: ${cleanPath}`)
45
+ return null
46
+ }
47
+ return `/articles/${articleSlug}/${normalizedPath}`
48
+ } else {
49
+ // Just a filename, construct full path
50
+ return `/articles/${articleSlug}/${cleanPath}`
51
+ }
52
+ }
53
+
54
+ const customRenderer: Plugin<[], Root> = () => {
55
+ return (tree: Root) => {
56
+ // First pass: Apply general styles
57
+ visit(tree, 'element', (node: Element) => {
58
+ if (node.tagName) {
59
+ const props = node.properties || {}
60
+
61
+ switch (node.tagName) {
62
+ case 'h1':
63
+ props.className = 'text-3xl font-bold text-foreground mt-8 mb-4 scroll-mt-20'
64
+ break
65
+ case 'h2':
66
+ props.className = 'text-2xl font-semibold text-foreground mt-6 mb-3 scroll-mt-20'
67
+ break
68
+ case 'h3':
69
+ props.className = 'text-xl font-semibold text-foreground mt-4 mb-2 scroll-mt-20'
70
+ break
71
+ case 'h4':
72
+ props.className = 'text-lg font-semibold text-foreground mt-3 mb-2 scroll-mt-20'
73
+ break
74
+ case 'p':
75
+ props.className = 'text-muted-foreground leading-relaxed mb-4'
76
+ break
77
+ case 'a':
78
+ props.className = 'text-primary hover:underline transition-colors duration-200'
79
+ props.target = '_blank'
80
+ props.rel = 'noopener noreferrer'
81
+ break
82
+ case 'ul':
83
+ props.className = 'list-disc list-inside mb-4 space-y-2 ml-4'
84
+ break
85
+ case 'ol':
86
+ props.className = 'list-decimal list-inside mb-4 space-y-2 ml-4'
87
+ break
88
+ case 'li':
89
+ props.className = 'mb-1'
90
+ break
91
+ case 'blockquote':
92
+ props.className = 'border-l-4 border-primary pl-4 italic my-4 text-muted-foreground'
93
+ break
94
+ case 'code':
95
+ props.className =
96
+ (props.className ? props.className + ' ' : '') +
97
+ 'bg-muted px-1 py-0.5 rounded text-sm font-mono'
98
+ break
99
+ case 'pre':
100
+ props.className = 'bg-muted rounded-lg p-4 overflow-x-auto my-4'
101
+ break
102
+ case 'img':
103
+ props.className = 'rounded-lg my-6 w-full max-w-2xl mx-auto'
104
+ break
105
+ case 'table':
106
+ props.className = 'border-collapse border border-border my-4 w-full'
107
+ break
108
+ case 'th':
109
+ props.className = 'border border-border px-2 py-1 bg-muted font-semibold'
110
+ break
111
+ case 'td':
112
+ props.className = 'border border-border px-2 py-1'
113
+ break
114
+ case 'hr':
115
+ props.className = 'my-8 border-border'
116
+ break
117
+ default:
118
+ break
119
+ }
120
+
121
+ node.properties = props
122
+ }
123
+ })
124
+
125
+ // Second pass: Fix footnotes and references
126
+ visit(tree, 'element', (node: Element) => {
127
+ // Handle footnote references in the body
128
+ if (
129
+ node.tagName === 'sup' &&
130
+ node.children?.[0]?.type === 'element' &&
131
+ node.children[0].tagName === 'a'
132
+ ) {
133
+ const link = node.children[0] as Element
134
+ if (
135
+ link.properties?.href &&
136
+ typeof link.properties.href === 'string' &&
137
+ link.properties.href.startsWith('#user-content-fn-')
138
+ ) {
139
+ // Remove target and rel, and update class
140
+ if (link.properties) {
141
+ delete link.properties.target
142
+ delete link.properties.rel
143
+ link.properties.className = 'text-primary hover:underline'
144
+ }
145
+
146
+ // Update link href
147
+ link.properties.href = link.properties.href.replaceAll('#user-content-fn-', '#footnote-')
148
+
149
+ // Move ID from sup to link and update format
150
+ if (node.properties?.id && typeof node.properties.id === 'string') {
151
+ const newId = node.properties.id.replaceAll('user-content-fnref-', 'ref-')
152
+ link.properties.id = newId
153
+ delete node.properties.id
154
+ }
155
+
156
+ // Add brackets to content
157
+ if (link.children?.[0]?.type === 'text') {
158
+ link.children[0].value = `[${link.children[0].value}]`
159
+ }
160
+ }
161
+ }
162
+
163
+ // Handle footnotes section
164
+ if (
165
+ node.tagName === 'section' &&
166
+ (Array.isArray(node.properties?.className)
167
+ ? node.properties.className.includes('footnotes')
168
+ : node.properties?.className === 'footnotes')
169
+ ) {
170
+ // Find the ol
171
+ const olCandidate = node.children.find(
172
+ (child) => child.type === 'element' && (child as Element).tagName === 'ol'
173
+ )
174
+ if (olCandidate?.type === 'element') {
175
+ const ol = olCandidate as Element
176
+ ol.properties.className = 'list-decimal ml-6 space-y-2 text-sm text-muted-foreground'
177
+
178
+ // Process list items
179
+ ol.children.forEach((li) => {
180
+ if (li.type === 'element' && li.tagName === 'li') {
181
+ const liEl = li as Element
182
+ if (liEl.properties) {
183
+ liEl.properties.className = 'pl-2'
184
+ // Update ID: user-content-fn-1 -> footnote-1
185
+ if (typeof liEl.properties.id === 'string') {
186
+ liEl.properties.id = liEl.properties.id.replaceAll(
187
+ 'user-content-fn-',
188
+ 'footnote-'
189
+ )
190
+ }
191
+ }
192
+
193
+ // Unwrap p tags inside li
194
+ const pIndex = liEl.children.findIndex(
195
+ (child) => child.type === 'element' && (child as Element).tagName === 'p'
196
+ )
197
+
198
+ if (pIndex !== -1) {
199
+ const p = liEl.children[pIndex] as Element
200
+ liEl.children.splice(pIndex, 1, ...p.children)
201
+ }
202
+
203
+ // Style links inside the list item
204
+ const styleLinks = (nodes: ElementContent[]): void => {
205
+ nodes.forEach((n) => {
206
+ if (n.type !== 'element') return
207
+ const el = n as Element
208
+ if (el.tagName === 'a') {
209
+ const isBackRef =
210
+ el.properties?.['dataFootnoteBackref'] !== undefined ||
211
+ (el.children[0]?.type === 'text' && el.children[0].value === '↩')
212
+
213
+ if (isBackRef) {
214
+ el.properties.className = 'text-primary hover:underline ml-1'
215
+ // Update backref href: #user-content-fnref-1 -> #ref-1
216
+ if (typeof el.properties.href === 'string') {
217
+ el.properties.href = el.properties.href.replaceAll(
218
+ '#user-content-fnref-',
219
+ '#ref-'
220
+ )
221
+ }
222
+ } else {
223
+ el.properties.className = 'text-primary hover:underline break-all'
224
+ }
225
+ }
226
+ if (el.children) styleLinks(el.children)
227
+ })
228
+ }
229
+
230
+ styleLinks(liEl.children)
231
+ }
232
+ })
233
+
234
+ // Reconstruct section children with HR and H3
235
+ const hr: Element = {
236
+ type: 'element',
237
+ tagName: 'hr',
238
+ properties: { className: 'my-8 border-border' },
239
+ children: [],
240
+ }
241
+
242
+ const h3: Element = {
243
+ type: 'element',
244
+ tagName: 'h3',
245
+ properties: { className: 'text-lg font-semibold mb-4' },
246
+ children: [{ type: 'text', value: 'References' }],
247
+ }
248
+
249
+ node.children = [hr, h3, ol]
250
+
251
+ // Remove the footnotes class to avoid default styling interference
252
+ if (node.properties) {
253
+ node.properties.className = undefined
254
+ }
255
+ }
256
+ }
257
+ })
258
+ }
259
+ }
260
+
261
+ // Custom rehype plugin to process image URLs
262
+ const rehypeProcessImages: Plugin<[{ articleSlug?: string }], Root> = (options = {}) => {
263
+ return (tree: Root) => {
264
+ visit(tree, 'element', (node: Element) => {
265
+ if (node.tagName === 'img' && node.properties) {
266
+ const src = node.properties.src
267
+ if (src && typeof src === 'string' && options.articleSlug) {
268
+ // Sanitize the image path
269
+ const sanitizedSrc = sanitizeImagePath(src, options.articleSlug)
270
+ if (sanitizedSrc) {
271
+ node.properties.src = sanitizedSrc
272
+ } else {
273
+ // If sanitization fails, use a placeholder
274
+ console.warn(`Using placeholder for unsafe image path: ${src}`)
275
+ node.properties.src = '/placeholder-logo.png'
276
+ }
277
+ }
278
+ }
279
+ })
280
+ }
281
+ }
282
+
283
+ export async function markdownToHtml(markdown: string, articleSlug?: string) {
284
+ try {
285
+ // Start building the remark processor
286
+ let processor = remark()
287
+ .use(remarkParse)
288
+ .use(remarkGfm)
289
+ .use(remarkRehype)
290
+ .use(customRenderer)
291
+ // @ts-ignore
292
+ .use(rehypePrism)
293
+ .use(rehypeSanitize, {
294
+ attributes: {
295
+ '*': ['className', 'class', 'id'],
296
+ a: ['href', 'target', 'rel', 'id'],
297
+ img: ['src', 'alt'],
298
+ },
299
+ })
300
+
301
+ // Add image processing plugin if articleSlug is provided
302
+ if (articleSlug) {
303
+ processor = processor.use(rehypeProcessImages, { articleSlug })
304
+ }
305
+
306
+ const result = await processor.use(rehypeStringify).process(markdown)
307
+
308
+ return result.toString()
309
+ } catch (error) {
310
+ console.error('Markdown conversion error:', error)
311
+ // Return the original markdown as fallback
312
+ return markdown
313
+ }
314
+ }
@@ -0,0 +1,155 @@
1
+ import type { Metadata, MetadataRoute } from 'next'
2
+ import {
3
+ getArticleMetadata,
4
+ getAllArticles,
5
+ getAllCategories,
6
+ getArticlesByCategory,
7
+ getAvailableArticleSlugs,
8
+ } from './server-articles'
9
+ import { ArticlesConfig } from './articlesConfig'
10
+
11
+ export function generateArticleStaticParams(): { slug: string }[] {
12
+ return getAvailableArticleSlugs().map((slug) => ({ slug }))
13
+ }
14
+
15
+ export async function generateCategoryStaticParams(): Promise<{ category: string }[]> {
16
+ const categories = await getAllCategories()
17
+ return categories.map((cat) => ({ category: cat.slug }))
18
+ }
19
+
20
+ function resolveImageUrl(featuredImage: string, siteUrl: string): string {
21
+ const base = siteUrl.replace(/\/$/, '')
22
+ if (featuredImage.startsWith('http://') || featuredImage.startsWith('https://')) {
23
+ return featuredImage
24
+ }
25
+ return `${base}/${featuredImage.replace(/^\/+/, '')}`
26
+ }
27
+
28
+ export async function generateArticleMetadata(
29
+ slug: string,
30
+ config: ArticlesConfig
31
+ ): Promise<Metadata> {
32
+ const article = await getArticleMetadata(slug)
33
+
34
+ if (!article) {
35
+ return {
36
+ title: 'Article Not Found',
37
+ description: 'The requested article could not be found.',
38
+ }
39
+ }
40
+
41
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
42
+ const articleUrl = `${siteUrl}/articles/${slug}`
43
+ const imageUrl = article.featuredImage
44
+ ? resolveImageUrl(article.featuredImage, siteUrl)
45
+ : `${siteUrl}/placeholder-logo.png`
46
+ const description = article.excerpt ?? `Read ${article.title} on ${config.siteName}.`
47
+
48
+ return {
49
+ title: `${article.title} | ${config.siteName}`,
50
+ description,
51
+ keywords: [
52
+ 'volunteer management software',
53
+ 'political campaign software',
54
+ 'campaign operations',
55
+ 'civic tech',
56
+ ...(article.tags ?? []).map((tag) => tag.toLowerCase()),
57
+ ].join(', '),
58
+ openGraph: {
59
+ title: article.title,
60
+ description,
61
+ url: articleUrl,
62
+ siteName: config.siteName,
63
+ images: [{ url: imageUrl, width: 1200, height: 630, alt: article.title }],
64
+ locale: 'en_US',
65
+ type: 'article',
66
+ ...(article.date && { publishedTime: article.date }),
67
+ authors: [article.author],
68
+ tags: article.tags ?? [],
69
+ },
70
+ twitter: {
71
+ card: 'summary_large_image',
72
+ title: article.title,
73
+ description,
74
+ images: [imageUrl],
75
+ },
76
+ alternates: {
77
+ canonical: articleUrl,
78
+ },
79
+ robots: {
80
+ index: true,
81
+ follow: true,
82
+ googleBot: {
83
+ index: true,
84
+ follow: true,
85
+ 'max-video-preview': -1,
86
+ 'max-image-preview': 'large',
87
+ 'max-snippet': -1,
88
+ },
89
+ },
90
+ other: {
91
+ 'article:author': article.author,
92
+ ...(article.date && {
93
+ 'article:published_time': new Date(article.date).toISOString(),
94
+ }),
95
+ 'article:section': article.category,
96
+ 'article:tag': article.tags?.join(',') ?? '',
97
+ 'linkedin:owner': process.env.NEXT_PUBLIC_LINKEDIN_COMPANY_ID ?? '',
98
+ },
99
+ }
100
+ }
101
+
102
+ export async function generateCategoryMetadata(
103
+ categorySlug: string,
104
+ config: ArticlesConfig
105
+ ): Promise<Metadata> {
106
+ const articles = await getArticlesByCategory(categorySlug)
107
+
108
+ if (articles.length === 0) return { title: 'Category Not Found' }
109
+
110
+ const categoryName = articles[0].category
111
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
112
+ const categoryUrl = `${siteUrl}/articles/category/${categorySlug}`
113
+ const raw = config.categoryDescriptions?.[categorySlug]
114
+ const fallback = `Browse ${articles.length} article${articles.length === 1 ? '' : 's'} in the ${categoryName} category.`
115
+ const description = typeof raw === 'string' ? raw : (raw?.short ?? fallback)
116
+
117
+ return {
118
+ title: `${categoryName} Articles | ${config.siteName}`,
119
+ description,
120
+ openGraph: {
121
+ title: `${categoryName} Articles`,
122
+ description,
123
+ url: categoryUrl,
124
+ siteName: config.siteName,
125
+ images: [{ url: articles[0].featuredImage }],
126
+ },
127
+ alternates: {
128
+ canonical: categoryUrl,
129
+ },
130
+ }
131
+ }
132
+
133
+ export async function getArticleSitemapEntries(baseUrl: string): Promise<MetadataRoute.Sitemap> {
134
+ try {
135
+ const [articles, categories] = await Promise.all([getAllArticles(), getAllCategories()])
136
+
137
+ const articleEntries: MetadataRoute.Sitemap = articles.map((article) => ({
138
+ url: `${baseUrl}/articles/${article.slug}`,
139
+ lastModified: article.date ? new Date(article.date) : undefined,
140
+ changeFrequency: 'weekly' as const,
141
+ priority: 0.8,
142
+ }))
143
+
144
+ const categoryEntries: MetadataRoute.Sitemap = categories.map((cat) => ({
145
+ url: `${baseUrl}/articles/category/${cat.slug}`,
146
+ lastModified: new Date(),
147
+ changeFrequency: 'weekly' as const,
148
+ priority: 0.7,
149
+ }))
150
+
151
+ return [...articleEntries, ...categoryEntries]
152
+ } catch {
153
+ return []
154
+ }
155
+ }