@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/src/markdown.ts CHANGED
@@ -11,8 +11,67 @@ import remarkRehype from 'remark-rehype'
11
11
  import { Plugin } from 'unified'
12
12
  import { visit } from 'unist-util-visit'
13
13
  import type { TocItem } from './articleTypes'
14
+ import type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'
14
15
  import { reportArticlesError } from './errorReporting'
15
16
 
17
+ type LinkTargetOptions = Readonly<{
18
+ strategy?: LinkTargetStrategy
19
+ siteUrl?: string
20
+ }>
21
+
22
+ const DEFAULT_LINK_TARGET_STRATEGY: LinkTargetStrategy = 'external-new-tab'
23
+
24
+ function isNonBrowserNavigationLink(href: string): boolean {
25
+ return (
26
+ /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href) &&
27
+ !href.startsWith('http://') &&
28
+ !href.startsWith('https://')
29
+ )
30
+ }
31
+
32
+ function getOrigin(url: string | undefined): string | null {
33
+ if (!url) return null
34
+ try {
35
+ return new URL(url).origin
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ function isExternalHttpLink(href: string, siteUrl?: string): boolean {
42
+ if (!href.startsWith('http://') && !href.startsWith('https://')) return false
43
+ const siteOrigin = getOrigin(siteUrl)
44
+ if (!siteOrigin) return true
45
+ return getOrigin(href) !== siteOrigin
46
+ }
47
+
48
+ function shouldOpenInNewTab(href: string, options: LinkTargetOptions = {}): boolean {
49
+ if (!href || href.startsWith('#') || isNonBrowserNavigationLink(href)) return false
50
+
51
+ const strategy = options.strategy ?? DEFAULT_LINK_TARGET_STRATEGY
52
+ if (strategy === 'same-tab') return false
53
+ if (strategy === 'all-new-tab') return true
54
+ return isExternalHttpLink(href, options.siteUrl)
55
+ }
56
+
57
+ function applyLinkTarget(props: Record<string, unknown>, options?: LinkTargetOptions): void {
58
+ const href = typeof props.href === 'string' ? props.href : ''
59
+ if (shouldOpenInNewTab(href, options)) {
60
+ props.target = '_blank'
61
+ props.rel = 'noopener noreferrer'
62
+ return
63
+ }
64
+ delete props.target
65
+ delete props.rel
66
+ }
67
+
68
+ function getLinkTargetOptions(config?: ArticlesConfig): LinkTargetOptions {
69
+ return {
70
+ strategy: config?.linkTargetStrategy,
71
+ siteUrl: config?.siteUrl,
72
+ }
73
+ }
74
+
16
75
  // Import the sanitizeImagePath function
17
76
  function sanitizeImagePath(rawPath: string, articleSlug: string): string | null {
18
77
  if (!rawPath || typeof rawPath !== 'string') {
@@ -169,7 +228,7 @@ function processFootnotesSection(node: Element): void {
169
228
  if (node.properties) node.properties.className = undefined
170
229
  }
171
230
 
172
- export const customRenderer: Plugin<[], Root> = () => {
231
+ export const customRenderer: Plugin<[LinkTargetOptions?], Root> = (linkTargetOptions = {}) => {
173
232
  return (tree: Root) => {
174
233
  // First pass: Apply general styles
175
234
  visit(tree, 'element', (node: Element) => {
@@ -194,11 +253,7 @@ export const customRenderer: Plugin<[], Root> = () => {
194
253
  break
195
254
  case 'a': {
196
255
  props.className = 'text-primary hover:underline transition-colors duration-200'
197
- const href = typeof props.href === 'string' ? props.href : ''
198
- if (!href.startsWith('#')) {
199
- props.target = '_blank'
200
- props.rel = 'noopener noreferrer'
201
- }
256
+ applyLinkTarget(props, linkTargetOptions)
202
257
  break
203
258
  }
204
259
  case 'ul':
@@ -278,7 +333,11 @@ const rehypeProcessImages: Plugin<[{ articleSlug?: string }], Root> = (options =
278
333
  }
279
334
  }
280
335
 
281
- export async function markdownToHtml(markdown: string, articleSlug?: string) {
336
+ export async function markdownToHtml(
337
+ markdown: string,
338
+ articleSlug?: string,
339
+ config?: ArticlesConfig
340
+ ) {
282
341
  try {
283
342
  // Start building the remark processor
284
343
  let processor = remark()
@@ -286,7 +345,7 @@ export async function markdownToHtml(markdown: string, articleSlug?: string) {
286
345
  .use(remarkGfm)
287
346
  .use(remarkGithubBlockquoteAlert)
288
347
  .use(remarkRehype)
289
- .use(customRenderer)
348
+ .use(customRenderer, getLinkTargetOptions(config))
290
349
  .use(rehypeSlug)
291
350
  // @ts-ignore
292
351
  .use(rehypePrism)
@@ -0,0 +1,48 @@
1
+ // Next.js proxy/middleware helper — Edge runtime safe (no Node.js built-ins)
2
+ // Import from '@fullstackdatasolutions/articles/middleware'
3
+ import { NextResponse } from 'next/server'
4
+ import type { NextRequest } from 'next/server'
5
+
6
+ const DEFAULT_API_BASE = '/api/articles-markdown'
7
+
8
+ /**
9
+ * Rewrite /articles/*.md requests to the markdown API route.
10
+ * Returns a NextResponse rewrite if the request matches, undefined otherwise.
11
+ *
12
+ * Usage in proxy.ts (Next.js 16+):
13
+ * import { rewriteArticleMarkdown } from '@fullstackdatasolutions/articles/middleware'
14
+ * export async function proxy(request: NextRequest) {
15
+ * return rewriteArticleMarkdown(request) ?? NextResponse.next()
16
+ * }
17
+ */
18
+ export function rewriteArticleMarkdown(
19
+ request: NextRequest,
20
+ apiBase = DEFAULT_API_BASE
21
+ ): NextResponse | undefined {
22
+ const { pathname } = request.nextUrl
23
+ if (pathname.startsWith('/articles/') && pathname.endsWith('.md')) {
24
+ const slug = pathname.slice('/articles/'.length)
25
+ return NextResponse.rewrite(new URL(`${apiBase}/${slug}`, request.url))
26
+ }
27
+ return undefined
28
+ }
29
+
30
+ // Legacy middleware.ts usage (Next.js 14/15)
31
+ function buildMiddleware(apiBase: string) {
32
+ return function middleware(request: NextRequest) {
33
+ return rewriteArticleMarkdown(request, apiBase)
34
+ }
35
+ }
36
+
37
+ export const middleware = buildMiddleware(DEFAULT_API_BASE)
38
+
39
+ export const config = {
40
+ matcher: '/articles/:path*.md',
41
+ }
42
+
43
+ export function createArticleMarkdownMiddleware(apiBase = DEFAULT_API_BASE) {
44
+ return {
45
+ middleware: buildMiddleware(apiBase),
46
+ config: { matcher: '/articles/:path*.md' },
47
+ }
48
+ }
package/src/nextjs.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Next.js API route handler factory — Node.js runtime only
2
+ // Import from '@fullstackdatasolutions/articles/nextjs'
3
+ import type { NextRequest } from 'next/server'
4
+ import { getArticleMarkdownResponse } from './server-articles'
5
+ import type { ArticlesConfig } from './articlesConfig'
6
+
7
+ /**
8
+ * Returns a Next.js App Router GET handler for serving article markdown.
9
+ *
10
+ * Usage in app/api/articles-markdown/[...slug]/route.ts:
11
+ * import { createArticleMarkdownHandler } from '@fullstackdatasolutions/articles/nextjs'
12
+ * import { siteConfig } from '@/config/articles'
13
+ * export const { GET } = createArticleMarkdownHandler(siteConfig)
14
+ */
15
+ export function createArticleMarkdownHandler(config: ArticlesConfig) {
16
+ async function GET(
17
+ _request: NextRequest,
18
+ context: { params: Promise<Record<string, string | string[]>> }
19
+ ) {
20
+ const params = await context.params
21
+ const slugParts = params['slug']
22
+ const slug = Array.isArray(slugParts) ? slugParts.join('/') : (slugParts ?? '')
23
+ const cleanSlug = slug.endsWith('.md') ? slug.slice(0, -3) : slug
24
+ return getArticleMarkdownResponse(cleanSlug, config)
25
+ }
26
+ return { GET }
27
+ }
package/src/renderMdx.tsx CHANGED
@@ -13,6 +13,7 @@ import rehypeSlug from 'rehype-slug'
13
13
  import remarkGfm from 'remark-gfm'
14
14
  import remarkGithubBlockquoteAlert from 'remark-github-blockquote-alert'
15
15
  import { customRenderer } from './markdown'
16
+ import type { ArticlesConfig } from './articlesConfig'
16
17
 
17
18
  type MdxContent = ComponentType<{
18
19
  components?: Record<string, ComponentType<unknown>>
@@ -26,7 +27,7 @@ function makeImgComponent(basePath: string) {
26
27
  }
27
28
  }
28
29
 
29
- export async function renderMdxSource(source: string, basePath?: string) {
30
+ export async function renderMdxSource(source: string, basePath?: string, config?: ArticlesConfig) {
30
31
  const isDevelopment = process.env.NODE_ENV === 'development'
31
32
 
32
33
  const mdxModule = await evaluate(source, {
@@ -34,7 +35,7 @@ export async function renderMdxSource(source: string, basePath?: string) {
34
35
  development: isDevelopment,
35
36
  remarkPlugins: [remarkGfm, remarkGithubBlockquoteAlert],
36
37
  rehypePlugins: [
37
- customRenderer,
38
+ [customRenderer, { strategy: config?.linkTargetStrategy, siteUrl: config?.siteUrl }],
38
39
  rehypeSlug,
39
40
  // @ts-ignore
40
41
  rehypePrism,
package/src/seoUtils.ts CHANGED
@@ -8,6 +8,60 @@ import {
8
8
  getAvailableArticleSlugs,
9
9
  } from './server-articles'
10
10
  import { ArticlesConfig } from './articlesConfig'
11
+ import type { Article } from './articleTypes'
12
+
13
+ function escapeXml(str: string): string {
14
+ return str
15
+ .replaceAll('&', '&amp;')
16
+ .replaceAll('<', '&lt;')
17
+ .replaceAll('>', '&gt;')
18
+ .replaceAll('"', '&quot;')
19
+ .replaceAll("'", '&apos;')
20
+ }
21
+
22
+ export function generateRssFeed(articles: Article[], config: ArticlesConfig): string {
23
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
24
+ const showAuthor = config.showAuthor !== false
25
+
26
+ const items = articles
27
+ .map((article) => {
28
+ const url = `${siteUrl}/articles/${article.slug}`
29
+ const pubDate = article.date ? new Date(article.date).toUTCString() : ''
30
+ const imageUrl = article.featuredImage ? resolveImageUrl(article.featuredImage, siteUrl) : ''
31
+
32
+ return [
33
+ ' <item>',
34
+ ` <title><![CDATA[${article.title}]]></title>`,
35
+ ` <link>${url}</link>`,
36
+ ` <guid isPermaLink="true">${url}</guid>`,
37
+ pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
38
+ article.excerpt ? ` <description><![CDATA[${article.excerpt}]]></description>` : '',
39
+ showAuthor && article.author ? ` <author>${escapeXml(article.author)}</author>` : '',
40
+ article.category ? ` <category><![CDATA[${article.category}]]></category>` : '',
41
+ imageUrl
42
+ ? ` <media:content url="${imageUrl}" medium="image" width="1200" height="630"/>`
43
+ : '',
44
+ ' </item>',
45
+ ]
46
+ .filter(Boolean)
47
+ .join('\n')
48
+ })
49
+ .join('\n')
50
+
51
+ const description = config.description ?? `${config.siteName} articles`
52
+
53
+ return `<?xml version="1.0" encoding="UTF-8" ?>
54
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
55
+ <channel>
56
+ <title><![CDATA[${config.siteName}]]></title>
57
+ <link>${siteUrl}/articles</link>
58
+ <description><![CDATA[${description}]]></description>
59
+ <language>en</language>
60
+ <atom:link href="${siteUrl}/articles/feed.xml" rel="self" type="application/rss+xml" />
61
+ ${items}
62
+ </channel>
63
+ </rss>`
64
+ }
11
65
 
12
66
  export function generateArticleStaticParams(): { slug: string }[] {
13
67
  return getAvailableArticleSlugs().map((slug) => ({ slug }))
@@ -197,33 +197,35 @@ async function getArticleSummary(slug: string): Promise<Article | null> {
197
197
  }
198
198
  }
199
199
 
200
- export const getArticleMetadata = cache(async (slug: string): Promise<Article | null> => {
201
- try {
202
- const summary = await getArticleSummary(slug)
203
- if (!summary) return null
204
- const found = findArticleFile(slug)
205
- if (!found) return null
206
- const fileContent = fs.readFileSync(found.filePath, 'utf8')
207
- const { content: markdownContent } = matter(fileContent)
208
- const toc = await extractToc(markdownContent)
209
- let htmlContent: string | undefined
210
- let mdxSource: string | undefined
211
- if (found.contentType === 'mdx') {
212
- mdxSource = markdownContent
213
- } else {
214
- htmlContent = await markdownToHtml(markdownContent, slug)
200
+ export const getArticleMetadata = cache(
201
+ async (slug: string, config?: ArticlesConfig): Promise<Article | null> => {
202
+ try {
203
+ const summary = await getArticleSummary(slug)
204
+ if (!summary) return null
205
+ const found = findArticleFile(slug)
206
+ if (!found) return null
207
+ const fileContent = fs.readFileSync(found.filePath, 'utf8')
208
+ const { content: markdownContent } = matter(fileContent)
209
+ const toc = await extractToc(markdownContent)
210
+ let htmlContent: string | undefined
211
+ let mdxSource: string | undefined
212
+ if (found.contentType === 'mdx') {
213
+ mdxSource = markdownContent
214
+ } else {
215
+ htmlContent = await markdownToHtml(markdownContent, slug, config)
216
+ }
217
+ return { ...summary, content: markdownContent, htmlContent, mdxSource, toc }
218
+ } catch (error) {
219
+ reportArticlesError({
220
+ code: 'article-load-failed',
221
+ message: 'Unable to load article metadata.',
222
+ error,
223
+ context: { slug },
224
+ })
225
+ return null
215
226
  }
216
- return { ...summary, content: markdownContent, htmlContent, mdxSource, toc }
217
- } catch (error) {
218
- reportArticlesError({
219
- code: 'article-load-failed',
220
- message: 'Unable to load article metadata.',
221
- error,
222
- context: { slug },
223
- })
224
- return null
225
227
  }
226
- })
228
+ )
227
229
 
228
230
  export const getAllArticles = cache(async (): Promise<Article[]> => {
229
231
  const slugs = getAvailableArticleSlugs()
package/src/server.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  } from './server-articles'
18
18
 
19
19
  export {
20
+ generateRssFeed,
20
21
  generateArticleStaticParams,
21
22
  generateCategoryStaticParams,
22
23
  generateArticlesIndexMetadata,
@@ -31,7 +32,7 @@ export { ArticleContent } from './ArticleContent'
31
32
  export { ArticleTOC } from './ArticleTOC'
32
33
 
33
34
  export type { Article, CategoryInfo, TocItem } from './articleTypes'
34
- export type { ArticlesConfig } from './articlesConfig'
35
+ export type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'
35
36
  export type {
36
37
  ArticlesErrorCode,
37
38
  ArticlesErrorContext,