next-posts 1.0.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/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { parse } from "@std/yaml";
4
+
5
+ export function parseFrontmatter(input: string): {
6
+ data: Record<string, unknown>;
7
+ content: string;
8
+ } {
9
+ const delimiter = "---";
10
+ if (
11
+ !input.startsWith(delimiter + "\n") &&
12
+ !input.startsWith(delimiter + "\r\n")
13
+ ) {
14
+ return { data: {}, content: input };
15
+ }
16
+
17
+ const afterOpen = input.slice(delimiter.length);
18
+ const closeIndex = afterOpen.indexOf("\n" + delimiter);
19
+ if (closeIndex === -1) {
20
+ return { data: {}, content: input };
21
+ }
22
+
23
+ const yamlStr = afterOpen.slice(0, closeIndex).trim();
24
+ let content = afterOpen.slice(closeIndex + 1 + delimiter.length);
25
+ if (content.startsWith("\r\n")) content = content.slice(2);
26
+ else if (content.startsWith("\n")) content = content.slice(1);
27
+
28
+ const parsed = parse(yamlStr);
29
+ const data =
30
+ typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
31
+ ? (parsed as Record<string, unknown>)
32
+ : {};
33
+
34
+ return { data, content };
35
+ }
36
+
37
+ export function getAllPostSlugs(directory: string = "posts/") {
38
+ const postsDirectory = path.join(process.cwd(), directory);
39
+ return fs.readdirSync(postsDirectory);
40
+ }
41
+
42
+ export function getAllPosts<T extends object = Record<string, unknown>>(
43
+ directory: string = "posts/",
44
+ ): { slug: string; metadata: T; content: string }[] {
45
+ const slugs = getAllPostSlugs(directory);
46
+ return slugs.map((slug) => getPostBySlug<T>(slug, directory));
47
+ }
48
+
49
+ export function getAllPostParams(directory: string = "posts/") {
50
+ const slugs = getAllPostSlugs(directory);
51
+ return slugs.map((slug) => ({ slug: slug.replace(/\.md$/, "") }));
52
+ }
53
+
54
+ export function getPostBySlug<T extends object = Record<string, unknown>>(
55
+ slug: string,
56
+ directory: string = "posts/",
57
+ ): { slug: string; metadata: T; content: string } {
58
+ const realSlug = slug.replace(/\.md$/, "");
59
+ const postsDirectory = path.join(process.cwd(), directory);
60
+ const fullPath = path.join(postsDirectory, `${realSlug}.md`);
61
+ const fileContents = fs.readFileSync(fullPath, "utf8");
62
+ const { data, content } = parseFrontmatter(fileContents);
63
+
64
+ return { slug: realSlug, metadata: data as T, content };
65
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "next-posts",
3
+ "version": "1.0.0",
4
+ "description": "Load the article and parse the YAML into Next.js!",
5
+ "homepage": "https://github.com/yd-tw/next-posts",
6
+ "license": "MIT",
7
+ "author": "yd-tw",
8
+ "type": "module",
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "keywords": [
12
+ "next.js",
13
+ "markdown",
14
+ "yaml",
15
+ "frontmatter",
16
+ "blog",
17
+ "post"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsdown && tsc --emitDeclarationOnly --outDir dist",
21
+ "format": "prettier --write .",
22
+ "test": "vitest run",
23
+ "coverage": "vitest run --coverage"
24
+ },
25
+ "devDependencies": {
26
+ "@std/yaml": "npm:@jsr/std__yaml@^1.0.12",
27
+ "@types/node": "^24.10.13",
28
+ "@vitest/coverage-v8": "^4.0.18",
29
+ "prettier": "^3.8.1",
30
+ "tsdown": "^0.12.0",
31
+ "typescript": "^5.9.3",
32
+ "vitest": "^4.0.18"
33
+ }
34
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ title: Custom Post
3
+ date: 2024-06-01
4
+ ---
5
+
6
+ Content from custom directory.
@@ -0,0 +1,6 @@
1
+ ---
2
+ title: Hello World
3
+ date: 2024-01-01
4
+ ---
5
+
6
+ This is the content of the hello world post.
@@ -0,0 +1,9 @@
1
+ ---
2
+ title: Second Post
3
+ date: 2024-01-02
4
+ tags:
5
+ - test
6
+ - blog
7
+ ---
8
+
9
+ This is the second post content.
@@ -0,0 +1,252 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { fileURLToPath } from "url";
3
+ import path from "path";
4
+ import {
5
+ getAllPostSlugs,
6
+ getAllPosts,
7
+ getAllPostParams,
8
+ getPostBySlug,
9
+ parseFrontmatter,
10
+ } from "../index.ts";
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+
15
+ const FIXTURES_DIR = path.join(__dirname, "fixtures");
16
+
17
+ beforeEach(() => {
18
+ vi.spyOn(process, "cwd").mockReturnValue(FIXTURES_DIR);
19
+ });
20
+
21
+ afterEach(() => {
22
+ vi.restoreAllMocks();
23
+ });
24
+
25
+ describe("getAllPostSlugs", () => {
26
+ // 回傳預設 posts 目錄中的所有檔名
27
+ it("returns filenames from default posts directory", () => {
28
+ const slugs = getAllPostSlugs();
29
+ expect(slugs).toEqual(
30
+ expect.arrayContaining(["hello-world.md", "second-post.md"]),
31
+ );
32
+ });
33
+
34
+ // 回傳自訂目錄中的所有檔名
35
+ it("returns filenames from custom directory", () => {
36
+ const slugs = getAllPostSlugs("custom-posts/");
37
+ expect(slugs).toEqual(["custom.md"]);
38
+ });
39
+
40
+ // 回傳目錄中所有檔案(數量正確)
41
+ it("returns all files in directory", () => {
42
+ const slugs = getAllPostSlugs();
43
+ expect(slugs).toHaveLength(2);
44
+ });
45
+ });
46
+
47
+ describe("getAllPostParams", () => {
48
+ // 回傳不含 .md 副檔名的 slug 參數物件
49
+ it("returns slug params without .md extension", () => {
50
+ const params = getAllPostParams();
51
+ expect(params).toEqual(
52
+ expect.arrayContaining([
53
+ { slug: "hello-world" },
54
+ { slug: "second-post" },
55
+ ]),
56
+ );
57
+ });
58
+
59
+ // 回傳自訂目錄中的 slug 參數
60
+ it("returns slug params from custom directory", () => {
61
+ const params = getAllPostParams("custom-posts/");
62
+ expect(params).toEqual([{ slug: "custom" }]);
63
+ });
64
+
65
+ // 確保所有 slug 都已移除 .md 副檔名
66
+ it("strips .md extension from all slugs", () => {
67
+ const params = getAllPostParams();
68
+ for (const param of params) {
69
+ expect(param.slug).not.toMatch(/\.md$/);
70
+ }
71
+ });
72
+ });
73
+
74
+ describe("getPostBySlug", () => {
75
+ // 使用不含副檔名的 slug 取得文章資料
76
+ it("returns post data for slug without extension", () => {
77
+ const post = getPostBySlug("hello-world");
78
+ expect(post.slug).toBe("hello-world");
79
+ expect(post.metadata.title).toBe("Hello World");
80
+ expect(post.content).toContain(
81
+ "This is the content of the hello world post.",
82
+ );
83
+ });
84
+
85
+ // 使用含 .md 副檔名的 slug 取得文章資料
86
+ it("returns post data for slug with .md extension", () => {
87
+ const post = getPostBySlug("hello-world.md");
88
+ expect(post.slug).toBe("hello-world");
89
+ expect(post.metadata.title).toBe("Hello World");
90
+ });
91
+
92
+ // 正確解析 frontmatter 中的 metadata
93
+ it("parses frontmatter metadata correctly", () => {
94
+ const post = getPostBySlug("second-post");
95
+ expect(post.metadata.title).toBe("Second Post");
96
+ expect(post.metadata.tags).toEqual(["test", "blog"]);
97
+ });
98
+
99
+ // 回傳內容不包含 frontmatter 區塊
100
+ it("returns content without frontmatter", () => {
101
+ const post = getPostBySlug("second-post");
102
+ expect(post.content).toContain("This is the second post content.");
103
+ expect(post.content).not.toContain("---");
104
+ });
105
+
106
+ // 可從自訂目錄中取得文章
107
+ it("returns post from custom directory", () => {
108
+ const post = getPostBySlug("custom", "custom-posts/");
109
+ expect(post.slug).toBe("custom");
110
+ expect(post.metadata.title).toBe("Custom Post");
111
+ expect(post.content).toContain("Content from custom directory.");
112
+ });
113
+
114
+ // 回傳的 slug 永遠不包含 .md 副檔名
115
+ it("returned slug never has .md extension", () => {
116
+ const postWithExt = getPostBySlug("hello-world.md");
117
+ const postWithoutExt = getPostBySlug("hello-world");
118
+ expect(postWithExt.slug).toBe("hello-world");
119
+ expect(postWithoutExt.slug).toBe("hello-world");
120
+ });
121
+ });
122
+
123
+ describe("parseFrontmatter", () => {
124
+ // 正確解析標準 frontmatter 並回傳 data 與 content
125
+ it("parses standard frontmatter and returns data and content", () => {
126
+ const input = "---\ntitle: Hello\nauthor: yd\n---\n\nContent here";
127
+ const { data, content } = parseFrontmatter(input);
128
+ expect(data.title).toBe("Hello");
129
+ expect(data.author).toBe("yd");
130
+ expect(content).toContain("Content here");
131
+ });
132
+
133
+ // 輸入不含 frontmatter 時回傳空 data 和原始內容
134
+ it("returns empty data and original input when no frontmatter", () => {
135
+ const input = "Just plain content without frontmatter.";
136
+ const { data, content } = parseFrontmatter(input);
137
+ expect(data).toEqual({});
138
+ expect(content).toBe(input);
139
+ });
140
+
141
+ // 缺少結尾 --- 時回傳空 data 和原始內容
142
+ it("returns empty data when closing delimiter is missing", () => {
143
+ const input = "---\ntitle: Hello\n";
144
+ const { data, content } = parseFrontmatter(input);
145
+ expect(data).toEqual({});
146
+ expect(content).toBe(input);
147
+ });
148
+
149
+ // 正確處理 Windows 換行符號 (CRLF)
150
+ it("handles Windows line endings (CRLF)", () => {
151
+ const input = "---\r\ntitle: Hello\r\n---\r\n\r\nContent here";
152
+ const { data, content } = parseFrontmatter(input);
153
+ expect(data.title).toBe("Hello");
154
+ expect(content).toContain("Content here");
155
+ });
156
+
157
+ // 關閉分隔符後緊接 CRLF 時,正確去除開頭的 \r\n
158
+ it("strips leading CRLF after closing delimiter", () => {
159
+ const input = "---\r\ntitle: Hello\r\n---\r\nContent here";
160
+ const { data, content } = parseFrontmatter(input);
161
+ expect(data.title).toBe("Hello");
162
+ expect(content).toBe("Content here");
163
+ });
164
+
165
+ // 關閉分隔符後無換行符時,內容保持原樣不做裁切
166
+ it("preserves content when no newline follows closing delimiter", () => {
167
+ const input = "---\ntitle: Hello\n---Content here";
168
+ const { data, content } = parseFrontmatter(input);
169
+ expect(data.title).toBe("Hello");
170
+ expect(content).toBe("Content here");
171
+ });
172
+
173
+ // 回傳內容不包含 frontmatter 區塊
174
+ it("strips frontmatter block from content", () => {
175
+ const input = "---\ntitle: Hello\n---\n\nContent here";
176
+ const { content } = parseFrontmatter(input);
177
+ expect(content).not.toContain("---");
178
+ expect(content).not.toContain("title:");
179
+ });
180
+
181
+ // @std/yaml 自動將裸 ISO 日期字串轉為 Date 物件
182
+ it("auto-converts bare ISO date strings to Date objects", () => {
183
+ const input = "---\ndate: 2024-01-01\n---\n\nContent";
184
+ const { data } = parseFrontmatter(input);
185
+ expect(data.date).toBeInstanceOf(Date);
186
+ expect(data.date).toEqual(new Date("2024-01-01"));
187
+ });
188
+
189
+ // 正確解析含陣列的 YAML 欄位
190
+ it("parses YAML array fields correctly", () => {
191
+ const input = "---\ntitle: Hello\ntags:\n - foo\n - bar\n---\n\nContent";
192
+ const { data } = parseFrontmatter(input);
193
+ expect(data.tags).toEqual(["foo", "bar"]);
194
+ });
195
+
196
+ // 當 YAML 根節點為陣列時回傳空 data
197
+ it("returns empty data when YAML root is an array", () => {
198
+ const input = "---\n- item1\n- item2\n---\n\nContent";
199
+ const { data } = parseFrontmatter(input);
200
+ expect(data).toEqual({});
201
+ });
202
+
203
+ // 空 frontmatter 區塊回傳空 data
204
+ it("returns empty data for empty frontmatter block", () => {
205
+ const input = "---\n---\n\nContent";
206
+ const { data, content } = parseFrontmatter(input);
207
+ expect(data).toEqual({});
208
+ expect(content).toContain("Content");
209
+ });
210
+ });
211
+
212
+ describe("getAllPosts", () => {
213
+ // 回傳預設目錄中的所有文章
214
+ it("returns all posts from default directory", () => {
215
+ const posts = getAllPosts();
216
+ expect(posts).toHaveLength(2);
217
+ });
218
+
219
+ // 回傳的文章物件結構正確
220
+ it("returns posts with correct structure", () => {
221
+ const posts = getAllPosts();
222
+ for (const post of posts) {
223
+ expect(post).toHaveProperty("slug");
224
+ expect(post).toHaveProperty("metadata");
225
+ expect(post).toHaveProperty("content");
226
+ }
227
+ });
228
+
229
+ // 所有文章的 slug 都不包含 .md 副檔名
230
+ it("returns posts with slugs without .md extension", () => {
231
+ const posts = getAllPosts();
232
+ for (const post of posts) {
233
+ expect(post.slug).not.toMatch(/\.md$/);
234
+ }
235
+ });
236
+
237
+ // 可從自訂目錄取得文章列表
238
+ it("returns posts from custom directory", () => {
239
+ const posts = getAllPosts("custom-posts/");
240
+ expect(posts).toHaveLength(1);
241
+ expect(posts[0].slug).toBe("custom");
242
+ expect(posts[0].metadata.title).toBe("Custom Post");
243
+ });
244
+
245
+ // 所有回傳的文章內容都不是空字串
246
+ it("all returned posts have non-empty content", () => {
247
+ const posts = getAllPosts();
248
+ for (const post of posts) {
249
+ expect(post.content.trim().length).toBeGreaterThan(0);
250
+ }
251
+ });
252
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2017",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "./dist",
7
+ "declaration": true,
8
+ "esModuleInterop": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "strict": true,
11
+ "skipLibCheck": true
12
+ },
13
+ "exclude": ["tests", "node_modules", "dist", "tsdown.config.ts"]
14
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export default defineConfig({
4
+ entry: { index: "./index.ts" },
5
+ outDir: "dist",
6
+ format: ["esm"],
7
+ clean: true,
8
+ dts: false,
9
+ external: ["fs", "path", "node:fs", "node:path"],
10
+ });