@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.
- package/README.md +233 -72
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js.map +1 -1
- package/dist/nextjs-middleware.cjs +61 -0
- package/dist/nextjs-middleware.cjs.map +1 -0
- package/dist/nextjs-middleware.d.cts +25 -0
- package/dist/nextjs-middleware.d.ts +25 -0
- package/dist/nextjs-middleware.js +33 -0
- package/dist/nextjs-middleware.js.map +1 -0
- package/dist/nextjs.cjs +692 -0
- package/dist/nextjs.cjs.map +1 -0
- package/dist/nextjs.d.cts +116 -0
- package/dist/nextjs.d.ts +116 -0
- package/dist/nextjs.js +658 -0
- package/dist/nextjs.js.map +1 -0
- package/dist/server.cjs +117 -38
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +10 -4
- package/dist/server.d.ts +10 -4
- package/dist/server.js +116 -38
- package/dist/server.js.map +1 -1
- package/package.json +11 -1
- package/src/ArticleContent.tsx +8 -3
- package/src/__tests__/ArticleContent.test.tsx +18 -2
- package/src/__tests__/markdown.test.ts +79 -3
- package/src/__tests__/nextjs-middleware.test.ts +183 -0
- package/src/__tests__/nextjs.test.ts +137 -0
- package/src/__tests__/renderMdx.test.tsx +22 -0
- package/src/__tests__/seoUtils.test.ts +102 -0
- package/src/__tests__/server-articles.test.ts +15 -1
- package/src/articlesConfig.ts +5 -0
- package/src/index.ts +1 -0
- package/src/markdown.ts +67 -8
- package/src/nextjs-middleware.ts +48 -0
- package/src/nextjs.ts +27 -0
- package/src/renderMdx.tsx +3 -2
- package/src/seoUtils.ts +54 -0
- package/src/server-articles.ts +27 -25
- 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
|
-
|
|
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(
|
|
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('&', '&')
|
|
16
|
+
.replaceAll('<', '<')
|
|
17
|
+
.replaceAll('>', '>')
|
|
18
|
+
.replaceAll('"', '"')
|
|
19
|
+
.replaceAll("'", ''')
|
|
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 }))
|
package/src/server-articles.ts
CHANGED
|
@@ -197,33 +197,35 @@ async function getArticleSummary(slug: string): Promise<Article | null> {
|
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
export const getArticleMetadata = cache(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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,
|