@ibalzam/codejitsu-core 0.2.1 → 0.3.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/MIGRATIONS/0.3.0.md +66 -0
- package/modules/blog/CLAUDE.md +71 -55
- package/modules/blog/src/collection.ts +97 -87
- package/modules/blog/src/fs.ts +2 -2
- package/modules/blog/src/types.ts +51 -14
- package/modules/blog/templates/lib/blog.ts +8 -6
- package/package.json +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# 0.3.0 — Blog CC API returns entries
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
|
|
5
|
+
`createBlogFromCollection` now returns raw `CollectionEntry[]` (with filtering applied) instead of normalized `BlogPostMetadata[]` objects. This preserves access to `entry.data`, `entry.id`, and the ability to call `render(entry)` for `<Content />` — matching what Astro CC consumers actually need.
|
|
6
|
+
|
|
7
|
+
Breaking change to the CC variant only. The fs variant (`createBlog`) is unchanged.
|
|
8
|
+
|
|
9
|
+
## Required actions
|
|
10
|
+
|
|
11
|
+
### If you weren't using `createBlogFromCollection` yet
|
|
12
|
+
|
|
13
|
+
No action needed. The fs variant (`createBlog`) is unchanged.
|
|
14
|
+
|
|
15
|
+
### If you were using `createBlogFromCollection`
|
|
16
|
+
|
|
17
|
+
The method names changed and the return shape is now `CollectionEntry[]` instead of `BlogPostMetadata[]`. Map your calls:
|
|
18
|
+
|
|
19
|
+
| v0.2.x | v0.3.0 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `getAllPosts()` | `getPublishedEntries()` |
|
|
22
|
+
| `getAllPostsIncludingFuture()` | `getAllEntries()` |
|
|
23
|
+
| `getPostBySlug(slug)` | `getEntryBySlug(slug)` |
|
|
24
|
+
| `getPostsByTag(tag)` | `getEntriesByTag(tag)` |
|
|
25
|
+
| `getPostsByCategory(slug)` | `getEntriesByCategory(slug)` |
|
|
26
|
+
| `getFutureBlogSlugs()` | (unchanged) |
|
|
27
|
+
| `getAllPostSlugs()` | (unchanged) |
|
|
28
|
+
| `getAllTags()` | (unchanged) |
|
|
29
|
+
|
|
30
|
+
The returned entries now have raw shape: `entry.id`, `entry.data.<field>`, `entry.body`. Use `astro:content`'s `render(entry)` to get `<Content />` for markdown rendering.
|
|
31
|
+
|
|
32
|
+
If you want the old normalized shape for some specific use (e.g. listing cards that don't need `render()`), pass entries through `blog.toMetadata(entry)`:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
const entries = await blog.getPublishedEntries();
|
|
36
|
+
const cards = entries.map((e) => blog.toMetadata(e));
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Type-safe entries
|
|
40
|
+
|
|
41
|
+
Pass your `CollectionEntry<'name'>` as a generic for full typing:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import type { CollectionEntry } from 'astro:content';
|
|
45
|
+
import { createBlogFromCollection } from '@ibalzam/codejitsu-core/blog';
|
|
46
|
+
|
|
47
|
+
export const blog = createBlogFromCollection<CollectionEntry<'blog'>>({
|
|
48
|
+
collectionName: 'blog',
|
|
49
|
+
dateField: 'pubDate',
|
|
50
|
+
draftField: 'draft',
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Why
|
|
55
|
+
|
|
56
|
+
v0.2.x's normalized API was modeled on the workzen (Next.js) blog and didn't fit Astro's CC pattern. Astro pages need `entry.data.faqs`, `entry.data.updatedDate`, `await render(entry)` — none of which the normalized objects exposed. Sites kept their homegrown CC loaders because the package's CC variant was unusable. v0.3.0 fixes that.
|
|
57
|
+
|
|
58
|
+
## Verify
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm update @ibalzam/codejitsu-core
|
|
62
|
+
npm run build
|
|
63
|
+
npx codejitsu-check
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
If pages reference `post.slug` / `post.title` (normalized fields), they need to be updated to `entry.id` / `entry.data.title`. The TypeScript compiler will flag these.
|
package/modules/blog/CLAUDE.md
CHANGED
|
@@ -1,32 +1,53 @@
|
|
|
1
1
|
# Blog module — instructions for Claude
|
|
2
2
|
|
|
3
|
-
When the user asks to **implement codejitsu/core/blog** (or "add the blog system"
|
|
3
|
+
When the user asks to **implement codejitsu/core/blog** (or "add the blog system"), do the following.
|
|
4
4
|
|
|
5
5
|
## What this module provides
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Two loader variants:
|
|
8
8
|
|
|
9
|
-
- **`createBlogFromCollection`** —
|
|
10
|
-
- **`createBlog`** —
|
|
9
|
+
- **`createBlogFromCollection`** — wraps Astro Content Collections. **Use this for any Astro site.** Returns raw `CollectionEntry` objects with filtering applied, preserving `entry.data`, `entry.id`, and the ability to call `render(entry)` for `<Content />`.
|
|
10
|
+
- **`createBlog`** — fs + gray-matter loader. Use for non-Astro projects. Returns normalized `BlogPostMetadata` / `BlogPost` objects.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
The two return **different shapes** because they serve different needs:
|
|
13
|
+
|
|
14
|
+
| | CC (`createBlogFromCollection`) | fs (`createBlog`) |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| Returns | `CollectionEntry[]` | `BlogPostMetadata[]` |
|
|
17
|
+
| Access | `entry.data.title`, `entry.id`, `await render(entry)` | `post.title`, `post.slug`, `post.content` (raw md) |
|
|
18
|
+
| Validation | Astro CC schema (Zod) | Frontmatter parsed by gray-matter, no validation |
|
|
19
|
+
| HMR | Yes (Astro) | No |
|
|
20
|
+
| Filter applied | draft + future-date | draft + future-date |
|
|
21
|
+
| Sort | newest first | newest first |
|
|
22
|
+
| Best for | Astro sites (most cases) | Non-Astro JS projects |
|
|
23
|
+
|
|
24
|
+
## CC variant API
|
|
13
25
|
|
|
14
26
|
```ts
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
const blog = createBlogFromCollection({
|
|
28
|
+
collectionName: 'blog',
|
|
29
|
+
dateField: 'pubDate', // matches your CC schema field
|
|
30
|
+
draftField: 'draft',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await blog.getPublishedEntries(); // CollectionEntry[] — not draft, date <= today
|
|
34
|
+
await blog.getAllEntries(); // CollectionEntry[] — not draft (includes future)
|
|
35
|
+
await blog.getEntryBySlug(slug); // CollectionEntry | null
|
|
36
|
+
await blog.getFutureBlogSlugs(); // string[] — for sitemap exclusion
|
|
37
|
+
await blog.getAllPostSlugs(); // string[] — for getStaticPaths
|
|
38
|
+
await blog.getEntriesByTag(tag); // CollectionEntry[]
|
|
39
|
+
await blog.getAllTags(); // string[]
|
|
40
|
+
await blog.getEntriesByCategory(s); // CollectionEntry[]
|
|
41
|
+
blog.toMetadata(entry); // BlogPostMetadata — normalized derivation
|
|
22
42
|
```
|
|
23
43
|
|
|
24
44
|
## Wiring into an Astro site
|
|
25
45
|
|
|
26
46
|
### 1. Set up the Content Collection
|
|
27
47
|
|
|
48
|
+
`src/content.config.ts`:
|
|
49
|
+
|
|
28
50
|
```ts
|
|
29
|
-
// src/content.config.ts
|
|
30
51
|
import { defineCollection, z } from 'astro:content';
|
|
31
52
|
import { glob } from 'astro/loaders';
|
|
32
53
|
|
|
@@ -35,7 +56,7 @@ const blog = defineCollection({
|
|
|
35
56
|
schema: z.object({
|
|
36
57
|
title: z.string(),
|
|
37
58
|
description: z.string(),
|
|
38
|
-
pubDate: z.coerce.date(),
|
|
59
|
+
pubDate: z.coerce.date(),
|
|
39
60
|
updatedDate: z.coerce.date().optional(),
|
|
40
61
|
author: z.string().default('editor'),
|
|
41
62
|
image: z.string().optional(),
|
|
@@ -54,32 +75,30 @@ export const collections = { blog };
|
|
|
54
75
|
### 2. Configure in `codejitsu.config.ts`
|
|
55
76
|
|
|
56
77
|
```ts
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
collectionName: 'blog',
|
|
64
|
-
dateField: 'pubDate', // matches the CC schema field
|
|
65
|
-
draftField: 'draft',
|
|
66
|
-
// categories: [...] // optional
|
|
67
|
-
},
|
|
68
|
-
});
|
|
78
|
+
blog: {
|
|
79
|
+
mode: 'collection',
|
|
80
|
+
collectionName: 'blog',
|
|
81
|
+
dateField: 'pubDate',
|
|
82
|
+
draftField: 'draft',
|
|
83
|
+
},
|
|
69
84
|
```
|
|
70
85
|
|
|
71
|
-
### 3. Create the loader
|
|
86
|
+
### 3. Create the loader instance
|
|
72
87
|
|
|
73
88
|
```ts
|
|
74
89
|
// src/lib/blog.ts
|
|
90
|
+
import type { CollectionEntry } from 'astro:content';
|
|
75
91
|
import { createBlogFromCollection } from '@ibalzam/codejitsu-core/blog';
|
|
76
92
|
|
|
77
|
-
export const blog = createBlogFromCollection({
|
|
93
|
+
export const blog = createBlogFromCollection<CollectionEntry<'blog'>>({
|
|
78
94
|
collectionName: 'blog',
|
|
79
95
|
dateField: 'pubDate',
|
|
80
96
|
draftField: 'draft',
|
|
81
|
-
defaultAuthor: 'editor',
|
|
82
97
|
});
|
|
98
|
+
|
|
99
|
+
// Backward-compat exports for sites migrating from a homegrown loader:
|
|
100
|
+
export const getPublishedPosts = () => blog.getPublishedEntries();
|
|
101
|
+
export const getAllPosts = () => blog.getAllEntries();
|
|
83
102
|
```
|
|
84
103
|
|
|
85
104
|
### 4. Use in pages
|
|
@@ -87,27 +106,36 @@ export const blog = createBlogFromCollection({
|
|
|
87
106
|
```astro
|
|
88
107
|
---
|
|
89
108
|
// src/pages/blog/[slug].astro
|
|
109
|
+
import { render } from 'astro:content';
|
|
90
110
|
import { blog } from '~/lib/blog';
|
|
91
111
|
|
|
92
112
|
export async function getStaticPaths() {
|
|
93
|
-
const
|
|
94
|
-
return
|
|
113
|
+
const entries = await blog.getAllEntries(); // includes future-dated for OG scrapers
|
|
114
|
+
return entries.map((entry) => ({
|
|
115
|
+
params: { slug: entry.id },
|
|
116
|
+
props: { entry },
|
|
117
|
+
}));
|
|
95
118
|
}
|
|
96
119
|
|
|
97
|
-
const
|
|
98
|
-
|
|
120
|
+
const { entry } = Astro.props;
|
|
121
|
+
const { Content } = await render(entry);
|
|
99
122
|
---
|
|
123
|
+
<article>
|
|
124
|
+
<h1>{entry.data.title}</h1>
|
|
125
|
+
<Content />
|
|
126
|
+
</article>
|
|
100
127
|
```
|
|
101
128
|
|
|
102
129
|
### 5. Wire scheduled-post filter into the sitemap
|
|
103
130
|
|
|
104
|
-
In `astro.config.mjs`, get future slugs from the blog instance and pass to the sitemap's `excludeFuturePosts` filter:
|
|
105
|
-
|
|
106
131
|
```ts
|
|
107
|
-
|
|
108
|
-
import {
|
|
132
|
+
// astro.config.mjs
|
|
133
|
+
import { createBlog } from '@ibalzam/codejitsu-core/blog';
|
|
134
|
+
import { excludeFuturePosts, defaultPriorityRules } from '@ibalzam/codejitsu-core/seo';
|
|
109
135
|
|
|
110
|
-
|
|
136
|
+
// Use the fs loader here — astro.config runs before Astro's CC is initialized.
|
|
137
|
+
const fsBlog = createBlog({ contentDir: 'src/content/blog', dateField: 'pubDate', draftField: 'draft' });
|
|
138
|
+
const futureSlugs = await fsBlog.getFutureBlogSlugs();
|
|
111
139
|
|
|
112
140
|
sitemap({
|
|
113
141
|
filter: excludeFuturePosts(futureSlugs),
|
|
@@ -115,25 +143,13 @@ sitemap({
|
|
|
115
143
|
});
|
|
116
144
|
```
|
|
117
145
|
|
|
118
|
-
## Frontmatter shape
|
|
119
|
-
|
|
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`.
|
|
123
|
-
|
|
124
|
-
Field names are flexible — set `dateField` and `draftField` in your CC schema and they'll flow through.
|
|
125
|
-
|
|
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.
|
|
129
|
-
|
|
130
146
|
## What must NOT be done
|
|
131
147
|
|
|
132
|
-
- **Don't use `createBlog` (fs
|
|
133
|
-
- **Don't
|
|
134
|
-
- **Don't use `
|
|
135
|
-
- **Don't
|
|
136
|
-
- **Don't
|
|
148
|
+
- **Don't use `createBlog` (fs) inside Astro pages.** You lose Astro's `render()` (needed for `<Content />`) and CC schema validation. Always prefer `createBlogFromCollection` in pages.
|
|
149
|
+
- **Don't access raw `getCollection('blog')` directly.** Go through the blog instance from `src/lib/blog.ts` so filtering/sorting/date logic stays in one place.
|
|
150
|
+
- **Don't use `getPublishedEntries()` for `getStaticPaths`** — use `getAllEntries()` so future-dated posts stay buildable (OG scrapers need to reach them before publish day).
|
|
151
|
+
- **Don't change `dateField` mid-project** without renaming the frontmatter field in every existing post AND updating the CC schema field name to match.
|
|
152
|
+
- **Don't rely on the CC variant's filtering for security.** A draft post's URL is still discoverable if anyone shares it. Use middleware/headers for true access control.
|
|
137
153
|
|
|
138
154
|
## Verify
|
|
139
155
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import readingTime from 'reading-time';
|
|
2
2
|
import type {
|
|
3
|
-
BlogAPI,
|
|
4
3
|
BlogCategory,
|
|
5
|
-
|
|
4
|
+
BlogCollectionAPI,
|
|
5
|
+
BlogCollectionEntry,
|
|
6
6
|
BlogPostMetadata,
|
|
7
7
|
CommonBlogConfig,
|
|
8
8
|
} from './types.js';
|
|
@@ -12,14 +12,6 @@ export interface CollectionBlogConfig extends CommonBlogConfig {
|
|
|
12
12
|
collectionName?: string;
|
|
13
13
|
}
|
|
14
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
15
|
function getTodayUTC(): Date {
|
|
24
16
|
const now = new Date();
|
|
25
17
|
return new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
|
@@ -31,118 +23,123 @@ function asISO(value: unknown): string {
|
|
|
31
23
|
return '';
|
|
32
24
|
}
|
|
33
25
|
|
|
26
|
+
function asDate(value: unknown): Date | null {
|
|
27
|
+
if (value instanceof Date) return value;
|
|
28
|
+
if (typeof value === 'string') {
|
|
29
|
+
const d = new Date(value);
|
|
30
|
+
return Number.isNaN(d.valueOf()) ? null : d;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
34
35
|
/**
|
|
35
36
|
* Astro Content Collections blog loader. Use this in Astro projects.
|
|
36
37
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
38
|
+
* Returns raw CollectionEntry objects (preserving `entry.data`, `entry.id`,
|
|
39
|
+
* and the ability to call `render(entry)` from astro:content). Filtering and
|
|
40
|
+
* sorting are applied:
|
|
41
|
+
* - Drafts excluded (when `draftField` is set)
|
|
42
|
+
* - Sorted newest first by `dateField`
|
|
43
|
+
* - `getPublishedEntries()` further excludes future-dated entries
|
|
44
|
+
*
|
|
45
|
+
* The collection's actual entry type is `CollectionEntry<'<name>'>` from
|
|
46
|
+
* `astro:content`. Pass it as the generic to get full typed `data`:
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* import type { CollectionEntry } from 'astro:content';
|
|
50
|
+
* export const blog = createBlogFromCollection<CollectionEntry<'blog'>>({
|
|
51
|
+
* collectionName: 'blog',
|
|
52
|
+
* dateField: 'pubDate',
|
|
53
|
+
* draftField: 'draft',
|
|
54
|
+
* });
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* Dynamically imports `astro:content` at call time so the package stays
|
|
58
|
+
* usable in non-Astro projects.
|
|
39
59
|
*/
|
|
40
|
-
export function createBlogFromCollection
|
|
60
|
+
export function createBlogFromCollection<E extends BlogCollectionEntry = BlogCollectionEntry>(
|
|
61
|
+
config: CollectionBlogConfig = {}
|
|
62
|
+
): BlogCollectionAPI<E> {
|
|
41
63
|
const collectionName = config.collectionName ?? 'blog';
|
|
42
64
|
const defaultAuthor = config.defaultAuthor;
|
|
43
65
|
const categories = config.categories ?? [];
|
|
44
66
|
const dateField = config.dateField ?? 'date';
|
|
45
67
|
const draftField = config.draftField ?? null;
|
|
46
68
|
|
|
47
|
-
async function getCollection(): Promise<
|
|
48
|
-
let mod: { getCollection: (name: string) => Promise<
|
|
69
|
+
async function getCollection(): Promise<E[]> {
|
|
70
|
+
let mod: { getCollection: (name: string) => Promise<E[]> };
|
|
49
71
|
try {
|
|
50
72
|
// @ts-expect-error - 'astro:content' is a virtual module resolved by Astro at build time.
|
|
51
73
|
mod = await import('astro:content');
|
|
52
74
|
} catch (err) {
|
|
53
75
|
throw new Error(
|
|
54
76
|
`createBlogFromCollection() requires Astro and a configured content collection ` +
|
|
55
|
-
`named '${collectionName}'.
|
|
56
|
-
`Original error: ${err instanceof Error ? err.message : String(err)}`
|
|
77
|
+
`named '${collectionName}'. Original error: ${err instanceof Error ? err.message : String(err)}`
|
|
57
78
|
);
|
|
58
79
|
}
|
|
59
80
|
return mod.getCollection(collectionName);
|
|
60
81
|
}
|
|
61
82
|
|
|
62
|
-
async function readAll(): Promise<
|
|
83
|
+
async function readAll(): Promise<E[]> {
|
|
63
84
|
const all = await getCollection();
|
|
64
|
-
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
};
|
|
85
|
+
const filtered = draftField ? all.filter((e) => !e.data[draftField]) : all;
|
|
86
|
+
return filtered.sort((a, b) => {
|
|
87
|
+
const da = asDate(a.data[dateField])?.valueOf() ?? 0;
|
|
88
|
+
const db = asDate(b.data[dateField])?.valueOf() ?? 0;
|
|
89
|
+
return db - da;
|
|
90
|
+
});
|
|
80
91
|
}
|
|
81
92
|
|
|
82
|
-
async function
|
|
83
|
-
|
|
84
|
-
return entries
|
|
85
|
-
.map(toMetadata)
|
|
86
|
-
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
93
|
+
async function getAllEntries(): Promise<E[]> {
|
|
94
|
+
return readAll();
|
|
87
95
|
}
|
|
88
96
|
|
|
89
|
-
async function
|
|
97
|
+
async function getPublishedEntries(): Promise<E[]> {
|
|
90
98
|
const today = getTodayUTC();
|
|
91
|
-
const all = await
|
|
92
|
-
return all.filter((
|
|
99
|
+
const all = await readAll();
|
|
100
|
+
return all.filter((e) => {
|
|
101
|
+
const d = asDate(e.data[dateField]);
|
|
102
|
+
return d ? d <= today : true;
|
|
103
|
+
});
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
async function getFutureBlogSlugs(): Promise<string[]> {
|
|
96
107
|
const today = getTodayUTC();
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
slugs.add(canonical);
|
|
105
|
-
if (e.slug !== canonical) slugs.add(e.slug);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return Array.from(slugs);
|
|
108
|
+
const all = await readAll();
|
|
109
|
+
return all
|
|
110
|
+
.filter((e) => {
|
|
111
|
+
const d = asDate(e.data[dateField]);
|
|
112
|
+
return d ? d > today : false;
|
|
113
|
+
})
|
|
114
|
+
.map((e) => e.id);
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
async function getAllPostSlugs(): Promise<string[]> {
|
|
112
|
-
const
|
|
113
|
-
|
|
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);
|
|
118
|
+
const all = await readAll();
|
|
119
|
+
return all.map((e) => e.id);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
async function
|
|
123
|
-
const
|
|
124
|
-
|
|
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
|
-
};
|
|
122
|
+
async function getEntryBySlug(slug: string): Promise<E | null> {
|
|
123
|
+
const all = await readAll();
|
|
124
|
+
return all.find((e) => e.id === slug) ?? null;
|
|
134
125
|
}
|
|
135
126
|
|
|
136
127
|
async function getAllTags(): Promise<string[]> {
|
|
137
|
-
const
|
|
128
|
+
const entries = await getPublishedEntries();
|
|
138
129
|
const tags = new Set<string>();
|
|
139
|
-
|
|
130
|
+
entries.forEach((e) => {
|
|
131
|
+
const t = e.data.tags as string[] | undefined;
|
|
132
|
+
t?.forEach((tag) => tags.add(tag));
|
|
133
|
+
});
|
|
140
134
|
return Array.from(tags).sort();
|
|
141
135
|
}
|
|
142
136
|
|
|
143
|
-
async function
|
|
144
|
-
const
|
|
145
|
-
return
|
|
137
|
+
async function getEntriesByTag(tag: string): Promise<E[]> {
|
|
138
|
+
const entries = await getPublishedEntries();
|
|
139
|
+
return entries.filter((e) => {
|
|
140
|
+
const t = e.data.tags as string[] | undefined;
|
|
141
|
+
return t?.includes(tag);
|
|
142
|
+
});
|
|
146
143
|
}
|
|
147
144
|
|
|
148
145
|
function getAllCategorySlugs(): string[] {
|
|
@@ -153,24 +150,37 @@ export function createBlogFromCollection(config: CollectionBlogConfig = {}): Blo
|
|
|
153
150
|
return categories.find((c) => c.slug === slug);
|
|
154
151
|
}
|
|
155
152
|
|
|
156
|
-
async function
|
|
153
|
+
async function getEntriesByCategory(slug: string): Promise<E[]> {
|
|
157
154
|
const cat = getCategoryBySlug(slug);
|
|
158
155
|
if (!cat) return [];
|
|
159
|
-
|
|
160
|
-
|
|
156
|
+
return getEntriesByTag(cat.tag);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toMetadata(entry: E): BlogPostMetadata {
|
|
160
|
+
return {
|
|
161
|
+
slug: entry.id,
|
|
162
|
+
title: (entry.data.title as string) ?? '',
|
|
163
|
+
description: (entry.data.description as string) ?? '',
|
|
164
|
+
date: asISO(entry.data[dateField]),
|
|
165
|
+
author: (entry.data.author as string) ?? defaultAuthor,
|
|
166
|
+
image: entry.data.image as string | undefined,
|
|
167
|
+
tags: entry.data.tags as string[] | undefined,
|
|
168
|
+
readingTime: readingTime(entry.body ?? '').text,
|
|
169
|
+
};
|
|
161
170
|
}
|
|
162
171
|
|
|
163
172
|
return {
|
|
164
|
-
|
|
165
|
-
|
|
173
|
+
getPublishedEntries,
|
|
174
|
+
getAllEntries,
|
|
166
175
|
getFutureBlogSlugs,
|
|
167
176
|
getAllPostSlugs,
|
|
168
|
-
|
|
177
|
+
getEntryBySlug,
|
|
169
178
|
getAllTags,
|
|
170
|
-
|
|
179
|
+
getEntriesByTag,
|
|
171
180
|
getAllCategorySlugs,
|
|
172
181
|
getCategoryBySlug,
|
|
173
|
-
|
|
182
|
+
getEntriesByCategory,
|
|
183
|
+
toMetadata,
|
|
174
184
|
categories,
|
|
175
185
|
};
|
|
176
186
|
}
|
package/modules/blog/src/fs.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
import readingTime from 'reading-time';
|
|
5
5
|
import type {
|
|
6
|
-
|
|
6
|
+
BlogFsAPI,
|
|
7
7
|
BlogCategory,
|
|
8
8
|
BlogPost,
|
|
9
9
|
BlogPostMetadata,
|
|
@@ -36,7 +36,7 @@ function asISO(value: unknown): string {
|
|
|
36
36
|
*
|
|
37
37
|
* For Astro projects with Content Collections (recommended), use `createBlogFromCollection`.
|
|
38
38
|
*/
|
|
39
|
-
export function createBlog(config: FsBlogConfig = {}):
|
|
39
|
+
export function createBlog(config: FsBlogConfig = {}): BlogFsAPI {
|
|
40
40
|
const contentDir = path.resolve(process.cwd(), config.contentDir ?? 'content/blog');
|
|
41
41
|
const defaultAuthor = config.defaultAuthor;
|
|
42
42
|
const categories = config.categories ?? [];
|
|
@@ -13,10 +13,10 @@ export interface BlogPostFrontmatter {
|
|
|
13
13
|
tags?: string[];
|
|
14
14
|
faqs?: FAQItem[];
|
|
15
15
|
draft?: boolean;
|
|
16
|
-
/** Additional fields. */
|
|
17
16
|
[key: string]: unknown;
|
|
18
17
|
}
|
|
19
18
|
|
|
19
|
+
/** Normalized blog post (fs variant only). */
|
|
20
20
|
export interface BlogPostMetadata {
|
|
21
21
|
slug: string;
|
|
22
22
|
title: string;
|
|
@@ -31,7 +31,7 @@ export interface BlogPostMetadata {
|
|
|
31
31
|
|
|
32
32
|
export interface BlogPost extends BlogPostMetadata {
|
|
33
33
|
faqs?: FAQItem[];
|
|
34
|
-
/** Raw markdown body
|
|
34
|
+
/** Raw markdown body. */
|
|
35
35
|
content: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -43,14 +43,35 @@ export interface BlogCategory {
|
|
|
43
43
|
metaDescription: string;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export interface
|
|
47
|
-
|
|
46
|
+
export interface CommonBlogConfig {
|
|
47
|
+
defaultAuthor?: string;
|
|
48
|
+
categories?: BlogCategory[];
|
|
49
|
+
/** Frontmatter field name for the date. Default 'date'. */
|
|
50
|
+
dateField?: string;
|
|
51
|
+
/** Frontmatter field name for the draft flag. Default null (no draft support). */
|
|
52
|
+
draftField?: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Minimal shape of an Astro CollectionEntry that the package depends on.
|
|
57
|
+
* In modern Astro (5+ with the glob loader), `id` is the slug (filename minus
|
|
58
|
+
* extension). `data` is the parsed frontmatter. `body` is the raw markdown.
|
|
59
|
+
*
|
|
60
|
+
* Sites can cast this to `CollectionEntry<'blog'>` from `astro:content` at use
|
|
61
|
+
* site to get full type information from their CC schema.
|
|
62
|
+
*/
|
|
63
|
+
export interface BlogCollectionEntry {
|
|
64
|
+
id: string;
|
|
65
|
+
data: Record<string, unknown>;
|
|
66
|
+
/** Optional — Astro's CollectionEntry types `body` as `string | undefined`. */
|
|
67
|
+
body?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** API for the fs (gray-matter) blog loader — normalized objects. */
|
|
71
|
+
export interface BlogFsAPI {
|
|
48
72
|
getAllPosts(): Promise<BlogPostMetadata[]>;
|
|
49
|
-
/** All non-draft posts including future-dated ones. Sorted newest first. */
|
|
50
73
|
getAllPostsIncludingFuture(): Promise<BlogPostMetadata[]>;
|
|
51
|
-
/** Slugs of non-draft posts with a future date. */
|
|
52
74
|
getFutureBlogSlugs(): Promise<string[]>;
|
|
53
|
-
/** Every slug needed for static path generation (includes future-dated, excludes drafts). */
|
|
54
75
|
getAllPostSlugs(): Promise<string[]>;
|
|
55
76
|
getPostBySlug(slug: string): Promise<BlogPost | null>;
|
|
56
77
|
getAllTags(): Promise<string[]>;
|
|
@@ -61,11 +82,27 @@ export interface BlogAPI {
|
|
|
61
82
|
categories: BlogCategory[];
|
|
62
83
|
}
|
|
63
84
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
85
|
+
/**
|
|
86
|
+
* API for the Astro Content Collections blog loader — raw entries.
|
|
87
|
+
* Preserves full access to entry.data, entry.id, and Astro's `render()`.
|
|
88
|
+
* Filtering (draft + future-date) is applied; sorting is newest-first by `dateField`.
|
|
89
|
+
*/
|
|
90
|
+
export interface BlogCollectionAPI<E extends BlogCollectionEntry = BlogCollectionEntry> {
|
|
91
|
+
/** Published entries: not draft, date <= today. Sorted newest first. */
|
|
92
|
+
getPublishedEntries(): Promise<E[]>;
|
|
93
|
+
/** All non-draft entries (includes future-dated). Sorted newest first. */
|
|
94
|
+
getAllEntries(): Promise<E[]>;
|
|
95
|
+
/** Slugs of non-draft entries with a future date. */
|
|
96
|
+
getFutureBlogSlugs(): Promise<string[]>;
|
|
97
|
+
/** Every slug needed for static path generation (non-draft, includes future). */
|
|
98
|
+
getAllPostSlugs(): Promise<string[]>;
|
|
99
|
+
getEntryBySlug(slug: string): Promise<E | null>;
|
|
100
|
+
getAllTags(): Promise<string[]>;
|
|
101
|
+
getEntriesByTag(tag: string): Promise<E[]>;
|
|
102
|
+
getAllCategorySlugs(): string[];
|
|
103
|
+
getCategoryBySlug(slug: string): BlogCategory | undefined;
|
|
104
|
+
getEntriesByCategory(slug: string): Promise<E[]>;
|
|
105
|
+
/** Convert a CollectionEntry into a normalized BlogPostMetadata. */
|
|
106
|
+
toMetadata(entry: E): BlogPostMetadata;
|
|
107
|
+
categories: BlogCategory[];
|
|
71
108
|
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
// For non-Astro projects, see `blog-fs.ts` (uses gray-matter directly).
|
|
3
|
-
|
|
1
|
+
import type { CollectionEntry } from 'astro:content';
|
|
4
2
|
import { createBlogFromCollection, type BlogCategory } from '@ibalzam/codejitsu-core/blog';
|
|
5
3
|
|
|
6
4
|
const categories: BlogCategory[] = [
|
|
@@ -13,13 +11,17 @@ const categories: BlogCategory[] = [
|
|
|
13
11
|
// },
|
|
14
12
|
];
|
|
15
13
|
|
|
16
|
-
export const blog = createBlogFromCollection({
|
|
14
|
+
export const blog = createBlogFromCollection<CollectionEntry<'blog'>>({
|
|
17
15
|
collectionName: 'blog',
|
|
18
|
-
// Match the field name
|
|
19
|
-
// Common choices: 'date' (default) or 'pubDate'.
|
|
16
|
+
// Match the field name in your Astro CC schema (`src/content.config.ts`).
|
|
20
17
|
dateField: 'pubDate',
|
|
21
18
|
// Set to null if your schema has no `draft` field.
|
|
22
19
|
draftField: 'draft',
|
|
23
20
|
defaultAuthor: 'TODO: Site Author',
|
|
24
21
|
categories,
|
|
25
22
|
});
|
|
23
|
+
|
|
24
|
+
// Optional backward-compat exports for sites migrating from a homegrown loader.
|
|
25
|
+
// Delete these if you don't need them.
|
|
26
|
+
export const getPublishedPosts = () => blog.getPublishedEntries();
|
|
27
|
+
export const getAllPosts = () => blog.getAllEntries();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ibalzam/codejitsu-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared core for Codejitsu Astro sites — reusable code and Claude-facing instructions for blog, SEO, images, deploy, and llms.txt.",
|
|
6
6
|
"keywords": [
|