@fullstackdatasolutions/articles 0.4.3 → 0.5.1

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/src/markdown.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  import type { Element, Root, ElementContent } from 'hast'
2
+ import rehypeAutolinkHeadings from 'rehype-autolink-headings'
2
3
  import rehypePrism from 'rehype-prism-plus'
3
4
  import rehypeSanitize from 'rehype-sanitize'
5
+ import rehypeSlug from 'rehype-slug'
4
6
  import rehypeStringify from 'rehype-stringify'
5
7
  import { remark } from 'remark'
6
8
  import remarkGfm from 'remark-gfm'
9
+ import remarkGithubBlockquoteAlert from 'remark-github-blockquote-alert'
7
10
  import remarkParse from 'remark-parse'
8
11
  import remarkRehype from 'remark-rehype'
9
12
  import { Plugin } from 'unified'
10
13
  import { visit } from 'unist-util-visit'
14
+ import type { TocItem } from './articleTypes'
11
15
 
12
16
  // Import the sanitizeImagePath function
13
17
  function sanitizeImagePath(rawPath: string, articleSlug: string): string | null {
@@ -51,6 +55,108 @@ function sanitizeImagePath(rawPath: string, articleSlug: string): string | null
51
55
  }
52
56
  }
53
57
 
58
+ function styleFootnoteLinks(nodes: ElementContent[]): void {
59
+ nodes.forEach((n) => {
60
+ if (n.type !== 'element') return
61
+ const el = n as Element
62
+ if (el.tagName === 'a') {
63
+ const isBackRef =
64
+ el.properties?.['dataFootnoteBackref'] !== undefined ||
65
+ (el.children[0]?.type === 'text' && el.children[0].value === '↩')
66
+ if (isBackRef) {
67
+ el.properties.className = 'text-primary hover:underline ml-1'
68
+ if (typeof el.properties.href === 'string') {
69
+ el.properties.href = el.properties.href.replaceAll('#user-content-fnref-', '#ref-')
70
+ }
71
+ } else {
72
+ el.properties.className = 'text-primary hover:underline break-all'
73
+ }
74
+ }
75
+ if (el.children) styleFootnoteLinks(el.children)
76
+ })
77
+ }
78
+
79
+ function processFootnoteRef(node: Element): void {
80
+ if (
81
+ node.tagName !== 'sup' ||
82
+ node.children?.[0]?.type !== 'element' ||
83
+ (node.children[0] as Element).tagName !== 'a'
84
+ )
85
+ return
86
+
87
+ const link = node.children[0] as Element
88
+ const href = link.properties?.href
89
+ if (typeof href !== 'string' || !href.startsWith('#user-content-fn-')) return
90
+
91
+ delete link.properties.target
92
+ delete link.properties.rel
93
+ link.properties.className = 'text-primary hover:underline'
94
+ link.properties.href = href.replaceAll('#user-content-fn-', '#footnote-')
95
+
96
+ if (typeof node.properties?.id === 'string') {
97
+ link.properties.id = node.properties.id.replaceAll('user-content-fnref-', 'ref-')
98
+ delete node.properties.id
99
+ }
100
+
101
+ if (link.children?.[0]?.type === 'text') {
102
+ link.children[0].value = `[${link.children[0].value}]`
103
+ }
104
+ }
105
+
106
+ function processFootnotesSection(node: Element): void {
107
+ const cls = node.properties?.className
108
+ const isFootnotes =
109
+ node.tagName === 'section' &&
110
+ (Array.isArray(cls) ? cls.includes('footnotes') : cls === 'footnotes')
111
+ if (!isFootnotes) return
112
+
113
+ const olCandidate = node.children.find(
114
+ (child) => child.type === 'element' && (child as Element).tagName === 'ol'
115
+ )
116
+ if (olCandidate?.type !== 'element') return
117
+
118
+ const ol = olCandidate as Element
119
+ ol.properties.className = 'list-decimal ml-6 space-y-2 text-sm text-muted-foreground'
120
+
121
+ ol.children.forEach((li) => {
122
+ if (li.type !== 'element' || li.tagName !== 'li') return
123
+ const liEl = li as Element
124
+
125
+ if (liEl.properties) {
126
+ liEl.properties.className = 'pl-2'
127
+ if (typeof liEl.properties.id === 'string') {
128
+ liEl.properties.id = liEl.properties.id.replaceAll('user-content-fn-', 'footnote-')
129
+ }
130
+ }
131
+
132
+ const pIndex = liEl.children.findIndex(
133
+ (child) => child.type === 'element' && (child as Element).tagName === 'p'
134
+ )
135
+ if (pIndex !== -1) {
136
+ const p = liEl.children[pIndex] as Element
137
+ liEl.children.splice(pIndex, 1, ...p.children)
138
+ }
139
+
140
+ styleFootnoteLinks(liEl.children)
141
+ })
142
+
143
+ const hr: Element = {
144
+ type: 'element',
145
+ tagName: 'hr',
146
+ properties: { className: 'my-8 border-border' },
147
+ children: [],
148
+ }
149
+ const h3: Element = {
150
+ type: 'element',
151
+ tagName: 'h3',
152
+ properties: { className: 'text-lg font-semibold mb-4' },
153
+ children: [{ type: 'text', value: 'References' }],
154
+ }
155
+
156
+ node.children = [hr, h3, ol]
157
+ if (node.properties) node.properties.className = undefined
158
+ }
159
+
54
160
  const customRenderer: Plugin<[], Root> = () => {
55
161
  return (tree: Root) => {
56
162
  // First pass: Apply general styles
@@ -124,136 +230,8 @@ const customRenderer: Plugin<[], Root> = () => {
124
230
 
125
231
  // Second pass: Fix footnotes and references
126
232
  visit(tree, 'element', (node: Element) => {
127
- // Handle footnote references in the body
128
- if (
129
- node.tagName === 'sup' &&
130
- node.children?.[0]?.type === 'element' &&
131
- node.children[0].tagName === 'a'
132
- ) {
133
- const link = node.children[0] as Element
134
- if (
135
- link.properties?.href &&
136
- typeof link.properties.href === 'string' &&
137
- link.properties.href.startsWith('#user-content-fn-')
138
- ) {
139
- // Remove target and rel, and update class
140
- if (link.properties) {
141
- delete link.properties.target
142
- delete link.properties.rel
143
- link.properties.className = 'text-primary hover:underline'
144
- }
145
-
146
- // Update link href
147
- link.properties.href = link.properties.href.replaceAll('#user-content-fn-', '#footnote-')
148
-
149
- // Move ID from sup to link and update format
150
- if (node.properties?.id && typeof node.properties.id === 'string') {
151
- const newId = node.properties.id.replaceAll('user-content-fnref-', 'ref-')
152
- link.properties.id = newId
153
- delete node.properties.id
154
- }
155
-
156
- // Add brackets to content
157
- if (link.children?.[0]?.type === 'text') {
158
- link.children[0].value = `[${link.children[0].value}]`
159
- }
160
- }
161
- }
162
-
163
- // Handle footnotes section
164
- if (
165
- node.tagName === 'section' &&
166
- (Array.isArray(node.properties?.className)
167
- ? node.properties.className.includes('footnotes')
168
- : node.properties?.className === 'footnotes')
169
- ) {
170
- // Find the ol
171
- const olCandidate = node.children.find(
172
- (child) => child.type === 'element' && (child as Element).tagName === 'ol'
173
- )
174
- if (olCandidate?.type === 'element') {
175
- const ol = olCandidate as Element
176
- ol.properties.className = 'list-decimal ml-6 space-y-2 text-sm text-muted-foreground'
177
-
178
- // Process list items
179
- ol.children.forEach((li) => {
180
- if (li.type === 'element' && li.tagName === 'li') {
181
- const liEl = li as Element
182
- if (liEl.properties) {
183
- liEl.properties.className = 'pl-2'
184
- // Update ID: user-content-fn-1 -> footnote-1
185
- if (typeof liEl.properties.id === 'string') {
186
- liEl.properties.id = liEl.properties.id.replaceAll(
187
- 'user-content-fn-',
188
- 'footnote-'
189
- )
190
- }
191
- }
192
-
193
- // Unwrap p tags inside li
194
- const pIndex = liEl.children.findIndex(
195
- (child) => child.type === 'element' && (child as Element).tagName === 'p'
196
- )
197
-
198
- if (pIndex !== -1) {
199
- const p = liEl.children[pIndex] as Element
200
- liEl.children.splice(pIndex, 1, ...p.children)
201
- }
202
-
203
- // Style links inside the list item
204
- const styleLinks = (nodes: ElementContent[]): void => {
205
- nodes.forEach((n) => {
206
- if (n.type !== 'element') return
207
- const el = n as Element
208
- if (el.tagName === 'a') {
209
- const isBackRef =
210
- el.properties?.['dataFootnoteBackref'] !== undefined ||
211
- (el.children[0]?.type === 'text' && el.children[0].value === '↩')
212
-
213
- if (isBackRef) {
214
- el.properties.className = 'text-primary hover:underline ml-1'
215
- // Update backref href: #user-content-fnref-1 -> #ref-1
216
- if (typeof el.properties.href === 'string') {
217
- el.properties.href = el.properties.href.replaceAll(
218
- '#user-content-fnref-',
219
- '#ref-'
220
- )
221
- }
222
- } else {
223
- el.properties.className = 'text-primary hover:underline break-all'
224
- }
225
- }
226
- if (el.children) styleLinks(el.children)
227
- })
228
- }
229
-
230
- styleLinks(liEl.children)
231
- }
232
- })
233
-
234
- // Reconstruct section children with HR and H3
235
- const hr: Element = {
236
- type: 'element',
237
- tagName: 'hr',
238
- properties: { className: 'my-8 border-border' },
239
- children: [],
240
- }
241
-
242
- const h3: Element = {
243
- type: 'element',
244
- tagName: 'h3',
245
- properties: { className: 'text-lg font-semibold mb-4' },
246
- children: [{ type: 'text', value: 'References' }],
247
- }
248
-
249
- node.children = [hr, h3, ol]
250
-
251
- // Remove the footnotes class to avoid default styling interference
252
- if (node.properties) {
253
- node.properties.className = undefined
254
- }
255
- }
256
- }
233
+ processFootnoteRef(node)
234
+ processFootnotesSection(node)
257
235
  })
258
236
  }
259
237
  }
@@ -286,8 +264,11 @@ export async function markdownToHtml(markdown: string, articleSlug?: string) {
286
264
  let processor = remark()
287
265
  .use(remarkParse)
288
266
  .use(remarkGfm)
267
+ .use(remarkGithubBlockquoteAlert)
289
268
  .use(remarkRehype)
290
269
  .use(customRenderer)
270
+ .use(rehypeSlug)
271
+ .use(rehypeAutolinkHeadings, { behavior: 'wrap' })
291
272
  // @ts-ignore
292
273
  .use(rehypePrism)
293
274
  .use(rehypeSanitize, {
@@ -312,3 +293,35 @@ export async function markdownToHtml(markdown: string, articleSlug?: string) {
312
293
  return markdown
313
294
  }
314
295
  }
296
+
297
+ function nodeTextValue(c: ElementContent): string {
298
+ return c.type === 'text' ? (c as { value: string }).value : ''
299
+ }
300
+
301
+ function extractHeadingItem(node: Element): TocItem | null {
302
+ const match = /^h([1-6])$/.exec(node.tagName)
303
+ if (!match) return null
304
+ const id = typeof node.properties?.id === 'string' ? node.properties.id : ''
305
+ const text = node.children.map(nodeTextValue).join('')
306
+ if (!id || !text) return null
307
+ return { id, depth: Number.parseInt(match[1], 10), text }
308
+ }
309
+
310
+ export async function extractToc(markdown: string): Promise<TocItem[]> {
311
+ const headings: TocItem[] = []
312
+ const collectHeadings: Plugin<[], Root> = () => (tree: Root) => {
313
+ visit(tree, 'element', (node: Element) => {
314
+ const item = extractHeadingItem(node)
315
+ if (item) headings.push(item)
316
+ })
317
+ }
318
+ await remark()
319
+ .use(remarkParse)
320
+ .use(remarkGfm)
321
+ .use(remarkRehype)
322
+ .use(rehypeSlug)
323
+ .use(collectHeadings)
324
+ .use(rehypeStringify)
325
+ .process(markdown)
326
+ return headings
327
+ }
package/src/seoUtils.ts CHANGED
@@ -40,6 +40,7 @@ export async function generateArticleMetadata(
40
40
 
41
41
  const siteUrl = config.siteUrl.replace(/\/$/, '')
42
42
  const articleUrl = `${siteUrl}/articles/${slug}`
43
+ const canonicalUrl = article.canonicalUrl ?? articleUrl
43
44
  const imageUrl = article.featuredImage
44
45
  ? resolveImageUrl(article.featuredImage, siteUrl)
45
46
  : `${siteUrl}/placeholder-logo.png`
@@ -48,13 +49,7 @@ export async function generateArticleMetadata(
48
49
  return {
49
50
  title: `${article.title} | ${config.siteName}`,
50
51
  description,
51
- keywords: [
52
- 'volunteer management software',
53
- 'political campaign software',
54
- 'campaign operations',
55
- 'civic tech',
56
- ...(article.tags ?? []).map((tag) => tag.toLowerCase()),
57
- ].join(', '),
52
+ keywords: [...(article.tags ?? []).map((tag) => tag.toLowerCase())].join(', '),
58
53
  openGraph: {
59
54
  title: article.title,
60
55
  description,
@@ -64,6 +59,7 @@ export async function generateArticleMetadata(
64
59
  locale: 'en_US',
65
60
  type: 'article',
66
61
  ...(article.date && { publishedTime: article.date }),
62
+ ...(article.lastmod && { modifiedTime: new Date(article.lastmod).toISOString() }),
67
63
  authors: [article.author],
68
64
  tags: article.tags ?? [],
69
65
  },
@@ -74,7 +70,7 @@ export async function generateArticleMetadata(
74
70
  images: [imageUrl],
75
71
  },
76
72
  alternates: {
77
- canonical: articleUrl,
73
+ canonical: canonicalUrl,
78
74
  },
79
75
  robots: {
80
76
  index: true,
@@ -92,6 +88,9 @@ export async function generateArticleMetadata(
92
88
  ...(article.date && {
93
89
  'article:published_time': new Date(article.date).toISOString(),
94
90
  }),
91
+ ...(article.lastmod && {
92
+ 'article:modified_time': new Date(article.lastmod).toISOString(),
93
+ }),
95
94
  'article:section': article.category,
96
95
  'article:tag': article.tags?.join(',') ?? '',
97
96
  'linkedin:owner': process.env.NEXT_PUBLIC_LINKEDIN_COMPANY_ID ?? '',
@@ -123,6 +122,9 @@ export function generateArticlesIndexMetadata(config: ArticlesConfig): Metadata
123
122
  },
124
123
  alternates: {
125
124
  canonical: indexUrl,
125
+ types: {
126
+ 'application/rss+xml': `${siteUrl}/articles/feed.xml`,
127
+ },
126
128
  },
127
129
  robots: {
128
130
  index: true,
@@ -198,12 +200,16 @@ export async function getArticleSitemapEntries(
198
200
  try {
199
201
  const [articles, categories] = await Promise.all([getAllArticles(), getAllCategories()])
200
202
 
201
- const articleEntries: MetadataRoute.Sitemap = articles.map((article) => ({
202
- url: `${baseUrl}/articles/${article.slug}`,
203
- lastModified: article.date ? new Date(article.date) : undefined,
204
- changeFrequency: 'weekly' as const,
205
- priority: 0.8,
206
- }))
203
+ const articleEntries: MetadataRoute.Sitemap = articles.map((article) => {
204
+ const dateStr = article.lastmod ?? article.date
205
+ const lastModified = dateStr ? new Date(dateStr) : undefined
206
+ return {
207
+ url: `${baseUrl}/articles/${article.slug}`,
208
+ lastModified,
209
+ changeFrequency: 'weekly' as const,
210
+ priority: 0.8,
211
+ }
212
+ })
207
213
 
208
214
  const categoryEntries: MetadataRoute.Sitemap = categories.map((cat) => ({
209
215
  url: `${baseUrl}/articles/category/${cat.slug}`,
@@ -3,8 +3,8 @@ import matter from 'gray-matter'
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import readingTime from 'reading-time'
6
- import { markdownToHtml } from './markdown'
7
- import type { Article, CategoryInfo } from './articleTypes'
6
+ import { markdownToHtml, extractToc } from './markdown'
7
+ import type { Article, CategoryInfo, FaqItem, HowToStep } from './articleTypes'
8
8
 
9
9
  const articlesDirectory = path.join(process.cwd(), 'public/articles')
10
10
 
@@ -56,6 +56,14 @@ function sanitizeImagePath(rawPath: string, articleSlug: string): string | null
56
56
  return `/articles/${articleSlug}/${cleanPath}`
57
57
  }
58
58
 
59
+ function findArticleFile(slug: string): { filePath: string; contentType: 'md' | 'mdx' } | null {
60
+ const mdPath = path.join(articlesDirectory, slug, 'article.md')
61
+ const mdxPath = path.join(articlesDirectory, slug, 'article.mdx')
62
+ if (fs.existsSync(mdPath)) return { filePath: mdPath, contentType: 'md' }
63
+ if (fs.existsSync(mdxPath)) return { filePath: mdxPath, contentType: 'mdx' }
64
+ return null
65
+ }
66
+
59
67
  export function getAvailableArticleSlugs(): string[] {
60
68
  try {
61
69
  if (!fs.existsSync(articlesDirectory)) return []
@@ -63,18 +71,8 @@ export function getAvailableArticleSlugs(): string[] {
63
71
  return items
64
72
  .filter((item) => item.isDirectory())
65
73
  .filter((dir) => {
66
- const articleDir = path.join(articlesDirectory, dir.name)
67
74
  try {
68
- const entries: (fs.Dirent | string)[] = fs.readdirSync(articleDir, {
69
- withFileTypes: true,
70
- })
71
- return entries.some((entry) => {
72
- const name = typeof entry === 'string' ? entry : entry?.name
73
- if (name !== 'article.md') return false
74
- if (typeof entry === 'string') return true
75
- if (typeof entry?.isFile === 'function') return entry.isFile()
76
- return true
77
- })
75
+ return findArticleFile(dir.name) !== null
78
76
  } catch {
79
77
  return false
80
78
  }
@@ -86,31 +84,55 @@ export function getAvailableArticleSlugs(): string[] {
86
84
  }
87
85
  }
88
86
 
87
+ function parseDateField(rawDate: unknown): string | undefined {
88
+ if (!rawDate) return undefined
89
+ try {
90
+ const parsed = new Date(rawDate as string)
91
+ if (!Number.isNaN(parsed.getTime())) return parsed.toISOString().split('T')[0]
92
+ } catch {
93
+ // ignore invalid dates
94
+ }
95
+ return undefined
96
+ }
97
+
98
+ function resolveFeaturedImage(rawImage: unknown, slug: string): string {
99
+ const img = typeof rawImage === 'string' ? rawImage : ''
100
+ if (img && !img.startsWith('http')) return sanitizeImagePath(img, slug) || '/placeholder-logo.png'
101
+ if (!img) return findArticleImage(slug) || '/placeholder-logo.png'
102
+ return img
103
+ }
104
+
105
+ function parseFaqItems(raw: unknown): FaqItem[] | undefined {
106
+ if (!Array.isArray(raw)) return undefined
107
+ const items = raw.filter(
108
+ (item): item is FaqItem =>
109
+ typeof item === 'object' &&
110
+ item !== null &&
111
+ typeof (item as FaqItem).question === 'string' &&
112
+ typeof (item as FaqItem).answer === 'string'
113
+ )
114
+ return items.length ? items : undefined
115
+ }
116
+
117
+ function parseHowToSteps(raw: unknown): HowToStep[] | undefined {
118
+ if (!Array.isArray(raw)) return undefined
119
+ const steps = raw.filter(
120
+ (item): item is HowToStep =>
121
+ typeof item === 'object' &&
122
+ item !== null &&
123
+ typeof (item as HowToStep).name === 'string' &&
124
+ typeof (item as HowToStep).text === 'string'
125
+ )
126
+ return steps.length ? steps : undefined
127
+ }
128
+
89
129
  async function getArticleSummary(slug: string): Promise<Article | null> {
90
130
  try {
91
- const articlePath = path.join(articlesDirectory, slug, 'article.md')
92
- if (!fs.existsSync(articlePath)) return null
93
- const fileContent = fs.readFileSync(articlePath, 'utf8')
131
+ const found = findArticleFile(slug)
132
+ if (!found) return null
133
+ const fileContent = fs.readFileSync(found.filePath, 'utf8')
94
134
  const { data, content: markdownContent } = matter(fileContent)
95
135
  const readTime = getReadingTime(markdownContent)
96
- let featuredImage = data.featuredImage || ''
97
- if (featuredImage && !featuredImage.startsWith('http')) {
98
- const sanitizedPath = sanitizeImagePath(featuredImage, slug)
99
- featuredImage = sanitizedPath || '/placeholder-logo.png'
100
- } else if (!featuredImage) {
101
- featuredImage = findArticleImage(slug) || '/placeholder-logo.png'
102
- }
103
- let articleDate: string | undefined
104
- if (data.date) {
105
- try {
106
- const parsedDate = new Date(data.date)
107
- if (!Number.isNaN(parsedDate.getTime())) {
108
- articleDate = parsedDate.toISOString().split('T')[0]
109
- }
110
- } catch {
111
- console.warn(`Invalid date for article ${slug}: ${data.date}`)
112
- }
113
- }
114
136
  const allTags: string[] = Array.isArray(data.tags)
115
137
  ? data.tags.filter((t: unknown) => typeof t === 'string' && String(t).trim())
116
138
  : []
@@ -120,13 +142,21 @@ async function getArticleSummary(slug: string): Promise<Article | null> {
120
142
  slug,
121
143
  title: data.title || slug.replaceAll('-', ' '),
122
144
  excerpt: data.excerpt || '',
123
- date: articleDate,
145
+ date: parseDateField(data.date),
146
+ lastmod: parseDateField(data.lastmod),
124
147
  author: data.author || 'Andrew Blase',
125
148
  category: categories[0],
126
149
  categories,
127
150
  readTime,
128
- featuredImage,
151
+ featuredImage: resolveFeaturedImage(data.featuredImage, slug),
129
152
  tags: data.tags || [],
153
+ contentType: found.contentType,
154
+ draft: data.draft === true,
155
+ faq: parseFaqItems(data.faq),
156
+ howTo: parseHowToSteps(data.howTo),
157
+ canonicalUrl: typeof data.canonicalUrl === 'string' ? data.canonicalUrl : undefined,
158
+ articleType: typeof data.articleType === 'string' ? data.articleType : undefined,
159
+ series: typeof data.series === 'string' ? data.series : undefined,
130
160
  }
131
161
  } catch (error) {
132
162
  console.error(`Error loading article ${slug}:`, error)
@@ -136,49 +166,21 @@ async function getArticleSummary(slug: string): Promise<Article | null> {
136
166
 
137
167
  export const getArticleMetadata = cache(async (slug: string): Promise<Article | null> => {
138
168
  try {
139
- const articlePath = path.join(articlesDirectory, slug, 'article.md')
140
- if (!fs.existsSync(articlePath)) return null
141
- const fileContent = fs.readFileSync(articlePath, 'utf8')
142
- const { data, content: markdownContent } = matter(fileContent)
143
- const htmlContent = await markdownToHtml(markdownContent, slug)
144
- const readTime = getReadingTime(markdownContent)
145
- let featuredImage = data.featuredImage || ''
146
- if (featuredImage && !featuredImage.startsWith('http')) {
147
- const sanitizedPath = sanitizeImagePath(featuredImage, slug)
148
- featuredImage = sanitizedPath || '/placeholder-logo.png'
149
- } else if (!featuredImage) {
150
- featuredImage = findArticleImage(slug) || '/placeholder-logo.png'
151
- }
152
- let articleDate: string | undefined
153
- if (data.date) {
154
- try {
155
- const parsedDate = new Date(data.date)
156
- if (!Number.isNaN(parsedDate.getTime())) {
157
- articleDate = parsedDate.toISOString().split('T')[0]
158
- }
159
- } catch {
160
- console.warn(`Invalid date for article ${slug}: ${data.date}`)
161
- }
162
- }
163
- const allTags: string[] = Array.isArray(data.tags)
164
- ? data.tags.filter((t: unknown) => typeof t === 'string' && String(t).trim())
165
- : []
166
- const categories: string[] =
167
- allTags.length > 0 ? allTags.map((t: string) => t.replaceAll('-', ' ').trim()) : ['Campaigns']
168
- return {
169
- slug,
170
- title: data.title || slug.replaceAll('-', ' '),
171
- excerpt: data.excerpt || '',
172
- date: articleDate,
173
- author: data.author || 'Andrew Blase',
174
- category: categories[0],
175
- categories,
176
- readTime,
177
- featuredImage,
178
- tags: data.tags || [],
179
- content: markdownContent,
180
- htmlContent,
169
+ const summary = await getArticleSummary(slug)
170
+ if (!summary) return null
171
+ const found = findArticleFile(slug)
172
+ if (!found) return null
173
+ const fileContent = fs.readFileSync(found.filePath, 'utf8')
174
+ const { content: markdownContent } = matter(fileContent)
175
+ const toc = await extractToc(markdownContent)
176
+ let htmlContent: string | undefined
177
+ let mdxSource: string | undefined
178
+ if (found.contentType === 'mdx') {
179
+ mdxSource = markdownContent
180
+ } else {
181
+ htmlContent = await markdownToHtml(markdownContent, slug)
181
182
  }
183
+ return { ...summary, content: markdownContent, htmlContent, mdxSource, toc }
182
184
  } catch (error) {
183
185
  console.error(`Error loading article ${slug}:`, error)
184
186
  return null
@@ -192,6 +194,7 @@ export const getAllArticles = cache(async (): Promise<Article[]> => {
192
194
  return articles
193
195
  .filter((article): article is Article => article !== null)
194
196
  .filter((article) => !article.date || article.date <= currentDate)
197
+ .filter((article) => !(article.draft && process.env.NODE_ENV === 'production'))
195
198
  .sort((a, b) => {
196
199
  if (!a.date && !b.date) return 0
197
200
  if (!a.date) return 1
package/src/server.ts CHANGED
@@ -20,7 +20,9 @@ export {
20
20
  getArticleSitemapEntries,
21
21
  } from './seoUtils'
22
22
 
23
- export { markdownToHtml } from './markdown'
23
+ export { markdownToHtml, extractToc } from './markdown'
24
+ export { ArticleContent } from './ArticleContent'
25
+ export { ArticleTOC } from './ArticleTOC'
24
26
 
25
- export type { Article, CategoryInfo } from './articleTypes'
27
+ export type { Article, CategoryInfo, TocItem } from './articleTypes'
26
28
  export type { ArticlesConfig } from './articlesConfig'