@fullstackdatasolutions/articles 0.8.2 → 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 +26 -65
- 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.cjs +74 -34
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +4 -0
- package/dist/nextjs.d.ts +4 -0
- package/dist/nextjs.js +74 -34
- package/dist/nextjs.js.map +1 -1
- 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 +1 -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__/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/renderMdx.tsx +3 -2
- package/src/seoUtils.ts +54 -0
- package/src/server-articles.ts +27 -25
- package/src/server.ts +2 -1
|
@@ -9,11 +9,16 @@ jest.mock('remark-github-blockquote-alert', () => () => () => {})
|
|
|
9
9
|
jest.mock('remark-parse', () => () => () => {})
|
|
10
10
|
jest.mock('remark-rehype', () => () => () => {})
|
|
11
11
|
|
|
12
|
+
const mockRemarkUseCalls: unknown[][] = []
|
|
13
|
+
|
|
12
14
|
// Mock remark() to return a chainable processor whose .process() resolves to ''
|
|
13
15
|
jest.mock('remark', () => {
|
|
14
16
|
const makeChain = (): Record<string, unknown> => {
|
|
15
17
|
const chain: Record<string, unknown> = {
|
|
16
|
-
use: () =>
|
|
18
|
+
use: (...args: unknown[]) => {
|
|
19
|
+
mockRemarkUseCalls.push(args)
|
|
20
|
+
return chain
|
|
21
|
+
},
|
|
17
22
|
process: async () => ({ toString: () => '' }),
|
|
18
23
|
}
|
|
19
24
|
return chain
|
|
@@ -80,8 +85,15 @@ function root(...nodes: HastElement[]): HastRoot {
|
|
|
80
85
|
return { type: 'root', children: nodes }
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
type LinkTargetOptions = Readonly<{
|
|
89
|
+
strategy?: 'external-new-tab' | 'all-new-tab' | 'same-tab'
|
|
90
|
+
siteUrl?: string
|
|
91
|
+
}>
|
|
92
|
+
|
|
93
|
+
function runRenderer(tree: HastRoot, options?: LinkTargetOptions) {
|
|
94
|
+
const transform = (
|
|
95
|
+
customRenderer as unknown as (options?: LinkTargetOptions) => (tree: HastRoot) => void
|
|
96
|
+
)(options)
|
|
85
97
|
transform(tree)
|
|
86
98
|
}
|
|
87
99
|
|
|
@@ -212,6 +224,13 @@ describe('customRenderer', () => {
|
|
|
212
224
|
expect(node.properties.rel).toBe('noopener noreferrer')
|
|
213
225
|
})
|
|
214
226
|
|
|
227
|
+
it('does NOT add target="_blank" to root-relative internal links by default', () => {
|
|
228
|
+
const node = el('a', { href: '/internal' })
|
|
229
|
+
runRenderer(root(node))
|
|
230
|
+
expect(node.properties.target).toBeUndefined()
|
|
231
|
+
expect(node.properties.rel).toBeUndefined()
|
|
232
|
+
})
|
|
233
|
+
|
|
215
234
|
it('does NOT add target="_blank" to internal anchor links (#)', () => {
|
|
216
235
|
const node = el('a', { href: '#section-id' })
|
|
217
236
|
runRenderer(root(node))
|
|
@@ -225,6 +244,46 @@ describe('customRenderer', () => {
|
|
|
225
244
|
expect(node.properties.target).toBeUndefined()
|
|
226
245
|
})
|
|
227
246
|
|
|
247
|
+
it('does NOT add target="_blank" to same-origin absolute links', () => {
|
|
248
|
+
const node = el('a', { href: 'https://example.com/articles/internal' })
|
|
249
|
+
runRenderer(root(node), { siteUrl: 'https://example.com' })
|
|
250
|
+
expect(node.properties.target).toBeUndefined()
|
|
251
|
+
expect(node.properties.rel).toBeUndefined()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('adds target="_blank" and rel to different-origin absolute links', () => {
|
|
255
|
+
const node = el('a', { href: 'https://different-site.com' })
|
|
256
|
+
runRenderer(root(node), { siteUrl: 'https://example.com' })
|
|
257
|
+
expect(node.properties.target).toBe('_blank')
|
|
258
|
+
expect(node.properties.rel).toBe('noopener noreferrer')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('opens browser navigation links in a new tab when strategy is all-new-tab', () => {
|
|
262
|
+
const node = el('a', { href: '/internal' })
|
|
263
|
+
runRenderer(root(node), { strategy: 'all-new-tab', siteUrl: 'https://example.com' })
|
|
264
|
+
expect(node.properties.target).toBe('_blank')
|
|
265
|
+
expect(node.properties.rel).toBe('noopener noreferrer')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('does not open links in a new tab when strategy is same-tab', () => {
|
|
269
|
+
const node = el('a', { href: 'https://different-site.com' })
|
|
270
|
+
runRenderer(root(node), { strategy: 'same-tab', siteUrl: 'https://example.com' })
|
|
271
|
+
expect(node.properties.target).toBeUndefined()
|
|
272
|
+
expect(node.properties.rel).toBeUndefined()
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('leaves mailto and tel links in the same tab', () => {
|
|
276
|
+
const nodes = [
|
|
277
|
+
el('a', { href: 'mailto:test@example.com' }),
|
|
278
|
+
el('a', { href: 'tel:+15555555555' }),
|
|
279
|
+
]
|
|
280
|
+
runRenderer(root(...nodes), { strategy: 'all-new-tab', siteUrl: 'https://example.com' })
|
|
281
|
+
nodes.forEach((node) => {
|
|
282
|
+
expect(node.properties.target).toBeUndefined()
|
|
283
|
+
expect(node.properties.rel).toBeUndefined()
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
228
287
|
it('applies link className to external anchors', () => {
|
|
229
288
|
const node = el('a', { href: 'https://external.com' })
|
|
230
289
|
runRenderer(root(node))
|
|
@@ -248,6 +307,10 @@ describe('customRenderer', () => {
|
|
|
248
307
|
// ---------------------------------------------------------------------------
|
|
249
308
|
|
|
250
309
|
describe('markdownToHtml', () => {
|
|
310
|
+
beforeEach(() => {
|
|
311
|
+
mockRemarkUseCalls.length = 0
|
|
312
|
+
})
|
|
313
|
+
|
|
251
314
|
it('returns a string', async () => {
|
|
252
315
|
const result = await markdownToHtml('# Hello')
|
|
253
316
|
expect(typeof result).toBe('string')
|
|
@@ -260,6 +323,19 @@ describe('markdownToHtml', () => {
|
|
|
260
323
|
it('resolves without throwing when articleSlug is provided', async () => {
|
|
261
324
|
await expect(markdownToHtml('', 'my-article')).resolves.toBeDefined()
|
|
262
325
|
})
|
|
326
|
+
|
|
327
|
+
it('passes link target config to customRenderer', async () => {
|
|
328
|
+
await markdownToHtml('# Hello', 'my-article', {
|
|
329
|
+
siteUrl: 'https://example.com',
|
|
330
|
+
siteName: 'Example',
|
|
331
|
+
linkTargetStrategy: 'external-new-tab',
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
expect(mockRemarkUseCalls).toContainEqual([
|
|
335
|
+
customRenderer,
|
|
336
|
+
{ strategy: 'external-new-tab', siteUrl: 'https://example.com' },
|
|
337
|
+
])
|
|
338
|
+
})
|
|
263
339
|
})
|
|
264
340
|
|
|
265
341
|
// ---------------------------------------------------------------------------
|
|
@@ -179,6 +179,28 @@ describe('renderMdxSource', () => {
|
|
|
179
179
|
expect(container.querySelector('a[href="#section-one"]')).not.toBeNull()
|
|
180
180
|
expect(container.querySelector('a[href="#section-two"]')).not.toBeNull()
|
|
181
181
|
})
|
|
182
|
+
|
|
183
|
+
it('passes link target config into the markdown renderer plugin', async () => {
|
|
184
|
+
mockEvaluate.mockResolvedValue({
|
|
185
|
+
default: () => React.createElement('a', { href: 'https://external.com' }, 'External'),
|
|
186
|
+
})
|
|
187
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
188
|
+
|
|
189
|
+
const config = {
|
|
190
|
+
siteUrl: 'https://example.com',
|
|
191
|
+
siteName: 'Example',
|
|
192
|
+
linkTargetStrategy: 'external-new-tab' as const,
|
|
193
|
+
}
|
|
194
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
195
|
+
await renderMdxSource('[External](https://external.com)', '/articles/my-article', config)
|
|
196
|
+
|
|
197
|
+
const callArgs = mockEvaluate.mock.calls[0][1]
|
|
198
|
+
expect(callArgs.rehypePlugins).toEqual(
|
|
199
|
+
expect.arrayContaining([
|
|
200
|
+
[expect.any(Function), { strategy: 'external-new-tab', siteUrl: 'https://example.com' }],
|
|
201
|
+
])
|
|
202
|
+
)
|
|
203
|
+
})
|
|
182
204
|
})
|
|
183
205
|
|
|
184
206
|
describe('basePath image resolution', () => {
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
generateArticleMetadata,
|
|
7
7
|
generateCategoryMetadata,
|
|
8
8
|
getArticleSitemapEntries,
|
|
9
|
+
generateRssFeed,
|
|
9
10
|
} from '../seoUtils'
|
|
10
11
|
|
|
11
12
|
jest.mock('../server-articles', () => ({
|
|
@@ -579,3 +580,104 @@ describe('getArticleSitemapEntries', () => {
|
|
|
579
580
|
expect(entries[0].lastModified).toEqual(new Date('2025-03-10'))
|
|
580
581
|
})
|
|
581
582
|
})
|
|
583
|
+
|
|
584
|
+
describe('generateRssFeed', () => {
|
|
585
|
+
const baseConfig: ArticlesConfig = {
|
|
586
|
+
siteUrl: 'https://example.com',
|
|
587
|
+
siteName: 'Example Site',
|
|
588
|
+
description: 'Test description',
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const baseArticle = {
|
|
592
|
+
slug: 'my-article',
|
|
593
|
+
title: 'My Article',
|
|
594
|
+
excerpt: 'A short excerpt',
|
|
595
|
+
date: '2024-01-15',
|
|
596
|
+
author: 'Jane Doe',
|
|
597
|
+
category: 'Tales of the Valiant',
|
|
598
|
+
categories: ['Tales of the Valiant'],
|
|
599
|
+
readTime: '3 min read',
|
|
600
|
+
featuredImage: '/articles/my-article/hero.jpg',
|
|
601
|
+
tags: [],
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
it('returns a string starting with the XML declaration', () => {
|
|
605
|
+
const xml = generateRssFeed([], baseConfig)
|
|
606
|
+
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8" ?>')
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
it('includes xmlns:media namespace in the rss element', () => {
|
|
610
|
+
const xml = generateRssFeed([], baseConfig)
|
|
611
|
+
expect(xml).toContain('xmlns:media="http://search.yahoo.com/mrss/"')
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
it('renders <category> from article.category', () => {
|
|
615
|
+
const xml = generateRssFeed([baseArticle], baseConfig)
|
|
616
|
+
expect(xml).toContain('<category><![CDATA[Tales of the Valiant]]></category>')
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
it('renders <media:content> with resolved absolute image URL', () => {
|
|
620
|
+
const xml = generateRssFeed([baseArticle], baseConfig)
|
|
621
|
+
expect(xml).toContain(
|
|
622
|
+
'<media:content url="https://example.com/articles/my-article/hero.jpg" medium="image" width="1200" height="630"/>'
|
|
623
|
+
)
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
it('passes through absolute featuredImage URLs unchanged in <media:content>', () => {
|
|
627
|
+
const article = { ...baseArticle, featuredImage: 'https://cdn.example.com/image.jpg' }
|
|
628
|
+
const xml = generateRssFeed([article], baseConfig)
|
|
629
|
+
expect(xml).toContain(
|
|
630
|
+
'<media:content url="https://cdn.example.com/image.jpg" medium="image" width="1200" height="630"/>'
|
|
631
|
+
)
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it('omits <category> when article.category is empty', () => {
|
|
635
|
+
const article = { ...baseArticle, category: '' }
|
|
636
|
+
const xml = generateRssFeed([article], baseConfig)
|
|
637
|
+
expect(xml).not.toContain('<category>')
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it('omits <media:content> when article.featuredImage is empty', () => {
|
|
641
|
+
const article = { ...baseArticle, featuredImage: '' }
|
|
642
|
+
const xml = generateRssFeed([article], baseConfig)
|
|
643
|
+
expect(xml).not.toContain('<media:content')
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
it('strips trailing slash from siteUrl when resolving image URL', () => {
|
|
647
|
+
const article = { ...baseArticle }
|
|
648
|
+
const xml = generateRssFeed([article], { ...baseConfig, siteUrl: 'https://example.com/' })
|
|
649
|
+
expect(xml).not.toContain('//articles')
|
|
650
|
+
expect(xml).toContain('https://example.com/articles/my-article/hero.jpg')
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
it('renders <title>, <link>, <guid>, <pubDate>, <description>, and <author>', () => {
|
|
654
|
+
const xml = generateRssFeed([baseArticle], baseConfig)
|
|
655
|
+
expect(xml).toContain('<![CDATA[My Article]]>')
|
|
656
|
+
expect(xml).toContain('https://example.com/articles/my-article')
|
|
657
|
+
expect(xml).toContain('<pubDate>')
|
|
658
|
+
expect(xml).toContain('<![CDATA[A short excerpt]]>')
|
|
659
|
+
expect(xml).toContain('<author>Jane Doe</author>')
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('uses config.description in the channel description', () => {
|
|
663
|
+
const xml = generateRssFeed([], baseConfig)
|
|
664
|
+
expect(xml).toContain('<![CDATA[Test description]]>')
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
it('falls back to siteName articles when description is absent', () => {
|
|
668
|
+
const xml = generateRssFeed([], { siteUrl: 'https://example.com', siteName: 'My Site' })
|
|
669
|
+
expect(xml).toContain('<![CDATA[My Site articles]]>')
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it('omits author when showAuthor is false', () => {
|
|
673
|
+
const xml = generateRssFeed([baseArticle], { ...baseConfig, showAuthor: false })
|
|
674
|
+
expect(xml).not.toContain('<author>')
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it('renders multiple items', () => {
|
|
678
|
+
const second = { ...baseArticle, slug: 'second', title: 'Second Article' }
|
|
679
|
+
const xml = generateRssFeed([baseArticle, second], baseConfig)
|
|
680
|
+
expect(xml).toContain('<![CDATA[My Article]]>')
|
|
681
|
+
expect(xml).toContain('<![CDATA[Second Article]]>')
|
|
682
|
+
})
|
|
683
|
+
})
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
searchArticles,
|
|
18
18
|
} from '../server-articles'
|
|
19
19
|
import { setArticlesErrorHandler } from '../errorReporting'
|
|
20
|
+
import { markdownToHtml } from '../markdown'
|
|
20
21
|
|
|
21
22
|
jest.mock('react', () => ({ cache: (fn: Function) => fn }))
|
|
22
23
|
jest.mock('node:fs')
|
|
@@ -27,6 +28,7 @@ jest.mock('../markdown', () => ({
|
|
|
27
28
|
jest.mock('reading-time', () => () => ({ text: '2 min read' }))
|
|
28
29
|
|
|
29
30
|
const mockedFs = fs as jest.Mocked<typeof fs>
|
|
31
|
+
const mockedMarkdownToHtml = markdownToHtml as jest.MockedFunction<typeof markdownToHtml>
|
|
30
32
|
const articlesDirectory = path.join(process.cwd(), 'public/articles')
|
|
31
33
|
|
|
32
34
|
beforeEach(() => {
|
|
@@ -480,7 +482,13 @@ describe('getArticleMetadata - content handling', () => {
|
|
|
480
482
|
},
|
|
481
483
|
])
|
|
482
484
|
|
|
483
|
-
const
|
|
485
|
+
const config = {
|
|
486
|
+
siteUrl: 'https://example.com',
|
|
487
|
+
siteName: 'Example',
|
|
488
|
+
linkTargetStrategy: 'external-new-tab' as const,
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const article = await getArticleMetadata('markdown-article', config)
|
|
484
492
|
|
|
485
493
|
expect(article).toEqual(
|
|
486
494
|
expect.objectContaining({
|
|
@@ -493,6 +501,11 @@ describe('getArticleMetadata - content handling', () => {
|
|
|
493
501
|
toc: [],
|
|
494
502
|
})
|
|
495
503
|
)
|
|
504
|
+
expect(mockedMarkdownToHtml).toHaveBeenCalledWith(
|
|
505
|
+
'\n## Heading\n\nMarkdown body',
|
|
506
|
+
'markdown-article',
|
|
507
|
+
config
|
|
508
|
+
)
|
|
496
509
|
})
|
|
497
510
|
|
|
498
511
|
it('loads mdx articles as source instead of rendered html', async () => {
|
|
@@ -515,6 +528,7 @@ describe('getArticleMetadata - content handling', () => {
|
|
|
515
528
|
})
|
|
516
529
|
)
|
|
517
530
|
expect(article?.mdxSource?.trim()).toBe('<Component />')
|
|
531
|
+
expect(mockedMarkdownToHtml).not.toHaveBeenCalled()
|
|
518
532
|
})
|
|
519
533
|
|
|
520
534
|
it('returns null when article file loading fails', async () => {
|
package/src/articlesConfig.ts
CHANGED
|
@@ -67,6 +67,9 @@ export interface HeroConfig {
|
|
|
67
67
|
description?: string
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Controls how article body links set target/rel attributes. */
|
|
71
|
+
export type LinkTargetStrategy = 'external-new-tab' | 'all-new-tab' | 'same-tab'
|
|
72
|
+
|
|
70
73
|
/** Top-level configuration object. Pass one instance to every library component. */
|
|
71
74
|
export interface ArticlesConfig {
|
|
72
75
|
/** Canonical base URL of the site, used in metadata and JSON-LD. Example: `'https://yoursite.com'` */
|
|
@@ -102,6 +105,8 @@ export interface ArticlesConfig {
|
|
|
102
105
|
showBackToArticles?: boolean
|
|
103
106
|
/** Set to false to hide author names from UI and metadata. Default: true. */
|
|
104
107
|
showAuthor?: boolean
|
|
108
|
+
/** Article body link target behavior. Default: `'external-new-tab'`. */
|
|
109
|
+
linkTargetStrategy?: LinkTargetStrategy
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
export const DEFAULT_PAGE_SIZE = 6
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ export type {
|
|
|
42
42
|
ArticlesSection,
|
|
43
43
|
CategoryDescription,
|
|
44
44
|
CommentsConfig,
|
|
45
|
+
LinkTargetStrategy,
|
|
45
46
|
} from './articlesConfig'
|
|
46
47
|
export { DEFAULT_LAYOUT, DEFAULT_PAGE_SIZE, DEFAULT_CATEGORIES_PAGE_SIZE } from './articlesConfig'
|
|
47
48
|
export type { Article, CategoryInfo, FaqItem, HowToStep, TocItem } from './articleTypes'
|
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)
|
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,
|