@ibalzam/codejitsu-core 0.2.1 → 0.3.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.
@@ -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.
@@ -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", "wire up the blog"), do the following.
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
- A markdown blog with two loader variants — pick whichever fits the project:
7
+ Two loader variants:
8
8
 
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.
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
- Both variants return the same `BlogAPI`:
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
- 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)
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(), // ← date field; configure dateField to match
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
- 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
- });
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 slugs = await blog.getAllPostSlugs(); // includes future-dated for OG scrapers
94
- return slugs.map((slug) => ({ params: { slug } }));
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 post = await blog.getPostBySlug(Astro.params.slug as string);
98
- if (!post) return Astro.redirect('/404');
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
- import { blog } from './src/lib/blog';
108
- import { excludeFuturePosts, defaultPriorityRules } from '@ibalzam/codejitsu-core/seo/sitemap';
132
+ // astro.config.mjs
133
+ import { createBlog } from '@ibalzam/codejitsu-core/blog';
134
+ import { excludeFuturePosts, defaultPriorityRules } from '@ibalzam/codejitsu-core/seo';
109
135
 
110
- const futureSlugs = await blog.getFutureBlogSlugs();
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 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.
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
- BlogPost,
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
- * 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.
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(config: CollectionBlogConfig = {}): BlogAPI {
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<AstroCollectionEntry[]> {
48
- let mod: { getCollection: (name: string) => Promise<AstroCollectionEntry[]> };
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}'. Add Astro to the project or use createBlog() (fs+gray-matter) instead. ` +
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<AstroCollectionEntry[]> {
83
+ async function readAll(): Promise<E[]> {
63
84
  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
- };
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 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());
93
+ async function getAllEntries(): Promise<E[]> {
94
+ return readAll();
87
95
  }
88
96
 
89
- async function getAllPosts(): Promise<BlogPostMetadata[]> {
97
+ async function getPublishedEntries(): Promise<E[]> {
90
98
  const today = getTodayUTC();
91
- const all = await getAllPostsIncludingFuture();
92
- return all.filter((p) => new Date(p.date) <= today);
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 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);
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 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);
118
+ const all = await readAll();
119
+ return all.map((e) => e.id);
120
120
  }
121
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
- };
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 posts = await getAllPosts();
128
+ const entries = await getPublishedEntries();
138
129
  const tags = new Set<string>();
139
- posts.forEach((p) => p.tags?.forEach((t) => tags.add(t)));
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 getPostsByTag(tag: string): Promise<BlogPostMetadata[]> {
144
- const posts = await getAllPosts();
145
- return posts.filter((p) => p.tags?.includes(tag));
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 getPostsByCategory(slug: string): Promise<BlogPostMetadata[]> {
153
+ async function getEntriesByCategory(slug: string): Promise<E[]> {
157
154
  const cat = getCategoryBySlug(slug);
158
155
  if (!cat) return [];
159
- const posts = await getAllPosts();
160
- return posts.filter((p) => p.tags?.includes(cat.tag));
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
- getAllPosts,
165
- getAllPostsIncludingFuture,
173
+ getPublishedEntries,
174
+ getAllEntries,
166
175
  getFutureBlogSlugs,
167
176
  getAllPostSlugs,
168
- getPostBySlug,
177
+ getEntryBySlug,
169
178
  getAllTags,
170
- getPostsByTag,
179
+ getEntriesByTag,
171
180
  getAllCategorySlugs,
172
181
  getCategoryBySlug,
173
- getPostsByCategory,
182
+ getEntriesByCategory,
183
+ toMetadata,
174
184
  categories,
175
185
  };
176
186
  }
@@ -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
- BlogAPI,
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 = {}): BlogAPI {
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 (fs mode) or rendered HTML (CC mode). See per-function notes. */
34
+ /** Raw markdown body. */
35
35
  content: string;
36
36
  }
37
37
 
@@ -43,14 +43,34 @@ export interface BlogCategory {
43
43
  metaDescription: string;
44
44
  }
45
45
 
46
- export interface BlogAPI {
47
- /** Published posts only (date <= today, not draft). Sorted newest first. */
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
+ body: string;
67
+ }
68
+
69
+ /** API for the fs (gray-matter) blog loader — normalized objects. */
70
+ export interface BlogFsAPI {
48
71
  getAllPosts(): Promise<BlogPostMetadata[]>;
49
- /** All non-draft posts including future-dated ones. Sorted newest first. */
50
72
  getAllPostsIncludingFuture(): Promise<BlogPostMetadata[]>;
51
- /** Slugs of non-draft posts with a future date. */
52
73
  getFutureBlogSlugs(): Promise<string[]>;
53
- /** Every slug needed for static path generation (includes future-dated, excludes drafts). */
54
74
  getAllPostSlugs(): Promise<string[]>;
55
75
  getPostBySlug(slug: string): Promise<BlogPost | null>;
56
76
  getAllTags(): Promise<string[]>;
@@ -61,11 +81,27 @@ export interface BlogAPI {
61
81
  categories: BlogCategory[];
62
82
  }
63
83
 
64
- export interface CommonBlogConfig {
65
- defaultAuthor?: string;
66
- categories?: BlogCategory[];
67
- /** Frontmatter field name for the date. Default 'date'. */
68
- dateField?: string;
69
- /** Frontmatter field name for the draft flag. Default null (no draft support). */
70
- draftField?: string | null;
84
+ /**
85
+ * API for the Astro Content Collections blog loader — raw entries.
86
+ * Preserves full access to entry.data, entry.id, and Astro's `render()`.
87
+ * Filtering (draft + future-date) is applied; sorting is newest-first by `dateField`.
88
+ */
89
+ export interface BlogCollectionAPI<E extends BlogCollectionEntry = BlogCollectionEntry> {
90
+ /** Published entries: not draft, date <= today. Sorted newest first. */
91
+ getPublishedEntries(): Promise<E[]>;
92
+ /** All non-draft entries (includes future-dated). Sorted newest first. */
93
+ getAllEntries(): Promise<E[]>;
94
+ /** Slugs of non-draft entries with a future date. */
95
+ getFutureBlogSlugs(): Promise<string[]>;
96
+ /** Every slug needed for static path generation (non-draft, includes future). */
97
+ getAllPostSlugs(): Promise<string[]>;
98
+ getEntryBySlug(slug: string): Promise<E | null>;
99
+ getAllTags(): Promise<string[]>;
100
+ getEntriesByTag(tag: string): Promise<E[]>;
101
+ getAllCategorySlugs(): string[];
102
+ getCategoryBySlug(slug: string): BlogCategory | undefined;
103
+ getEntriesByCategory(slug: string): Promise<E[]>;
104
+ /** Convert a CollectionEntry into a normalized BlogPostMetadata. */
105
+ toMetadata(entry: E): BlogPostMetadata;
106
+ categories: BlogCategory[];
71
107
  }
@@ -1,6 +1,4 @@
1
- // Astro Content Collections variant recommended for Astro sites.
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 from your Astro CC schema (`src/content.config.ts`).
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.2.1",
3
+ "version": "0.3.0",
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": [