@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.
@@ -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')
@@ -37,6 +37,7 @@ export interface Article {
37
37
  canonicalUrl?: string
38
38
  articleType?: string
39
39
  series?: string
40
+ aiCrawl?: boolean
40
41
  }
41
42
 
42
43
  export interface CategoryInfo {
@@ -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
- console.warn(`Potentially unsafe image path detected: ${cleanPath}`)
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
- console.warn(`Invalid characters in image path: ${cleanPath}`)
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
- console.warn(`Path traversal attempt detected: ${cleanPath}`)
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
- console.warn(`Using placeholder for unsafe image path: ${src}`)
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
- console.error('Markdown conversion error:', error)
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
  }),
@@ -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
- console.warn(`Potentially unsafe image path detected: ${cleanPath}`)
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
- console.warn(`Invalid characters in image path: ${cleanPath}`)
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
- console.warn(`Path traversal attempt detected: ${cleanPath}`)
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
- console.error('Error reading articles directory:', error)
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
- console.error(`Error loading article ${slug}:`, error)
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
- console.error(`Error loading article ${slug}:`, error)
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 searchArticles(query: string): Promise<Article[]> {
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'