@ibalzam/codejitsu-core 0.1.0 → 0.2.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/CLAUDE.md +55 -39
- package/MIGRATIONS/0.2.0.md +166 -0
- package/README.md +25 -5
- package/checklist/bin/run.mjs +94 -54
- package/modules/blog/CLAUDE.md +105 -52
- package/modules/blog/src/collection.ts +176 -0
- package/modules/blog/src/fs.ts +167 -0
- package/modules/blog/src/index.ts +5 -201
- package/modules/blog/src/types.ts +71 -0
- package/modules/blog/templates/content.config.ts +27 -0
- package/modules/blog/templates/lib/blog-fs.ts +14 -0
- package/modules/blog/templates/lib/blog.ts +11 -3
- package/modules/config/CLAUDE.md +121 -0
- package/modules/config/src/define.ts +18 -0
- package/modules/config/src/index.ts +3 -0
- package/modules/config/src/load.ts +96 -0
- package/modules/config/src/types.ts +203 -0
- package/modules/images/CLAUDE.md +56 -39
- package/modules/images/bin/optimize.mjs +42 -34
- package/modules/images/checklist.md +15 -7
- package/modules/images/src/auto-blog.mjs +112 -0
- package/modules/images/src/index.ts +3 -18
- package/modules/images/src/optimize.mjs +7 -9
- package/modules/llms/CLAUDE.md +121 -28
- package/modules/llms/bin/generate.mjs +13 -23
- package/modules/llms/checklist.md +7 -6
- package/modules/llms/src/generate.mjs +374 -108
- package/modules/seo/CLAUDE.md +65 -21
- package/modules/seo/templates/Head.astro +99 -27
- package/package.json +11 -1
- package/src/index.ts +1 -1
- package/modules/images/templates/codejitsu-images.config.mjs +0 -18
- package/modules/llms/templates/codejitsu-llms.config.mjs +0 -39
package/modules/blog/CLAUDE.md
CHANGED
|
@@ -1,87 +1,140 @@
|
|
|
1
1
|
# Blog module — instructions for Claude
|
|
2
2
|
|
|
3
|
-
When the user asks to **implement codejitsu/core/blog** (or "add the blog system"), do the following.
|
|
3
|
+
When the user asks to **implement codejitsu/core/blog** (or "add the blog system", "wire up the blog"), do the following.
|
|
4
4
|
|
|
5
5
|
## What this module provides
|
|
6
6
|
|
|
7
|
-
A markdown
|
|
8
|
-
- File-based posts in `content/blog/*.md` (gray-matter frontmatter)
|
|
9
|
-
- Scheduled publishing — future-dated posts are hidden from public pages and sitemap, but their slugs stay buildable so OG meta scrapers (Hootsuite, etc.) can hit them
|
|
10
|
-
- Dual-slug resolution — filename slug (`2026-02-08-foo`) and canonical frontmatter slug (`foo`) both resolve to the same post; the frontmatter slug is canonical for SEO
|
|
11
|
-
- Reading time, tags, FAQs, categories
|
|
12
|
-
- Listing, tag, category pages
|
|
7
|
+
A markdown blog with two loader variants — pick whichever fits the project:
|
|
13
8
|
|
|
14
|
-
|
|
9
|
+
- **`createBlogFromCollection`** — uses Astro Content Collections. **Use this for any Astro site.** Type-safe via the collection's Zod schema, schema-validated, HMR works.
|
|
10
|
+
- **`createBlog`** — reads `content/blog/*.md` via gray-matter directly. Use for non-Astro projects or when CC isn't an option.
|
|
15
11
|
|
|
16
|
-
|
|
12
|
+
Both variants return the same `BlogAPI`:
|
|
17
13
|
|
|
18
|
-
```
|
|
19
|
-
|
|
14
|
+
```ts
|
|
15
|
+
getAllPosts() // Published posts (date <= today, not draft). Sorted newest first.
|
|
16
|
+
getAllPostsIncludingFuture() // All non-draft posts.
|
|
17
|
+
getFutureBlogSlugs() // Slugs of future-dated drafts (for sitemap exclusion).
|
|
18
|
+
getAllPostSlugs() // Every slug for getStaticPaths (includes future, excludes drafts).
|
|
19
|
+
getPostBySlug(slug) // Resolves filename-slug OR canonical (frontmatter) slug.
|
|
20
|
+
getAllTags() / getPostsByTag(tag)
|
|
21
|
+
getAllCategorySlugs() / getCategoryBySlug(slug) / getPostsByCategory(slug)
|
|
20
22
|
```
|
|
21
|
-
(They're transitive deps of `@ibalzam/codejitsu-core`, but Astro's bundler resolves them from the site's `node_modules`.)
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
## Wiring into an Astro site
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
### 1. Set up the Content Collection
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
```ts
|
|
29
|
+
// src/content.config.ts
|
|
30
|
+
import { defineCollection, z } from 'astro:content';
|
|
31
|
+
import { glob } from 'astro/loaders';
|
|
32
|
+
|
|
33
|
+
const blog = defineCollection({
|
|
34
|
+
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
|
|
35
|
+
schema: z.object({
|
|
36
|
+
title: z.string(),
|
|
37
|
+
description: z.string(),
|
|
38
|
+
pubDate: z.coerce.date(), // ← date field; configure dateField to match
|
|
39
|
+
updatedDate: z.coerce.date().optional(),
|
|
40
|
+
author: z.string().default('editor'),
|
|
41
|
+
image: z.string().optional(),
|
|
42
|
+
tags: z.array(z.string()).default([]),
|
|
43
|
+
draft: z.boolean().default(false),
|
|
44
|
+
faqs: z.array(z.object({
|
|
45
|
+
question: z.string(),
|
|
46
|
+
answer: z.string(),
|
|
47
|
+
})).optional(),
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const collections = { blog };
|
|
52
|
+
```
|
|
28
53
|
|
|
29
|
-
|
|
30
|
-
- `blog/index.astro` — listing
|
|
31
|
-
- `blog/[...slug].astro` — detail (handles both filename and canonical slug forms)
|
|
32
|
-
- `blog/tag/[tag].astro` — tag pages
|
|
33
|
-
- `blog/category/[category].astro` — category pages (skip if site has no categories)
|
|
54
|
+
### 2. Configure in `codejitsu.config.ts`
|
|
34
55
|
|
|
35
|
-
|
|
56
|
+
```ts
|
|
57
|
+
import { defineConfig } from '@ibalzam/codejitsu-core/config';
|
|
58
|
+
|
|
59
|
+
export default defineConfig({
|
|
60
|
+
site: { url: '...', name: '...', defaultAuthor: 'editor' },
|
|
61
|
+
blog: {
|
|
62
|
+
mode: 'collection',
|
|
63
|
+
collectionName: 'blog',
|
|
64
|
+
dateField: 'pubDate', // matches the CC schema field
|
|
65
|
+
draftField: 'draft',
|
|
66
|
+
// categories: [...] // optional
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
```
|
|
36
70
|
|
|
37
|
-
###
|
|
71
|
+
### 3. Create the loader
|
|
38
72
|
|
|
39
|
-
|
|
73
|
+
```ts
|
|
74
|
+
// src/lib/blog.ts
|
|
75
|
+
import { createBlogFromCollection } from '@ibalzam/codejitsu-core/blog';
|
|
76
|
+
|
|
77
|
+
export const blog = createBlogFromCollection({
|
|
78
|
+
collectionName: 'blog',
|
|
79
|
+
dateField: 'pubDate',
|
|
80
|
+
draftField: 'draft',
|
|
81
|
+
defaultAuthor: 'editor',
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 4. Use in pages
|
|
86
|
+
|
|
87
|
+
```astro
|
|
88
|
+
---
|
|
89
|
+
// src/pages/blog/[slug].astro
|
|
90
|
+
import { blog } from '~/lib/blog';
|
|
91
|
+
|
|
92
|
+
export async function getStaticPaths() {
|
|
93
|
+
const slugs = await blog.getAllPostSlugs(); // includes future-dated for OG scrapers
|
|
94
|
+
return slugs.map((slug) => ({ params: { slug } }));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const post = await blog.getPostBySlug(Astro.params.slug as string);
|
|
98
|
+
if (!post) return Astro.redirect('/404');
|
|
99
|
+
---
|
|
100
|
+
```
|
|
40
101
|
|
|
41
102
|
### 5. Wire scheduled-post filter into the sitemap
|
|
42
103
|
|
|
43
|
-
In `astro.config.mjs`,
|
|
104
|
+
In `astro.config.mjs`, get future slugs from the blog instance and pass to the sitemap's `excludeFuturePosts` filter:
|
|
44
105
|
|
|
45
106
|
```ts
|
|
46
107
|
import { blog } from './src/lib/blog';
|
|
108
|
+
import { excludeFuturePosts, defaultPriorityRules } from '@ibalzam/codejitsu-core/seo/sitemap';
|
|
109
|
+
|
|
47
110
|
const futureSlugs = await blog.getFutureBlogSlugs();
|
|
48
111
|
|
|
49
|
-
|
|
50
|
-
filter: (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
112
|
+
sitemap({
|
|
113
|
+
filter: excludeFuturePosts(futureSlugs),
|
|
114
|
+
serialize: defaultPriorityRules(SITE),
|
|
115
|
+
});
|
|
54
116
|
```
|
|
55
117
|
|
|
56
|
-
|
|
118
|
+
## Frontmatter shape
|
|
57
119
|
|
|
58
|
-
|
|
120
|
+
Required: `title`, `description`, date (default field name `date`, configurable via `dateField`).
|
|
121
|
+
Recommended: `image`, `tags`, `author`.
|
|
122
|
+
Optional: `slug` (canonical override), `faqs`, `draft`, `updatedDate`.
|
|
59
123
|
|
|
60
|
-
|
|
124
|
+
Field names are flexible — set `dateField` and `draftField` in your CC schema and they'll flow through.
|
|
61
125
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
description: "Quick guide to BTU sizing" # required (used as meta description)
|
|
66
|
-
date: 2026-03-15 # required; future date = hidden until that day
|
|
67
|
-
slug: how-to-size-a-furnace # optional; if set, this is the canonical URL
|
|
68
|
-
author: "Pearl Remodeling" # optional; falls back to defaultAuthor in config
|
|
69
|
-
image: /images/blog/furnace-sizing.webp # optional; used for OG + listing card
|
|
70
|
-
tags: [HVAC, Heating, Guides] # optional
|
|
71
|
-
faqs: # optional; rendered as FAQ schema + section
|
|
72
|
-
- question: "What BTU do I need?"
|
|
73
|
-
answer: "Roughly 30 BTU per sq ft as a starting point..."
|
|
74
|
-
---
|
|
75
|
-
```
|
|
126
|
+
## Dual-slug behavior
|
|
127
|
+
|
|
128
|
+
If a post's frontmatter has `slug: 'short-form'` and its filename is `2026-02-08-long-form.md`, **both URLs resolve to the same post** but `slug` (frontmatter) is canonical. This lets you ship short URLs while keeping date-prefixed URLs alive. Set `<link rel="canonical">` to the frontmatter slug.
|
|
76
129
|
|
|
77
130
|
## What must NOT be done
|
|
78
131
|
|
|
79
|
-
- **Don't
|
|
80
|
-
- **Don't bypass `getAllPosts()` for the listing.** It
|
|
81
|
-
- **Don't
|
|
82
|
-
- **Don't
|
|
83
|
-
- **Don't change
|
|
132
|
+
- **Don't use `createBlog` (fs mode) in an Astro project.** You lose schema validation, HMR, type safety. Always prefer `createBlogFromCollection`.
|
|
133
|
+
- **Don't bypass `getAllPosts()` for the listing.** It filters future-dated and drafts; bypassing leaks drafts.
|
|
134
|
+
- **Don't use `getAllPosts()` for `getStaticPaths`** — use `getAllPostSlugs()` so future-dated posts stay buildable (OG scrapers need to reach them before publish day).
|
|
135
|
+
- **Don't read the collection directly** (e.g. `await getCollection('blog')`) in pages. Use the `blog` instance from `src/lib/blog.ts` so filtering/sorting/date logic stays in one place.
|
|
136
|
+
- **Don't change `dateField` mid-project** without renaming the frontmatter field in every existing post.
|
|
84
137
|
|
|
85
138
|
## Verify
|
|
86
139
|
|
|
87
|
-
Run `modules/blog/checklist.md` after wiring.
|
|
140
|
+
Run `modules/blog/checklist.md` after wiring.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import readingTime from 'reading-time';
|
|
2
|
+
import type {
|
|
3
|
+
BlogAPI,
|
|
4
|
+
BlogCategory,
|
|
5
|
+
BlogPost,
|
|
6
|
+
BlogPostMetadata,
|
|
7
|
+
CommonBlogConfig,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
|
|
10
|
+
export interface CollectionBlogConfig extends CommonBlogConfig {
|
|
11
|
+
/** Astro Content Collection name. Default 'blog'. */
|
|
12
|
+
collectionName?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Minimal shape of an Astro CollectionEntry that we depend on. */
|
|
16
|
+
interface AstroCollectionEntry {
|
|
17
|
+
id: string;
|
|
18
|
+
slug: string;
|
|
19
|
+
data: Record<string, unknown>;
|
|
20
|
+
body: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getTodayUTC(): Date {
|
|
24
|
+
const now = new Date();
|
|
25
|
+
return new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function asISO(value: unknown): string {
|
|
29
|
+
if (value instanceof Date) return value.toISOString().split('T')[0];
|
|
30
|
+
if (typeof value === 'string') return value;
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Astro Content Collections blog loader. Use this in Astro projects.
|
|
36
|
+
*
|
|
37
|
+
* Dynamically imports `astro:content` at call time so the rest of this package
|
|
38
|
+
* stays usable in non-Astro environments. Throws a clear error if Astro is missing.
|
|
39
|
+
*/
|
|
40
|
+
export function createBlogFromCollection(config: CollectionBlogConfig = {}): BlogAPI {
|
|
41
|
+
const collectionName = config.collectionName ?? 'blog';
|
|
42
|
+
const defaultAuthor = config.defaultAuthor;
|
|
43
|
+
const categories = config.categories ?? [];
|
|
44
|
+
const dateField = config.dateField ?? 'date';
|
|
45
|
+
const draftField = config.draftField ?? null;
|
|
46
|
+
|
|
47
|
+
async function getCollection(): Promise<AstroCollectionEntry[]> {
|
|
48
|
+
let mod: { getCollection: (name: string) => Promise<AstroCollectionEntry[]> };
|
|
49
|
+
try {
|
|
50
|
+
// @ts-expect-error - 'astro:content' is a virtual module resolved by Astro at build time.
|
|
51
|
+
mod = await import('astro:content');
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`createBlogFromCollection() requires Astro and a configured content collection ` +
|
|
55
|
+
`named '${collectionName}'. Add Astro to the project or use createBlog() (fs+gray-matter) instead. ` +
|
|
56
|
+
`Original error: ${err instanceof Error ? err.message : String(err)}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return mod.getCollection(collectionName);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readAll(): Promise<AstroCollectionEntry[]> {
|
|
63
|
+
const all = await getCollection();
|
|
64
|
+
if (!draftField) return all;
|
|
65
|
+
return all.filter((e) => !e.data[draftField]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toMetadata(e: AstroCollectionEntry): BlogPostMetadata {
|
|
69
|
+
const canonicalSlug = (e.data.slug as string | undefined) || e.slug;
|
|
70
|
+
return {
|
|
71
|
+
slug: canonicalSlug,
|
|
72
|
+
title: (e.data.title as string) ?? '',
|
|
73
|
+
description: (e.data.description as string) ?? '',
|
|
74
|
+
date: asISO(e.data[dateField]),
|
|
75
|
+
author: (e.data.author as string) ?? defaultAuthor,
|
|
76
|
+
image: e.data.image as string | undefined,
|
|
77
|
+
tags: e.data.tags as string[] | undefined,
|
|
78
|
+
readingTime: readingTime(e.body).text,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function getAllPostsIncludingFuture(): Promise<BlogPostMetadata[]> {
|
|
83
|
+
const entries = await readAll();
|
|
84
|
+
return entries
|
|
85
|
+
.map(toMetadata)
|
|
86
|
+
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function getAllPosts(): Promise<BlogPostMetadata[]> {
|
|
90
|
+
const today = getTodayUTC();
|
|
91
|
+
const all = await getAllPostsIncludingFuture();
|
|
92
|
+
return all.filter((p) => new Date(p.date) <= today);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function getFutureBlogSlugs(): Promise<string[]> {
|
|
96
|
+
const today = getTodayUTC();
|
|
97
|
+
const entries = await readAll();
|
|
98
|
+
const slugs = new Set<string>();
|
|
99
|
+
for (const e of entries) {
|
|
100
|
+
const date = asISO(e.data[dateField]);
|
|
101
|
+
if (!date) continue;
|
|
102
|
+
if (new Date(date) > today) {
|
|
103
|
+
const canonical = (e.data.slug as string | undefined) || e.slug;
|
|
104
|
+
slugs.add(canonical);
|
|
105
|
+
if (e.slug !== canonical) slugs.add(e.slug);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return Array.from(slugs);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function getAllPostSlugs(): Promise<string[]> {
|
|
112
|
+
const entries = await readAll();
|
|
113
|
+
const slugs = new Set<string>();
|
|
114
|
+
for (const e of entries) {
|
|
115
|
+
slugs.add(e.slug);
|
|
116
|
+
const canonical = (e.data.slug as string | undefined) || e.slug;
|
|
117
|
+
if (canonical !== e.slug) slugs.add(canonical);
|
|
118
|
+
}
|
|
119
|
+
return Array.from(slugs);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function getPostBySlug(slug: string): Promise<BlogPost | null> {
|
|
123
|
+
const entries = await readAll();
|
|
124
|
+
const match = entries.find((e) => {
|
|
125
|
+
const canonical = (e.data.slug as string | undefined) || e.slug;
|
|
126
|
+
return canonical === slug || e.slug === slug;
|
|
127
|
+
});
|
|
128
|
+
if (!match) return null;
|
|
129
|
+
return {
|
|
130
|
+
...toMetadata(match),
|
|
131
|
+
faqs: match.data.faqs as BlogPost['faqs'],
|
|
132
|
+
content: match.body,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function getAllTags(): Promise<string[]> {
|
|
137
|
+
const posts = await getAllPosts();
|
|
138
|
+
const tags = new Set<string>();
|
|
139
|
+
posts.forEach((p) => p.tags?.forEach((t) => tags.add(t)));
|
|
140
|
+
return Array.from(tags).sort();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function getPostsByTag(tag: string): Promise<BlogPostMetadata[]> {
|
|
144
|
+
const posts = await getAllPosts();
|
|
145
|
+
return posts.filter((p) => p.tags?.includes(tag));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getAllCategorySlugs(): string[] {
|
|
149
|
+
return categories.map((c) => c.slug);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getCategoryBySlug(slug: string): BlogCategory | undefined {
|
|
153
|
+
return categories.find((c) => c.slug === slug);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function getPostsByCategory(slug: string): Promise<BlogPostMetadata[]> {
|
|
157
|
+
const cat = getCategoryBySlug(slug);
|
|
158
|
+
if (!cat) return [];
|
|
159
|
+
const posts = await getAllPosts();
|
|
160
|
+
return posts.filter((p) => p.tags?.includes(cat.tag));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
getAllPosts,
|
|
165
|
+
getAllPostsIncludingFuture,
|
|
166
|
+
getFutureBlogSlugs,
|
|
167
|
+
getAllPostSlugs,
|
|
168
|
+
getPostBySlug,
|
|
169
|
+
getAllTags,
|
|
170
|
+
getPostsByTag,
|
|
171
|
+
getAllCategorySlugs,
|
|
172
|
+
getCategoryBySlug,
|
|
173
|
+
getPostsByCategory,
|
|
174
|
+
categories,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import readingTime from 'reading-time';
|
|
5
|
+
import type {
|
|
6
|
+
BlogAPI,
|
|
7
|
+
BlogCategory,
|
|
8
|
+
BlogPost,
|
|
9
|
+
BlogPostMetadata,
|
|
10
|
+
CommonBlogConfig,
|
|
11
|
+
} from './types.js';
|
|
12
|
+
|
|
13
|
+
export interface FsBlogConfig extends CommonBlogConfig {
|
|
14
|
+
/** Directory of .md files (relative to cwd). Default 'content/blog'. */
|
|
15
|
+
contentDir?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getTodayUTC(): Date {
|
|
19
|
+
const now = new Date();
|
|
20
|
+
return new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fileSlug(name: string): string {
|
|
24
|
+
return name.replace(/\.md$/, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function asISO(value: unknown): string {
|
|
28
|
+
if (value instanceof Date) return value.toISOString().split('T')[0];
|
|
29
|
+
if (typeof value === 'string') return value;
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Markdown + gray-matter blog loader. Reads .md files directly from disk.
|
|
35
|
+
* Use this for non-Astro projects, or Astro projects that don't want Content Collections.
|
|
36
|
+
*
|
|
37
|
+
* For Astro projects with Content Collections (recommended), use `createBlogFromCollection`.
|
|
38
|
+
*/
|
|
39
|
+
export function createBlog(config: FsBlogConfig = {}): BlogAPI {
|
|
40
|
+
const contentDir = path.resolve(process.cwd(), config.contentDir ?? 'content/blog');
|
|
41
|
+
const defaultAuthor = config.defaultAuthor;
|
|
42
|
+
const categories = config.categories ?? [];
|
|
43
|
+
const dateField = config.dateField ?? 'date';
|
|
44
|
+
const draftField = config.draftField ?? null;
|
|
45
|
+
|
|
46
|
+
function readAllFiles() {
|
|
47
|
+
if (!fs.existsSync(contentDir)) return [];
|
|
48
|
+
return fs
|
|
49
|
+
.readdirSync(contentDir)
|
|
50
|
+
.filter((n) => n.endsWith('.md'))
|
|
51
|
+
.map((fileName) => {
|
|
52
|
+
const raw = fs.readFileSync(path.join(contentDir, fileName), 'utf8');
|
|
53
|
+
const parsed = matter(raw);
|
|
54
|
+
const data = parsed.data as Record<string, unknown>;
|
|
55
|
+
const slug = (data.slug as string | undefined) || fileSlug(fileName);
|
|
56
|
+
return {
|
|
57
|
+
fileName,
|
|
58
|
+
fileSlug: fileSlug(fileName),
|
|
59
|
+
canonicalSlug: slug,
|
|
60
|
+
data,
|
|
61
|
+
content: parsed.content,
|
|
62
|
+
};
|
|
63
|
+
})
|
|
64
|
+
.filter((f) => (draftField ? !f.data[draftField] : true));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toMetadata(f: ReturnType<typeof readAllFiles>[number]): BlogPostMetadata {
|
|
68
|
+
return {
|
|
69
|
+
slug: f.canonicalSlug,
|
|
70
|
+
title: (f.data.title as string) ?? '',
|
|
71
|
+
description: (f.data.description as string) ?? '',
|
|
72
|
+
date: asISO(f.data[dateField]),
|
|
73
|
+
author: (f.data.author as string) ?? defaultAuthor,
|
|
74
|
+
image: f.data.image as string | undefined,
|
|
75
|
+
tags: f.data.tags as string[] | undefined,
|
|
76
|
+
readingTime: readingTime(f.content).text,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function getAllPostsIncludingFuture(): Promise<BlogPostMetadata[]> {
|
|
81
|
+
return readAllFiles()
|
|
82
|
+
.map(toMetadata)
|
|
83
|
+
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function getAllPosts(): Promise<BlogPostMetadata[]> {
|
|
87
|
+
const today = getTodayUTC();
|
|
88
|
+
const all = await getAllPostsIncludingFuture();
|
|
89
|
+
return all.filter((p) => new Date(p.date) <= today);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function getFutureBlogSlugs(): Promise<string[]> {
|
|
93
|
+
const today = getTodayUTC();
|
|
94
|
+
const slugs = new Set<string>();
|
|
95
|
+
for (const f of readAllFiles()) {
|
|
96
|
+
const date = asISO(f.data[dateField]);
|
|
97
|
+
if (!date) continue;
|
|
98
|
+
if (new Date(date) > today) {
|
|
99
|
+
slugs.add(f.canonicalSlug);
|
|
100
|
+
if (f.fileSlug !== f.canonicalSlug) slugs.add(f.fileSlug);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return Array.from(slugs);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function getAllPostSlugs(): Promise<string[]> {
|
|
107
|
+
const slugs = new Set<string>();
|
|
108
|
+
for (const f of readAllFiles()) {
|
|
109
|
+
slugs.add(f.fileSlug);
|
|
110
|
+
if (f.canonicalSlug !== f.fileSlug) slugs.add(f.canonicalSlug);
|
|
111
|
+
}
|
|
112
|
+
return Array.from(slugs);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function getPostBySlug(slug: string): Promise<BlogPost | null> {
|
|
116
|
+
const match = readAllFiles().find(
|
|
117
|
+
(f) => f.canonicalSlug === slug || f.fileSlug === slug
|
|
118
|
+
);
|
|
119
|
+
if (!match) return null;
|
|
120
|
+
return {
|
|
121
|
+
...toMetadata(match),
|
|
122
|
+
faqs: match.data.faqs as BlogPost['faqs'],
|
|
123
|
+
content: match.content,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function getAllTags(): Promise<string[]> {
|
|
128
|
+
const posts = await getAllPosts();
|
|
129
|
+
const tags = new Set<string>();
|
|
130
|
+
posts.forEach((p) => p.tags?.forEach((t) => tags.add(t)));
|
|
131
|
+
return Array.from(tags).sort();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function getPostsByTag(tag: string): Promise<BlogPostMetadata[]> {
|
|
135
|
+
const posts = await getAllPosts();
|
|
136
|
+
return posts.filter((p) => p.tags?.includes(tag));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getAllCategorySlugs(): string[] {
|
|
140
|
+
return categories.map((c) => c.slug);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getCategoryBySlug(slug: string): BlogCategory | undefined {
|
|
144
|
+
return categories.find((c) => c.slug === slug);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function getPostsByCategory(slug: string): Promise<BlogPostMetadata[]> {
|
|
148
|
+
const cat = getCategoryBySlug(slug);
|
|
149
|
+
if (!cat) return [];
|
|
150
|
+
const posts = await getAllPosts();
|
|
151
|
+
return posts.filter((p) => p.tags?.includes(cat.tag));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
getAllPosts,
|
|
156
|
+
getAllPostsIncludingFuture,
|
|
157
|
+
getFutureBlogSlugs,
|
|
158
|
+
getAllPostSlugs,
|
|
159
|
+
getPostBySlug,
|
|
160
|
+
getAllTags,
|
|
161
|
+
getPostsByTag,
|
|
162
|
+
getAllCategorySlugs,
|
|
163
|
+
getCategoryBySlug,
|
|
164
|
+
getPostsByCategory,
|
|
165
|
+
categories,
|
|
166
|
+
};
|
|
167
|
+
}
|