@ibalzam/codejitsu-core 0.1.0 → 0.2.0

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