@ibalzam/codejitsu-core 0.1.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/CLAUDE.md +67 -0
- package/LICENSE +21 -0
- package/MIGRATIONS/README.md +30 -0
- package/README.md +35 -0
- package/checklist/bin/run.mjs +189 -0
- package/checklist/core.md +55 -0
- package/modules/blog/CLAUDE.md +87 -0
- package/modules/blog/checklist.md +36 -0
- package/modules/blog/src/components/index.ts +1 -0
- package/modules/blog/src/index.ts +201 -0
- package/modules/blog/templates/content/_sample-post.md +18 -0
- package/modules/blog/templates/lib/blog.ts +17 -0
- package/modules/blog/templates/pages/blog/[...slug].astro +53 -0
- package/modules/blog/templates/pages/blog/category/[category].astro +40 -0
- package/modules/blog/templates/pages/blog/index.astro +30 -0
- package/modules/blog/templates/pages/blog/tag/[tag].astro +35 -0
- package/modules/deploy/CLAUDE.md +58 -0
- package/modules/deploy/checklist.md +9 -0
- package/modules/deploy/templates/daily-deploy.yml +29 -0
- package/modules/deploy/templates/wrangler.toml +2 -0
- package/modules/images/CLAUDE.md +77 -0
- package/modules/images/bin/optimize.mjs +44 -0
- package/modules/images/checklist.md +23 -0
- package/modules/images/src/index.ts +21 -0
- package/modules/images/src/optimize.mjs +113 -0
- package/modules/images/templates/codejitsu-images.config.mjs +18 -0
- package/modules/llms/CLAUDE.md +57 -0
- package/modules/llms/bin/generate.mjs +35 -0
- package/modules/llms/checklist.md +10 -0
- package/modules/llms/src/generate.mjs +179 -0
- package/modules/llms/templates/codejitsu-llms.config.mjs +39 -0
- package/modules/seo/CLAUDE.md +95 -0
- package/modules/seo/checklist.md +30 -0
- package/modules/seo/src/index.ts +2 -0
- package/modules/seo/src/schema.ts +203 -0
- package/modules/seo/src/sitemap.ts +109 -0
- package/modules/seo/templates/Head.astro +53 -0
- package/modules/seo/templates/robots.txt +5 -0
- package/package.json +73 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import readingTime from 'reading-time';
|
|
5
|
+
|
|
6
|
+
export interface FAQItem {
|
|
7
|
+
question: string;
|
|
8
|
+
answer: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BlogPostFrontmatter {
|
|
12
|
+
title: string;
|
|
13
|
+
description: string;
|
|
14
|
+
date: string;
|
|
15
|
+
slug?: string;
|
|
16
|
+
author?: string;
|
|
17
|
+
image?: string;
|
|
18
|
+
tags?: string[];
|
|
19
|
+
faqs?: FAQItem[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BlogPostMetadata {
|
|
23
|
+
slug: string;
|
|
24
|
+
title: string;
|
|
25
|
+
description: string;
|
|
26
|
+
date: string;
|
|
27
|
+
author?: string;
|
|
28
|
+
image?: string;
|
|
29
|
+
tags?: string[];
|
|
30
|
+
readingTime: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BlogPost extends BlogPostMetadata {
|
|
34
|
+
faqs?: FAQItem[];
|
|
35
|
+
content: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BlogCategory {
|
|
39
|
+
slug: string;
|
|
40
|
+
tag: string;
|
|
41
|
+
title: string;
|
|
42
|
+
subtitle: string;
|
|
43
|
+
metaDescription: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BlogConfig {
|
|
47
|
+
contentDir?: string;
|
|
48
|
+
defaultAuthor?: string;
|
|
49
|
+
categories?: BlogCategory[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface BlogAPI {
|
|
53
|
+
getAllPosts(): Promise<BlogPostMetadata[]>;
|
|
54
|
+
getAllPostsIncludingFuture(): Promise<BlogPostMetadata[]>;
|
|
55
|
+
getFutureBlogSlugs(): Promise<string[]>;
|
|
56
|
+
getAllPostSlugs(): Promise<string[]>;
|
|
57
|
+
getPostBySlug(slug: string): Promise<BlogPost | null>;
|
|
58
|
+
getAllTags(): Promise<string[]>;
|
|
59
|
+
getPostsByTag(tag: string): Promise<BlogPostMetadata[]>;
|
|
60
|
+
getAllCategorySlugs(): string[];
|
|
61
|
+
getCategoryBySlug(slug: string): BlogCategory | undefined;
|
|
62
|
+
getPostsByCategory(slug: string): Promise<BlogPostMetadata[]>;
|
|
63
|
+
categories: BlogCategory[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getTodayUTC(): Date {
|
|
67
|
+
const now = new Date();
|
|
68
|
+
return new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fileSlug(name: string): string {
|
|
72
|
+
return name.replace(/\.md$/, '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function canonicalSlugFor(name: string, fm: { slug?: string }): string {
|
|
76
|
+
return fm.slug || fileSlug(name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createBlog(config: BlogConfig = {}): BlogAPI {
|
|
80
|
+
const contentDir = path.resolve(process.cwd(), config.contentDir ?? 'content/blog');
|
|
81
|
+
const defaultAuthor = config.defaultAuthor;
|
|
82
|
+
const categories = config.categories ?? [];
|
|
83
|
+
|
|
84
|
+
function readAllFiles() {
|
|
85
|
+
if (!fs.existsSync(contentDir)) return [];
|
|
86
|
+
return fs.readdirSync(contentDir)
|
|
87
|
+
.filter((n) => n.endsWith('.md'))
|
|
88
|
+
.map((fileName) => {
|
|
89
|
+
const raw = fs.readFileSync(path.join(contentDir, fileName), 'utf8');
|
|
90
|
+
const parsed = matter(raw);
|
|
91
|
+
const data = parsed.data as Partial<BlogPostFrontmatter>;
|
|
92
|
+
return {
|
|
93
|
+
fileName,
|
|
94
|
+
fileSlug: fileSlug(fileName),
|
|
95
|
+
canonicalSlug: canonicalSlugFor(fileName, data),
|
|
96
|
+
data,
|
|
97
|
+
content: parsed.content,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toMetadata(f: ReturnType<typeof readAllFiles>[number]): BlogPostMetadata {
|
|
103
|
+
return {
|
|
104
|
+
slug: f.canonicalSlug,
|
|
105
|
+
title: f.data.title ?? '',
|
|
106
|
+
description: f.data.description ?? '',
|
|
107
|
+
date: f.data.date ?? '',
|
|
108
|
+
author: f.data.author ?? defaultAuthor,
|
|
109
|
+
image: f.data.image,
|
|
110
|
+
tags: f.data.tags,
|
|
111
|
+
readingTime: readingTime(f.content).text,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function getAllPostsIncludingFuture(): Promise<BlogPostMetadata[]> {
|
|
116
|
+
return readAllFiles()
|
|
117
|
+
.map(toMetadata)
|
|
118
|
+
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function getAllPosts(): Promise<BlogPostMetadata[]> {
|
|
122
|
+
const today = getTodayUTC();
|
|
123
|
+
const all = await getAllPostsIncludingFuture();
|
|
124
|
+
return all.filter((p) => new Date(p.date) <= today);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function getFutureBlogSlugs(): Promise<string[]> {
|
|
128
|
+
const today = getTodayUTC();
|
|
129
|
+
const slugs = new Set<string>();
|
|
130
|
+
for (const f of readAllFiles()) {
|
|
131
|
+
if (!f.data.date) continue;
|
|
132
|
+
if (new Date(f.data.date) > today) {
|
|
133
|
+
slugs.add(f.canonicalSlug);
|
|
134
|
+
if (f.fileSlug !== f.canonicalSlug) slugs.add(f.fileSlug);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return Array.from(slugs);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function getAllPostSlugs(): Promise<string[]> {
|
|
141
|
+
const slugs = new Set<string>();
|
|
142
|
+
for (const f of readAllFiles()) {
|
|
143
|
+
slugs.add(f.fileSlug);
|
|
144
|
+
if (f.canonicalSlug !== f.fileSlug) slugs.add(f.canonicalSlug);
|
|
145
|
+
}
|
|
146
|
+
return Array.from(slugs);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function getPostBySlug(slug: string): Promise<BlogPost | null> {
|
|
150
|
+
const match = readAllFiles().find(
|
|
151
|
+
(f) => f.canonicalSlug === slug || f.fileSlug === slug
|
|
152
|
+
);
|
|
153
|
+
if (!match) return null;
|
|
154
|
+
return {
|
|
155
|
+
...toMetadata(match),
|
|
156
|
+
faqs: match.data.faqs,
|
|
157
|
+
content: match.content,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function getAllTags(): Promise<string[]> {
|
|
162
|
+
const posts = await getAllPosts();
|
|
163
|
+
const tags = new Set<string>();
|
|
164
|
+
posts.forEach((p) => p.tags?.forEach((t) => tags.add(t)));
|
|
165
|
+
return Array.from(tags).sort();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function getPostsByTag(tag: string): Promise<BlogPostMetadata[]> {
|
|
169
|
+
const posts = await getAllPosts();
|
|
170
|
+
return posts.filter((p) => p.tags?.includes(tag));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function getAllCategorySlugs(): string[] {
|
|
174
|
+
return categories.map((c) => c.slug);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function getCategoryBySlug(slug: string): BlogCategory | undefined {
|
|
178
|
+
return categories.find((c) => c.slug === slug);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function getPostsByCategory(slug: string): Promise<BlogPostMetadata[]> {
|
|
182
|
+
const cat = getCategoryBySlug(slug);
|
|
183
|
+
if (!cat) return [];
|
|
184
|
+
const posts = await getAllPosts();
|
|
185
|
+
return posts.filter((p) => p.tags?.includes(cat.tag));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
getAllPosts,
|
|
190
|
+
getAllPostsIncludingFuture,
|
|
191
|
+
getFutureBlogSlugs,
|
|
192
|
+
getAllPostSlugs,
|
|
193
|
+
getPostBySlug,
|
|
194
|
+
getAllTags,
|
|
195
|
+
getPostsByTag,
|
|
196
|
+
getAllCategorySlugs,
|
|
197
|
+
getCategoryBySlug,
|
|
198
|
+
getPostsByCategory,
|
|
199
|
+
categories,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Welcome to the blog"
|
|
3
|
+
description: "First post — a short introduction to what we'll cover here."
|
|
4
|
+
date: 2026-01-01
|
|
5
|
+
slug: welcome
|
|
6
|
+
author: "Codejitsu"
|
|
7
|
+
image: /images/blog/welcome.webp
|
|
8
|
+
tags: [Announcements]
|
|
9
|
+
faqs:
|
|
10
|
+
- question: "Will posts be published on a schedule?"
|
|
11
|
+
answer: "Yes — drop a markdown file with a future `date`, and it will go live on that day after the daily deploy runs."
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
This is the first post. Replace this content with your real post.
|
|
15
|
+
|
|
16
|
+
## Heading
|
|
17
|
+
|
|
18
|
+
Body copy. Use standard markdown — headings, lists, links, images.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createBlog, type BlogCategory } from '@ibalzam/codejitsu-core/blog';
|
|
2
|
+
|
|
3
|
+
const categories: BlogCategory[] = [
|
|
4
|
+
// {
|
|
5
|
+
// slug: 'guides',
|
|
6
|
+
// tag: 'Guides',
|
|
7
|
+
// title: 'Guides',
|
|
8
|
+
// subtitle: 'Practical how-tos',
|
|
9
|
+
// metaDescription: '...',
|
|
10
|
+
// },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export const blog = createBlog({
|
|
14
|
+
contentDir: 'content/blog',
|
|
15
|
+
defaultAuthor: 'TODO: Site Author',
|
|
16
|
+
categories,
|
|
17
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { blog } from '../../lib/blog';
|
|
3
|
+
|
|
4
|
+
export async function getStaticPaths() {
|
|
5
|
+
const slugs = await blog.getAllPostSlugs();
|
|
6
|
+
return slugs.map((slug) => ({ params: { slug } }));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { slug } = Astro.params;
|
|
10
|
+
const post = await blog.getPostBySlug(slug as string);
|
|
11
|
+
|
|
12
|
+
if (!post) {
|
|
13
|
+
return Astro.redirect('/404');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const canonical = `${Astro.site}blog/${post.slug}/`;
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<html lang="en">
|
|
20
|
+
<head>
|
|
21
|
+
<meta charset="utf-8" />
|
|
22
|
+
<title>{post.title}</title>
|
|
23
|
+
<meta name="description" content={post.description} />
|
|
24
|
+
<link rel="canonical" href={canonical} />
|
|
25
|
+
{post.image && <meta property="og:image" content={`${Astro.site.origin}${post.image}`} />}
|
|
26
|
+
<meta property="og:title" content={post.title} />
|
|
27
|
+
<meta property="og:description" content={post.description} />
|
|
28
|
+
<meta property="og:type" content="article" />
|
|
29
|
+
<meta property="og:url" content={canonical} />
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
<article>
|
|
33
|
+
<header>
|
|
34
|
+
<h1>{post.title}</h1>
|
|
35
|
+
<p>{post.date} · {post.readingTime}{post.author && ` · ${post.author}`}</p>
|
|
36
|
+
</header>
|
|
37
|
+
<div set:html={post.content} />
|
|
38
|
+
{post.faqs && post.faqs.length > 0 && (
|
|
39
|
+
<section>
|
|
40
|
+
<h2>FAQs</h2>
|
|
41
|
+
<dl>
|
|
42
|
+
{post.faqs.map((faq) => (
|
|
43
|
+
<>
|
|
44
|
+
<dt>{faq.question}</dt>
|
|
45
|
+
<dd>{faq.answer}</dd>
|
|
46
|
+
</>
|
|
47
|
+
))}
|
|
48
|
+
</dl>
|
|
49
|
+
</section>
|
|
50
|
+
)}
|
|
51
|
+
</article>
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { blog } from '../../../lib/blog';
|
|
3
|
+
|
|
4
|
+
export async function getStaticPaths() {
|
|
5
|
+
return blog.getAllCategorySlugs().map((category) => ({ params: { category } }));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { category } = Astro.params;
|
|
9
|
+
const cat = blog.getCategoryBySlug(category as string);
|
|
10
|
+
const posts = await blog.getPostsByCategory(category as string);
|
|
11
|
+
|
|
12
|
+
if (!cat) {
|
|
13
|
+
return Astro.redirect('/404');
|
|
14
|
+
}
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
<html lang="en">
|
|
18
|
+
<head>
|
|
19
|
+
<meta charset="utf-8" />
|
|
20
|
+
<title>{cat.title}</title>
|
|
21
|
+
<meta name="description" content={cat.metaDescription} />
|
|
22
|
+
<link rel="canonical" href={`${Astro.site}blog/category/${cat.slug}/`} />
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
<main>
|
|
26
|
+
<h1>{cat.title}</h1>
|
|
27
|
+
<p>{cat.subtitle}</p>
|
|
28
|
+
<ul>
|
|
29
|
+
{posts.map((post) => (
|
|
30
|
+
<li>
|
|
31
|
+
<a href={`/blog/${post.slug}/`}>
|
|
32
|
+
<h2>{post.title}</h2>
|
|
33
|
+
<p>{post.description}</p>
|
|
34
|
+
</a>
|
|
35
|
+
</li>
|
|
36
|
+
))}
|
|
37
|
+
</ul>
|
|
38
|
+
</main>
|
|
39
|
+
</body>
|
|
40
|
+
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { blog } from '../../lib/blog';
|
|
3
|
+
|
|
4
|
+
const posts = await blog.getAllPosts();
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8" />
|
|
10
|
+
<title>Blog</title>
|
|
11
|
+
<meta name="description" content="Latest posts" />
|
|
12
|
+
<link rel="canonical" href={`${Astro.site}blog/`} />
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<main>
|
|
16
|
+
<h1>Blog</h1>
|
|
17
|
+
<ul>
|
|
18
|
+
{posts.map((post) => (
|
|
19
|
+
<li>
|
|
20
|
+
<a href={`/blog/${post.slug}/`}>
|
|
21
|
+
<h2>{post.title}</h2>
|
|
22
|
+
<p>{post.description}</p>
|
|
23
|
+
<small>{post.date} · {post.readingTime}</small>
|
|
24
|
+
</a>
|
|
25
|
+
</li>
|
|
26
|
+
))}
|
|
27
|
+
</ul>
|
|
28
|
+
</main>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { blog } from '../../../lib/blog';
|
|
3
|
+
|
|
4
|
+
export async function getStaticPaths() {
|
|
5
|
+
const tags = await blog.getAllTags();
|
|
6
|
+
return tags.map((tag) => ({ params: { tag } }));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { tag } = Astro.params;
|
|
10
|
+
const posts = await blog.getPostsByTag(tag as string);
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<html lang="en">
|
|
14
|
+
<head>
|
|
15
|
+
<meta charset="utf-8" />
|
|
16
|
+
<title>Posts tagged "{tag}"</title>
|
|
17
|
+
<meta name="description" content={`Articles tagged with ${tag}`} />
|
|
18
|
+
<link rel="canonical" href={`${Astro.site}blog/tag/${tag}/`} />
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<main>
|
|
22
|
+
<h1>Tag: {tag}</h1>
|
|
23
|
+
<ul>
|
|
24
|
+
{posts.map((post) => (
|
|
25
|
+
<li>
|
|
26
|
+
<a href={`/blog/${post.slug}/`}>
|
|
27
|
+
<h2>{post.title}</h2>
|
|
28
|
+
<p>{post.description}</p>
|
|
29
|
+
</a>
|
|
30
|
+
</li>
|
|
31
|
+
))}
|
|
32
|
+
</ul>
|
|
33
|
+
</main>
|
|
34
|
+
</body>
|
|
35
|
+
</html>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Deploy module — instructions for Claude
|
|
2
|
+
|
|
3
|
+
When the user asks to **set up codejitsu/core/deploy** (or "wire up the Cloudflare deploy", "add the daily deploy"), do the following.
|
|
4
|
+
|
|
5
|
+
## What this module provides
|
|
6
|
+
|
|
7
|
+
- `templates/wrangler.toml` — minimal Cloudflare Pages config.
|
|
8
|
+
- `templates/daily-deploy.yml` — GitHub Action that pings a Cloudflare deploy hook every morning so scheduled blog posts (or any time-gated content) graduate from hidden to public on their publish date.
|
|
9
|
+
|
|
10
|
+
## Wiring it into a site
|
|
11
|
+
|
|
12
|
+
### 1. Copy templates
|
|
13
|
+
|
|
14
|
+
- `templates/wrangler.toml` → `wrangler.toml` at site root. Edit the `name` field to match the Cloudflare Pages project name.
|
|
15
|
+
- `templates/daily-deploy.yml` → `.github/workflows/daily-deploy.yml`. No edits needed (cron is set for 13:00 UTC = 06:00 PDT / 05:00 PST).
|
|
16
|
+
|
|
17
|
+
### 2. Create the Cloudflare deploy hook
|
|
18
|
+
|
|
19
|
+
Tell the user to do this manually (Claude can't):
|
|
20
|
+
|
|
21
|
+
1. Open the Cloudflare dashboard → Pages → the site's project → **Settings → Builds & deployments → Deploy hooks**.
|
|
22
|
+
2. Create a new hook (any name, e.g. `daily-scheduled-content`). Branch: `main`.
|
|
23
|
+
3. Copy the generated URL.
|
|
24
|
+
|
|
25
|
+
### 3. Set the GH secret
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
gh secret set CLOUDFLARE_DEPLOY_HOOK_URL --body "<paste URL>"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or via the GitHub UI: Settings → Secrets and variables → Actions → New repository secret, named `CLOUDFLARE_DEPLOY_HOOK_URL`.
|
|
32
|
+
|
|
33
|
+
### 4. Verify
|
|
34
|
+
|
|
35
|
+
- Trigger the workflow manually: `gh workflow run "Daily Deploy"` (or via the GH UI).
|
|
36
|
+
- A Cloudflare Pages deployment should kick off within seconds.
|
|
37
|
+
|
|
38
|
+
## Build command (for Cloudflare Pages git integration)
|
|
39
|
+
|
|
40
|
+
If using Cloudflare's git integration (preferred for push-driven deploys):
|
|
41
|
+
- **Build command:** `npm run build`
|
|
42
|
+
- **Build output directory:** `dist`
|
|
43
|
+
- **Root directory:** `/`
|
|
44
|
+
- **Node version:** 20 (set via `NODE_VERSION=20` env var in Pages settings)
|
|
45
|
+
|
|
46
|
+
## What must NOT be done
|
|
47
|
+
|
|
48
|
+
- **Don't commit the deploy hook URL.** It belongs in `CLOUDFLARE_DEPLOY_HOOK_URL` secret only.
|
|
49
|
+
- **Don't change the cron without reason.** 13:00 UTC is intentional — early-morning Pacific so posts are live by the time users wake up. If a site is on a different timezone, change it explicitly and note why in a comment in the workflow file.
|
|
50
|
+
- **Don't add a `wrangler deploy` step to the cron workflow.** The workflow only *pings* the deploy hook; Cloudflare does the actual build via git integration. Doing the build twice causes drift.
|
|
51
|
+
- **Don't skip the daily-deploy workflow even if the site has no scheduled content yet.** It's the safety net for when the user adds a scheduled post six months later.
|
|
52
|
+
|
|
53
|
+
## Verify
|
|
54
|
+
|
|
55
|
+
- [ ] `wrangler.toml` present at site root with correct `name`.
|
|
56
|
+
- [ ] `.github/workflows/daily-deploy.yml` present.
|
|
57
|
+
- [ ] `CLOUDFLARE_DEPLOY_HOOK_URL` secret set in the repo (check with `gh secret list`).
|
|
58
|
+
- [ ] Cloudflare Pages project exists and is connected to the git repo.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Deploy module — checklist
|
|
2
|
+
|
|
3
|
+
- [ ] `wrangler.toml` exists at site root with the correct Cloudflare Pages project `name`.
|
|
4
|
+
- [ ] `pages_build_output_dir = "dist"` in `wrangler.toml`.
|
|
5
|
+
- [ ] `.github/workflows/daily-deploy.yml` exists and is unmodified from the template (or modifications are documented in a comment).
|
|
6
|
+
- [ ] `CLOUDFLARE_DEPLOY_HOOK_URL` secret is set in the repo (`gh secret list`).
|
|
7
|
+
- [ ] Cloudflare Pages project exists and is connected to the GitHub repo (git integration).
|
|
8
|
+
- [ ] Pages build command is `npm run build`, output dir is `dist`, Node version is 20.
|
|
9
|
+
- [ ] Manual run of `gh workflow run "Daily Deploy"` triggers a Cloudflare deployment within seconds.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Daily Deploy
|
|
2
|
+
|
|
3
|
+
# Triggers a Cloudflare Pages rebuild each morning so any scheduled content
|
|
4
|
+
# whose publish date has arrived graduates from hidden to public.
|
|
5
|
+
#
|
|
6
|
+
# Requires a repository secret named CLOUDFLARE_DEPLOY_HOOK_URL holding
|
|
7
|
+
# the Deploy Hook URL from Cloudflare Pages (Project settings -> Builds &
|
|
8
|
+
# deployments -> Deploy hooks).
|
|
9
|
+
|
|
10
|
+
on:
|
|
11
|
+
schedule:
|
|
12
|
+
# 13:00 UTC = 06:00 PDT / 05:00 PST
|
|
13
|
+
- cron: '0 13 * * *'
|
|
14
|
+
workflow_dispatch: {}
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
trigger-cloudflare-build:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
timeout-minutes: 5
|
|
20
|
+
steps:
|
|
21
|
+
- name: Ping Cloudflare deploy hook
|
|
22
|
+
env:
|
|
23
|
+
HOOK: ${{ secrets.CLOUDFLARE_DEPLOY_HOOK_URL }}
|
|
24
|
+
run: |
|
|
25
|
+
if [ -z "$HOOK" ]; then
|
|
26
|
+
echo "CLOUDFLARE_DEPLOY_HOOK_URL secret is not set"
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
curl --fail-with-body -X POST "$HOOK"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Images module — instructions for Claude
|
|
2
|
+
|
|
3
|
+
When the user asks to **set up codejitsu/core/images** (or "wire up the image pipeline", "convert PNGs to WebP"), do the following.
|
|
4
|
+
|
|
5
|
+
## What this module provides
|
|
6
|
+
|
|
7
|
+
Two complementary layers:
|
|
8
|
+
|
|
9
|
+
1. **Astro sharp service** (runtime, automatic) — handles `<Image>` imports and `<Picture>` components inside `.astro` files. Configured in `astro.config.mjs`.
|
|
10
|
+
2. **Pre-pass CLI** (`npx codejitsu-optimize-images`) — recursively converts every `.png`/`.jpg`/`.jpeg` in `public/images/` to `.webp` (plus thumbnails). For images referenced by URL (in HTML strings, CSS `background-image`, or `<img src>` outside Astro processing).
|
|
11
|
+
|
|
12
|
+
Both layers are needed. Astro's service can't reach files referenced by URL; the pre-pass can't add the responsive variants Astro generates.
|
|
13
|
+
|
|
14
|
+
## Wiring it into a site
|
|
15
|
+
|
|
16
|
+
### 1. Configure Astro
|
|
17
|
+
|
|
18
|
+
In `astro.config.mjs`:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
export default defineConfig({
|
|
22
|
+
// ...
|
|
23
|
+
image: {
|
|
24
|
+
service: { entrypoint: 'astro/assets/services/sharp' },
|
|
25
|
+
defaults: { quality: 82, format: 'webp' },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### 2. Copy the optimizer config
|
|
31
|
+
|
|
32
|
+
Copy `templates/codejitsu-images.config.mjs` → site root. Edit `specialRules` for any images that need special handling (logo, OG share images, hero images that need aggressive compression).
|
|
33
|
+
|
|
34
|
+
### 3. Wire the pre-pass into the build
|
|
35
|
+
|
|
36
|
+
In the site's `package.json`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"scripts": {
|
|
41
|
+
"prebuild": "codejitsu-optimize-images",
|
|
42
|
+
"build": "astro build"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
If the site already has a `prebuild` script, chain: `"prebuild": "codejitsu-optimize-images && existing-script"`.
|
|
48
|
+
|
|
49
|
+
### 4. Run once
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm run prebuild
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Every PNG/JPG in `public/images/` now has a `.webp` sibling. References in HTML/CSS should use the `.webp` filename.
|
|
56
|
+
|
|
57
|
+
## How to reference images in templates
|
|
58
|
+
|
|
59
|
+
- **In Astro `<Image>` / `<Picture>`:** import from `src/assets/` or `~/assets/`. Astro processes them. Format defaults to WebP.
|
|
60
|
+
- **In raw `<img>` / CSS `background-image`:** reference the `.webp` file directly. Don't reference `.png` if a `.webp` exists.
|
|
61
|
+
- **OG / share images:** these are referenced by absolute URL on a CDN. Set `optimizePng: true` in `specialRules` so the original PNG is also compressed (Facebook scrapers sometimes prefer PNG over WebP).
|
|
62
|
+
|
|
63
|
+
## What must NOT be done
|
|
64
|
+
|
|
65
|
+
- **Don't reference `.png` in production HTML when the same image exists as `.webp`.** The `<img src="/images/x.png">` should be `<img src="/images/x.webp">`. Checklist enforces this.
|
|
66
|
+
- **Don't commit the generated `.webp` files... actually, DO commit them.** They're build artifacts but committing avoids forcing CI to install sharp. (Re-evaluate if `public/images` grows huge.)
|
|
67
|
+
- **Don't run the optimizer manually expecting it to skip already-converted files.** Sharp re-processes each run; this is intentional (so quality changes propagate). Re-running is cheap because outputs are written to siblings, not appended.
|
|
68
|
+
- **Don't put the optimizer in a `postinstall` hook.** Building on every `npm install` is annoying.
|
|
69
|
+
- **Don't add raw PNG/JPG to `src/assets/` for Astro `<Image>` use.** Astro handles those fine; the pre-pass is only for `public/`.
|
|
70
|
+
|
|
71
|
+
## Verify
|
|
72
|
+
|
|
73
|
+
- [ ] `astro.config.mjs` has `image.defaults: { format: 'webp' }`.
|
|
74
|
+
- [ ] `codejitsu-images.config.mjs` exists at site root.
|
|
75
|
+
- [ ] `package.json` calls `codejitsu-optimize-images` in `prebuild`.
|
|
76
|
+
- [ ] Every `.png`/`.jpg` in `public/images/` has a `.webp` sibling after build.
|
|
77
|
+
- [ ] No `<img src="*.png">` in built HTML where a `.webp` sibling exists.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { pathToFileURL } from 'url';
|
|
5
|
+
import { optimizeImages } from '../src/optimize.mjs';
|
|
6
|
+
|
|
7
|
+
const cwd = process.cwd();
|
|
8
|
+
const configCandidates = [
|
|
9
|
+
'codejitsu-images.config.mjs',
|
|
10
|
+
'codejitsu-images.config.js',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const defaults = {
|
|
14
|
+
sourceDir: path.join(cwd, 'public/images'),
|
|
15
|
+
thumbDir: path.join(cwd, 'public/images/thumbs'),
|
|
16
|
+
defaultQuality: 75,
|
|
17
|
+
defaultMaxSize: 1200,
|
|
18
|
+
thumbSize: 400,
|
|
19
|
+
thumbQuality: 70,
|
|
20
|
+
specialRules: {},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let userConfig = {};
|
|
24
|
+
for (const name of configCandidates) {
|
|
25
|
+
const p = path.join(cwd, name);
|
|
26
|
+
if (existsSync(p)) {
|
|
27
|
+
userConfig = (await import(pathToFileURL(p).href)).default ?? {};
|
|
28
|
+
console.log(`Loaded config: ${name}`);
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const config = {
|
|
34
|
+
...defaults,
|
|
35
|
+
...userConfig,
|
|
36
|
+
sourceDir: userConfig.sourceDir
|
|
37
|
+
? path.resolve(cwd, userConfig.sourceDir)
|
|
38
|
+
: defaults.sourceDir,
|
|
39
|
+
thumbDir: userConfig.thumbDir
|
|
40
|
+
? path.resolve(cwd, userConfig.thumbDir)
|
|
41
|
+
: defaults.thumbDir,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
await optimizeImages(config);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Images module — checklist
|
|
2
|
+
|
|
3
|
+
## Astro
|
|
4
|
+
|
|
5
|
+
- [ ] `astro.config.mjs` has `image.service: { entrypoint: 'astro/assets/services/sharp' }`.
|
|
6
|
+
- [ ] `astro.config.mjs` has `image.defaults: { quality: 82, format: 'webp' }` (or justified deviation).
|
|
7
|
+
|
|
8
|
+
## Pre-pass
|
|
9
|
+
|
|
10
|
+
- [ ] `codejitsu-images.config.mjs` exists at site root.
|
|
11
|
+
- [ ] `package.json` calls `codejitsu-optimize-images` in `prebuild` (or `build`).
|
|
12
|
+
- [ ] Every PNG/JPG in `public/images/` has a `.webp` sibling.
|
|
13
|
+
|
|
14
|
+
## Production HTML
|
|
15
|
+
|
|
16
|
+
- [ ] No `<img src>` references `.png` or `.jpg` where a `.webp` exists for the same path.
|
|
17
|
+
- [ ] CSS `background-image` URLs reference `.webp`.
|
|
18
|
+
- [ ] OG / Twitter share images have both PNG (for legacy scrapers) and WebP variants; the `og:image` meta tag points to the PNG (more compatible).
|
|
19
|
+
|
|
20
|
+
## Sizes
|
|
21
|
+
|
|
22
|
+
- [ ] No single image in `public/images/` is > 500KB. If so, it has a `specialRules` entry tuning quality/dimensions.
|
|
23
|
+
- [ ] Hero images have `width` and `height` attributes set (CLS).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface SpecialRule {
|
|
2
|
+
maxWidth?: number | null;
|
|
3
|
+
maxHeight?: number | null;
|
|
4
|
+
quality?: number;
|
|
5
|
+
smartSubsample?: boolean;
|
|
6
|
+
generateAvif?: boolean;
|
|
7
|
+
optimizePng?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface OptimizeImagesConfig {
|
|
11
|
+
sourceDir: string;
|
|
12
|
+
thumbDir?: string;
|
|
13
|
+
defaultQuality?: number;
|
|
14
|
+
defaultMaxSize?: number;
|
|
15
|
+
thumbSize?: number;
|
|
16
|
+
thumbQuality?: number;
|
|
17
|
+
specialRules?: Record<string, SpecialRule>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// @ts-expect-error - .mjs file resolved by Node at runtime
|
|
21
|
+
export { optimizeImages } from './optimize.mjs';
|