@fullstackdatasolutions/articles 0.4.3 → 0.5.1

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 CHANGED
@@ -72,23 +72,76 @@ export default function Page() {
72
72
  }
73
73
 
74
74
  // app/articles/[slug]/page.tsx
75
- import { ArticlePage, generateArticleStaticParams, generateArticleMetadata } from '@fullstackdatasolutions/articles/server'
75
+ import { notFound } from 'next/navigation'
76
+ import {
77
+ ArticleDetailHero,
78
+ ArticleNavigation,
79
+ ArticleSEO,
80
+ ArticleSocialShare,
81
+ BreadcrumbSchema,
82
+ CommentsSection,
83
+ ScrollToTop,
84
+ } from '@fullstackdatasolutions/articles'
85
+ import {
86
+ ArticleContent,
87
+ ArticleTOC,
88
+ generateArticleMetadata,
89
+ getAdjacentArticles,
90
+ getArticleMetadata,
91
+ } from '@fullstackdatasolutions/articles/server'
76
92
  import { siteConfig } from '@/config/articles'
77
- export const generateStaticParams = () => generateArticleStaticParams()
78
- export const generateMetadata = ({ params }: { params: { slug: string } }) =>
79
- generateArticleMetadata(params.slug, siteConfig)
80
- export default function Page({ params }: { params: { slug: string } }) {
81
- return <ArticlePage slug={params.slug} config={siteConfig} />
93
+ export { generateArticleStaticParams as generateStaticParams } from '@fullstackdatasolutions/articles/server'
94
+ type ArticlePageProps = Readonly<{ params: Promise<{ slug: string }> }>
95
+ export async function generateMetadata({ params }: ArticlePageProps) {
96
+ const { slug } = await params
97
+ return generateArticleMetadata(slug, siteConfig)
98
+ }
99
+ export default async function Page({ params }: ArticlePageProps) {
100
+ const { slug } = await params
101
+ const article = await getArticleMetadata(slug)
102
+ if (!article) notFound()
103
+ const { previous, next } = await getAdjacentArticles(slug)
104
+ const siteUrl = siteConfig.siteUrl.replace(/\/$/, '')
105
+ const articleUrl = `${siteUrl}/articles/${slug}`
106
+ return (
107
+ <>
108
+ <ArticleSEO article={article} articleUrl={articleUrl} siteName={siteConfig.siteName} />
109
+ <BreadcrumbSchema
110
+ items={[
111
+ { name: 'Home', url: siteUrl },
112
+ { name: 'Articles', url: `${siteUrl}/articles` },
113
+ { name: article.title, url: articleUrl },
114
+ ]}
115
+ />
116
+ <ArticleDetailHero article={article} categoryBasePath="/articles/category" />
117
+ {siteConfig.showToc !== false && article.toc && article.toc.length > 0 && (
118
+ <ArticleTOC toc={article.toc} />
119
+ )}
120
+ <ArticleContent article={article} />
121
+ <ArticleSocialShare title={article.title} url={articleUrl} excerpt={article.excerpt} />
122
+ {siteConfig.comments && <CommentsSection articleSlug={slug} config={siteConfig.comments} />}
123
+ <ArticleNavigation previous={previous} next={next} basePath="/articles" />
124
+ <ScrollToTop />
125
+ </>
126
+ )
82
127
  }
83
128
 
84
129
  // app/articles/category/[category]/page.tsx
85
- import { CategoryArticlesPage, generateCategoryStaticParams, generateCategoryMetadata } from '@fullstackdatasolutions/articles/server'
130
+ import { notFound } from 'next/navigation'
131
+ import { CategoryArticlesPage } from '@fullstackdatasolutions/articles'
132
+ import { generateCategoryMetadata, getArticlesByCategory } from '@fullstackdatasolutions/articles/server'
86
133
  import { siteConfig } from '@/config/articles'
87
- export const generateStaticParams = () => generateCategoryStaticParams()
88
- export const generateMetadata = ({ params }: { params: { category: string } }) =>
89
- generateCategoryMetadata(params.category, siteConfig)
90
- export default function Page({ params }: { params: { category: string } }) {
91
- return <CategoryArticlesPage category={params.category} config={siteConfig} />
134
+ export { generateCategoryStaticParams as generateStaticParams } from '@fullstackdatasolutions/articles/server'
135
+ type CategoryPageProps = Readonly<{ params: Promise<{ category: string }> }>
136
+ export async function generateMetadata({ params }: CategoryPageProps) {
137
+ const { category } = await params
138
+ return generateCategoryMetadata(category, siteConfig)
139
+ }
140
+ export default async function Page({ params }: CategoryPageProps) {
141
+ const { category } = await params
142
+ const articles = await getArticlesByCategory(category)
143
+ if (articles.length === 0) notFound()
144
+ return <CategoryArticlesPage category={category} articles={articles} config={siteConfig} />
92
145
  }
93
146
  ```
94
147
 
@@ -98,6 +151,7 @@ These cannot live inside the package — Next.js file-based routing requires the
98
151
 
99
152
  ```
100
153
  app/api/articles/route.ts
154
+ app/api/articles/comments-store.ts
101
155
  app/api/articles/[slug]/comments/route.ts
102
156
  app/api/articles/[slug]/comments/[commentId]/route.ts
103
157
  ```
@@ -134,6 +188,8 @@ Article body in Markdown...
134
188
  | `categoryDescriptions` | `Record<string, string \| CategoryDescription>` | — | Short/long text per category slug. |
135
189
  | `hero` | `HeroConfig` | — | Hero section title and description. Omit to use built-in defaults. |
136
190
  | `comments` | `CommentsConfig` | — | Comments feature config. Omit to disable entirely. |
191
+ | `showToc` | `boolean` | `true` | Show the table of contents on article detail pages. Set to `false` to hide on all articles. |
192
+ | `description` | `string` | — | Short description used as the RSS feed channel description. Falls back to `siteName` if omitted. |
137
193
 
138
194
  **Default layout order:** `['hero', 'search', 'featured', 'latest', 'categories']`
139
195
 
@@ -248,6 +304,44 @@ const { previous, next } = await getAdjacentArticles(slug)
248
304
 
249
305
  ---
250
306
 
307
+ ## ArticleTOC
308
+
309
+ `ArticleTOC` renders the article's table of contents as an inline nav block above article content. Server component (no `'use client'`). Returns `null` when the `toc` array is empty, so it is safe to render unconditionally.
310
+
311
+ ```tsx
312
+ import { ArticleTOC } from '@fullstackdatasolutions/articles/server'
313
+
314
+ <ArticleTOC toc={article.toc} />
315
+ ```
316
+
317
+ | Prop | Type | Required | Description |
318
+ |---|---|---|---|
319
+ | `toc` | `TocItem[]` | yes | Array of table of contents items from the article |
320
+ | `className` | `string` | no | Optional CSS class to add to the nav wrapper |
321
+
322
+ - Indentation: h2=0rem, h3=1rem, h4=2rem (based on `item.depth - 2`)
323
+ - Returns `null` when `toc` is empty
324
+ - Gate used in article detail pages: `siteConfig.showToc !== false && article.toc && article.toc.length > 0`
325
+
326
+ ---
327
+
328
+ ## ScrollToTop
329
+
330
+ `ScrollToTop` is a fixed-position floating button in the bottom-right corner that appears when the user scrolls past 300px, and smooth-scrolls back to the top on click. Client component (`'use client'`). No props required - fully self-contained, safe to place anywhere in your layout.
331
+
332
+ ```tsx
333
+ import { ScrollToTop } from '@fullstackdatasolutions/articles'
334
+
335
+ <ScrollToTop />
336
+ ```
337
+
338
+ - No props
339
+ - Appears only after 300px of vertical scroll
340
+ - Smooth scroll animation
341
+ - Fixed position, bottom-right corner
342
+
343
+ ---
344
+
251
345
  ## Comments setup
252
346
 
253
347
  ### 1. Enable in config
@@ -274,7 +368,8 @@ app/api/articles/[slug]/comments/[commentId]/route.ts ← DELETE (soft)
274
368
  ### 3. Requirements
275
369
 
276
370
  - **NextAuth** session — users must be signed in to post. Unauthenticated visitors see a sign-in prompt.
277
- - **PostgreSQL** — comments are stored via Prisma. Run the migration from the reference app's `backend/` directory.
371
+ - **File storage** — the reference implementation stores comments in `public/articles/{slug}/comments.json` via `app/api/articles/comments-store.ts`. Make sure the app can write to that path in the environment where comments are enabled.
372
+ - The reference API supports one reply level only: top-level comments can have replies, but replies cannot have nested replies.
278
373
 
279
374
  ---
280
375
 
@@ -289,8 +384,9 @@ import { generateArticlesIndexMetadata } from '@fullstackdatasolutions/articles/
289
384
  import { siteConfig } from '@/config/articles'
290
385
  export const metadata: Metadata = generateArticlesIndexMetadata(siteConfig)
291
386
  // Produces: title, description, full OpenGraph (type: 'website'), Twitter Card,
292
- // canonical URL (/articles), and robots directives.
387
+ // canonical URL (/articles), robots directives, and RSS feed alternate link.
293
388
  // Description falls back to config.hero?.description when set.
389
+ // Automatically emits <link rel="alternate" type="application/rss+xml"> pointing to /articles/feed.xml
294
390
  ```
295
391
 
296
392
  ### Article and category metadata
@@ -308,6 +404,75 @@ export const generateMetadata = ({ params }) =>
308
404
  generateCategoryMetadata(params.category, siteConfig)
309
405
  ```
310
406
 
407
+ ### RSS feed
408
+
409
+ To enable RSS 2.0 feed generation at `/articles/feed.xml`, copy this route file into your consuming app:
410
+
411
+ ```ts
412
+ // app/articles/feed.xml/route.ts
413
+ import { getAllArticles } from '@fullstackdatasolutions/articles/server'
414
+ import { siteConfig } from '@/config/articles'
415
+
416
+ export const dynamic = 'force-static'
417
+
418
+ function escapeXml(str: string): string {
419
+ return str
420
+ .replaceAll('&', '&amp;')
421
+ .replaceAll('<', '&lt;')
422
+ .replaceAll('>', '&gt;')
423
+ .replaceAll('"', '&quot;')
424
+ .replaceAll("'", '&apos;')
425
+ }
426
+
427
+ export async function GET() {
428
+ const siteUrl = siteConfig.siteUrl.replace(/\/$/, '')
429
+ const articles = await getAllArticles()
430
+
431
+ const items = articles
432
+ .map((article) => {
433
+ const url = `${siteUrl}/articles/${article.slug}`
434
+ const pubDate = article.date ? new Date(article.date).toUTCString() : ''
435
+ return [
436
+ ' <item>',
437
+ ` <title><![CDATA[${article.title}]]></title>`,
438
+ ` <link>${url}</link>`,
439
+ ` <guid isPermaLink="true">${url}</guid>`,
440
+ pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
441
+ article.excerpt ? ` <description><![CDATA[${article.excerpt}]]></description>` : '',
442
+ article.author ? ` <author>${escapeXml(article.author)}</author>` : '',
443
+ ' </item>',
444
+ ]
445
+ .filter(Boolean)
446
+ .join('\n')
447
+ })
448
+ .join('\n')
449
+
450
+ const description = siteConfig.description ?? `${siteConfig.siteName} articles`
451
+ const xml = `<?xml version="1.0" encoding="UTF-8" ?>
452
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
453
+ <channel>
454
+ <title><![CDATA[${siteConfig.siteName}]]></title>
455
+ <link>${siteUrl}/articles</link>
456
+ <description><![CDATA[${description}]]></description>
457
+ <language>en</language>
458
+ <atom:link href="${siteUrl}/articles/feed.xml" rel="self" type="application/rss+xml" />
459
+ ${items}
460
+ </channel>
461
+ </rss>`
462
+
463
+ return new Response(xml, {
464
+ headers: {
465
+ 'Content-Type': 'application/xml; charset=utf-8',
466
+ 'Cache-Control': 'public, max-age=3600',
467
+ },
468
+ })
469
+ }
470
+ ```
471
+
472
+ - Uses `force-static` for build-time generation
473
+ - Uses `siteConfig.description` for the channel description field (falls back to `siteName` if omitted)
474
+ - The `<link rel="alternate" type="application/rss+xml">` tag is added automatically to the articles index `<head>` via `generateArticlesIndexMetadata`
475
+
311
476
  ### Sitemaps
312
477
 
313
478
  Use `getArticleSitemapEntries` to generate article and category sitemap entries. Pass your `ArticlesConfig` object (preferred) or a plain URL string.
@@ -339,12 +504,21 @@ JSON-LD structured data (`ArticleSchema`, `BreadcrumbSchema`, `CollectionPageSch
339
504
 
340
505
  | Field | Type | Required | Description |
341
506
  |---|---|---|---|
342
- | `title` | `string` | yes | Article title |
343
- | `excerpt` | `string` | yes | One-sentence summary for cards and meta |
507
+ | `title` | `string` | yes | Article title. If omitted, falls back to the article slug with dashes replaced by spaces. |
508
+ | `excerpt` | `string` | yes | One-sentence summary for cards and meta. If omitted, resolves to an empty string. |
344
509
  | `author` | `string` | no | Defaults to `'Andrew Blase'` |
345
510
  | `tags` | `string[]` | no | Used as categories. First tag = primary category. ArticleDetailHero displays at most 4 category tags; articles with more than 4 show only the first 4 in the hero. |
346
511
  | `date` | `YYYY-MM-DD` | no | Omit to publish immediately. Future dates hide until that date. |
347
- | `featuredImage` | `string` | no | Filename relative to the article directory. Auto-detected if omitted. |
512
+ | `lastmod` | `YYYY-MM-DD` | no | Last modified date used by sitemap metadata when available. |
513
+ | `featuredImage` | `string` | no | Filename or nested path relative to the article directory, or an absolute `http(s)` URL. Auto-detected from the first image in the article directory if omitted. Falls back to `/placeholder-logo.png`. Relative paths are sanitized and resolved to `/articles/{slug}/{featuredImage}`. |
514
+ | `draft` | `boolean` | no | Set to `true` to hide the article in production. Drafts remain visible outside production. |
515
+ | `faq` | `{ question: string; answer: string }[]` | no | FAQ items used by the article structured data. Invalid entries are ignored. |
516
+ | `howTo` | `{ name: string; text: string }[]` | no | How-to steps used by the article structured data. Invalid entries are ignored. |
517
+ | `canonicalUrl` | `string` | no | Canonical URL override for article metadata. |
518
+ | `articleType` | `string` | no | Optional article type value included in article metadata/structured data. |
519
+ | `series` | `string` | no | Optional series label for grouping related articles. |
520
+
521
+ Articles can be authored as either `article.md` or `article.mdx` inside `public/articles/{slug}/`. Markdown files are converted to HTML; MDX files are returned as `mdxSource` for the consuming app to render. Reading time and the table of contents are generated from the article body automatically.
348
522
 
349
523
  ---
350
524
 
@@ -354,6 +528,8 @@ Import from `@fullstackdatasolutions/articles/server` — these use `fs`/`path`
354
528
 
355
529
  ```ts
356
530
  import {
531
+ ArticleContent,
532
+ ArticleTOC,
357
533
  getAllArticles,
358
534
  getArticleMetadata,
359
535
  getArticlesByCategory,
@@ -362,6 +538,11 @@ import {
362
538
  getAdjacentArticles,
363
539
  searchArticles,
364
540
  categoryToSlug,
541
+ sanitizeImagePath,
542
+ markdownToHtml,
543
+ extractToc,
544
+ generateArticleStaticParams,
545
+ generateCategoryStaticParams,
365
546
  getArticleSitemapEntries,
366
547
  generateArticlesIndexMetadata,
367
548
  generateArticleMetadata,
@@ -408,9 +589,11 @@ Paste this block into your app's `AGENTS.md` so AI assistants know how the libra
408
589
  This app uses `@fullstackdatasolutions/articles` for the articles section.
409
590
 
410
591
  ### Key files
411
- - `config/articles.ts` — all library behaviour (layout, theme, page size, comments) is controlled here
592
+ - `config/articles.ts` — all library behaviour (layout, theme, page size, comments, TOC, RSS) is controlled here
412
593
  - `app/api/articles/` — thin API route wrappers; do not add business logic here
413
- - `public/articles/[slug]/article.md` — one directory per article
594
+ - `app/api/articles/comments-store.ts` — reference file-backed comment persistence helper used by the comment API routes
595
+ - `app/articles/feed.xml/route.ts` — RSS 2.0 feed (copy from reference implementation)
596
+ - `public/articles/[slug]/article.md` or `article.mdx` — one directory per article
414
597
 
415
598
  ### CSS (Tailwind v4)
416
599
  The package ships its source TypeScript files so Tailwind can scan them.
@@ -418,6 +601,10 @@ The package ships its source TypeScript files so Tailwind can scan them.
418
601
  @source "./node_modules/@fullstackdatasolutions/articles/src";
419
602
  If this line is missing, most component classes will not render correctly. Note: `ArticleCategoryGrid` images, `CategoryArticlesPage` hero, and `ArticleDetailHero` image use inline styles for critical layout so they render correctly without this line. All other styling still requires it.
420
603
 
604
+ ### Components
605
+ - `ArticleTOC` — server component imported from `@fullstackdatasolutions/articles/server`; renders `article.toc[]` as a nav block. Controlled by `siteConfig.showToc` (defaults `true`). Returns `null` when empty.
606
+ - `ScrollToTop` — client component, no props. Fixed-position button appears after 300px scroll.
607
+
421
608
  ### Server utilities
422
609
  For sitemaps and metadata: `import { getArticleSitemapEntries, generateArticlesIndexMetadata, generateArticleMetadata, generateCategoryMetadata } from '@fullstackdatasolutions/articles/server'`. See README for usage examples.
423
610
 
@@ -426,6 +613,8 @@ For sitemaps and metadata: `import { getArticleSitemapEntries, generateArticlesI
426
613
  - When adding a new article: create `public/articles/[slug]/article.md` with title, excerpt, author, tags.
427
614
  - API routes are copied from the library — update the package version and re-copy if the library API changes.
428
615
  - `hero.title` and `hero.description` in `ArticlesConfig` control the hero heading — pass them to customize per app.
616
+ - `showToc?: boolean` (default `true`) controls TOC visibility on all article detail pages.
617
+ - `description?: string` sets the RSS feed channel description (falls back to `siteName`).
429
618
  - Full library docs: `node_modules/@fullstackdatasolutions/articles/README.md`
430
619
  ```
431
620