@ibalzam/codejitsu-core 0.3.2 → 0.4.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,45 @@
1
+ #!/usr/bin/env node
2
+ import { runBlog } from '../modules/cli/src/blog.mjs';
3
+
4
+ const subcommand = process.argv[2];
5
+
6
+ const COMMANDS = {
7
+ 'blog:list': () => runBlog('blog:list'),
8
+ 'blog:drafts': () => runBlog('blog:drafts'),
9
+ // Aliases for the existing standalone bins
10
+ llms: () => import('../modules/llms/bin/generate.mjs'),
11
+ 'optimize-images': () => import('../modules/images/bin/optimize.mjs'),
12
+ check: () => import('../checklist/bin/run.mjs'),
13
+ };
14
+
15
+ if (!subcommand || subcommand === '--help' || subcommand === '-h') {
16
+ printHelp();
17
+ process.exit(0);
18
+ }
19
+
20
+ const handler = COMMANDS[subcommand];
21
+ if (!handler) {
22
+ console.error(`Unknown subcommand: ${subcommand}\n`);
23
+ printHelp();
24
+ process.exit(1);
25
+ }
26
+
27
+ try {
28
+ await handler();
29
+ } catch (err) {
30
+ console.error(err instanceof Error ? err.message : String(err));
31
+ process.exit(1);
32
+ }
33
+
34
+ function printHelp() {
35
+ console.log(`\nUsage: codejitsu <subcommand>\n`);
36
+ console.log(`Subcommands:`);
37
+ console.log(` blog:list List every non-draft post with publish status + URL + image check`);
38
+ console.log(` blog:drafts List future-dated (pending) posts only`);
39
+ console.log(``);
40
+ console.log(` llms Generate public/llms.txt and public/llms-full.txt`);
41
+ console.log(` optimize-images Optimize images per codejitsu.config`);
42
+ console.log(` check Run sitewide checklist`);
43
+ console.log(``);
44
+ console.log(`All commands read codejitsu.config.{ts,mjs,json} from the current directory.`);
45
+ }
@@ -4,28 +4,14 @@ export interface CollectionBlogConfig extends CommonBlogConfig {
4
4
  collectionName?: string;
5
5
  }
6
6
  /**
7
- * Astro Content Collections blog loader. Use this in Astro projects.
7
+ * Astro Content Collections blog loader. Use in any Astro project.
8
8
  *
9
- * Returns raw CollectionEntry objects (preserving `entry.data`, `entry.id`,
10
- * and the ability to call `render(entry)` from astro:content). Filtering and
11
- * sorting are applied:
12
- * - Drafts excluded (when `draftField` is set)
13
- * - Sorted newest first by `dateField`
14
- * - `getPublishedEntries()` further excludes future-dated entries
9
+ * Returns raw CollectionEntry objects with filtering applied (drafts excluded,
10
+ * sorted newest first by `dateField`). Preserves `entry.data`, `entry.id`,
11
+ * and the ability to call `render(entry)` for `<Content />`.
15
12
  *
16
- * The collection's actual entry type is `CollectionEntry<'<name>'>` from
17
- * `astro:content`. Pass it as the generic to get full typed `data`:
18
- *
19
- * ```ts
20
- * import type { CollectionEntry } from 'astro:content';
21
- * export const blog = createBlogFromCollection<CollectionEntry<'blog'>>({
22
- * collectionName: 'blog',
23
- * dateField: 'pubDate',
24
- * draftField: 'draft',
25
- * });
26
- * ```
27
- *
28
- * Dynamically imports `astro:content` at call time so the package stays
29
- * usable in non-Astro projects.
13
+ * Import only from inside Astro code (`src/lib/blog.ts`, page routes). Do not
14
+ * import from `astro.config.mjs` Astro CC isn't initialized at config time.
15
+ * Use `createBlog` (fs) for astro.config.
30
16
  */
31
17
  export declare function createBlogFromCollection<E extends BlogCollectionEntry = BlogCollectionEntry>(config?: CollectionBlogConfig): BlogCollectionAPI<E>;
@@ -1,3 +1,9 @@
1
+ // @ts-expect-error - 'astro:content' is a virtual module resolved by Astro at build time.
2
+ // Static import (not dynamic) so Vite/Astro processes the dependency correctly.
3
+ // This file is only safe to import from inside an Astro project. It lives at the
4
+ // `/blog/collection` subpath; sites that aren't Astro should import from `/blog`
5
+ // (which doesn't pull this in).
6
+ import { getCollection as astroGetCollection } from 'astro:content';
1
7
  import readingTime from 'reading-time';
2
8
  function getTodayUTC() {
3
9
  const now = new Date();
@@ -20,29 +26,15 @@ function asDate(value) {
20
26
  return null;
21
27
  }
22
28
  /**
23
- * Astro Content Collections blog loader. Use this in Astro projects.
29
+ * Astro Content Collections blog loader. Use in any Astro project.
24
30
  *
25
- * Returns raw CollectionEntry objects (preserving `entry.data`, `entry.id`,
26
- * and the ability to call `render(entry)` from astro:content). Filtering and
27
- * sorting are applied:
28
- * - Drafts excluded (when `draftField` is set)
29
- * - Sorted newest first by `dateField`
30
- * - `getPublishedEntries()` further excludes future-dated entries
31
+ * Returns raw CollectionEntry objects with filtering applied (drafts excluded,
32
+ * sorted newest first by `dateField`). Preserves `entry.data`, `entry.id`,
33
+ * and the ability to call `render(entry)` for `<Content />`.
31
34
  *
32
- * The collection's actual entry type is `CollectionEntry<'<name>'>` from
33
- * `astro:content`. Pass it as the generic to get full typed `data`:
34
- *
35
- * ```ts
36
- * import type { CollectionEntry } from 'astro:content';
37
- * export const blog = createBlogFromCollection<CollectionEntry<'blog'>>({
38
- * collectionName: 'blog',
39
- * dateField: 'pubDate',
40
- * draftField: 'draft',
41
- * });
42
- * ```
43
- *
44
- * Dynamically imports `astro:content` at call time so the package stays
45
- * usable in non-Astro projects.
35
+ * Import only from inside Astro code (`src/lib/blog.ts`, page routes). Do not
36
+ * import from `astro.config.mjs` Astro CC isn't initialized at config time.
37
+ * Use `createBlog` (fs) for astro.config.
46
38
  */
47
39
  export function createBlogFromCollection(config = {}) {
48
40
  const collectionName = config.collectionName ?? 'blog';
@@ -50,20 +42,8 @@ export function createBlogFromCollection(config = {}) {
50
42
  const categories = config.categories ?? [];
51
43
  const dateField = config.dateField ?? 'date';
52
44
  const draftField = config.draftField ?? null;
53
- async function getCollection() {
54
- let mod;
55
- try {
56
- // @ts-expect-error - 'astro:content' is a virtual module resolved by Astro at build time.
57
- mod = await import('astro:content');
58
- }
59
- catch (err) {
60
- throw new Error(`createBlogFromCollection() requires Astro and a configured content collection ` +
61
- `named '${collectionName}'. Original error: ${err instanceof Error ? err.message : String(err)}`);
62
- }
63
- return mod.getCollection(collectionName);
64
- }
65
45
  async function readAll() {
66
- const all = await getCollection();
46
+ const all = (await astroGetCollection(collectionName));
67
47
  const filtered = draftField ? all.filter((e) => !e.data[draftField]) : all;
68
48
  return filtered.sort((a, b) => {
69
49
  const da = asDate(a.data[dateField])?.valueOf() ?? 0;
@@ -1,3 +1,9 @@
1
+ // @ts-expect-error - 'astro:content' is a virtual module resolved by Astro at build time.
2
+ // Static import (not dynamic) so Vite/Astro processes the dependency correctly.
3
+ // This file is only safe to import from inside an Astro project. It lives at the
4
+ // `/blog/collection` subpath; sites that aren't Astro should import from `/blog`
5
+ // (which doesn't pull this in).
6
+ import { getCollection as astroGetCollection } from 'astro:content';
1
7
  import readingTime from 'reading-time';
2
8
  import type {
3
9
  BlogCategory,
@@ -33,29 +39,15 @@ function asDate(value: unknown): Date | null {
33
39
  }
34
40
 
35
41
  /**
36
- * Astro Content Collections blog loader. Use this in Astro projects.
42
+ * Astro Content Collections blog loader. Use in any Astro project.
37
43
  *
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
+ * Returns raw CollectionEntry objects with filtering applied (drafts excluded,
45
+ * sorted newest first by `dateField`). Preserves `entry.data`, `entry.id`,
46
+ * and the ability to call `render(entry)` for `<Content />`.
44
47
  *
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.
48
+ * Import only from inside Astro code (`src/lib/blog.ts`, page routes). Do not
49
+ * import from `astro.config.mjs` Astro CC isn't initialized at config time.
50
+ * Use `createBlog` (fs) for astro.config.
59
51
  */
60
52
  export function createBlogFromCollection<E extends BlogCollectionEntry = BlogCollectionEntry>(
61
53
  config: CollectionBlogConfig = {}
@@ -66,22 +58,8 @@ export function createBlogFromCollection<E extends BlogCollectionEntry = BlogCol
66
58
  const dateField = config.dateField ?? 'date';
67
59
  const draftField = config.draftField ?? null;
68
60
 
69
- async function getCollection(): Promise<E[]> {
70
- let mod: { getCollection: (name: string) => Promise<E[]> };
71
- try {
72
- // @ts-expect-error - 'astro:content' is a virtual module resolved by Astro at build time.
73
- mod = await import('astro:content');
74
- } catch (err) {
75
- throw new Error(
76
- `createBlogFromCollection() requires Astro and a configured content collection ` +
77
- `named '${collectionName}'. Original error: ${err instanceof Error ? err.message : String(err)}`
78
- );
79
- }
80
- return mod.getCollection(collectionName);
81
- }
82
-
83
61
  async function readAll(): Promise<E[]> {
84
- const all = await getCollection();
62
+ const all = (await astroGetCollection(collectionName)) as E[];
85
63
  const filtered = draftField ? all.filter((e) => !e.data[draftField]) : all;
86
64
  return filtered.sort((a, b) => {
87
65
  const da = asDate(a.data[dateField])?.valueOf() ?? 0;
@@ -1,5 +1,3 @@
1
1
  export * from './types.js';
2
2
  export { createBlog } from './fs.js';
3
3
  export type { FsBlogConfig } from './fs.js';
4
- export { createBlogFromCollection } from './collection.js';
5
- export type { CollectionBlogConfig } from './collection.js';
@@ -1,3 +1,8 @@
1
+ // fs (gray-matter) blog loader — safe to import from anywhere (including
2
+ // astro.config.mjs and non-Astro projects).
3
+ //
4
+ // For the Astro Content Collections variant, import from
5
+ // `@ibalzam/codejitsu-core/blog/collection` (separate subpath because it
6
+ // statically imports `astro:content`, which is only available inside Astro).
1
7
  export * from './types.js';
2
8
  export { createBlog } from './fs.js';
3
- export { createBlogFromCollection } from './collection.js';
@@ -1,5 +1,10 @@
1
+ // fs (gray-matter) blog loader — safe to import from anywhere (including
2
+ // astro.config.mjs and non-Astro projects).
3
+ //
4
+ // For the Astro Content Collections variant, import from
5
+ // `@ibalzam/codejitsu-core/blog/collection` (separate subpath because it
6
+ // statically imports `astro:content`, which is only available inside Astro).
7
+
1
8
  export * from './types.js';
2
9
  export { createBlog } from './fs.js';
3
10
  export type { FsBlogConfig } from './fs.js';
4
- export { createBlogFromCollection } from './collection.js';
5
- export type { CollectionBlogConfig } from './collection.js';
@@ -0,0 +1,99 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { loadConfig, isModuleEnabled } from '../../config/src/load.mjs';
4
+ import { createBlog } from '../../blog/src/fs.js';
5
+ import { c, table } from './format.mjs';
6
+
7
+ /**
8
+ * `codejitsu blog:list` — show every non-draft post (published + pending).
9
+ * `codejitsu blog:drafts` — show only future-dated (pending) posts.
10
+ */
11
+ export async function runBlog(subcommand) {
12
+ const cwd = process.cwd();
13
+ const config = await loadConfig(cwd);
14
+
15
+ if (!isModuleEnabled(config, 'blog')) {
16
+ console.error(c.red('blog module is disabled in codejitsu.config'));
17
+ process.exit(1);
18
+ }
19
+
20
+ const blogCfg = config.blog && typeof config.blog === 'object' ? config.blog : {};
21
+ const contentDir = blogCfg.contentDir ?? 'src/content/blog';
22
+ const dateField = blogCfg.dateField ?? 'date';
23
+ const draftField = blogCfg.draftField ?? null;
24
+
25
+ const blog = createBlog({
26
+ contentDir,
27
+ dateField,
28
+ draftField,
29
+ });
30
+
31
+ const today = todayUTC();
32
+ const siteUrl = config.site.url.replace(/\/$/, '');
33
+
34
+ if (subcommand === 'blog:drafts') {
35
+ const future = await blog.getAllPostsIncludingFuture();
36
+ const drafts = future.filter((p) => new Date(p.date) > today);
37
+ printPosts(drafts, { siteUrl, contentDir, today, kind: 'drafts' });
38
+ return;
39
+ }
40
+
41
+ // blog:list — published + pending, sorted newest first
42
+ const all = await blog.getAllPostsIncludingFuture();
43
+ printPosts(all, { siteUrl, contentDir, today, kind: 'all' });
44
+ }
45
+
46
+ function todayUTC() {
47
+ const now = new Date();
48
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
49
+ }
50
+
51
+ function daysBetween(future, today) {
52
+ return Math.round((future.getTime() - today.getTime()) / 86_400_000);
53
+ }
54
+
55
+ function printPosts(posts, { siteUrl, contentDir, today, kind }) {
56
+ if (posts.length === 0) {
57
+ console.log(c.dim(kind === 'drafts' ? 'No pending posts.' : 'No posts.'));
58
+ return;
59
+ }
60
+
61
+ const cwd = process.cwd();
62
+ const publicDir = path.join(cwd, 'public');
63
+
64
+ const rows = posts.map((p) => {
65
+ const postDate = new Date(p.date);
66
+ const isFuture = postDate > today;
67
+ const days = isFuture ? `+${daysBetween(postDate, today)}d` : 'live';
68
+
69
+ const url = `${siteUrl}/blog/${p.slug}/`;
70
+ const imgStatus = formatImageStatus(p.image, publicDir);
71
+
72
+ return [
73
+ isFuture ? c.yellow(days) : c.green(days),
74
+ p.date,
75
+ imgStatus,
76
+ url,
77
+ ];
78
+ });
79
+
80
+ table(['STATUS', 'DATE', 'IMG', 'URL'], rows);
81
+
82
+ const published = posts.filter((p) => new Date(p.date) <= today).length;
83
+ const pending = posts.length - published;
84
+ console.log('');
85
+ console.log(
86
+ `${c.bold(String(posts.length))} posts · ` +
87
+ `${c.green(`${published} live`)} · ` +
88
+ `${c.yellow(`${pending} pending`)}`
89
+ );
90
+ }
91
+
92
+ function formatImageStatus(image, publicDir) {
93
+ if (!image) return c.gray('—');
94
+ const rel = image.startsWith('/') ? image.slice(1) : image;
95
+ const fullPath = path.join(publicDir, rel);
96
+ if (fs.existsSync(fullPath)) return c.green('✓');
97
+ return c.red(`✗ ${image}`);
98
+ }
99
+
@@ -0,0 +1,65 @@
1
+ // Tiny ANSI color + table helpers. No deps.
2
+
3
+ const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
4
+
5
+ function wrap(code) {
6
+ return isTTY ? (s) => `\x1b[${code}m${s}\x1b[0m` : (s) => s;
7
+ }
8
+
9
+ export const c = {
10
+ dim: wrap('2'),
11
+ bold: wrap('1'),
12
+ red: wrap('31'),
13
+ green: wrap('32'),
14
+ yellow: wrap('33'),
15
+ blue: wrap('34'),
16
+ magenta: wrap('35'),
17
+ cyan: wrap('36'),
18
+ gray: wrap('90'),
19
+ };
20
+
21
+ const ansiRe = /\x1b\[[0-9;]*m/g;
22
+
23
+ function visibleLength(s) {
24
+ return s.replace(ansiRe, '').length;
25
+ }
26
+
27
+ function padCol(s, width) {
28
+ const diff = width - visibleLength(s);
29
+ return diff > 0 ? s + ' '.repeat(diff) : s;
30
+ }
31
+
32
+ function truncate(s, width) {
33
+ if (visibleLength(s) <= width) return s;
34
+ const plain = s.replace(ansiRe, '');
35
+ return plain.slice(0, width - 1) + '…';
36
+ }
37
+
38
+ /**
39
+ * Print a table.
40
+ *
41
+ * @param {string[]} headers
42
+ * @param {string[][]} rows
43
+ * @param {object} [opts]
44
+ * @param {number[]} [opts.maxWidths]
45
+ */
46
+ export function table(headers, rows, opts = {}) {
47
+ const cols = headers.length;
48
+ const widths = new Array(cols).fill(0);
49
+
50
+ for (let i = 0; i < cols; i++) {
51
+ widths[i] = visibleLength(headers[i]);
52
+ for (const row of rows) {
53
+ widths[i] = Math.max(widths[i], visibleLength(row[i] ?? ''));
54
+ }
55
+ if (opts.maxWidths?.[i]) {
56
+ widths[i] = Math.min(widths[i], opts.maxWidths[i]);
57
+ }
58
+ }
59
+
60
+ console.log(headers.map((h, i) => c.bold(padCol(h, widths[i]))).join(' '));
61
+ console.log(widths.map((w) => c.gray('─'.repeat(w))).join(' '));
62
+ for (const row of rows) {
63
+ console.log(row.map((cell, i) => padCol(truncate(cell ?? '', widths[i]), widths[i])).join(' '));
64
+ }
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibalzam/codejitsu-core",
3
- "version": "0.3.2",
3
+ "version": "0.4.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": [
@@ -43,6 +43,10 @@
43
43
  "types": "./modules/blog/src/index.d.ts",
44
44
  "default": "./modules/blog/src/index.js"
45
45
  },
46
+ "./blog/collection": {
47
+ "types": "./modules/blog/src/collection.d.ts",
48
+ "default": "./modules/blog/src/collection.js"
49
+ },
46
50
  "./seo": {
47
51
  "types": "./modules/seo/src/index.d.ts",
48
52
  "default": "./modules/seo/src/index.js"
@@ -64,6 +68,7 @@
64
68
  "./package.json": "./package.json"
65
69
  },
66
70
  "bin": {
71
+ "codejitsu": "./bin/codejitsu.mjs",
67
72
  "codejitsu-llms": "./modules/llms/bin/generate.mjs",
68
73
  "codejitsu-optimize-images": "./modules/images/bin/optimize.mjs",
69
74
  "codejitsu-check": "./checklist/bin/run.mjs"