@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 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server-articles.ts","../src/markdown.ts","../src/seoUtils.ts"],"sourcesContent":["import { cache } from 'react'\nimport matter from 'gray-matter'\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport readingTime from 'reading-time'\nimport { markdownToHtml } from './markdown'\nimport type { Article, CategoryInfo } from './articleTypes'\n\nconst articlesDirectory = path.join(process.cwd(), 'public/articles')\n\nfunction getReadingTime(content: string): string {\n return readingTime(content).text\n}\n\nfunction findArticleImage(slug: string): string | null {\n try {\n const articleDir = path.join(articlesDirectory, slug)\n const entries: (fs.Dirent | string)[] = fs.readdirSync(articleDir, { withFileTypes: true })\n const names = entries\n .map((entry) => {\n if (typeof entry === 'string') return entry\n if (entry && typeof entry.name === 'string') return entry.name\n return null\n })\n .filter((name): name is string => Boolean(name))\n const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp']\n const imageFileName = names.find((name) =>\n imageExtensions.some((ext) => name.toLowerCase().endsWith(ext))\n )\n return imageFileName ? sanitizeImagePath(imageFileName, slug) : null\n } catch {\n return null\n }\n}\n\nfunction sanitizeImagePath(rawPath: string, articleSlug: string): string | null {\n if (!rawPath || typeof rawPath !== 'string') return null\n const cleanPath = rawPath.replaceAll(/[\\x00-\\x1f\\x7f-\\x9f]/g, '')\n if (cleanPath.startsWith('http://') || cleanPath.startsWith('https://')) return cleanPath\n if (cleanPath.includes('..') || cleanPath.includes('\\\\') || cleanPath.startsWith('/')) {\n console.warn(`Potentially unsafe image path detected: ${cleanPath}`)\n return null\n }\n if (!/^[a-zA-Z0-9._/-]+$/.test(cleanPath)) {\n console.warn(`Invalid characters in image path: ${cleanPath}`)\n return null\n }\n if (cleanPath.includes('/')) {\n const normalizedPath = path.normalize(cleanPath)\n if (normalizedPath.startsWith('..') || normalizedPath.includes('../')) {\n console.warn(`Path traversal attempt detected: ${cleanPath}`)\n return null\n }\n return `/articles/${articleSlug}/${cleanPath}`\n }\n return `/articles/${articleSlug}/${cleanPath}`\n}\n\nexport function getAvailableArticleSlugs(): string[] {\n try {\n if (!fs.existsSync(articlesDirectory)) return []\n const items = fs.readdirSync(articlesDirectory, { withFileTypes: true })\n return items\n .filter((item) => item.isDirectory())\n .filter((dir) => {\n const articleDir = path.join(articlesDirectory, dir.name)\n try {\n const entries: (fs.Dirent | string)[] = fs.readdirSync(articleDir, {\n withFileTypes: true,\n })\n return entries.some((entry) => {\n const name = typeof entry === 'string' ? entry : entry?.name\n if (name !== 'article.md') return false\n if (typeof entry === 'string') return true\n if (typeof entry?.isFile === 'function') return entry.isFile()\n return true\n })\n } catch {\n return false\n }\n })\n .map((dir) => dir.name)\n } catch (error) {\n console.error('Error reading articles directory:', error)\n return []\n }\n}\n\nasync function getArticleSummary(slug: string): Promise<Article | null> {\n try {\n const articlePath = path.join(articlesDirectory, slug, 'article.md')\n if (!fs.existsSync(articlePath)) return null\n const fileContent = fs.readFileSync(articlePath, 'utf8')\n const { data, content: markdownContent } = matter(fileContent)\n const readTime = getReadingTime(markdownContent)\n let featuredImage = data.featuredImage || ''\n if (featuredImage && !featuredImage.startsWith('http')) {\n const sanitizedPath = sanitizeImagePath(featuredImage, slug)\n featuredImage = sanitizedPath || '/placeholder-logo.png'\n } else if (!featuredImage) {\n featuredImage = findArticleImage(slug) || '/placeholder-logo.png'\n }\n let articleDate: string | undefined\n if (data.date) {\n try {\n const parsedDate = new Date(data.date)\n if (!Number.isNaN(parsedDate.getTime())) {\n articleDate = parsedDate.toISOString().split('T')[0]\n }\n } catch {\n console.warn(`Invalid date for article ${slug}: ${data.date}`)\n }\n }\n const allTags: string[] = Array.isArray(data.tags)\n ? data.tags.filter((t: unknown) => typeof t === 'string' && String(t).trim())\n : []\n const categories: string[] =\n allTags.length > 0 ? allTags.map((t: string) => t.replaceAll('-', ' ').trim()) : ['Campaigns']\n return {\n slug,\n title: data.title || slug.replaceAll('-', ' '),\n excerpt: data.excerpt || '',\n date: articleDate,\n author: data.author || 'Andrew Blase',\n category: categories[0],\n categories,\n readTime,\n featuredImage,\n tags: data.tags || [],\n }\n } catch (error) {\n console.error(`Error loading article ${slug}:`, error)\n return null\n }\n}\n\nexport const getArticleMetadata = cache(async (slug: string): Promise<Article | null> => {\n try {\n const articlePath = path.join(articlesDirectory, slug, 'article.md')\n if (!fs.existsSync(articlePath)) return null\n const fileContent = fs.readFileSync(articlePath, 'utf8')\n const { data, content: markdownContent } = matter(fileContent)\n const htmlContent = await markdownToHtml(markdownContent, slug)\n const readTime = getReadingTime(markdownContent)\n let featuredImage = data.featuredImage || ''\n if (featuredImage && !featuredImage.startsWith('http')) {\n const sanitizedPath = sanitizeImagePath(featuredImage, slug)\n featuredImage = sanitizedPath || '/placeholder-logo.png'\n } else if (!featuredImage) {\n featuredImage = findArticleImage(slug) || '/placeholder-logo.png'\n }\n let articleDate: string | undefined\n if (data.date) {\n try {\n const parsedDate = new Date(data.date)\n if (!Number.isNaN(parsedDate.getTime())) {\n articleDate = parsedDate.toISOString().split('T')[0]\n }\n } catch {\n console.warn(`Invalid date for article ${slug}: ${data.date}`)\n }\n }\n const allTags: string[] = Array.isArray(data.tags)\n ? data.tags.filter((t: unknown) => typeof t === 'string' && String(t).trim())\n : []\n const categories: string[] =\n allTags.length > 0 ? allTags.map((t: string) => t.replaceAll('-', ' ').trim()) : ['Campaigns']\n return {\n slug,\n title: data.title || slug.replaceAll('-', ' '),\n excerpt: data.excerpt || '',\n date: articleDate,\n author: data.author || 'Andrew Blase',\n category: categories[0],\n categories,\n readTime,\n featuredImage,\n tags: data.tags || [],\n content: markdownContent,\n htmlContent,\n }\n } catch (error) {\n console.error(`Error loading article ${slug}:`, error)\n return null\n }\n})\n\nexport const getAllArticles = cache(async (): Promise<Article[]> => {\n const slugs = getAvailableArticleSlugs()\n const articles = await Promise.all(slugs.map((slug) => getArticleSummary(slug)))\n const currentDate = new Date().toISOString().split('T')[0]\n return articles\n .filter((article): article is Article => article !== null)\n .filter((article) => !article.date || article.date <= currentDate)\n .sort((a, b) => {\n if (!a.date && !b.date) return 0\n if (!a.date) return 1\n if (!b.date) return -1\n return new Date(b.date).getTime() - new Date(a.date).getTime()\n })\n})\n\nexport async function getAdjacentArticles(\n currentSlug: string\n): Promise<{ previous: Article | null; next: Article | null }> {\n const allArticles = await getAllArticles()\n const currentIndex = allArticles.findIndex((article) => article.slug === currentSlug)\n if (currentIndex === -1) return { previous: null, next: null }\n const previous = currentIndex < allArticles.length - 1 ? allArticles[currentIndex + 1] : null\n const next = currentIndex > 0 ? allArticles[currentIndex - 1] : null\n return { previous, next }\n}\n\nexport async function searchArticles(query: string): Promise<Article[]> {\n if (!query?.trim()) return getAllArticles()\n const articles = await getAllArticles()\n const searchTerm = query.toLowerCase().trim()\n return articles.filter((article) => {\n const matchesTitle = article.title.toLowerCase().includes(searchTerm)\n const matchesExcerpt = article.excerpt.toLowerCase().includes(searchTerm)\n const matchesAuthor = article.author.toLowerCase().includes(searchTerm)\n const matchesCategory = article.categories.some((cat) => cat.toLowerCase().includes(searchTerm))\n const matchesTags = article.tags?.some((tag) => tag.toLowerCase().includes(searchTerm))\n return (\n matchesTitle || matchesExcerpt || matchesAuthor || matchesCategory || Boolean(matchesTags)\n )\n })\n}\n\nexport function categoryToSlug(category: string): string {\n return category\n .toLowerCase()\n .replaceAll(/\\s+/g, '-')\n .replaceAll(/[^a-z0-9-]/g, '')\n}\n\nexport async function getAllCategories(): Promise<CategoryInfo[]> {\n const articles = await getAllArticles()\n const categoryMap = new Map<string, { count: number; featuredImage: string }>()\n for (const article of articles) {\n for (const cat of article.categories) {\n if (!categoryMap.has(cat)) {\n categoryMap.set(cat, { count: 0, featuredImage: article.featuredImage })\n }\n categoryMap.get(cat)!.count++\n }\n }\n return Array.from(categoryMap.entries())\n .map(([name, { count, featuredImage }]) => ({\n name,\n slug: categoryToSlug(name),\n count,\n featuredImage,\n }))\n .sort((a, b) => b.count - a.count)\n}\n\nexport async function getArticlesByCategory(categorySlug: string): Promise<Article[]> {\n const articles = await getAllArticles()\n return articles.filter((article) =>\n article.categories.some((cat) => categoryToSlug(cat) === categorySlug)\n )\n}\n\nexport { sanitizeImagePath }\n","import type { Element, Root, ElementContent } from 'hast'\nimport rehypePrism from 'rehype-prism-plus'\nimport rehypeSanitize from 'rehype-sanitize'\nimport rehypeStringify from 'rehype-stringify'\nimport { remark } from 'remark'\nimport remarkGfm from 'remark-gfm'\nimport remarkParse from 'remark-parse'\nimport remarkRehype from 'remark-rehype'\nimport { Plugin } from 'unified'\nimport { visit } from 'unist-util-visit'\n\n// Import the sanitizeImagePath function\nfunction sanitizeImagePath(rawPath: string, articleSlug: string): string | null {\n if (!rawPath || typeof rawPath !== 'string') {\n return null\n }\n\n // Remove any null bytes or control characters\n const cleanPath = rawPath.replaceAll(/[\\x00-\\x1f\\x7f-\\x9f]/g, '')\n\n // Check for absolute URLs (http/https)\n if (cleanPath.startsWith('http://') || cleanPath.startsWith('https://')) {\n // For external URLs, just return as-is (they're safe)\n return cleanPath\n }\n\n // For relative paths, ensure they don't contain dangerous patterns\n if (cleanPath.includes('..') || cleanPath.includes('\\\\') || cleanPath.startsWith('/')) {\n console.warn(`Potentially unsafe image path detected: ${cleanPath}`)\n return null\n }\n\n // Only allow alphanumeric characters, hyphens, underscores, dots, and forward slashes\n if (!/^[a-zA-Z0-9._/-]+$/.test(cleanPath)) {\n console.warn(`Invalid characters in image path: ${cleanPath}`)\n return null\n }\n\n // Construct safe path within articles directory\n if (cleanPath.includes('/')) {\n // Relative path, ensure it's within the article directory\n const normalizedPath = cleanPath.replaceAll('\\\\', '/')\n if (normalizedPath.startsWith('..') || normalizedPath.includes('../')) {\n console.warn(`Path traversal attempt detected: ${cleanPath}`)\n return null\n }\n return `/articles/${articleSlug}/${normalizedPath}`\n } else {\n // Just a filename, construct full path\n return `/articles/${articleSlug}/${cleanPath}`\n }\n}\n\nconst customRenderer: Plugin<[], Root> = () => {\n return (tree: Root) => {\n // First pass: Apply general styles\n visit(tree, 'element', (node: Element) => {\n if (node.tagName) {\n const props = node.properties || {}\n\n switch (node.tagName) {\n case 'h1':\n props.className = 'text-3xl font-bold text-foreground mt-8 mb-4 scroll-mt-20'\n break\n case 'h2':\n props.className = 'text-2xl font-semibold text-foreground mt-6 mb-3 scroll-mt-20'\n break\n case 'h3':\n props.className = 'text-xl font-semibold text-foreground mt-4 mb-2 scroll-mt-20'\n break\n case 'h4':\n props.className = 'text-lg font-semibold text-foreground mt-3 mb-2 scroll-mt-20'\n break\n case 'p':\n props.className = 'text-muted-foreground leading-relaxed mb-4'\n break\n case 'a':\n props.className = 'text-primary hover:underline transition-colors duration-200'\n props.target = '_blank'\n props.rel = 'noopener noreferrer'\n break\n case 'ul':\n props.className = 'list-disc list-inside mb-4 space-y-2 ml-4'\n break\n case 'ol':\n props.className = 'list-decimal list-inside mb-4 space-y-2 ml-4'\n break\n case 'li':\n props.className = 'mb-1'\n break\n case 'blockquote':\n props.className = 'border-l-4 border-primary pl-4 italic my-4 text-muted-foreground'\n break\n case 'code':\n props.className =\n (props.className ? props.className + ' ' : '') +\n 'bg-muted px-1 py-0.5 rounded text-sm font-mono'\n break\n case 'pre':\n props.className = 'bg-muted rounded-lg p-4 overflow-x-auto my-4'\n break\n case 'img':\n props.className = 'rounded-lg my-6 w-full max-w-2xl mx-auto'\n break\n case 'table':\n props.className = 'border-collapse border border-border my-4 w-full'\n break\n case 'th':\n props.className = 'border border-border px-2 py-1 bg-muted font-semibold'\n break\n case 'td':\n props.className = 'border border-border px-2 py-1'\n break\n case 'hr':\n props.className = 'my-8 border-border'\n break\n default:\n break\n }\n\n node.properties = props\n }\n })\n\n // Second pass: Fix footnotes and references\n visit(tree, 'element', (node: Element) => {\n // Handle footnote references in the body\n if (\n node.tagName === 'sup' &&\n node.children?.[0]?.type === 'element' &&\n node.children[0].tagName === 'a'\n ) {\n const link = node.children[0] as Element\n if (\n link.properties?.href &&\n typeof link.properties.href === 'string' &&\n link.properties.href.startsWith('#user-content-fn-')\n ) {\n // Remove target and rel, and update class\n if (link.properties) {\n delete link.properties.target\n delete link.properties.rel\n link.properties.className = 'text-primary hover:underline'\n }\n\n // Update link href\n link.properties.href = link.properties.href.replaceAll('#user-content-fn-', '#footnote-')\n\n // Move ID from sup to link and update format\n if (node.properties?.id && typeof node.properties.id === 'string') {\n const newId = node.properties.id.replaceAll('user-content-fnref-', 'ref-')\n link.properties.id = newId\n delete node.properties.id\n }\n\n // Add brackets to content\n if (link.children?.[0]?.type === 'text') {\n link.children[0].value = `[${link.children[0].value}]`\n }\n }\n }\n\n // Handle footnotes section\n if (\n node.tagName === 'section' &&\n (Array.isArray(node.properties?.className)\n ? node.properties.className.includes('footnotes')\n : node.properties?.className === 'footnotes')\n ) {\n // Find the ol\n const olCandidate = node.children.find(\n (child) => child.type === 'element' && (child as Element).tagName === 'ol'\n )\n if (olCandidate?.type === 'element') {\n const ol = olCandidate as Element\n ol.properties.className = 'list-decimal ml-6 space-y-2 text-sm text-muted-foreground'\n\n // Process list items\n ol.children.forEach((li) => {\n if (li.type === 'element' && li.tagName === 'li') {\n const liEl = li as Element\n if (liEl.properties) {\n liEl.properties.className = 'pl-2'\n // Update ID: user-content-fn-1 -> footnote-1\n if (typeof liEl.properties.id === 'string') {\n liEl.properties.id = liEl.properties.id.replaceAll(\n 'user-content-fn-',\n 'footnote-'\n )\n }\n }\n\n // Unwrap p tags inside li\n const pIndex = liEl.children.findIndex(\n (child) => child.type === 'element' && (child as Element).tagName === 'p'\n )\n\n if (pIndex !== -1) {\n const p = liEl.children[pIndex] as Element\n liEl.children.splice(pIndex, 1, ...p.children)\n }\n\n // Style links inside the list item\n const styleLinks = (nodes: ElementContent[]): void => {\n nodes.forEach((n) => {\n if (n.type !== 'element') return\n const el = n as Element\n if (el.tagName === 'a') {\n const isBackRef =\n el.properties?.['dataFootnoteBackref'] !== undefined ||\n (el.children[0]?.type === 'text' && el.children[0].value === '↩')\n\n if (isBackRef) {\n el.properties.className = 'text-primary hover:underline ml-1'\n // Update backref href: #user-content-fnref-1 -> #ref-1\n if (typeof el.properties.href === 'string') {\n el.properties.href = el.properties.href.replaceAll(\n '#user-content-fnref-',\n '#ref-'\n )\n }\n } else {\n el.properties.className = 'text-primary hover:underline break-all'\n }\n }\n if (el.children) styleLinks(el.children)\n })\n }\n\n styleLinks(liEl.children)\n }\n })\n\n // Reconstruct section children with HR and H3\n const hr: Element = {\n type: 'element',\n tagName: 'hr',\n properties: { className: 'my-8 border-border' },\n children: [],\n }\n\n const h3: Element = {\n type: 'element',\n tagName: 'h3',\n properties: { className: 'text-lg font-semibold mb-4' },\n children: [{ type: 'text', value: 'References' }],\n }\n\n node.children = [hr, h3, ol]\n\n // Remove the footnotes class to avoid default styling interference\n if (node.properties) {\n node.properties.className = undefined\n }\n }\n }\n })\n }\n}\n\n// Custom rehype plugin to process image URLs\nconst rehypeProcessImages: Plugin<[{ articleSlug?: string }], Root> = (options = {}) => {\n return (tree: Root) => {\n visit(tree, 'element', (node: Element) => {\n if (node.tagName === 'img' && node.properties) {\n const src = node.properties.src\n if (src && typeof src === 'string' && options.articleSlug) {\n // Sanitize the image path\n const sanitizedSrc = sanitizeImagePath(src, options.articleSlug)\n if (sanitizedSrc) {\n node.properties.src = sanitizedSrc\n } else {\n // If sanitization fails, use a placeholder\n console.warn(`Using placeholder for unsafe image path: ${src}`)\n node.properties.src = '/placeholder-logo.png'\n }\n }\n }\n })\n }\n}\n\nexport async function markdownToHtml(markdown: string, articleSlug?: string) {\n try {\n // Start building the remark processor\n let processor = remark()\n .use(remarkParse)\n .use(remarkGfm)\n .use(remarkRehype)\n .use(customRenderer)\n // @ts-ignore\n .use(rehypePrism)\n .use(rehypeSanitize, {\n attributes: {\n '*': ['className', 'class', 'id'],\n a: ['href', 'target', 'rel', 'id'],\n img: ['src', 'alt'],\n },\n })\n\n // Add image processing plugin if articleSlug is provided\n if (articleSlug) {\n processor = processor.use(rehypeProcessImages, { articleSlug })\n }\n\n const result = await processor.use(rehypeStringify).process(markdown)\n\n return result.toString()\n } catch (error) {\n console.error('Markdown conversion error:', error)\n // Return the original markdown as fallback\n return markdown\n }\n}\n","import type { Metadata, MetadataRoute } from 'next'\nimport {\n getArticleMetadata,\n getAllArticles,\n getAllCategories,\n getArticlesByCategory,\n getAvailableArticleSlugs,\n} from './server-articles'\nimport { ArticlesConfig } from './articlesConfig'\n\nexport function generateArticleStaticParams(): { slug: string }[] {\n return getAvailableArticleSlugs().map((slug) => ({ slug }))\n}\n\nexport async function generateCategoryStaticParams(): Promise<{ category: string }[]> {\n const categories = await getAllCategories()\n return categories.map((cat) => ({ category: cat.slug }))\n}\n\nfunction resolveImageUrl(featuredImage: string, siteUrl: string): string {\n const base = siteUrl.replace(/\\/$/, '')\n if (featuredImage.startsWith('http://') || featuredImage.startsWith('https://')) {\n return featuredImage\n }\n return `${base}/${featuredImage.replace(/^\\/+/, '')}`\n}\n\nexport async function generateArticleMetadata(\n slug: string,\n config: ArticlesConfig\n): Promise<Metadata> {\n const article = await getArticleMetadata(slug)\n\n if (!article) {\n return {\n title: 'Article Not Found',\n description: 'The requested article could not be found.',\n }\n }\n\n const siteUrl = config.siteUrl.replace(/\\/$/, '')\n const articleUrl = `${siteUrl}/articles/${slug}`\n const imageUrl = article.featuredImage\n ? resolveImageUrl(article.featuredImage, siteUrl)\n : `${siteUrl}/placeholder-logo.png`\n const description = article.excerpt ?? `Read ${article.title} on ${config.siteName}.`\n\n return {\n title: `${article.title} | ${config.siteName}`,\n description,\n keywords: [\n 'volunteer management software',\n 'political campaign software',\n 'campaign operations',\n 'civic tech',\n ...(article.tags ?? []).map((tag) => tag.toLowerCase()),\n ].join(', '),\n openGraph: {\n title: article.title,\n description,\n url: articleUrl,\n siteName: config.siteName,\n images: [{ url: imageUrl, width: 1200, height: 630, alt: article.title }],\n locale: 'en_US',\n type: 'article',\n ...(article.date && { publishedTime: article.date }),\n authors: [article.author],\n tags: article.tags ?? [],\n },\n twitter: {\n card: 'summary_large_image',\n title: article.title,\n description,\n images: [imageUrl],\n },\n alternates: {\n canonical: articleUrl,\n },\n robots: {\n index: true,\n follow: true,\n googleBot: {\n index: true,\n follow: true,\n 'max-video-preview': -1,\n 'max-image-preview': 'large',\n 'max-snippet': -1,\n },\n },\n other: {\n 'article:author': article.author,\n ...(article.date && {\n 'article:published_time': new Date(article.date).toISOString(),\n }),\n 'article:section': article.category,\n 'article:tag': article.tags?.join(',') ?? '',\n 'linkedin:owner': process.env.NEXT_PUBLIC_LINKEDIN_COMPANY_ID ?? '',\n },\n }\n}\n\nexport async function generateCategoryMetadata(\n categorySlug: string,\n config: ArticlesConfig\n): Promise<Metadata> {\n const articles = await getArticlesByCategory(categorySlug)\n\n if (articles.length === 0) return { title: 'Category Not Found' }\n\n const categoryName = articles[0].category\n const siteUrl = config.siteUrl.replace(/\\/$/, '')\n const categoryUrl = `${siteUrl}/articles/category/${categorySlug}`\n const raw = config.categoryDescriptions?.[categorySlug]\n const fallback = `Browse ${articles.length} article${articles.length === 1 ? '' : 's'} in the ${categoryName} category.`\n const description = typeof raw === 'string' ? raw : (raw?.short ?? fallback)\n\n return {\n title: `${categoryName} Articles | ${config.siteName}`,\n description,\n openGraph: {\n title: `${categoryName} Articles`,\n description,\n url: categoryUrl,\n siteName: config.siteName,\n images: [{ url: articles[0].featuredImage }],\n },\n alternates: {\n canonical: categoryUrl,\n },\n }\n}\n\nexport async function getArticleSitemapEntries(baseUrl: string): Promise<MetadataRoute.Sitemap> {\n try {\n const [articles, categories] = await Promise.all([getAllArticles(), getAllCategories()])\n\n const articleEntries: MetadataRoute.Sitemap = articles.map((article) => ({\n url: `${baseUrl}/articles/${article.slug}`,\n lastModified: article.date ? new Date(article.date) : undefined,\n changeFrequency: 'weekly' as const,\n priority: 0.8,\n }))\n\n const categoryEntries: MetadataRoute.Sitemap = categories.map((cat) => ({\n url: `${baseUrl}/articles/category/${cat.slug}`,\n lastModified: new Date(),\n changeFrequency: 'weekly' as const,\n priority: 0.7,\n }))\n\n return [...articleEntries, ...categoryEntries]\n } catch {\n return []\n }\n}\n"],"mappings":"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","names":["_a","_b","sanitizeImagePath"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fullstackdatasolutions/articles",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"require": "./dist/index.cjs",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./server": {
|
|
14
|
+
"import": "./dist/server.js",
|
|
15
|
+
"require": "./dist/server.cjs",
|
|
16
|
+
"types": "./dist/server.d.ts"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"next": ">=14",
|
|
26
|
+
"next-auth": ">=4",
|
|
27
|
+
"react": ">=18",
|
|
28
|
+
"react-dom": ">=18",
|
|
29
|
+
"@types/react": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"gray-matter": "^4.0.3",
|
|
33
|
+
"lucide-react": "^0.454.0",
|
|
34
|
+
"reading-time": "^1.5.0",
|
|
35
|
+
"rehype-highlight": "^7.0.1",
|
|
36
|
+
"rehype-prism-plus": "^2.0.0",
|
|
37
|
+
"rehype-raw": "^7.0.0",
|
|
38
|
+
"rehype-sanitize": "^6.0.0",
|
|
39
|
+
"rehype-stringify": "^10.0.1",
|
|
40
|
+
"remark": "^15.0.1",
|
|
41
|
+
"remark-gfm": "^4.0.0",
|
|
42
|
+
"remark-parse": "^11.0.0",
|
|
43
|
+
"remark-rehype": "^11.1.1",
|
|
44
|
+
"unified": "^11.0.5",
|
|
45
|
+
"unist-util-visit": "^5.0.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
49
|
+
"@typescript-eslint/parser": "^8.0.0",
|
|
50
|
+
"eslint": "^9.0.0",
|
|
51
|
+
"eslint-plugin-react": "^7.0.0",
|
|
52
|
+
"eslint-plugin-react-hooks": "^5.0.0",
|
|
53
|
+
"globals": "^15.0.0",
|
|
54
|
+
"prettier": "^3.0.0",
|
|
55
|
+
"@testing-library/jest-dom": "^6.0.0",
|
|
56
|
+
"@testing-library/react": "^16.0.0",
|
|
57
|
+
"@testing-library/user-event": "^14.0.0",
|
|
58
|
+
"@types/hast": "^3.0.0",
|
|
59
|
+
"@types/jest": "^29.0.0",
|
|
60
|
+
"@types/node": "^20.0.0",
|
|
61
|
+
"@types/react": "^19.0.0",
|
|
62
|
+
"@types/react-dom": "^19.0.0",
|
|
63
|
+
"jest": "^29.0.0",
|
|
64
|
+
"jest-environment-jsdom": "^29.0.0",
|
|
65
|
+
"ts-jest": "^29.0.0",
|
|
66
|
+
"tsup": "^8.0.0",
|
|
67
|
+
"typescript": "^5.0.0"
|
|
68
|
+
},
|
|
69
|
+
"publishConfig": {
|
|
70
|
+
"registry": "https://registry.npmjs.org",
|
|
71
|
+
"access": "public"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"build": "tsup",
|
|
75
|
+
"dev": "tsup --watch",
|
|
76
|
+
"typecheck": "tsc --noEmit",
|
|
77
|
+
"lint": "eslint src",
|
|
78
|
+
"lint:fix": "eslint src --fix",
|
|
79
|
+
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
|
80
|
+
"test": "jest"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Image from 'next/image'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { ArrowRight, Calendar, Clock } from 'lucide-react'
|
|
6
|
+
import type { Article } from './articleTypes'
|
|
7
|
+
|
|
8
|
+
type ArticleCardProps = Readonly<{
|
|
9
|
+
article: Article
|
|
10
|
+
}>
|
|
11
|
+
|
|
12
|
+
export function ArticleCard({ article }: ArticleCardProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="rounded-lg border border-border bg-card text-card-foreground shadow-sm hover:shadow-lg transition-shadow duration-300">
|
|
15
|
+
<div className="p-0">
|
|
16
|
+
<Image
|
|
17
|
+
src={article.featuredImage}
|
|
18
|
+
alt={article.title}
|
|
19
|
+
className="w-full h-48 object-cover rounded-t-lg"
|
|
20
|
+
width={400}
|
|
21
|
+
height={200}
|
|
22
|
+
/>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="p-6">
|
|
25
|
+
<div className="flex gap-2 mb-3">
|
|
26
|
+
<span className="bg-primary/10 text-primary text-xs font-medium px-2 py-1 rounded">
|
|
27
|
+
{article.category}
|
|
28
|
+
</span>
|
|
29
|
+
</div>
|
|
30
|
+
<h3 className="text-lg font-semibold leading-none tracking-tight mb-2">
|
|
31
|
+
<Link
|
|
32
|
+
href={`/articles/${article.slug}`}
|
|
33
|
+
className="hover:text-indigo-600 transition-colors"
|
|
34
|
+
>
|
|
35
|
+
{article.title}
|
|
36
|
+
</Link>
|
|
37
|
+
</h3>
|
|
38
|
+
<p className="text-muted-foreground mb-4 line-clamp-3">{article.excerpt}</p>
|
|
39
|
+
<div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4">
|
|
40
|
+
<div className="flex items-center gap-3">
|
|
41
|
+
{article.date && (
|
|
42
|
+
<div className="flex items-center gap-2">
|
|
43
|
+
<Calendar className="h-3 w-3" />
|
|
44
|
+
<span>{article.date}</span>
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
<div className="flex items-center gap-2">
|
|
48
|
+
<Clock className="h-3 w-3" />
|
|
49
|
+
<span>{article.readTime}</span>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<Link
|
|
53
|
+
href={`/articles/${article.slug}`}
|
|
54
|
+
className="inline-flex items-center justify-center h-8 w-8 p-0 rounded-md hover:bg-accent transition-colors"
|
|
55
|
+
aria-label={`Read ${article.title}`}
|
|
56
|
+
>
|
|
57
|
+
<ArrowRight className="h-4 w-4" />
|
|
58
|
+
</Link>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Image from 'next/image'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { useState, useEffect } from 'react'
|
|
6
|
+
import type { CategoryInfo } from './articleTypes'
|
|
7
|
+
|
|
8
|
+
type ArticleCategoryGridProps = Readonly<{
|
|
9
|
+
categories: CategoryInfo[]
|
|
10
|
+
pageSize?: number
|
|
11
|
+
}>
|
|
12
|
+
|
|
13
|
+
export function ArticleCategoryGrid({ categories, pageSize = 8 }: ArticleCategoryGridProps) {
|
|
14
|
+
const [visibleCount, setVisibleCount] = useState(pageSize)
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
setVisibleCount(pageSize)
|
|
18
|
+
}, [categories, pageSize])
|
|
19
|
+
|
|
20
|
+
if (categories.length === 0) return null
|
|
21
|
+
|
|
22
|
+
const visible = categories.slice(0, visibleCount)
|
|
23
|
+
const hasMore = visibleCount < categories.length
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<section className="py-16 bg-background">
|
|
27
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
28
|
+
<div className="text-center mb-12">
|
|
29
|
+
<h2 className="text-3xl font-bold text-foreground mb-4">Browse by Category</h2>
|
|
30
|
+
<p className="text-lg text-muted-foreground">
|
|
31
|
+
Explore our articles by topic to find the insights most relevant to your interests.
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6">
|
|
36
|
+
{visible.map((cat) => (
|
|
37
|
+
<Link key={cat.slug} href={`/articles/category/${cat.slug}`}>
|
|
38
|
+
<div className="group overflow-hidden rounded-xl border border-border shadow-sm hover:shadow-lg transition-all duration-300 cursor-pointer bg-card">
|
|
39
|
+
<div className="relative h-52 overflow-hidden">
|
|
40
|
+
<Image
|
|
41
|
+
src={cat.featuredImage}
|
|
42
|
+
alt={cat.name}
|
|
43
|
+
fill
|
|
44
|
+
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
|
45
|
+
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
<div className="p-4 text-center">
|
|
49
|
+
<h3 className="font-semibold text-foreground text-base">{cat.name}</h3>
|
|
50
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
51
|
+
{cat.count} {cat.count === 1 ? 'article' : 'articles'}
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</Link>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{hasMore && (
|
|
60
|
+
<div className="mt-10 text-center">
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
onClick={() => setVisibleCount((c) => c + pageSize)}
|
|
64
|
+
className="inline-flex items-center justify-center px-4 py-2 border border-input bg-background hover:bg-accent rounded-md font-medium text-sm transition-colors"
|
|
65
|
+
>
|
|
66
|
+
Load more categories
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Article } from './articleTypes'
|
|
2
|
+
|
|
3
|
+
type ArticleSchemaProps = Readonly<{
|
|
4
|
+
article: Article
|
|
5
|
+
articleUrl: string
|
|
6
|
+
siteName: string
|
|
7
|
+
}>
|
|
8
|
+
|
|
9
|
+
export function ArticleSchema({ article, articleUrl, siteName }: ArticleSchemaProps) {
|
|
10
|
+
const schema = {
|
|
11
|
+
'@context': 'https://schema.org',
|
|
12
|
+
'@type': 'Article',
|
|
13
|
+
headline: article.title,
|
|
14
|
+
description: article.excerpt,
|
|
15
|
+
image: article.featuredImage,
|
|
16
|
+
...(article.date && { datePublished: new Date(article.date).toISOString() }),
|
|
17
|
+
author: { '@type': 'Person', name: article.author },
|
|
18
|
+
publisher: { '@type': 'Organization', name: siteName },
|
|
19
|
+
mainEntityOfPage: { '@type': 'WebPage', '@id': articleUrl },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<script
|
|
24
|
+
type="application/ld+json"
|
|
25
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
|
26
|
+
/>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type BreadcrumbSchemaProps = Readonly<{
|
|
31
|
+
items: ReadonlyArray<{ name: string; url: string }>
|
|
32
|
+
}>
|
|
33
|
+
|
|
34
|
+
export function BreadcrumbSchema({ items }: BreadcrumbSchemaProps) {
|
|
35
|
+
const schema = {
|
|
36
|
+
'@context': 'https://schema.org',
|
|
37
|
+
'@type': 'BreadcrumbList',
|
|
38
|
+
itemListElement: items.map((item, index) => ({
|
|
39
|
+
'@type': 'ListItem',
|
|
40
|
+
position: index + 1,
|
|
41
|
+
name: item.name,
|
|
42
|
+
item: item.url,
|
|
43
|
+
})),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<script
|
|
48
|
+
type="application/ld+json"
|
|
49
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type CollectionPageSchemaProps = Readonly<{
|
|
55
|
+
title: string
|
|
56
|
+
description: string
|
|
57
|
+
url: string
|
|
58
|
+
articleCount: number
|
|
59
|
+
}>
|
|
60
|
+
|
|
61
|
+
export function CollectionPageSchema({
|
|
62
|
+
title,
|
|
63
|
+
description,
|
|
64
|
+
url,
|
|
65
|
+
articleCount,
|
|
66
|
+
}: CollectionPageSchemaProps) {
|
|
67
|
+
const schema = {
|
|
68
|
+
'@context': 'https://schema.org',
|
|
69
|
+
'@type': 'CollectionPage',
|
|
70
|
+
name: title,
|
|
71
|
+
description,
|
|
72
|
+
url,
|
|
73
|
+
numberOfItems: articleCount,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<script
|
|
78
|
+
type="application/ld+json"
|
|
79
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Search, X } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
type ArticleSearchBarProps = Readonly<{
|
|
4
|
+
value: string
|
|
5
|
+
onChange: (query: string) => void
|
|
6
|
+
disabled?: boolean
|
|
7
|
+
resultCount?: number
|
|
8
|
+
}>
|
|
9
|
+
|
|
10
|
+
export function ArticleSearchBar({
|
|
11
|
+
value,
|
|
12
|
+
onChange,
|
|
13
|
+
disabled,
|
|
14
|
+
resultCount,
|
|
15
|
+
}: ArticleSearchBarProps) {
|
|
16
|
+
return (
|
|
17
|
+
<section className="py-8 bg-background border-b border-border">
|
|
18
|
+
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
19
|
+
<div className="relative">
|
|
20
|
+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-5 w-5" />
|
|
21
|
+
<input
|
|
22
|
+
type="text"
|
|
23
|
+
placeholder="Search articles (3+ characters required)..."
|
|
24
|
+
value={value}
|
|
25
|
+
onChange={(e) => onChange(e.target.value)}
|
|
26
|
+
disabled={disabled}
|
|
27
|
+
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 pl-10 pr-10 py-3 text-lg"
|
|
28
|
+
/>
|
|
29
|
+
{value && (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={() => onChange('')}
|
|
33
|
+
aria-label="Clear search"
|
|
34
|
+
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
35
|
+
>
|
|
36
|
+
<X className="h-5 w-5" />
|
|
37
|
+
</button>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
{value && (
|
|
41
|
+
<div className="mt-2 text-sm text-muted-foreground">
|
|
42
|
+
{resultCount === 0
|
|
43
|
+
? 'No articles found matching your search.'
|
|
44
|
+
: `Found ${resultCount} article${resultCount === 1 ? '' : 's'} matching "${value}"`}
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
</section>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import Link from 'next/link'
|
|
2
|
+
|
|
3
|
+
export function ArticlesHero() {
|
|
4
|
+
return (
|
|
5
|
+
<section className="bg-linear-to-r from-gray-50 to-gray-100 py-16 md:py-24">
|
|
6
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
7
|
+
<div className="max-w-3xl mx-auto text-center">
|
|
8
|
+
<h1 className="text-4xl md:text-5xl font-bold text-foreground mb-6">
|
|
9
|
+
Vox Populus Insights
|
|
10
|
+
</h1>
|
|
11
|
+
<p className="text-xl text-muted-foreground mb-8">
|
|
12
|
+
Expert analysis, campaign strategies, and voter engagement insights from the frontlines
|
|
13
|
+
of democracy.
|
|
14
|
+
</p>
|
|
15
|
+
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
|
16
|
+
<Link
|
|
17
|
+
href="#latest"
|
|
18
|
+
className="inline-flex items-center justify-center px-4 py-2 bg-primary hover:bg-primary/90 text-primary-foreground rounded-md font-medium text-sm transition-colors"
|
|
19
|
+
>
|
|
20
|
+
Browse Latest Articles
|
|
21
|
+
</Link>
|
|
22
|
+
<Link
|
|
23
|
+
href="/contact"
|
|
24
|
+
className="inline-flex items-center justify-center px-4 py-2 border border-primary text-primary hover:bg-primary/10 rounded-md font-medium text-sm transition-colors"
|
|
25
|
+
>
|
|
26
|
+
Contact Our Team
|
|
27
|
+
</Link>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</section>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import type { Article, CategoryInfo } from './articleTypes'
|
|
5
|
+
import { ArticleCategoryGrid } from './ArticleCategoryGrid'
|
|
6
|
+
import { CollectionPageSchema } from './ArticleSchemas'
|
|
7
|
+
import { ArticleSearchBar } from './ArticleSearchBar'
|
|
8
|
+
import { ArticlesHero } from './ArticlesHero'
|
|
9
|
+
import { FeaturedArticle } from './FeaturedArticle'
|
|
10
|
+
import { LatestArticlesSection } from './LatestArticlesSection'
|
|
11
|
+
import {
|
|
12
|
+
ArticlesConfig,
|
|
13
|
+
ArticlesSection,
|
|
14
|
+
ArticlesTheme,
|
|
15
|
+
DEFAULT_CATEGORIES_PAGE_SIZE,
|
|
16
|
+
DEFAULT_LAYOUT,
|
|
17
|
+
DEFAULT_PAGE_SIZE,
|
|
18
|
+
} from './articlesConfig'
|
|
19
|
+
import { useArticles } from './useArticles'
|
|
20
|
+
|
|
21
|
+
interface SectionContext {
|
|
22
|
+
articles: Article[]
|
|
23
|
+
displayedArticles: Article[]
|
|
24
|
+
categories: CategoryInfo[]
|
|
25
|
+
searchQuery: string
|
|
26
|
+
loading: boolean
|
|
27
|
+
error: string | null
|
|
28
|
+
handleSearch: (query: string) => void
|
|
29
|
+
pageSize: number
|
|
30
|
+
categoriesPageSize: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildThemeVars(theme?: ArticlesTheme): React.CSSProperties {
|
|
34
|
+
if (!theme) return {}
|
|
35
|
+
const vars: Record<string, string> = {}
|
|
36
|
+
if (theme.fontFamily) vars['--articles-font-family'] = theme.fontFamily
|
|
37
|
+
if (theme.headerColor) vars['--articles-header-color'] = theme.headerColor
|
|
38
|
+
if (theme.textColor) vars['--articles-text-color'] = theme.textColor
|
|
39
|
+
if (theme.backgroundColor) vars['--articles-bg-color'] = theme.backgroundColor
|
|
40
|
+
if (theme.linkColor) vars['--articles-link-color'] = theme.linkColor
|
|
41
|
+
if (theme.linkHoverColor) vars['--articles-link-hover-color'] = theme.linkHoverColor
|
|
42
|
+
if (theme.linkTextDecoration) vars['--articles-link-decoration'] = theme.linkTextDecoration
|
|
43
|
+
if (theme.headerFontWeight) vars['--articles-header-font-weight'] = String(theme.headerFontWeight)
|
|
44
|
+
if (theme.headerFontSize) vars['--articles-header-font-size'] = theme.headerFontSize
|
|
45
|
+
if (theme.bodyFontSize) vars['--articles-body-font-size'] = theme.bodyFontSize
|
|
46
|
+
if (theme.lineHeight) vars['--articles-line-height'] = theme.lineHeight
|
|
47
|
+
if (theme.borderRadius) vars['--articles-border-radius'] = theme.borderRadius
|
|
48
|
+
return vars as React.CSSProperties
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderSection(section: ArticlesSection, ctx: SectionContext): React.ReactNode {
|
|
52
|
+
const {
|
|
53
|
+
articles,
|
|
54
|
+
displayedArticles,
|
|
55
|
+
categories,
|
|
56
|
+
searchQuery,
|
|
57
|
+
loading,
|
|
58
|
+
error,
|
|
59
|
+
handleSearch,
|
|
60
|
+
pageSize,
|
|
61
|
+
categoriesPageSize,
|
|
62
|
+
} = ctx
|
|
63
|
+
|
|
64
|
+
switch (section) {
|
|
65
|
+
case 'hero':
|
|
66
|
+
return <ArticlesHero key="hero" />
|
|
67
|
+
|
|
68
|
+
case 'search':
|
|
69
|
+
return (
|
|
70
|
+
<ArticleSearchBar
|
|
71
|
+
key="search"
|
|
72
|
+
value={searchQuery}
|
|
73
|
+
onChange={handleSearch}
|
|
74
|
+
disabled={loading}
|
|
75
|
+
resultCount={articles.length}
|
|
76
|
+
/>
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
case 'featured':
|
|
80
|
+
return articles.length > 0 && !searchQuery ? (
|
|
81
|
+
<FeaturedArticle key="featured" article={articles[0]} />
|
|
82
|
+
) : null
|
|
83
|
+
|
|
84
|
+
case 'latest':
|
|
85
|
+
if (loading && articles.length === 0) {
|
|
86
|
+
return (
|
|
87
|
+
<section key="latest" id="latest" className="py-16 bg-muted/50 flex-1">
|
|
88
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-center py-24">
|
|
89
|
+
<div className="text-center">
|
|
90
|
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4" />
|
|
91
|
+
<p className="text-muted-foreground">Loading articles...</p>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</section>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
if (error) {
|
|
98
|
+
return (
|
|
99
|
+
<section key="latest" id="latest" className="py-16 bg-muted/50 flex-1">
|
|
100
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-center">
|
|
101
|
+
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md text-center">
|
|
102
|
+
<h3 className="text-lg font-semibold text-red-800 mb-2">Error Loading Articles</h3>
|
|
103
|
+
<p className="text-red-600 mb-4">{error}</p>
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={() => handleSearch('')}
|
|
107
|
+
className="inline-flex items-center justify-center px-4 py-2 border border-input bg-background hover:bg-accent rounded-md font-medium text-sm transition-colors"
|
|
108
|
+
>
|
|
109
|
+
Try again
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</section>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
return (
|
|
117
|
+
<LatestArticlesSection
|
|
118
|
+
key="latest"
|
|
119
|
+
articles={displayedArticles}
|
|
120
|
+
searchQuery={searchQuery}
|
|
121
|
+
onClearSearch={() => handleSearch('')}
|
|
122
|
+
pageSize={pageSize}
|
|
123
|
+
/>
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
case 'categories':
|
|
127
|
+
return searchQuery ? null : (
|
|
128
|
+
<ArticleCategoryGrid
|
|
129
|
+
key="categories"
|
|
130
|
+
categories={categories}
|
|
131
|
+
pageSize={categoriesPageSize}
|
|
132
|
+
/>
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
// 'newsletter' is intentionally omitted — inject it via the layout prop in the consuming app
|
|
136
|
+
default:
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function ArticlesPage({ config }: Readonly<{ config: ArticlesConfig }>) {
|
|
142
|
+
const state = useArticles()
|
|
143
|
+
const layout = config.layout ?? DEFAULT_LAYOUT
|
|
144
|
+
const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE
|
|
145
|
+
const categoriesPageSize = config.categoriesPageSize ?? DEFAULT_CATEGORIES_PAGE_SIZE
|
|
146
|
+
const themeVars = buildThemeVars(config.theme)
|
|
147
|
+
|
|
148
|
+
const hasFeatured = layout.includes('featured')
|
|
149
|
+
const articlesWithoutFeatured = hasFeatured ? state.articles.slice(1) : state.articles
|
|
150
|
+
const displayedArticles = state.searchQuery ? state.articles : articlesWithoutFeatured
|
|
151
|
+
|
|
152
|
+
const ctx: SectionContext = { ...state, displayedArticles, pageSize, categoriesPageSize }
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div style={themeVars}>
|
|
156
|
+
{layout.map((section) => renderSection(section, ctx))}
|
|
157
|
+
{state.articles.length > 0 && (
|
|
158
|
+
<CollectionPageSchema
|
|
159
|
+
title={`Articles | ${config.siteName}`}
|
|
160
|
+
description={`Browse all ${state.articles.length} articles on ${config.siteName}.`}
|
|
161
|
+
url={`${config.siteUrl}/articles`}
|
|
162
|
+
articleCount={state.articles.length}
|
|
163
|
+
/>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|