@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 +208 -19
- package/dist/index.cjs +182 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -1
- package/dist/index.d.ts +50 -1
- package/dist/index.js +241 -110
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +247 -188
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +42 -1
- package/dist/server.d.ts +42 -1
- package/dist/server.js +244 -188
- package/dist/server.js.map +1 -1
- package/package.json +5 -1
- package/src/ArticleContent.tsx +17 -0
- package/src/ArticleSchemas.tsx +97 -1
- package/src/ArticleSocialShare.tsx +1 -1
- package/src/ArticleTOC.tsx +29 -0
- package/src/ScrollToTop.tsx +25 -0
- package/src/__tests__/ArticleContent.test.tsx +91 -0
- package/src/__tests__/ArticleSchemas.test.tsx +212 -1
- package/src/__tests__/ArticleSocialShare.test.tsx +7 -7
- package/src/__tests__/ArticleTOC.test.tsx +75 -0
- package/src/__tests__/ScrollToTop.test.tsx +87 -0
- package/src/__tests__/seoUtils.test.ts +266 -3
- package/src/__tests__/server-articles.test.ts +98 -0
- package/src/articleTypes.ts +26 -0
- package/src/articlesConfig.ts +4 -0
- package/src/index.ts +10 -2
- package/src/markdown.ts +143 -130
- package/src/seoUtils.ts +20 -14
- package/src/server-articles.ts +81 -78
- package/src/server.ts +4 -2
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 {
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return
|
|
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 {
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return
|
|
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
|
-
- **
|
|
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
|
|
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('&', '&')
|
|
421
|
+
.replaceAll('<', '<')
|
|
422
|
+
.replaceAll('>', '>')
|
|
423
|
+
.replaceAll('"', '"')
|
|
424
|
+
.replaceAll("'", ''')
|
|
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
|
-
| `
|
|
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
|
-
- `
|
|
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
|
|