@fullstackdatasolutions/articles 0.7.2 → 0.8.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 +139 -76
- package/dist/index.cjs +30 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -4
- package/dist/index.d.ts +11 -4
- package/dist/index.js +30 -13
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +180 -25
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +33 -14
- package/dist/server.d.ts +33 -14
- package/dist/server.js +173 -24
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleDetailHero.tsx +8 -4
- package/src/ArticleSchemas.tsx +17 -4
- package/src/ArticlesPage.tsx +5 -1
- package/src/FeaturedArticle.tsx +8 -5
- package/src/__tests__/ArticleDetailHero.test.tsx +12 -0
- package/src/__tests__/ArticleSchemas.test.tsx +28 -0
- package/src/__tests__/FeaturedArticle.test.tsx +5 -0
- package/src/__tests__/errorReporting.test.ts +89 -0
- package/src/__tests__/seoUtils.test.ts +38 -0
- package/src/__tests__/server-articles.test.ts +194 -0
- package/src/articleTypes.ts +1 -0
- package/src/articlesConfig.ts +2 -0
- package/src/errorReporting.ts +34 -0
- package/src/markdown.ts +27 -5
- package/src/seoUtils.ts +10 -2
- package/src/server-articles.ts +124 -9
- package/src/server.ts +12 -0
|
@@ -3,14 +3,20 @@ import path from 'node:path'
|
|
|
3
3
|
import {
|
|
4
4
|
categoryToSlug,
|
|
5
5
|
getAdjacentArticles,
|
|
6
|
+
getAiRobotsTxtRules,
|
|
6
7
|
getAllArticles,
|
|
7
8
|
getAllCategories,
|
|
9
|
+
getArticleAiHeaders,
|
|
10
|
+
getArticleMarkdown,
|
|
11
|
+
getArticleMarkdownResponse,
|
|
12
|
+
getArticleMarkdownUrl,
|
|
8
13
|
getArticleMetadata,
|
|
9
14
|
getArticlesByCategory,
|
|
10
15
|
getAvailableArticleSlugs,
|
|
11
16
|
sanitizeImagePath,
|
|
12
17
|
searchArticles,
|
|
13
18
|
} from '../server-articles'
|
|
19
|
+
import { setArticlesErrorHandler } from '../errorReporting'
|
|
14
20
|
|
|
15
21
|
jest.mock('react', () => ({ cache: (fn: Function) => fn }))
|
|
16
22
|
jest.mock('node:fs')
|
|
@@ -23,6 +29,52 @@ jest.mock('reading-time', () => () => ({ text: '2 min read' }))
|
|
|
23
29
|
const mockedFs = fs as jest.Mocked<typeof fs>
|
|
24
30
|
const articlesDirectory = path.join(process.cwd(), 'public/articles')
|
|
25
31
|
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
setArticlesErrorHandler(() => undefined)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
setArticlesErrorHandler()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
class TestHeaders {
|
|
41
|
+
private readonly values: Map<string, string>
|
|
42
|
+
|
|
43
|
+
constructor(values?: Record<string, string>) {
|
|
44
|
+
this.values = new Map(
|
|
45
|
+
Object.entries(values ?? {}).map(([key, value]) => [key.toLowerCase(), value])
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get(name: string): string | null {
|
|
50
|
+
return this.values.get(name.toLowerCase()) ?? null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class TestResponse {
|
|
55
|
+
readonly headers: TestHeaders
|
|
56
|
+
readonly status: number
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly body: string,
|
|
60
|
+
init?: Readonly<{ status?: number; headers?: Record<string, string> }>
|
|
61
|
+
) {
|
|
62
|
+
this.status = init?.status ?? 200
|
|
63
|
+
this.headers = new TestHeaders(init?.headers)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
text(): Promise<string> {
|
|
67
|
+
return Promise.resolve(this.body)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (globalThis.Response === undefined) {
|
|
72
|
+
Object.defineProperty(globalThis, 'Response', {
|
|
73
|
+
value: TestResponse,
|
|
74
|
+
configurable: true,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
26
78
|
function mockDirectory(name: string): fs.Dirent {
|
|
27
79
|
return {
|
|
28
80
|
name,
|
|
@@ -166,6 +218,21 @@ describe('getArticleMetadata - frontmatter parsing', () => {
|
|
|
166
218
|
expect(article).not.toBeNull()
|
|
167
219
|
expect(article!.howTo).toBeUndefined()
|
|
168
220
|
})
|
|
221
|
+
|
|
222
|
+
const aiCrawlCases = [
|
|
223
|
+
{ frontmatter: 'aiCrawl: true\n', expected: true, description: 'explicit true' },
|
|
224
|
+
{ frontmatter: 'aiCrawl: false\n', expected: false, description: 'explicit false' },
|
|
225
|
+
{ frontmatter: '', expected: false, description: 'omitted value' },
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
aiCrawlCases.forEach(({ frontmatter, expected, description }) => {
|
|
229
|
+
it(`parses aiCrawl as ${expected} for ${description}`, async () => {
|
|
230
|
+
setupArticleMock(frontmatter)
|
|
231
|
+
const article = await getArticleMetadata('test-slug')
|
|
232
|
+
expect(article).not.toBeNull()
|
|
233
|
+
expect(article!.aiCrawl).toBe(expected)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
169
236
|
})
|
|
170
237
|
|
|
171
238
|
describe('getAvailableArticleSlugs', () => {
|
|
@@ -223,6 +290,24 @@ describe('getAvailableArticleSlugs', () => {
|
|
|
223
290
|
|
|
224
291
|
expect(getAvailableArticleSlugs()).toEqual([])
|
|
225
292
|
})
|
|
293
|
+
|
|
294
|
+
it('reports directory read errors through the configured error handler', () => {
|
|
295
|
+
const reports: Array<{ code: string; context?: Record<string, unknown> }> = []
|
|
296
|
+
setArticlesErrorHandler((report) => {
|
|
297
|
+
reports.push({ code: report.code, context: report.context })
|
|
298
|
+
})
|
|
299
|
+
mockedFs.existsSync.mockImplementation(() => {
|
|
300
|
+
throw new Error('read failed')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
expect(getAvailableArticleSlugs()).toEqual([])
|
|
304
|
+
expect(reports).toEqual([
|
|
305
|
+
{
|
|
306
|
+
code: 'article-directory-read-failed',
|
|
307
|
+
context: { directory: articlesDirectory },
|
|
308
|
+
},
|
|
309
|
+
])
|
|
310
|
+
})
|
|
226
311
|
})
|
|
227
312
|
|
|
228
313
|
describe('article collection utilities', () => {
|
|
@@ -337,6 +422,26 @@ describe('article collection utilities', () => {
|
|
|
337
422
|
])
|
|
338
423
|
})
|
|
339
424
|
|
|
425
|
+
it('skips author matching when showAuthor is false', async () => {
|
|
426
|
+
setupArticleTreeMock([
|
|
427
|
+
{
|
|
428
|
+
slug: 'author-match',
|
|
429
|
+
frontmatter: 'date: 2025-01-01\n',
|
|
430
|
+
},
|
|
431
|
+
])
|
|
432
|
+
|
|
433
|
+
await expect(searchArticles('test author')).resolves.toEqual([
|
|
434
|
+
expect.objectContaining({ slug: 'author-match' }),
|
|
435
|
+
])
|
|
436
|
+
await expect(
|
|
437
|
+
searchArticles('test author', {
|
|
438
|
+
siteUrl: 'https://example.com',
|
|
439
|
+
siteName: 'Example',
|
|
440
|
+
showAuthor: false,
|
|
441
|
+
})
|
|
442
|
+
).resolves.toEqual([])
|
|
443
|
+
})
|
|
444
|
+
|
|
340
445
|
it('groups categories and filters articles by category slug', async () => {
|
|
341
446
|
setupArticleTreeMock([
|
|
342
447
|
{
|
|
@@ -422,6 +527,95 @@ describe('getArticleMetadata - content handling', () => {
|
|
|
422
527
|
})
|
|
423
528
|
})
|
|
424
529
|
|
|
530
|
+
describe('AI markdown helpers', () => {
|
|
531
|
+
beforeEach(() => {
|
|
532
|
+
jest.clearAllMocks()
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
it('returns markdown only for articles with aiCrawl true', async () => {
|
|
536
|
+
setupArticleTreeMock([
|
|
537
|
+
{
|
|
538
|
+
slug: 'allowed',
|
|
539
|
+
frontmatter: 'date: 2025-01-01\naiCrawl: true\n',
|
|
540
|
+
body: '# Allowed',
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
slug: 'blocked',
|
|
544
|
+
frontmatter: 'date: 2025-01-01\naiCrawl: false\n',
|
|
545
|
+
body: '# Blocked',
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
slug: 'default-blocked',
|
|
549
|
+
frontmatter: 'date: 2025-01-01\n',
|
|
550
|
+
body: '# Default blocked',
|
|
551
|
+
},
|
|
552
|
+
])
|
|
553
|
+
|
|
554
|
+
await expect(getArticleMarkdown('allowed')).resolves.toBe('\n# Allowed')
|
|
555
|
+
await expect(getArticleMarkdown('blocked')).resolves.toBeNull()
|
|
556
|
+
await expect(getArticleMarkdown('default-blocked')).resolves.toBeNull()
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
it('builds markdown URLs only for opted-in articles', () => {
|
|
560
|
+
expect(
|
|
561
|
+
getArticleMarkdownUrl({ slug: 'allowed', aiCrawl: true }, { siteUrl: 'https://example.com/' })
|
|
562
|
+
).toBe('https://example.com/articles/allowed.md')
|
|
563
|
+
expect(getArticleMarkdownUrl({ slug: 'blocked', aiCrawl: false })).toBeUndefined()
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('returns alternate link headers for allowed articles and AI opt-out headers otherwise', () => {
|
|
567
|
+
expect(
|
|
568
|
+
getArticleAiHeaders({ slug: 'allowed', aiCrawl: true }, { siteUrl: 'https://example.com' })
|
|
569
|
+
).toEqual({
|
|
570
|
+
Link: '<https://example.com/articles/allowed.md>; rel="alternate"; type="text/markdown"',
|
|
571
|
+
})
|
|
572
|
+
expect(getArticleAiHeaders({ slug: 'blocked', aiCrawl: false })).toEqual({
|
|
573
|
+
'X-Robots-Tag': 'noai, noimageai',
|
|
574
|
+
})
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('returns markdown responses for opted-in articles and 404 for blocked articles', async () => {
|
|
578
|
+
setupArticleTreeMock([
|
|
579
|
+
{
|
|
580
|
+
slug: 'allowed',
|
|
581
|
+
frontmatter: 'date: 2025-01-01\naiCrawl: true\n',
|
|
582
|
+
body: '# Allowed',
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
slug: 'blocked',
|
|
586
|
+
frontmatter: 'date: 2025-01-01\n',
|
|
587
|
+
body: '# Blocked',
|
|
588
|
+
},
|
|
589
|
+
])
|
|
590
|
+
|
|
591
|
+
const allowed = await getArticleMarkdownResponse('allowed', {
|
|
592
|
+
siteUrl: 'https://example.com',
|
|
593
|
+
siteName: 'Example',
|
|
594
|
+
})
|
|
595
|
+
expect(allowed.status).toBe(200)
|
|
596
|
+
expect(allowed.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8')
|
|
597
|
+
await expect(allowed.text()).resolves.toBe('\n# Allowed')
|
|
598
|
+
|
|
599
|
+
const blocked = await getArticleMarkdownResponse('blocked', {
|
|
600
|
+
siteUrl: 'https://example.com',
|
|
601
|
+
siteName: 'Example',
|
|
602
|
+
})
|
|
603
|
+
expect(blocked.status).toBe(404)
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
it('generates robots rules for AI crawlers and blocked articles only', async () => {
|
|
607
|
+
setupArticleTreeMock([
|
|
608
|
+
{ slug: 'allowed', frontmatter: 'date: 2025-01-01\naiCrawl: true\n' },
|
|
609
|
+
{ slug: 'blocked', frontmatter: 'date: 2025-01-01\n' },
|
|
610
|
+
])
|
|
611
|
+
|
|
612
|
+
const rules = await getAiRobotsTxtRules()
|
|
613
|
+
expect(rules).toContain('User-agent: GPTBot')
|
|
614
|
+
expect(rules).toContain('Disallow: /articles/blocked')
|
|
615
|
+
expect(rules).not.toContain('Disallow: /articles/allowed')
|
|
616
|
+
})
|
|
617
|
+
})
|
|
618
|
+
|
|
425
619
|
describe('article path helpers', () => {
|
|
426
620
|
it('sanitizes image paths and rejects unsafe values', () => {
|
|
427
621
|
expect(sanitizeImagePath('image.png', 'article')).toBe('/articles/article/image.png')
|
package/src/articleTypes.ts
CHANGED
package/src/articlesConfig.ts
CHANGED
|
@@ -100,6 +100,8 @@ export interface ArticlesConfig {
|
|
|
100
100
|
showToc?: boolean
|
|
101
101
|
/** Set to false to hide the "Back to Articles" navigation link on article detail pages. Default: true. */
|
|
102
102
|
showBackToArticles?: boolean
|
|
103
|
+
/** Set to false to hide author names from UI and metadata. Default: true. */
|
|
104
|
+
showAuthor?: boolean
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
export const DEFAULT_PAGE_SIZE = 6
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type ArticlesErrorCode =
|
|
2
|
+
| 'article-directory-read-failed'
|
|
3
|
+
| 'article-load-failed'
|
|
4
|
+
| 'article-markdown-load-failed'
|
|
5
|
+
| 'markdown-conversion-failed'
|
|
6
|
+
| 'unsafe-image-path'
|
|
7
|
+
|
|
8
|
+
export type ArticlesErrorContext = Readonly<Record<string, string | number | boolean | undefined>>
|
|
9
|
+
|
|
10
|
+
export type ArticlesErrorReport = Readonly<{
|
|
11
|
+
code: ArticlesErrorCode
|
|
12
|
+
message: string
|
|
13
|
+
error?: unknown
|
|
14
|
+
context?: ArticlesErrorContext
|
|
15
|
+
}>
|
|
16
|
+
|
|
17
|
+
export type ArticlesErrorHandler = (report: ArticlesErrorReport) => void
|
|
18
|
+
|
|
19
|
+
function defaultArticlesErrorHandler(report: ArticlesErrorReport): void {
|
|
20
|
+
if (process.env.NODE_ENV === 'production') return
|
|
21
|
+
const context = report.context ? ` ${JSON.stringify(report.context)}` : ''
|
|
22
|
+
const detail = report.error instanceof Error ? `: ${report.error.message}` : ''
|
|
23
|
+
console.warn(`[articles:${report.code}] ${report.message}${context}${detail}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let articlesErrorHandler: ArticlesErrorHandler = defaultArticlesErrorHandler
|
|
27
|
+
|
|
28
|
+
export function setArticlesErrorHandler(handler?: ArticlesErrorHandler): void {
|
|
29
|
+
articlesErrorHandler = handler ?? defaultArticlesErrorHandler
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function reportArticlesError(report: ArticlesErrorReport): void {
|
|
33
|
+
articlesErrorHandler(report)
|
|
34
|
+
}
|
package/src/markdown.ts
CHANGED
|
@@ -11,6 +11,7 @@ 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 { reportArticlesError } from './errorReporting'
|
|
14
15
|
|
|
15
16
|
// Import the sanitizeImagePath function
|
|
16
17
|
function sanitizeImagePath(rawPath: string, articleSlug: string): string | null {
|
|
@@ -29,13 +30,21 @@ function sanitizeImagePath(rawPath: string, articleSlug: string): string | null
|
|
|
29
30
|
|
|
30
31
|
// For relative paths, ensure they don't contain dangerous patterns
|
|
31
32
|
if (cleanPath.includes('..') || cleanPath.includes('\\') || cleanPath.startsWith('/')) {
|
|
32
|
-
|
|
33
|
+
reportArticlesError({
|
|
34
|
+
code: 'unsafe-image-path',
|
|
35
|
+
message: 'Rejected unsafe markdown image path.',
|
|
36
|
+
context: { articleSlug, path: cleanPath },
|
|
37
|
+
})
|
|
33
38
|
return null
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
// Only allow alphanumeric characters, hyphens, underscores, dots, and forward slashes
|
|
37
42
|
if (!/^[a-zA-Z0-9._/-]+$/.test(cleanPath)) {
|
|
38
|
-
|
|
43
|
+
reportArticlesError({
|
|
44
|
+
code: 'unsafe-image-path',
|
|
45
|
+
message: 'Rejected markdown image path with invalid characters.',
|
|
46
|
+
context: { articleSlug, path: cleanPath },
|
|
47
|
+
})
|
|
39
48
|
return null
|
|
40
49
|
}
|
|
41
50
|
|
|
@@ -44,7 +53,11 @@ function sanitizeImagePath(rawPath: string, articleSlug: string): string | null
|
|
|
44
53
|
// Relative path, ensure it's within the article directory
|
|
45
54
|
const normalizedPath = cleanPath.replaceAll('\\', '/')
|
|
46
55
|
if (normalizedPath.startsWith('..') || normalizedPath.includes('../')) {
|
|
47
|
-
|
|
56
|
+
reportArticlesError({
|
|
57
|
+
code: 'unsafe-image-path',
|
|
58
|
+
message: 'Rejected markdown image path traversal attempt.',
|
|
59
|
+
context: { articleSlug, path: cleanPath },
|
|
60
|
+
})
|
|
48
61
|
return null
|
|
49
62
|
}
|
|
50
63
|
return `/articles/${articleSlug}/${normalizedPath}`
|
|
@@ -252,7 +265,11 @@ const rehypeProcessImages: Plugin<[{ articleSlug?: string }], Root> = (options =
|
|
|
252
265
|
node.properties.src = sanitizedSrc
|
|
253
266
|
} else {
|
|
254
267
|
// If sanitization fails, use a placeholder
|
|
255
|
-
|
|
268
|
+
reportArticlesError({
|
|
269
|
+
code: 'unsafe-image-path',
|
|
270
|
+
message: 'Using placeholder for unsafe markdown image path.',
|
|
271
|
+
context: { articleSlug: options.articleSlug, path: src },
|
|
272
|
+
})
|
|
256
273
|
node.properties.src = '/placeholder-logo.png'
|
|
257
274
|
}
|
|
258
275
|
}
|
|
@@ -290,7 +307,12 @@ export async function markdownToHtml(markdown: string, articleSlug?: string) {
|
|
|
290
307
|
|
|
291
308
|
return result.toString()
|
|
292
309
|
} catch (error) {
|
|
293
|
-
|
|
310
|
+
reportArticlesError({
|
|
311
|
+
code: 'markdown-conversion-failed',
|
|
312
|
+
message: 'Unable to convert markdown to HTML.',
|
|
313
|
+
error,
|
|
314
|
+
context: { articleSlug },
|
|
315
|
+
})
|
|
294
316
|
// Return the original markdown as fallback
|
|
295
317
|
return markdown
|
|
296
318
|
}
|
package/src/seoUtils.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getAllArticles,
|
|
5
5
|
getAllCategories,
|
|
6
6
|
getArticlesByCategory,
|
|
7
|
+
getArticleMarkdownUrl,
|
|
7
8
|
getAvailableArticleSlugs,
|
|
8
9
|
} from './server-articles'
|
|
9
10
|
import { ArticlesConfig } from './articlesConfig'
|
|
@@ -45,6 +46,8 @@ export async function generateArticleMetadata(
|
|
|
45
46
|
? resolveImageUrl(article.featuredImage, siteUrl)
|
|
46
47
|
: `${siteUrl}/placeholder-logo.png`
|
|
47
48
|
const description = article.excerpt ?? `Read ${article.title} on ${config.siteName}.`
|
|
49
|
+
const showAuthor = config.showAuthor !== false
|
|
50
|
+
const markdownUrl = getArticleMarkdownUrl(article, config)
|
|
48
51
|
|
|
49
52
|
return {
|
|
50
53
|
title: `${article.title} | ${config.siteName}`,
|
|
@@ -60,7 +63,7 @@ export async function generateArticleMetadata(
|
|
|
60
63
|
type: 'article',
|
|
61
64
|
...(article.date && { publishedTime: article.date }),
|
|
62
65
|
...(article.lastmod && { modifiedTime: new Date(article.lastmod).toISOString() }),
|
|
63
|
-
authors: [article.author],
|
|
66
|
+
...(showAuthor && { authors: [article.author] }),
|
|
64
67
|
tags: article.tags ?? [],
|
|
65
68
|
},
|
|
66
69
|
twitter: {
|
|
@@ -71,6 +74,11 @@ export async function generateArticleMetadata(
|
|
|
71
74
|
},
|
|
72
75
|
alternates: {
|
|
73
76
|
canonical: canonicalUrl,
|
|
77
|
+
...(markdownUrl && {
|
|
78
|
+
types: {
|
|
79
|
+
'text/markdown': markdownUrl,
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
74
82
|
},
|
|
75
83
|
robots: {
|
|
76
84
|
index: true,
|
|
@@ -84,7 +92,7 @@ export async function generateArticleMetadata(
|
|
|
84
92
|
},
|
|
85
93
|
},
|
|
86
94
|
other: {
|
|
87
|
-
'article:author': article.author,
|
|
95
|
+
...(showAuthor && { 'article:author': article.author }),
|
|
88
96
|
...(article.date && {
|
|
89
97
|
'article:published_time': new Date(article.date).toISOString(),
|
|
90
98
|
}),
|
package/src/server-articles.ts
CHANGED
|
@@ -5,8 +5,10 @@ import path from 'node:path'
|
|
|
5
5
|
import readingTime from 'reading-time'
|
|
6
6
|
import { markdownToHtml, extractToc } from './markdown'
|
|
7
7
|
import type { Article, CategoryInfo, FaqItem, HowToStep } from './articleTypes'
|
|
8
|
+
import type { ArticlesConfig } from './articlesConfig'
|
|
9
|
+
import { reportArticlesError } from './errorReporting'
|
|
8
10
|
|
|
9
|
-
const articlesDirectory = path.join(process.cwd(), 'public/articles')
|
|
11
|
+
const articlesDirectory = path.join(/* turbopackIgnore: true */ process.cwd(), 'public/articles')
|
|
10
12
|
|
|
11
13
|
function getReadingTime(content: string): string {
|
|
12
14
|
return readingTime(content).text
|
|
@@ -38,17 +40,29 @@ function sanitizeImagePath(rawPath: string, articleSlug: string): string | null
|
|
|
38
40
|
const cleanPath = rawPath.replaceAll(/[\x00-\x1f\x7f-\x9f]/g, '')
|
|
39
41
|
if (cleanPath.startsWith('http://') || cleanPath.startsWith('https://')) return cleanPath
|
|
40
42
|
if (cleanPath.includes('..') || cleanPath.includes('\\') || cleanPath.startsWith('/')) {
|
|
41
|
-
|
|
43
|
+
reportArticlesError({
|
|
44
|
+
code: 'unsafe-image-path',
|
|
45
|
+
message: 'Rejected unsafe article image path.',
|
|
46
|
+
context: { articleSlug, path: cleanPath },
|
|
47
|
+
})
|
|
42
48
|
return null
|
|
43
49
|
}
|
|
44
50
|
if (!/^[a-zA-Z0-9._/-]+$/.test(cleanPath)) {
|
|
45
|
-
|
|
51
|
+
reportArticlesError({
|
|
52
|
+
code: 'unsafe-image-path',
|
|
53
|
+
message: 'Rejected article image path with invalid characters.',
|
|
54
|
+
context: { articleSlug, path: cleanPath },
|
|
55
|
+
})
|
|
46
56
|
return null
|
|
47
57
|
}
|
|
48
58
|
if (cleanPath.includes('/')) {
|
|
49
59
|
const normalizedPath = path.normalize(cleanPath)
|
|
50
60
|
if (normalizedPath.startsWith('..') || normalizedPath.includes('../')) {
|
|
51
|
-
|
|
61
|
+
reportArticlesError({
|
|
62
|
+
code: 'unsafe-image-path',
|
|
63
|
+
message: 'Rejected article image path traversal attempt.',
|
|
64
|
+
context: { articleSlug, path: cleanPath },
|
|
65
|
+
})
|
|
52
66
|
return null
|
|
53
67
|
}
|
|
54
68
|
return `/articles/${articleSlug}/${cleanPath}`
|
|
@@ -69,7 +83,12 @@ export function getAvailableArticleSlugs(): string[] {
|
|
|
69
83
|
if (!fs.existsSync(articlesDirectory)) return []
|
|
70
84
|
return walkArticleDir(articlesDirectory, '')
|
|
71
85
|
} catch (error) {
|
|
72
|
-
|
|
86
|
+
reportArticlesError({
|
|
87
|
+
code: 'article-directory-read-failed',
|
|
88
|
+
message: 'Unable to read articles directory.',
|
|
89
|
+
error,
|
|
90
|
+
context: { directory: articlesDirectory },
|
|
91
|
+
})
|
|
73
92
|
return []
|
|
74
93
|
}
|
|
75
94
|
}
|
|
@@ -165,9 +184,15 @@ async function getArticleSummary(slug: string): Promise<Article | null> {
|
|
|
165
184
|
canonicalUrl: typeof data.canonicalUrl === 'string' ? data.canonicalUrl : undefined,
|
|
166
185
|
articleType: typeof data.articleType === 'string' ? data.articleType : undefined,
|
|
167
186
|
series: typeof data.series === 'string' ? data.series : undefined,
|
|
187
|
+
aiCrawl: data.aiCrawl === true,
|
|
168
188
|
}
|
|
169
189
|
} catch (error) {
|
|
170
|
-
|
|
190
|
+
reportArticlesError({
|
|
191
|
+
code: 'article-load-failed',
|
|
192
|
+
message: 'Unable to load article summary.',
|
|
193
|
+
error,
|
|
194
|
+
context: { slug },
|
|
195
|
+
})
|
|
171
196
|
return null
|
|
172
197
|
}
|
|
173
198
|
}
|
|
@@ -190,7 +215,12 @@ export const getArticleMetadata = cache(async (slug: string): Promise<Article |
|
|
|
190
215
|
}
|
|
191
216
|
return { ...summary, content: markdownContent, htmlContent, mdxSource, toc }
|
|
192
217
|
} catch (error) {
|
|
193
|
-
|
|
218
|
+
reportArticlesError({
|
|
219
|
+
code: 'article-load-failed',
|
|
220
|
+
message: 'Unable to load article metadata.',
|
|
221
|
+
error,
|
|
222
|
+
context: { slug },
|
|
223
|
+
})
|
|
194
224
|
return null
|
|
195
225
|
}
|
|
196
226
|
})
|
|
@@ -222,14 +252,99 @@ export async function getAdjacentArticles(
|
|
|
222
252
|
return { previous, next }
|
|
223
253
|
}
|
|
224
254
|
|
|
225
|
-
export async function
|
|
255
|
+
export async function getArticleMarkdown(slug: string): Promise<string | null> {
|
|
256
|
+
try {
|
|
257
|
+
const summary = await getArticleSummary(slug)
|
|
258
|
+
if (!summary?.aiCrawl) return null
|
|
259
|
+
const found = findArticleFile(slug)
|
|
260
|
+
if (!found) return null
|
|
261
|
+
const fileContent = fs.readFileSync(found.filePath, 'utf8')
|
|
262
|
+
const { content: markdownContent } = matter(fileContent)
|
|
263
|
+
return markdownContent
|
|
264
|
+
} catch (error) {
|
|
265
|
+
reportArticlesError({
|
|
266
|
+
code: 'article-markdown-load-failed',
|
|
267
|
+
message: 'Unable to load article markdown.',
|
|
268
|
+
error,
|
|
269
|
+
context: { slug },
|
|
270
|
+
})
|
|
271
|
+
return null
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function getArticleMarkdownResponse(
|
|
276
|
+
slug: string,
|
|
277
|
+
config: ArticlesConfig
|
|
278
|
+
): Promise<Response> {
|
|
279
|
+
const markdown = await getArticleMarkdown(slug)
|
|
280
|
+
if (markdown === null) return new Response('Not Found', { status: 404 })
|
|
281
|
+
const article = await getArticleMetadata(slug)
|
|
282
|
+
return new Response(markdown, {
|
|
283
|
+
headers: {
|
|
284
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
285
|
+
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
|
286
|
+
...(article ? getArticleAiHeaders(article, config) : {}),
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function getArticleMarkdownUrl(
|
|
292
|
+
article: Pick<Article, 'slug' | 'aiCrawl'>,
|
|
293
|
+
config?: Pick<ArticlesConfig, 'siteUrl'>
|
|
294
|
+
): string | undefined {
|
|
295
|
+
if (article.aiCrawl !== true) return undefined
|
|
296
|
+
const pathname = `/articles/${article.slug}.md`
|
|
297
|
+
if (!config) return pathname
|
|
298
|
+
return `${config.siteUrl.replace(/\/$/, '')}${pathname}`
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function getArticleAiHeaders(
|
|
302
|
+
article: Pick<Article, 'slug' | 'aiCrawl'>,
|
|
303
|
+
config?: Pick<ArticlesConfig, 'siteUrl'>
|
|
304
|
+
): Record<string, string> {
|
|
305
|
+
const markdownUrl = getArticleMarkdownUrl(article, config)
|
|
306
|
+
if (markdownUrl) {
|
|
307
|
+
return {
|
|
308
|
+
Link: `<${markdownUrl}>; rel="alternate"; type="text/markdown"`,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
'X-Robots-Tag': 'noai, noimageai',
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function getAiRobotsTxtRules(): Promise<string> {
|
|
317
|
+
const articles = await getAllArticles()
|
|
318
|
+
const blockedArticles = articles.filter((article) => article.aiCrawl !== true)
|
|
319
|
+
if (blockedArticles.length === 0) return ''
|
|
320
|
+
|
|
321
|
+
const aiCrawlers = [
|
|
322
|
+
'GPTBot',
|
|
323
|
+
'ChatGPT-User',
|
|
324
|
+
'CCBot',
|
|
325
|
+
'ClaudeBot',
|
|
326
|
+
'Claude-User',
|
|
327
|
+
'PerplexityBot',
|
|
328
|
+
'Google-Extended',
|
|
329
|
+
]
|
|
330
|
+
const disallowRules = blockedArticles
|
|
331
|
+
.map((article) => `Disallow: /articles/${article.slug}`)
|
|
332
|
+
.join('\n')
|
|
333
|
+
|
|
334
|
+
return aiCrawlers
|
|
335
|
+
.map((crawler) => [`User-agent: ${crawler}`, disallowRules].join('\n'))
|
|
336
|
+
.join('\n\n')
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function searchArticles(query: string, config?: ArticlesConfig): Promise<Article[]> {
|
|
226
340
|
if (!query?.trim()) return getAllArticles()
|
|
227
341
|
const articles = await getAllArticles()
|
|
228
342
|
const searchTerm = query.toLowerCase().trim()
|
|
343
|
+
const includeAuthor = config?.showAuthor !== false
|
|
229
344
|
return articles.filter((article) => {
|
|
230
345
|
const matchesTitle = article.title.toLowerCase().includes(searchTerm)
|
|
231
346
|
const matchesExcerpt = article.excerpt.toLowerCase().includes(searchTerm)
|
|
232
|
-
const matchesAuthor = article.author.toLowerCase().includes(searchTerm)
|
|
347
|
+
const matchesAuthor = includeAuthor && article.author.toLowerCase().includes(searchTerm)
|
|
233
348
|
const matchesCategory = article.categories.some((cat) => cat.toLowerCase().includes(searchTerm))
|
|
234
349
|
const matchesTags = article.tags?.some((tag) => tag.toLowerCase().includes(searchTerm))
|
|
235
350
|
return (
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// Server-only exports — uses fs/path; never import this in a client bundle
|
|
2
2
|
export {
|
|
3
3
|
getAllArticles,
|
|
4
|
+
getAiRobotsTxtRules,
|
|
5
|
+
getArticleAiHeaders,
|
|
6
|
+
getArticleMarkdown,
|
|
7
|
+
getArticleMarkdownResponse,
|
|
8
|
+
getArticleMarkdownUrl,
|
|
4
9
|
getArticleMetadata,
|
|
5
10
|
getAvailableArticleSlugs,
|
|
6
11
|
getAdjacentArticles,
|
|
@@ -21,8 +26,15 @@ export {
|
|
|
21
26
|
} from './seoUtils'
|
|
22
27
|
|
|
23
28
|
export { markdownToHtml, extractToc } from './markdown'
|
|
29
|
+
export { setArticlesErrorHandler } from './errorReporting'
|
|
24
30
|
export { ArticleContent } from './ArticleContent'
|
|
25
31
|
export { ArticleTOC } from './ArticleTOC'
|
|
26
32
|
|
|
27
33
|
export type { Article, CategoryInfo, TocItem } from './articleTypes'
|
|
28
34
|
export type { ArticlesConfig } from './articlesConfig'
|
|
35
|
+
export type {
|
|
36
|
+
ArticlesErrorCode,
|
|
37
|
+
ArticlesErrorContext,
|
|
38
|
+
ArticlesErrorHandler,
|
|
39
|
+
ArticlesErrorReport,
|
|
40
|
+
} from './errorReporting'
|