@ibalzam/codejitsu-core 0.2.0 → 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.
- package/MIGRATIONS/0.3.0.md +66 -0
- package/checklist/bin/run.mjs +4 -2
- 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 +50 -14
- package/modules/blog/templates/lib/blog.ts +8 -6
- package/modules/config/src/define.mjs +14 -0
- package/modules/config/src/index.ts +5 -3
- package/modules/config/src/{load.ts → load.mjs} +19 -23
- package/modules/images/bin/optimize.mjs +1 -1
- package/modules/llms/bin/generate.mjs +1 -1
- package/package.json +1 -1
- package/modules/config/src/define.ts +0 -18
|
@@ -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/checklist/bin/run.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import fs from 'fs';
|
|
13
13
|
import path from 'path';
|
|
14
|
-
import { loadConfig, isModuleEnabled } from '../../modules/config/src/load.
|
|
14
|
+
import { loadConfig, isModuleEnabled } from '../../modules/config/src/load.mjs';
|
|
15
15
|
|
|
16
16
|
const cwd = process.cwd();
|
|
17
17
|
const distDir = path.join(cwd, 'dist');
|
|
@@ -163,7 +163,9 @@ const webpSet = new Set();
|
|
|
163
163
|
}
|
|
164
164
|
})(distDir);
|
|
165
165
|
|
|
166
|
-
|
|
166
|
+
// Matches actual placeholder *content*, not CSS ::placeholder or HTML
|
|
167
|
+
// placeholder="..." attributes (both legitimate).
|
|
168
|
+
const PLACEHOLDER_RE = /\b(lorem ipsum|TODO:|FIXME:|XXX:)\b/i;
|
|
167
169
|
|
|
168
170
|
for (const file of htmlFiles) {
|
|
169
171
|
const rel = path.relative(distDir, file);
|
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,34 @@ 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
|
+
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity helper that types your `codejitsu.config.ts` export.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* // codejitsu.config.ts
|
|
6
|
+
* import { defineConfig } from '@ibalzam/codejitsu-core/config';
|
|
7
|
+
* export default defineConfig({ site: { url: '...', name: '...' } });
|
|
8
|
+
*
|
|
9
|
+
* @param {import('./types.js').CodejitsuConfig} config
|
|
10
|
+
* @returns {import('./types.js').CodejitsuConfig}
|
|
11
|
+
*/
|
|
12
|
+
export function defineConfig(config) {
|
|
13
|
+
return config;
|
|
14
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
export * from './types.js';
|
|
2
|
-
|
|
3
|
-
export {
|
|
1
|
+
export type * from './types.js';
|
|
2
|
+
// @ts-expect-error - .mjs runtime resolves at use time
|
|
3
|
+
export { defineConfig } from './define.mjs';
|
|
4
|
+
// @ts-expect-error - .mjs runtime resolves at use time
|
|
5
|
+
export { loadConfig, isModuleEnabled } from './load.mjs';
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { pathToFileURL } from 'url';
|
|
4
|
-
import type { CodejitsuConfig } from './types.js';
|
|
5
4
|
|
|
6
5
|
const CANDIDATES = [
|
|
7
6
|
'codejitsu.config.ts',
|
|
@@ -12,43 +11,41 @@ const CANDIDATES = [
|
|
|
12
11
|
];
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
|
-
* Loads the Codejitsu config from the current working directory
|
|
14
|
+
* Loads the Codejitsu config from the current working directory.
|
|
16
15
|
*
|
|
17
16
|
* Search order:
|
|
18
17
|
* 1. `codejitsu.config.{ts,mts,mjs,js,json}` at cwd root.
|
|
19
18
|
* 2. `codejitsu` key in `package.json`.
|
|
20
19
|
*
|
|
21
|
-
* `.ts` and `.mts` files
|
|
22
|
-
*
|
|
20
|
+
* `.ts` and `.mts` files load via `jiti`. If `jiti` isn't installed, the
|
|
21
|
+
* loader warns once and falls through to other candidates.
|
|
23
22
|
*
|
|
24
|
-
*
|
|
23
|
+
* @param {string} [cwd=process.cwd()]
|
|
24
|
+
* @returns {Promise<import('./types.js').CodejitsuConfig>}
|
|
25
25
|
*/
|
|
26
|
-
export async function loadConfig(cwd
|
|
26
|
+
export async function loadConfig(cwd = process.cwd()) {
|
|
27
27
|
for (const name of CANDIDATES) {
|
|
28
28
|
const filePath = path.join(cwd, name);
|
|
29
29
|
if (!fs.existsSync(filePath)) continue;
|
|
30
30
|
|
|
31
31
|
if (name.endsWith('.json')) {
|
|
32
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
32
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
if (name.endsWith('.ts') || name.endsWith('.mts')) {
|
|
36
36
|
const config = await loadWithJiti(filePath);
|
|
37
37
|
if (config) return config;
|
|
38
|
-
// jiti unavailable; fall through to other candidates.
|
|
39
38
|
continue;
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
// .mjs / .js — Node can load these directly.
|
|
43
41
|
const mod = await import(pathToFileURL(filePath).href);
|
|
44
|
-
return
|
|
42
|
+
return mod.default ?? mod;
|
|
45
43
|
}
|
|
46
44
|
|
|
47
|
-
// Fallback: package.json `codejitsu` key.
|
|
48
45
|
const pkgPath = path.join(cwd, 'package.json');
|
|
49
46
|
if (fs.existsSync(pkgPath)) {
|
|
50
47
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
51
|
-
if (pkg.codejitsu) return pkg.codejitsu
|
|
48
|
+
if (pkg.codejitsu) return pkg.codejitsu;
|
|
52
49
|
}
|
|
53
50
|
|
|
54
51
|
throw new Error(
|
|
@@ -58,16 +55,16 @@ export async function loadConfig(cwd: string = process.cwd()): Promise<Codejitsu
|
|
|
58
55
|
}
|
|
59
56
|
|
|
60
57
|
let jitiWarned = false;
|
|
61
|
-
async function loadWithJiti(filePath
|
|
58
|
+
async function loadWithJiti(filePath) {
|
|
62
59
|
try {
|
|
63
60
|
const jitiMod = await import('jiti');
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
if (!jiti) {
|
|
61
|
+
const factory = jitiMod.createJiti ?? jitiMod.default ?? jitiMod;
|
|
62
|
+
if (typeof factory !== 'function') {
|
|
67
63
|
throw new Error('Unexpected jiti API shape.');
|
|
68
64
|
}
|
|
65
|
+
const jiti = factory(process.cwd(), { interopDefault: true });
|
|
69
66
|
const mod = await jiti.import(filePath, { default: true });
|
|
70
|
-
return
|
|
67
|
+
return mod;
|
|
71
68
|
} catch (err) {
|
|
72
69
|
if (!jitiWarned) {
|
|
73
70
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -83,14 +80,13 @@ async function loadWithJiti(filePath: string): Promise<CodejitsuConfig | null> {
|
|
|
83
80
|
|
|
84
81
|
/**
|
|
85
82
|
* Returns true if the named module is enabled in the config.
|
|
86
|
-
*
|
|
83
|
+
* @param {import('./types.js').CodejitsuConfig} config
|
|
84
|
+
* @param {'blog'|'seo'|'images'|'llms'|'deploy'} module
|
|
85
|
+
* @returns {boolean}
|
|
87
86
|
*/
|
|
88
|
-
export function isModuleEnabled(
|
|
89
|
-
config: CodejitsuConfig,
|
|
90
|
-
module: 'blog' | 'seo' | 'images' | 'llms' | 'deploy'
|
|
91
|
-
): boolean {
|
|
87
|
+
export function isModuleEnabled(config, module) {
|
|
92
88
|
const value = config[module];
|
|
93
89
|
if (value === undefined || value === false) return false;
|
|
94
|
-
if (typeof value === 'object' && value !== null &&
|
|
90
|
+
if (typeof value === 'object' && value !== null && value.enabled === false) return false;
|
|
95
91
|
return true;
|
|
96
92
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { loadConfig, isModuleEnabled } from '../../config/src/load.
|
|
3
|
+
import { loadConfig, isModuleEnabled } from '../../config/src/load.mjs';
|
|
4
4
|
import { optimizeImages } from '../src/optimize.mjs';
|
|
5
5
|
import { autoBlogImages } from '../src/auto-blog.mjs';
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { loadConfig, isModuleEnabled } from '../../config/src/load.
|
|
3
|
+
import { loadConfig, isModuleEnabled } from '../../config/src/load.mjs';
|
|
4
4
|
import { generateLlms } from '../src/generate.mjs';
|
|
5
5
|
|
|
6
6
|
const cwd = process.cwd();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ibalzam/codejitsu-core",
|
|
3
|
-
"version": "0.
|
|
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": [
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import type { CodejitsuConfig } from './types.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Identity helper that types your `codejitsu.config.ts` export.
|
|
5
|
-
*
|
|
6
|
-
* @example
|
|
7
|
-
* // codejitsu.config.ts
|
|
8
|
-
* import { defineConfig } from '@ibalzam/codejitsu-core/config';
|
|
9
|
-
*
|
|
10
|
-
* export default defineConfig({
|
|
11
|
-
* site: { url: 'https://example.com', name: 'Example' },
|
|
12
|
-
* blog: { mode: 'collection', dateField: 'pubDate', draftField: 'draft' },
|
|
13
|
-
* // ...
|
|
14
|
-
* });
|
|
15
|
-
*/
|
|
16
|
-
export function defineConfig(config: CodejitsuConfig): CodejitsuConfig {
|
|
17
|
-
return config;
|
|
18
|
-
}
|