@fullstackdatasolutions/articles 0.4.2 → 0.5.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 +138 -3
- package/dist/index.cjs +182 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -1
- package/dist/index.d.ts +50 -1
- package/dist/index.js +241 -110
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +281 -189
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +37 -1
- package/dist/server.d.ts +37 -1
- package/dist/server.js +278 -189
- package/dist/server.js.map +1 -1
- package/package.json +5 -1
- package/src/ArticleContent.tsx +17 -0
- package/src/ArticleSchemas.tsx +97 -1
- package/src/ArticleSocialShare.tsx +1 -1
- package/src/ArticleTOC.tsx +29 -0
- package/src/ScrollToTop.tsx +25 -0
- package/src/__tests__/ArticleContent.test.tsx +91 -0
- package/src/__tests__/ArticleSchemas.test.tsx +212 -1
- package/src/__tests__/ArticleSocialShare.test.tsx +7 -7
- package/src/__tests__/ArticleTOC.test.tsx +75 -0
- package/src/__tests__/ScrollToTop.test.tsx +87 -0
- package/src/__tests__/seoUtils.test.ts +284 -0
- package/src/__tests__/server-articles.test.ts +98 -0
- package/src/articleTypes.ts +26 -0
- package/src/articlesConfig.ts +4 -0
- package/src/index.ts +10 -2
- package/src/markdown.ts +143 -130
- package/src/seoUtils.ts +79 -15
- package/src/server-articles.ts +81 -78
- package/src/server.ts +4 -2
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
|
-
|
|
128
|
-
|
|
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:
|
|
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 ?? '',
|
|
@@ -99,6 +98,48 @@ export async function generateArticleMetadata(
|
|
|
99
98
|
}
|
|
100
99
|
}
|
|
101
100
|
|
|
101
|
+
export function generateArticlesIndexMetadata(config: ArticlesConfig): Metadata {
|
|
102
|
+
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
103
|
+
const indexUrl = `${siteUrl}/articles`
|
|
104
|
+
const title = `Articles | ${config.siteName}`
|
|
105
|
+
const description =
|
|
106
|
+
config.hero?.description ?? `Expert analysis and insights from ${config.siteName}.`
|
|
107
|
+
return {
|
|
108
|
+
title,
|
|
109
|
+
description,
|
|
110
|
+
openGraph: {
|
|
111
|
+
title,
|
|
112
|
+
description,
|
|
113
|
+
url: indexUrl,
|
|
114
|
+
siteName: config.siteName,
|
|
115
|
+
type: 'website',
|
|
116
|
+
locale: 'en_US',
|
|
117
|
+
},
|
|
118
|
+
twitter: {
|
|
119
|
+
card: 'summary_large_image',
|
|
120
|
+
title,
|
|
121
|
+
description,
|
|
122
|
+
},
|
|
123
|
+
alternates: {
|
|
124
|
+
canonical: indexUrl,
|
|
125
|
+
types: {
|
|
126
|
+
'application/rss+xml': `${siteUrl}/articles/feed.xml`,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
robots: {
|
|
130
|
+
index: true,
|
|
131
|
+
follow: true,
|
|
132
|
+
googleBot: {
|
|
133
|
+
index: true,
|
|
134
|
+
follow: true,
|
|
135
|
+
'max-video-preview': -1,
|
|
136
|
+
'max-image-preview': 'large',
|
|
137
|
+
'max-snippet': -1,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
102
143
|
export async function generateCategoryMetadata(
|
|
103
144
|
categorySlug: string,
|
|
104
145
|
config: ArticlesConfig
|
|
@@ -114,8 +155,9 @@ export async function generateCategoryMetadata(
|
|
|
114
155
|
const fallback = `Browse ${articles.length} article${articles.length === 1 ? '' : 's'} in the ${categoryName} category.`
|
|
115
156
|
const description = typeof raw === 'string' ? raw : (raw?.short ?? fallback)
|
|
116
157
|
|
|
158
|
+
const title = `${categoryName} Articles | ${config.siteName}`
|
|
117
159
|
return {
|
|
118
|
-
title
|
|
160
|
+
title,
|
|
119
161
|
description,
|
|
120
162
|
openGraph: {
|
|
121
163
|
title: `${categoryName} Articles`,
|
|
@@ -123,10 +165,28 @@ export async function generateCategoryMetadata(
|
|
|
123
165
|
url: categoryUrl,
|
|
124
166
|
siteName: config.siteName,
|
|
125
167
|
images: [{ url: articles[0].featuredImage }],
|
|
168
|
+
type: 'website',
|
|
169
|
+
locale: 'en_US',
|
|
170
|
+
},
|
|
171
|
+
twitter: {
|
|
172
|
+
card: 'summary_large_image',
|
|
173
|
+
title: `${categoryName} Articles`,
|
|
174
|
+
description,
|
|
126
175
|
},
|
|
127
176
|
alternates: {
|
|
128
177
|
canonical: categoryUrl,
|
|
129
178
|
},
|
|
179
|
+
robots: {
|
|
180
|
+
index: true,
|
|
181
|
+
follow: true,
|
|
182
|
+
googleBot: {
|
|
183
|
+
index: true,
|
|
184
|
+
follow: true,
|
|
185
|
+
'max-video-preview': -1,
|
|
186
|
+
'max-image-preview': 'large',
|
|
187
|
+
'max-snippet': -1,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
130
190
|
}
|
|
131
191
|
}
|
|
132
192
|
|
|
@@ -140,12 +200,16 @@ export async function getArticleSitemapEntries(
|
|
|
140
200
|
try {
|
|
141
201
|
const [articles, categories] = await Promise.all([getAllArticles(), getAllCategories()])
|
|
142
202
|
|
|
143
|
-
const articleEntries: MetadataRoute.Sitemap = articles.map((article) =>
|
|
144
|
-
|
|
145
|
-
lastModified
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
})
|
|
149
213
|
|
|
150
214
|
const categoryEntries: MetadataRoute.Sitemap = categories.map((cat) => ({
|
|
151
215
|
url: `${baseUrl}/articles/category/${cat.slug}`,
|
package/src/server-articles.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
92
|
-
if (!
|
|
93
|
-
const fileContent = fs.readFileSync(
|
|
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:
|
|
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
|
|
140
|
-
if (!
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|