@fullstackdatasolutions/articles 0.8.1 → 0.9.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 (41) hide show
  1. package/README.md +233 -72
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +5 -1
  4. package/dist/index.d.ts +5 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/nextjs-middleware.cjs +61 -0
  7. package/dist/nextjs-middleware.cjs.map +1 -0
  8. package/dist/nextjs-middleware.d.cts +25 -0
  9. package/dist/nextjs-middleware.d.ts +25 -0
  10. package/dist/nextjs-middleware.js +33 -0
  11. package/dist/nextjs-middleware.js.map +1 -0
  12. package/dist/nextjs.cjs +692 -0
  13. package/dist/nextjs.cjs.map +1 -0
  14. package/dist/nextjs.d.cts +116 -0
  15. package/dist/nextjs.d.ts +116 -0
  16. package/dist/nextjs.js +658 -0
  17. package/dist/nextjs.js.map +1 -0
  18. package/dist/server.cjs +117 -38
  19. package/dist/server.cjs.map +1 -1
  20. package/dist/server.d.cts +10 -4
  21. package/dist/server.d.ts +10 -4
  22. package/dist/server.js +116 -38
  23. package/dist/server.js.map +1 -1
  24. package/package.json +11 -1
  25. package/src/ArticleContent.tsx +8 -3
  26. package/src/__tests__/ArticleContent.test.tsx +18 -2
  27. package/src/__tests__/markdown.test.ts +79 -3
  28. package/src/__tests__/nextjs-middleware.test.ts +183 -0
  29. package/src/__tests__/nextjs.test.ts +137 -0
  30. package/src/__tests__/renderMdx.test.tsx +22 -0
  31. package/src/__tests__/seoUtils.test.ts +102 -0
  32. package/src/__tests__/server-articles.test.ts +15 -1
  33. package/src/articlesConfig.ts +5 -0
  34. package/src/index.ts +1 -0
  35. package/src/markdown.ts +67 -8
  36. package/src/nextjs-middleware.ts +48 -0
  37. package/src/nextjs.ts +27 -0
  38. package/src/renderMdx.tsx +3 -2
  39. package/src/seoUtils.ts +54 -0
  40. package/src/server-articles.ts +27 -25
  41. package/src/server.ts +2 -1
package/README.md CHANGED
@@ -125,7 +125,7 @@ export async function generateMetadata({ params }: ArticlePageProps) {
125
125
  export default async function Page({ params }: ArticlePageProps) {
126
126
  const { slug: slugSegments } = await params
127
127
  const slug = normalizeSlug(slugSegments)
128
- const article = await getArticleMetadata(slug)
128
+ const article = await getArticleMetadata(slug, siteConfig)
129
129
  if (!article) notFound()
130
130
  const { previous, next } = await getAdjacentArticles(slug)
131
131
  const siteUrl = siteConfig.siteUrl.replace(/\/$/, '')
@@ -144,7 +144,7 @@ export default async function Page({ params }: ArticlePageProps) {
144
144
  {siteConfig.showToc !== false && article.toc && article.toc.length > 0 && (
145
145
  <ArticleTOC toc={article.toc} />
146
146
  )}
147
- <ArticleContent article={article} />
147
+ <ArticleContent article={article} config={siteConfig} />
148
148
  <ArticleSocialShare title={article.title} url={articleUrl} excerpt={article.excerpt} />
149
149
  {siteConfig.comments && <CommentsSection articleSlug={slug} config={siteConfig.comments} />}
150
150
  <ArticleNavigation previous={previous} next={next} basePath="/articles" />
@@ -175,7 +175,57 @@ export default async function Page({ params }: CategoryPageProps) {
175
175
  }
176
176
  ```
177
177
 
178
- ### 6. Copy the API routes
178
+ ### 6. Set up markdown handler (Next.js 16+)
179
+
180
+ Create an API route to serve article markdown at `/api/articles-markdown/[...slug]`:
181
+
182
+ ```tsx
183
+ // app/api/articles-markdown/[...slug]/route.ts
184
+ import { createArticleMarkdownHandler } from '@fullstackdatasolutions/articles/nextjs'
185
+ import { siteConfig } from '@/config/articles'
186
+
187
+ export const { GET } = createArticleMarkdownHandler(siteConfig)
188
+ ```
189
+
190
+ Then wire the markdown rewrite into your proxy.ts (Next.js 16+ edge proxy):
191
+
192
+ ```ts
193
+ // proxy.ts
194
+ import { NextResponse, NextRequest } from 'next/server'
195
+ import { rewriteArticleMarkdown } from '@fullstackdatasolutions/articles/middleware'
196
+
197
+ export async function proxy(request: NextRequest) {
198
+ const mdRewrite = rewriteArticleMarkdown(request)
199
+ if (mdRewrite) return mdRewrite
200
+
201
+ return NextResponse.next()
202
+ }
203
+
204
+ export const config = {
205
+ matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
206
+ }
207
+ ```
208
+
209
+ This pattern rewrites requests from `/articles/slug.md` to `/api/articles-markdown/slug` where the handler serves the markdown. See the AI markdown twins section below for details on how to expose markdown and configure AI crawling rules.
210
+
211
+ For **Next.js 14/15**, use the middleware.ts pattern below instead.
212
+
213
+ ### 6b. Set up middleware (Next.js 14/15 only)
214
+
215
+ If using Next.js 14 or 15 and cannot use a proxy, create middleware.ts to rewrite markdown requests:
216
+
217
+ ```ts
218
+ // middleware.ts
219
+ import { NextRequest } from 'next/server'
220
+ import { middleware, config } from '@fullstackdatasolutions/articles/middleware'
221
+
222
+ export { config }
223
+ export default middleware
224
+ ```
225
+
226
+ This pattern does NOT work in Next.js 16 (use the proxy.ts pattern instead). You still need the handler route above to actually serve the markdown.
227
+
228
+ ### 6c. Copy the comments API routes
179
229
 
180
230
  These cannot live inside the package — Next.js file-based routing requires them in your app. Copy these files from the reference implementation:
181
231
 
@@ -207,23 +257,105 @@ Article directories can be nested to any depth below `public/articles`. For exam
207
257
 
208
258
  ---
209
259
 
260
+ ## Markdown handler exports
261
+
262
+ ### `./nextjs` — Article markdown handler factory
263
+
264
+ Import from `@fullstackdatasolutions/articles/nextjs`. Node.js runtime only (use in API routes, not Edge middleware).
265
+
266
+ #### `createArticleMarkdownHandler(config)`
267
+
268
+ Factory function that returns a `{ GET }` handler for serving article markdown.
269
+
270
+ ```ts
271
+ import { createArticleMarkdownHandler } from '@fullstackdatasolutions/articles/nextjs'
272
+ import { siteConfig } from '@/config/articles'
273
+
274
+ export const { GET } = createArticleMarkdownHandler(siteConfig)
275
+ ```
276
+
277
+ - **config** - `ArticlesConfig` instance (required). Must include `siteUrl` and `siteName` at minimum.
278
+ - **returns** - Object with `GET` async function matching Next.js Route Handler signature.
279
+ - The handler extracts slug from `params['slug']` and strips `.md` extension if present.
280
+ - Uses `getArticleMarkdownResponse` from the server utilities to fetch and return markdown.
281
+ - Only returns markdown for articles with `aiCrawl: true` in frontmatter (returns 404 otherwise).
282
+
283
+ ### `./middleware` — Article markdown rewrite helpers
284
+
285
+ Import from `@fullstackdatasolutions/articles/middleware`. Edge runtime safe (no Node.js built-ins).
286
+
287
+ #### `rewriteArticleMarkdown(request, apiBase?)`
288
+
289
+ Rewrites `/articles/*.md` requests to your markdown handler route.
290
+
291
+ ```ts
292
+ import { rewriteArticleMarkdown } from '@fullstackdatasolutions/articles/middleware'
293
+
294
+ export async function proxy(request: NextRequest) {
295
+ const mdRewrite = rewriteArticleMarkdown(request)
296
+ if (mdRewrite) return mdRewrite
297
+
298
+ return NextResponse.next()
299
+ }
300
+ ```
301
+
302
+ - **request** - `NextRequest` object (required).
303
+ - **apiBase** - Base path to your markdown handler. Default: `'/api/articles-markdown'`
304
+ - **returns** - `NextResponse` if request matches `/articles/...md`, `undefined` otherwise.
305
+ - Intended use: call in proxy.ts (Next.js 16+) or middleware.ts. If result is truthy, return it; otherwise continue processing.
306
+ - Example: request to `/articles/my-post.md` rewrites to `/api/articles-markdown/my-post`.
307
+
308
+ #### `createArticleMarkdownMiddleware(apiBase?)`
309
+
310
+ Factory for building custom middleware/proxy with a custom API base path.
311
+
312
+ ```ts
313
+ import { createArticleMarkdownMiddleware } from '@fullstackdatasolutions/articles/middleware'
314
+
315
+ const { middleware, config } = createArticleMarkdownMiddleware('/api/my-custom-path')
316
+
317
+ export { config }
318
+ export default middleware
319
+ ```
320
+
321
+ - **apiBase** - Base path to your markdown handler. Default: `'/api/articles-markdown'`
322
+ - **returns** - Object with `{ middleware, config }` ready to export from middleware.ts.
323
+ - `config.matcher` automatically targets `/articles/:path*.md`.
324
+
325
+ #### `middleware` (default export)
326
+
327
+ Pre-built middleware using the default API base (`'/api/articles-markdown'`).
328
+
329
+ ```ts
330
+ // middleware.ts
331
+ import { middleware, config } from '@fullstackdatasolutions/articles/middleware'
332
+
333
+ export { config }
334
+ export default middleware
335
+ ```
336
+
337
+ Use this if you don't need a custom API base path. For custom paths, use `createArticleMarkdownMiddleware()` instead.
338
+
339
+ ---
340
+
210
341
  ## `ArticlesConfig` reference
211
342
 
212
- | Field | Type | Default | Description |
213
- | ---------------------- | ----------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
214
- | `siteUrl` | `string` | — | Canonical base URL. Used in metadata and JSON-LD. |
215
- | `siteName` | `string` | — | Site name in title tags and JSON-LD. |
216
- | `pageSize` | `number` | `6` | Articles per page / per "Load more" click. |
217
- | `categoriesPageSize` | `number` | `8` | Category cards before "Load more categories". |
218
- | `layout` | `ArticlesSection[]` | see below | Ordered sections to render. Omit a key to hide it. |
219
- | `theme` | `ArticlesTheme` | — | CSS custom-property overrides for colors and fonts. |
220
- | `categoryDescriptions` | `Record<string, string \| CategoryDescription>` | — | Short/long text per category slug. |
221
- | `hero` | `HeroConfig` | — | Hero section title and description. Omit to use built-in defaults. |
222
- | `comments` | `CommentsConfig` | — | Comments feature config. Omit to disable entirely. |
223
- | `showToc` | `boolean` | `true` | Show the table of contents on article detail pages. Set to `false` to hide on all articles. |
224
- | `showBackToArticles` | `boolean` | `true` | Show the back-navigation link on article detail pages. Set to `false` to hide `ArticleBackLink`. |
225
- | `showAuthor` | `boolean` | `true` | Show author names in UI and metadata. Set to `false` to omit author display, OpenGraph authors, JSON-LD author fields, and RSS authors. |
226
- | `description` | `string` | | Short description used as the RSS feed channel description. Falls back to `siteName` if omitted. |
343
+ | Field | Type | Default | Description |
344
+ | ---------------------- | --------------------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
345
+ | `siteUrl` | `string` | — | Canonical base URL. Used in metadata and JSON-LD. |
346
+ | `siteName` | `string` | — | Site name in title tags and JSON-LD. |
347
+ | `pageSize` | `number` | `6` | Articles per page / per "Load more" click. |
348
+ | `categoriesPageSize` | `number` | `8` | Category cards before "Load more categories". |
349
+ | `layout` | `ArticlesSection[]` | see below | Ordered sections to render. Omit a key to hide it. |
350
+ | `theme` | `ArticlesTheme` | — | CSS custom-property overrides for colors and fonts. |
351
+ | `categoryDescriptions` | `Record<string, string \| CategoryDescription>` | — | Short/long text per category slug. |
352
+ | `hero` | `HeroConfig` | — | Hero section title and description. Omit to use built-in defaults. |
353
+ | `comments` | `CommentsConfig` | — | Comments feature config. Omit to disable entirely. |
354
+ | `showToc` | `boolean` | `true` | Show the table of contents on article detail pages. Set to `false` to hide on all articles. |
355
+ | `showBackToArticles` | `boolean` | `true` | Show the back-navigation link on article detail pages. Set to `false` to hide `ArticleBackLink`. |
356
+ | `showAuthor` | `boolean` | `true` | Show author names in UI and metadata. Set to `false` to omit author display, OpenGraph authors, JSON-LD author fields, and RSS authors. |
357
+ | `linkTargetStrategy` | `'external-new-tab' \| 'all-new-tab' \| 'same-tab'` | `'external-new-tab'` | Controls article body links. Internal links open in the same window by default; external `http`/`https` links open in a new window. |
358
+ | `description` | `string` | — | Short description used as the RSS feed channel description. Falls back to `siteName` if omitted. |
227
359
 
228
360
  **Default layout order:** `['hero', 'search', 'featured', 'latest', 'categories']`
229
361
 
@@ -470,56 +602,14 @@ To enable RSS 2.0 feed generation at `/articles/feed.xml`, copy this route file
470
602
 
471
603
  ```ts
472
604
  // app/articles/feed.xml/route.ts
473
- import { getAllArticles } from '@fullstackdatasolutions/articles/server'
605
+ import { generateRssFeed, getAllArticles } from '@fullstackdatasolutions/articles/server'
474
606
  import { siteConfig } from '@/config/articles'
475
607
 
476
608
  export const dynamic = 'force-static'
477
609
 
478
- function escapeXml(str: string): string {
479
- return str
480
- .replaceAll('&', '&amp;')
481
- .replaceAll('<', '&lt;')
482
- .replaceAll('>', '&gt;')
483
- .replaceAll('"', '&quot;')
484
- .replaceAll("'", '&apos;')
485
- }
486
-
487
610
  export async function GET() {
488
- const siteUrl = siteConfig.siteUrl.replace(/\/$/, '')
489
611
  const articles = await getAllArticles()
490
- const showAuthor = siteConfig.showAuthor !== false
491
-
492
- const items = articles
493
- .map((article) => {
494
- const url = `${siteUrl}/articles/${article.slug}`
495
- const pubDate = article.date ? new Date(article.date).toUTCString() : ''
496
- return [
497
- ' <item>',
498
- ` <title><![CDATA[${article.title}]]></title>`,
499
- ` <link>${url}</link>`,
500
- ` <guid isPermaLink="true">${url}</guid>`,
501
- pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
502
- article.excerpt ? ` <description><![CDATA[${article.excerpt}]]></description>` : '',
503
- showAuthor && article.author ? ` <author>${escapeXml(article.author)}</author>` : '',
504
- ' </item>',
505
- ]
506
- .filter(Boolean)
507
- .join('\n')
508
- })
509
- .join('\n')
510
-
511
- const description = siteConfig.description ?? `${siteConfig.siteName} articles`
512
- const xml = `<?xml version="1.0" encoding="UTF-8" ?>
513
- <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
514
- <channel>
515
- <title><![CDATA[${siteConfig.siteName}]]></title>
516
- <link>${siteUrl}/articles</link>
517
- <description><![CDATA[${description}]]></description>
518
- <language>en</language>
519
- <atom:link href="${siteUrl}/articles/feed.xml" rel="self" type="application/rss+xml" />
520
- ${items}
521
- </channel>
522
- </rss>`
612
+ const xml = generateRssFeed(articles, siteConfig)
523
613
 
524
614
  return new Response(xml, {
525
615
  headers: {
@@ -531,6 +621,7 @@ ${items}
531
621
  ```
532
622
 
533
623
  - Uses `force-static` for build-time generation
624
+ - Uses `generateRssFeed(articles, siteConfig)` from the package so RSS escaping, authors, descriptions, and feed URLs stay consistent across consuming apps
534
625
  - Uses `siteConfig.description` for the channel description field (falls back to `siteName` if omitted)
535
626
  - The `<link rel="alternate" type="application/rss+xml">` tag is added automatically to the articles index `<head>` via `generateArticlesIndexMetadata`
536
627
 
@@ -548,24 +639,82 @@ aiCrawl: true
548
639
  Markdown body...
549
640
  ```
550
641
 
551
- Runtime route example for Next App Router:
642
+ To expose markdown at public URLs like `/articles/my-post.md`, use the markdown handler factories above:
643
+
644
+ - **Next.js 16+** — Use `createArticleMarkdownHandler()` + `rewriteArticleMarkdown()` in proxy.ts (see Step 6 above).
645
+ - **Next.js 14/15** — Use `createArticleMarkdownHandler()` + middleware.ts with `middleware` export from the package (see Step 6b above).
646
+
647
+ For static generation, call `getArticleMarkdown(slug)` during your build and write the returned content beside your generated HTML; it returns `null` unless `aiCrawl: true`.
648
+
649
+ **robots.txt - block AI crawlers from non-aiCrawl articles**
650
+
651
+ Wire `getAiRobotsTxtRules()` into your robots.txt route. It returns disallow rules for known AI crawler agents (GPTBot, Claude-Web, etc.) targeting any article that does not have `aiCrawl: true`. Without this step, AI crawlers can still reach all articles regardless of the frontmatter flag.
552
652
 
553
653
  ```ts
554
- // app/articles-md/[...slug]/route.ts
555
- import { getArticleMarkdownResponse } from '@fullstackdatasolutions/articles/server'
556
- import { siteConfig } from '@/config/articles'
654
+ // app/robots.txt/route.ts
655
+ import { NextResponse } from 'next/server'
656
+ import { getAiRobotsTxtRules } from '@fullstackdatasolutions/articles/server'
557
657
 
558
- type RouteProps = Readonly<{ params: Promise<{ slug: string[] }> }>
658
+ export async function GET() {
659
+ const siteUrl = 'https://yoursite.com'
660
+ const aiRules = await getAiRobotsTxtRules()
559
661
 
560
- export async function GET(_request: Request, { params }: RouteProps) {
561
- const { slug } = await params
562
- return getArticleMarkdownResponse(slug.join('/'), siteConfig)
662
+ const robotsTxt = `User-agent: *
663
+ Allow: /
664
+
665
+ Sitemap: ${siteUrl}/sitemap.xml${aiRules ? `\n\n${aiRules}` : ''}`
666
+
667
+ return new NextResponse(robotsTxt, {
668
+ headers: {
669
+ 'Content-Type': 'text/plain',
670
+ 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
671
+ },
672
+ })
673
+ }
674
+ ```
675
+
676
+ `getAiRobotsTxtRules()` returns a string of `User-agent` / `Disallow` blocks, one per known AI crawler, or `null` if every article is opted in. The template literal appends it only when non-null so no blank lines appear when it is unused.
677
+
678
+ **article page metadata - emit alternate link and noai directive**
679
+
680
+ `generateArticleMetadata` handles the `text/markdown` alternate link automatically when `aiCrawl: true`. If you override `generateMetadata` and build the metadata object yourself, cast the return value as `Metadata` before spreading so TypeScript accepts it:
681
+
682
+ ```ts
683
+ // app/articles/[...slug]/page.tsx
684
+ import type { Metadata } from 'next'
685
+ import {
686
+ generateArticleMetadata,
687
+ getArticleMetadata,
688
+ getArticleMarkdownUrl,
689
+ } from '@fullstackdatasolutions/articles/server'
690
+ import { siteConfig } from '@/config/articles'
691
+
692
+ export async function generateMetadata({ params }): Promise<Metadata> {
693
+ const slug = (await params).slug.join('/')
694
+ const base = await generateArticleMetadata(slug, siteConfig)
695
+ const article = await getArticleMetadata(slug, siteConfig)
696
+ if (!article) return base
697
+
698
+ const markdownUrl = getArticleMarkdownUrl(article, siteConfig)
699
+ if (markdownUrl) {
700
+ return {
701
+ ...base,
702
+ alternates: {
703
+ ...(base.alternates as Record<string, unknown>),
704
+ types: { 'text/markdown': markdownUrl },
705
+ },
706
+ }
707
+ }
708
+ return {
709
+ ...base,
710
+ robots: 'index, follow, noai, noimageai',
711
+ }
563
712
  }
564
713
  ```
565
714
 
566
- If you want public URLs like `/articles/my-post.md`, add a rewrite from `/articles/:path*.md` to `/articles-md/:path*` in the consuming app. For static generation, call `getArticleMarkdown(slug)` during your build and write the returned content beside your generated HTML; it returns `null` unless `aiCrawl: true`.
715
+ Do **not** use `headers()` from `next/headers` to set response headers in a Server Component page - it is read-only in the App Router (reads request headers). Use `generateMetadata` instead.
567
716
 
568
- Use `getArticleAiHeaders(article, siteConfig)` in an HTML article route if you want response headers in addition to Next metadata. Opted-in articles receive a markdown `Link` header. Blocked/default articles receive `X-Robots-Tag: noai, noimageai`. `getAiRobotsTxtRules()` returns optional robots.txt blocks for known AI crawler user agents and articles that are not opted in.
717
+ Use `getArticleAiHeaders(article, siteConfig)` only in a Route Handler (not a page), where you control the `Response` object directly. Opted-in articles receive a markdown `Link` header; all others receive `X-Robots-Tag: noai, noimageai`.
569
718
 
570
719
  ### Sitemaps
571
720
 
@@ -645,6 +794,7 @@ import {
645
794
  generateArticleStaticParams,
646
795
  generateCategoryStaticParams,
647
796
  getArticleSitemapEntries,
797
+ generateRssFeed,
648
798
  generateArticlesIndexMetadata,
649
799
  generateArticleMetadata,
650
800
  generateCategoryMetadata,
@@ -739,6 +889,7 @@ For sitemaps and metadata: `import { getArticleSitemapEntries, generateArticlesI
739
889
  - `hero.title` and `hero.description` in `ArticlesConfig` control the hero heading — pass them to customize per app.
740
890
  - `showToc?: boolean` (default `true`) controls TOC visibility on all article detail pages.
741
891
  - `description?: string` sets the RSS feed channel description (falls back to `siteName`).
892
+ - `aiCrawl: true` in article frontmatter opts that article into AI indexing. Wire up three things: (1) `getAiRobotsTxtRules()` in `app/robots.txt/route.ts` appended to the robots.txt string; (2) a `app/articles/[slug].md/route.ts` Route Handler calling `getArticleMarkdownResponse`; (3) `generateMetadata` in the article page casting the base metadata as `Metadata` and spreading it before adding `alternates.types` or `robots`. Do NOT use `next/headers` `headers()` to set response headers in a page - it is read-only.
742
893
  - Full library docs: `node_modules/@fullstackdatasolutions/articles/README.md`
743
894
  ```
744
895
 
@@ -757,6 +908,16 @@ GitHub Actions runs typecheck → test → build → `npm publish --access publi
757
908
 
758
909
  ---
759
910
 
911
+ ## Examples
912
+
913
+ See this package in use at:
914
+
915
+ - [voxpopulus.us/articles](https://voxpopulus.us/articles/)
916
+ - [loopvolt.io/articles](https://loopvolt.io/articles)
917
+ - [theroleplayersguild.com/articles](https://theroleplayersguild.com/articles)
918
+
919
+ ---
920
+
760
921
  ## Upgrading
761
922
 
762
923
  Follow semver. Breaking changes (major) include a migration note in the changelog.