This repository was archived by the owner on May 15, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Expand file tree
/
Copy pathcontent.ts
More file actions
211 lines (184 loc) · 5.35 KB
/
Copy pathcontent.ts
File metadata and controls
211 lines (184 loc) · 5.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
import fs from "fs"
import path from "path"
import matter from "gray-matter"
import { ZodError } from "zod"
import type { BlogPost } from "./types"
import { blogFrontmatterSchema } from "./types"
import { getNowPt } from "./pt-time"
import { isPublished } from "./publishing"
/**
* Path to the blog content directory (relative to project root).
*/
const CONTENT_DIR = "content/blog"
/**
* Get the absolute path to the blog content directory.
*/
function getContentDir(): string {
return path.join(process.cwd(), CONTENT_DIR)
}
/**
* Error thrown when blog content validation fails.
*/
export class BlogContentError extends Error {
constructor(
message: string,
public filename?: string,
) {
super(filename ? `[${filename}] ${message}` : message)
this.name = "BlogContentError"
}
}
/**
* Parse a single markdown file into a BlogPost object.
*
* @param filename - Name of the markdown file (e.g., "my-post.md")
* @returns Parsed BlogPost object
* @throws BlogContentError if frontmatter is invalid
*/
function parseMarkdownFile(filename: string): BlogPost {
const filePath = path.join(getContentDir(), filename)
const fileContent = fs.readFileSync(filePath, "utf8")
// Parse frontmatter using gray-matter
const { data, content } = matter(fileContent)
// Validate frontmatter with zod
try {
const frontmatter = blogFrontmatterSchema.parse(data)
// Verify slug matches filename (without .md extension)
const expectedSlug = filename.replace(/\.md$/, "")
if (frontmatter.slug !== expectedSlug) {
throw new BlogContentError(
`Slug mismatch: frontmatter slug "${frontmatter.slug}" does not match filename "${expectedSlug}"`,
filename,
)
}
return {
...frontmatter,
content,
filename,
}
} catch (error) {
if (error instanceof ZodError) {
const issues = error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n")
throw new BlogContentError(`Invalid frontmatter:\n${issues}`, filename)
}
throw error
}
}
/**
* Load all markdown files from the content directory.
*
* @returns Array of all parsed blog posts (including drafts)
* @throws BlogContentError if any file has invalid frontmatter or duplicate slugs
*/
function loadAllPosts(): BlogPost[] {
const contentDir = getContentDir()
// Check if content directory exists
if (!fs.existsSync(contentDir)) {
return []
}
// Get all .md files
const files = fs.readdirSync(contentDir).filter((file) => file.endsWith(".md"))
// Parse all files
const posts: BlogPost[] = []
const slugToFilename = new Map<string, string>()
for (const filename of files) {
const post = parseMarkdownFile(filename)
// Check for duplicate slugs
const existingFilename = slugToFilename.get(post.slug)
if (existingFilename) {
throw new BlogContentError(
`Duplicate slug "${post.slug}" found in files: "${existingFilename}" and "${filename}"`,
)
}
slugToFilename.set(post.slug, filename)
posts.push(post)
}
return posts
}
/**
* Options for getAllBlogPosts.
*/
export interface GetAllBlogPostsOptions {
/**
* Include draft posts in the results.
* @default false
*/
includeDrafts?: boolean
}
/**
* Get all blog posts, optionally filtered by publish status.
*
* By default, only returns published posts that are past their scheduled
* publish time (evaluated at request time in Pacific Time).
*
* @param options - Options for filtering posts
* @returns Array of blog posts, sorted by publish_date (newest first)
*
* @example
* ```ts
* // Get only published posts (default)
* const posts = getAllBlogPosts();
*
* // Include drafts (e.g., for preview in CMS)
* const allPosts = getAllBlogPosts({ includeDrafts: true });
* ```
*/
export function getAllBlogPosts(options: GetAllBlogPostsOptions = {}): BlogPost[] {
const { includeDrafts = false } = options
const allPosts = loadAllPosts()
const nowPt = getNowPt()
// Filter posts based on publish status
const filteredPosts = includeDrafts ? allPosts : allPosts.filter((post) => isPublished(post, nowPt))
// Sort by publish_date (newest first), then by publish_time_pt
return filteredPosts.sort((a, b) => {
// Compare dates first (descending)
const dateCompare = b.publish_date.localeCompare(a.publish_date)
if (dateCompare !== 0) {
return dateCompare
}
// Same date - compare times (descending)
return parsePublishTimePt(b.publish_time_pt) - parsePublishTimePt(a.publish_time_pt)
})
}
/**
* Get a single blog post by its slug.
*
* Only returns the post if it's published and past its scheduled publish time.
* Draft posts and future-scheduled posts will return null.
*
* @param slug - The URL slug of the post
* @returns The blog post if found and published, null otherwise
*
* @example
* ```ts
* const post = getBlogPostBySlug('my-great-article');
* if (post) {
* // Render the post
* } else {
* // Show 404
* }
* ```
*/
export function getBlogPostBySlug(slug: string): BlogPost | null {
const allPosts = loadAllPosts()
const nowPt = getNowPt()
const post = allPosts.find((p) => p.slug === slug)
// Post not found
if (!post) {
return null
}
// Check if published
if (!isPublished(post, nowPt)) {
return null
}
return post
}
/**
* Get all valid slugs for published posts.
* Useful for generating static paths or sitemaps.
*
* @returns Array of slugs for published posts
*/
export function getPublishedSlugs(): string[] {
return getAllBlogPosts().map((post) => post.slug)
}