@ibalzam/codejitsu-core 0.1.0 → 0.2.1
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 +55 -39
- package/MIGRATIONS/0.2.0.md +166 -0
- package/README.md +25 -5
- package/checklist/bin/run.mjs +97 -55
- package/modules/blog/CLAUDE.md +105 -52
- package/modules/blog/src/collection.ts +176 -0
- package/modules/blog/src/fs.ts +167 -0
- package/modules/blog/src/index.ts +5 -201
- package/modules/blog/src/types.ts +71 -0
- package/modules/blog/templates/content.config.ts +27 -0
- package/modules/blog/templates/lib/blog-fs.ts +14 -0
- package/modules/blog/templates/lib/blog.ts +11 -3
- package/modules/config/CLAUDE.md +121 -0
- package/modules/config/src/define.mjs +14 -0
- package/modules/config/src/index.ts +5 -0
- package/modules/config/src/load.mjs +92 -0
- package/modules/config/src/types.ts +203 -0
- package/modules/images/CLAUDE.md +56 -39
- package/modules/images/bin/optimize.mjs +42 -34
- package/modules/images/checklist.md +15 -7
- package/modules/images/src/auto-blog.mjs +112 -0
- package/modules/images/src/index.ts +3 -18
- package/modules/images/src/optimize.mjs +7 -9
- package/modules/llms/CLAUDE.md +121 -28
- package/modules/llms/bin/generate.mjs +13 -23
- package/modules/llms/checklist.md +7 -6
- package/modules/llms/src/generate.mjs +374 -108
- package/modules/seo/CLAUDE.md +65 -21
- package/modules/seo/templates/Head.astro +99 -27
- package/package.json +11 -1
- package/src/index.ts +1 -1
- package/modules/images/templates/codejitsu-images.config.mjs +0 -18
- package/modules/llms/templates/codejitsu-llms.config.mjs +0 -39
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Codejitsu config — the shape of `codejitsu.config.ts` at site root.
|
|
3
|
+
*
|
|
4
|
+
* One config drives every module. Each top-level module key (`blog`, `seo`,
|
|
5
|
+
* `images`, `llms`, `deploy`) is optional: omit it to disable that module,
|
|
6
|
+
* or include it (even as `{}`) to enable with defaults. Set `enabled: false`
|
|
7
|
+
* to explicitly disable while keeping the section for documentation.
|
|
8
|
+
*/
|
|
9
|
+
export interface CodejitsuConfig {
|
|
10
|
+
/** Site-wide identity and metadata, used by multiple modules. */
|
|
11
|
+
site: SiteConfig;
|
|
12
|
+
|
|
13
|
+
blog?: BlogConfig | false;
|
|
14
|
+
seo?: SeoConfig | false;
|
|
15
|
+
images?: ImagesConfig | false;
|
|
16
|
+
llms?: LlmsConfig | false;
|
|
17
|
+
deploy?: DeployConfig | false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SiteConfig {
|
|
21
|
+
/** Absolute site URL, no trailing slash. e.g. 'https://example.com'. */
|
|
22
|
+
url: string;
|
|
23
|
+
/** Brand name. e.g. 'Pearl Remodeling'. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** Appended to <title> tags. e.g. ' — Pearl Remodeling'. */
|
|
26
|
+
titleSuffix?: string;
|
|
27
|
+
/** Default author when blog posts don't specify one. */
|
|
28
|
+
defaultAuthor?: string;
|
|
29
|
+
/** Default OG image (relative path or absolute URL). */
|
|
30
|
+
defaultOgImage?: string;
|
|
31
|
+
/** HTML lang attribute. e.g. 'en-US', 'en'. */
|
|
32
|
+
locale?: string;
|
|
33
|
+
/** Optional structured business info for Organization / LocalBusiness schema. */
|
|
34
|
+
business?: BusinessInfo;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BusinessInfo {
|
|
38
|
+
legalName?: string;
|
|
39
|
+
telephone?: string;
|
|
40
|
+
email?: string;
|
|
41
|
+
/** Used for LocalBusiness schema and contact pages. */
|
|
42
|
+
address?: PostalAddress;
|
|
43
|
+
geo?: { latitude: number; longitude: number };
|
|
44
|
+
/** Social profile URLs. */
|
|
45
|
+
sameAs?: string[];
|
|
46
|
+
/** e.g. '$$', '$$$'. */
|
|
47
|
+
priceRange?: string;
|
|
48
|
+
/** Service areas. Strings (city names) or objects. */
|
|
49
|
+
areaServed?: string[];
|
|
50
|
+
/** License number for licensed trades. */
|
|
51
|
+
license?: string;
|
|
52
|
+
/** Schema.org type override, e.g. 'HVACBusiness', 'HomeAndConstructionBusiness'. */
|
|
53
|
+
schemaType?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PostalAddress {
|
|
57
|
+
streetAddress?: string;
|
|
58
|
+
addressLocality: string;
|
|
59
|
+
addressRegion?: string;
|
|
60
|
+
postalCode?: string;
|
|
61
|
+
addressCountry: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface BlogConfig {
|
|
65
|
+
enabled?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* 'collection' — use Astro Content Collections (recommended for Astro sites).
|
|
68
|
+
* 'fs' — read .md files directly via gray-matter (for non-Astro projects).
|
|
69
|
+
* Defaults to 'collection' if the site has astro as a dep; otherwise 'fs'.
|
|
70
|
+
*/
|
|
71
|
+
mode?: 'collection' | 'fs';
|
|
72
|
+
/** For 'fs' mode: where the .md files live. Default 'content/blog'. */
|
|
73
|
+
contentDir?: string;
|
|
74
|
+
/** For 'collection' mode: name of the Astro CC. Default 'blog'. */
|
|
75
|
+
collectionName?: string;
|
|
76
|
+
/** Frontmatter field for the post date. Default 'date'. Pearl uses 'pubDate'. */
|
|
77
|
+
dateField?: string;
|
|
78
|
+
/** Frontmatter field for draft state. Default null (no draft support). Pearl uses 'draft'. */
|
|
79
|
+
draftField?: string | null;
|
|
80
|
+
/** Category definitions for /blog/category/[slug] pages. */
|
|
81
|
+
categories?: BlogCategory[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface BlogCategory {
|
|
85
|
+
slug: string;
|
|
86
|
+
tag: string;
|
|
87
|
+
title: string;
|
|
88
|
+
subtitle: string;
|
|
89
|
+
metaDescription: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface SeoConfig {
|
|
93
|
+
enabled?: boolean;
|
|
94
|
+
sitemap?: {
|
|
95
|
+
/** Regex patterns to exclude from the sitemap. */
|
|
96
|
+
excludePatterns?: RegExp[];
|
|
97
|
+
/**
|
|
98
|
+
* Site-specific priority rules, evaluated before defaults.
|
|
99
|
+
* First matching pattern wins.
|
|
100
|
+
*/
|
|
101
|
+
priorityRules?: Array<{
|
|
102
|
+
pattern: RegExp;
|
|
103
|
+
priority: number;
|
|
104
|
+
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
105
|
+
}>;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Default schemas to inject site-wide when not overridden per-page.
|
|
109
|
+
* e.g. always-on Organization or LocalBusiness on every page.
|
|
110
|
+
*/
|
|
111
|
+
defaultSchemas?: ('organization' | 'localBusiness' | 'website')[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ImagesConfig {
|
|
115
|
+
enabled?: boolean;
|
|
116
|
+
/** Source dir for the recursive optimizer. Default 'public/images'. */
|
|
117
|
+
sourceDir?: string;
|
|
118
|
+
/** Thumbnail output dir. Set to null to disable thumb generation. Default null. */
|
|
119
|
+
thumbDir?: string | null;
|
|
120
|
+
defaultQuality?: number;
|
|
121
|
+
defaultMaxSize?: number;
|
|
122
|
+
thumbSize?: number;
|
|
123
|
+
thumbQuality?: number;
|
|
124
|
+
/**
|
|
125
|
+
* Per-file rule overrides. Key = path relative to sourceDir without extension.
|
|
126
|
+
* e.g. 'logos/logo': { maxWidth: 329, quality: 35, generateAvif: true }
|
|
127
|
+
*/
|
|
128
|
+
specialRules?: Record<string, SpecialRule>;
|
|
129
|
+
/**
|
|
130
|
+
* Blog-post image automation. Replaces hand-maintained title→slug maps.
|
|
131
|
+
* If set, the optimizer scans `contentDir` for post filenames and looks for
|
|
132
|
+
* matching source images in `sourceImageDir`, optimizing them into `outputDir`.
|
|
133
|
+
*/
|
|
134
|
+
autoBlogImages?: {
|
|
135
|
+
contentDir: string;
|
|
136
|
+
sourceImageDir: string;
|
|
137
|
+
outputDir: string;
|
|
138
|
+
width: number;
|
|
139
|
+
height?: number | null;
|
|
140
|
+
quality?: number;
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface SpecialRule {
|
|
145
|
+
maxWidth?: number | null;
|
|
146
|
+
maxHeight?: number | null;
|
|
147
|
+
quality?: number;
|
|
148
|
+
smartSubsample?: boolean;
|
|
149
|
+
generateAvif?: boolean;
|
|
150
|
+
optimizePng?: boolean;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface LlmsConfig {
|
|
154
|
+
enabled?: boolean;
|
|
155
|
+
/**
|
|
156
|
+
* 'config' — sections are listed explicitly in this config (simplest sites).
|
|
157
|
+
* 'content-scan' — modules scan content dirs to enumerate URLs (pearl pattern).
|
|
158
|
+
* Default 'config'.
|
|
159
|
+
*/
|
|
160
|
+
mode?: 'config' | 'content-scan';
|
|
161
|
+
|
|
162
|
+
/** One-line tagline appended to the title in the output. */
|
|
163
|
+
tagline?: string;
|
|
164
|
+
/** Short "About" paragraph (used in llms.txt). */
|
|
165
|
+
about?: string;
|
|
166
|
+
/** Longer "About" content (used in llms-full.txt; falls back to `about`). */
|
|
167
|
+
aboutFull?: string;
|
|
168
|
+
/** Sections for 'config' mode. Ignored in 'content-scan' mode. */
|
|
169
|
+
sections?: LlmsSection[];
|
|
170
|
+
/** "For AI Assistants" block content (both modes). */
|
|
171
|
+
aiGuidance?: string;
|
|
172
|
+
|
|
173
|
+
/** Blog directory (auto-included in both modes). */
|
|
174
|
+
blogDir?: string;
|
|
175
|
+
blogLimit?: number;
|
|
176
|
+
blogFullLimit?: number;
|
|
177
|
+
|
|
178
|
+
/** Settings for 'content-scan' mode. */
|
|
179
|
+
contentScan?: {
|
|
180
|
+
servicesDir?: string;
|
|
181
|
+
locationsDir?: string;
|
|
182
|
+
pagesDir?: string;
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface LlmsSection {
|
|
187
|
+
title: string;
|
|
188
|
+
description?: string;
|
|
189
|
+
items: LlmsSectionItem[];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface LlmsSectionItem {
|
|
193
|
+
title: string;
|
|
194
|
+
description: string;
|
|
195
|
+
url: string;
|
|
196
|
+
fullDescription?: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface DeployConfig {
|
|
200
|
+
enabled?: boolean;
|
|
201
|
+
/** Cloudflare Pages project name. Used for documentation only. */
|
|
202
|
+
cloudflarePagesName?: string;
|
|
203
|
+
}
|
package/modules/images/CLAUDE.md
CHANGED
|
@@ -1,39 +1,62 @@
|
|
|
1
1
|
# Images module — instructions for Claude
|
|
2
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.
|
|
3
|
+
When the user asks to **set up codejitsu/core/images** (or "wire up the image pipeline", "convert PNGs to WebP", "automate blog images"), do the following.
|
|
4
4
|
|
|
5
5
|
## What this module provides
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Three layers — use whichever you need:
|
|
8
8
|
|
|
9
|
-
1. **Astro sharp service** (runtime, automatic) —
|
|
10
|
-
2.
|
|
9
|
+
1. **Astro sharp service** (runtime, automatic) — `<Image>` / `<Picture>` in `.astro` files. Configured in `astro.config.mjs`.
|
|
10
|
+
2. **`codejitsu-optimize-images` CLI** — recursive PNG/JPG → WebP pre-pass for `public/` (general site assets, logos, hero images). Per-file `specialRules` overrides.
|
|
11
|
+
3. **`autoBlogImages` mode** — given a content collection of `<slug>.md` files and a source-image directory with `<slug>.<ext>` files, emits optimized WebPs to an output dir. Replaces hand-maintained title→slug maps.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
All three read from the **single `codejitsu.config.ts`** at site root.
|
|
13
14
|
|
|
14
15
|
## Wiring it into a site
|
|
15
16
|
|
|
16
|
-
### 1. Configure Astro
|
|
17
|
+
### 1. Configure Astro sharp service
|
|
17
18
|
|
|
18
19
|
In `astro.config.mjs`:
|
|
19
20
|
|
|
20
21
|
```ts
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
defaults: { quality: 82, format: 'webp' },
|
|
26
|
-
},
|
|
27
|
-
});
|
|
22
|
+
image: {
|
|
23
|
+
service: { entrypoint: 'astro/assets/services/sharp' },
|
|
24
|
+
defaults: { quality: 82, format: 'webp' },
|
|
25
|
+
},
|
|
28
26
|
```
|
|
29
27
|
|
|
30
|
-
### 2.
|
|
28
|
+
### 2. Configure the CLI in `codejitsu.config.ts`
|
|
31
29
|
|
|
32
|
-
|
|
30
|
+
```ts
|
|
31
|
+
import { defineConfig } from '@ibalzam/codejitsu-core/config';
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
export default defineConfig({
|
|
34
|
+
site: { url: '...', name: '...' },
|
|
35
|
+
images: {
|
|
36
|
+
// General optimizer — scan a dir, convert every PNG/JPG to WebP.
|
|
37
|
+
sourceDir: 'public/assets/images',
|
|
38
|
+
defaultQuality: 82,
|
|
39
|
+
defaultMaxSize: 1376,
|
|
40
|
+
|
|
41
|
+
specialRules: {
|
|
42
|
+
'logos/logo': { maxWidth: 400, quality: 35, generateAvif: true },
|
|
43
|
+
'sharing/og-default': { maxWidth: 1200, maxHeight: 630, quality: 85, optimizePng: true },
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Blog image automation — one image per post slug.
|
|
47
|
+
autoBlogImages: {
|
|
48
|
+
contentDir: 'src/content/blog',
|
|
49
|
+
sourceImageDir: 'private/blog-source-images', // not committed
|
|
50
|
+
outputDir: 'public/assets/images/blog',
|
|
51
|
+
width: 1376,
|
|
52
|
+
height: null, // preserve aspect ratio
|
|
53
|
+
quality: 82,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
```
|
|
35
58
|
|
|
36
|
-
|
|
59
|
+
### 3. Wire into prebuild
|
|
37
60
|
|
|
38
61
|
```json
|
|
39
62
|
{
|
|
@@ -44,34 +67,28 @@ In the site's `package.json`:
|
|
|
44
67
|
}
|
|
45
68
|
```
|
|
46
69
|
|
|
47
|
-
|
|
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.
|
|
70
|
+
### 4. Workflow for new blog images (with autoBlogImages)
|
|
56
71
|
|
|
57
|
-
|
|
72
|
+
1. Generate or save the source image (any size, any format).
|
|
73
|
+
2. **Name it after the post slug** — e.g. `backyard-patio-ideas-henderson-summer-heat.png`.
|
|
74
|
+
3. Place it in `images.autoBlogImages.sourceImageDir`.
|
|
75
|
+
4. Run `npm run prebuild` (or just `npm run build` — it runs prebuild first).
|
|
76
|
+
5. The optimized WebP appears at `<outputDir>/<slug>.webp`. Commit it.
|
|
58
77
|
|
|
59
|
-
|
|
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).
|
|
78
|
+
The CLI warns about post slugs with no matching source image. No more hand-edited maps.
|
|
62
79
|
|
|
63
80
|
## What must NOT be done
|
|
64
81
|
|
|
65
|
-
- **Don't
|
|
66
|
-
- **Don't
|
|
67
|
-
- **Don't
|
|
68
|
-
- **Don't
|
|
69
|
-
- **Don't
|
|
82
|
+
- **Don't keep an old `codejitsu-images.config.mjs` file around.** v0.2.0 hard-broke it; only `codejitsu.config.ts` is read.
|
|
83
|
+
- **Don't reference `.png` in production HTML when a `.webp` exists** at the same path. The Astro `<Image>` component handles it; raw `<img src>` must point to the `.webp`.
|
|
84
|
+
- **Don't put source images for `autoBlogImages` inside `public/`.** They get served. Use a sibling dir like `private/blog-source-images/` (and add it to `.gitignore` if the sources are heavy / AI-generated).
|
|
85
|
+
- **Don't run the optimizer expecting it to skip up-to-date general files.** Only `autoBlogImages` does mtime-based skipping. The general optimizer re-processes every file (so quality changes propagate). Re-running is cheap.
|
|
86
|
+
- **Don't put `autoBlogImages.sourceImageDir` and `outputDir` to the same place.** That'd recursively re-optimize outputs.
|
|
70
87
|
|
|
71
88
|
## Verify
|
|
72
89
|
|
|
73
|
-
- [ ] `
|
|
74
|
-
- [ ] `
|
|
75
|
-
- [ ] `
|
|
76
|
-
- [ ]
|
|
90
|
+
- [ ] `codejitsu.config.ts` has an `images` section.
|
|
91
|
+
- [ ] `prebuild` script in `package.json` runs `codejitsu-optimize-images`.
|
|
92
|
+
- [ ] Every PNG/JPG in `images.sourceDir` has a `.webp` sibling after build.
|
|
93
|
+
- [ ] If `autoBlogImages` is configured: every `.md` in `contentDir` has a matching `.webp` in `outputDir`.
|
|
77
94
|
- [ ] No `<img src="*.png">` in built HTML where a `.webp` sibling exists.
|
|
@@ -1,44 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import {
|
|
4
|
-
import { pathToFileURL } from 'url';
|
|
3
|
+
import { loadConfig, isModuleEnabled } from '../../config/src/load.mjs';
|
|
5
4
|
import { optimizeImages } from '../src/optimize.mjs';
|
|
5
|
+
import { autoBlogImages } from '../src/auto-blog.mjs';
|
|
6
6
|
|
|
7
7
|
const cwd = process.cwd();
|
|
8
|
-
const configCandidates = [
|
|
9
|
-
'codejitsu-images.config.mjs',
|
|
10
|
-
'codejitsu-images.config.js',
|
|
11
|
-
];
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
specialRules: {},
|
|
21
|
-
};
|
|
9
|
+
let config;
|
|
10
|
+
try {
|
|
11
|
+
config = await loadConfig(cwd);
|
|
12
|
+
} catch (err) {
|
|
13
|
+
console.error(`[codejitsu-optimize-images] ${err.message}`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
22
16
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (existsSync(p)) {
|
|
27
|
-
userConfig = (await import(pathToFileURL(p).href)).default ?? {};
|
|
28
|
-
console.log(`Loaded config: ${name}`);
|
|
29
|
-
break;
|
|
30
|
-
}
|
|
17
|
+
if (!isModuleEnabled(config, 'images')) {
|
|
18
|
+
console.log('[codejitsu-optimize-images] images module disabled; skipping.');
|
|
19
|
+
process.exit(0);
|
|
31
20
|
}
|
|
32
21
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
? path.resolve(cwd,
|
|
41
|
-
:
|
|
42
|
-
|
|
22
|
+
const images = config.images;
|
|
23
|
+
|
|
24
|
+
// 1. General recursive optimizer (if sourceDir configured).
|
|
25
|
+
if (images.sourceDir) {
|
|
26
|
+
console.log(`[codejitsu-optimize-images] Scanning ${images.sourceDir}…`);
|
|
27
|
+
await optimizeImages({
|
|
28
|
+
sourceDir: path.resolve(cwd, images.sourceDir),
|
|
29
|
+
thumbDir: images.thumbDir ? path.resolve(cwd, images.thumbDir) : null,
|
|
30
|
+
defaultQuality: images.defaultQuality,
|
|
31
|
+
defaultMaxSize: images.defaultMaxSize,
|
|
32
|
+
thumbSize: images.thumbSize,
|
|
33
|
+
thumbQuality: images.thumbQuality,
|
|
34
|
+
specialRules: images.specialRules,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 2. Blog-image auto-processing (if configured).
|
|
39
|
+
if (images.autoBlogImages) {
|
|
40
|
+
const a = images.autoBlogImages;
|
|
41
|
+
console.log(`[codejitsu-optimize-images] Auto blog images: ${a.contentDir} → ${a.outputDir}`);
|
|
42
|
+
await autoBlogImages({
|
|
43
|
+
contentDir: path.resolve(cwd, a.contentDir),
|
|
44
|
+
sourceImageDir: path.resolve(cwd, a.sourceImageDir),
|
|
45
|
+
outputDir: path.resolve(cwd, a.outputDir),
|
|
46
|
+
width: a.width,
|
|
47
|
+
height: a.height,
|
|
48
|
+
quality: a.quality,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
console.log('[codejitsu-optimize-images] Done.');
|
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
# Images module — checklist
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Setup
|
|
4
4
|
|
|
5
5
|
- [ ] `astro.config.mjs` has `image.service: { entrypoint: 'astro/assets/services/sharp' }`.
|
|
6
6
|
- [ ] `astro.config.mjs` has `image.defaults: { quality: 82, format: 'webp' }` (or justified deviation).
|
|
7
|
+
- [ ] `codejitsu.config.ts` has an `images` section.
|
|
8
|
+
- [ ] `package.json` calls `codejitsu-optimize-images` in `prebuild` (or `build`).
|
|
7
9
|
|
|
8
|
-
##
|
|
10
|
+
## General optimization
|
|
9
11
|
|
|
10
|
-
- [ ] `
|
|
11
|
-
- [ ]
|
|
12
|
-
|
|
12
|
+
- [ ] Every PNG/JPG under `images.sourceDir` has a `.webp` sibling after build.
|
|
13
|
+
- [ ] Critical files have `specialRules` entries (logo, OG images, hero images).
|
|
14
|
+
|
|
15
|
+
## Blog automation (if configured)
|
|
16
|
+
|
|
17
|
+
- [ ] Every `.md` filename in `autoBlogImages.contentDir` has a matching `.webp` in `outputDir`.
|
|
18
|
+
- [ ] `autoBlogImages.sourceImageDir` is not inside `public/` (sources shouldn't ship to browsers).
|
|
19
|
+
- [ ] `autoBlogImages.sourceImageDir` is in `.gitignore` if sources are heavy or AI-generated.
|
|
20
|
+
- [ ] No "missing source" warnings from the last `codejitsu-optimize-images` run (or, if any, they're for posts intentionally without images).
|
|
13
21
|
|
|
14
22
|
## Production HTML
|
|
15
23
|
|
|
16
24
|
- [ ] No `<img src>` references `.png` or `.jpg` where a `.webp` exists for the same path.
|
|
17
25
|
- [ ] CSS `background-image` URLs reference `.webp`.
|
|
18
|
-
- [ ] OG / Twitter share images
|
|
26
|
+
- [ ] OG / Twitter share images: PNG (for legacy scrapers) AND WebP variants; the `og:image` meta tag points to the PNG (more compatible).
|
|
19
27
|
|
|
20
28
|
## Sizes
|
|
21
29
|
|
|
22
|
-
- [ ] No single image in `public
|
|
30
|
+
- [ ] No single image in production `public/` is > 500KB. If so, it has a `specialRules` entry tuning quality/dimensions.
|
|
23
31
|
- [ ] Hero images have `width` and `height` attributes set (CLS).
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { readdir, mkdir, stat } from 'fs/promises';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { join, parse } from 'path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Auto-process blog post images based on filename-as-slug convention.
|
|
8
|
+
*
|
|
9
|
+
* For every .md file in `contentDir`, looks for a matching source image
|
|
10
|
+
* (`<slug>.png`, `<slug>.jpg`, `<slug>.jpeg`, `<slug>.webp`) in `sourceImageDir`.
|
|
11
|
+
* If found, emits an optimized WebP to `<outputDir>/<slug>.webp`.
|
|
12
|
+
*
|
|
13
|
+
* Skips when the output is newer than the source (incremental builds).
|
|
14
|
+
* Warns about slugs with no matching source image.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} config
|
|
17
|
+
* @param {string} config.contentDir Where .md posts live, e.g. 'src/content/blog'.
|
|
18
|
+
* @param {string} config.sourceImageDir Where source images live (one per slug).
|
|
19
|
+
* @param {string} config.outputDir Where to write WebPs.
|
|
20
|
+
* @param {number} config.width
|
|
21
|
+
* @param {number|null} [config.height] Null = preserve aspect ratio.
|
|
22
|
+
* @param {number} [config.quality=82]
|
|
23
|
+
*/
|
|
24
|
+
export async function autoBlogImages(config) {
|
|
25
|
+
const {
|
|
26
|
+
contentDir,
|
|
27
|
+
sourceImageDir,
|
|
28
|
+
outputDir,
|
|
29
|
+
width,
|
|
30
|
+
height = null,
|
|
31
|
+
quality = 82,
|
|
32
|
+
} = config;
|
|
33
|
+
|
|
34
|
+
if (!existsSync(contentDir)) {
|
|
35
|
+
console.log(`autoBlogImages: contentDir ${contentDir} doesn't exist; skipping.`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!existsSync(sourceImageDir)) {
|
|
39
|
+
console.log(`autoBlogImages: sourceImageDir ${sourceImageDir} doesn't exist; skipping.`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!existsSync(outputDir)) {
|
|
43
|
+
await mkdir(outputDir, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const slugs = await collectSlugs(contentDir);
|
|
47
|
+
const exts = ['png', 'jpg', 'jpeg', 'webp'];
|
|
48
|
+
|
|
49
|
+
let processed = 0;
|
|
50
|
+
let skipped = 0;
|
|
51
|
+
const missing = [];
|
|
52
|
+
|
|
53
|
+
for (const slug of slugs) {
|
|
54
|
+
let sourcePath = null;
|
|
55
|
+
for (const ext of exts) {
|
|
56
|
+
const candidate = join(sourceImageDir, `${slug}.${ext}`);
|
|
57
|
+
if (existsSync(candidate)) {
|
|
58
|
+
sourcePath = candidate;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!sourcePath) {
|
|
64
|
+
missing.push(slug);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const outputPath = join(outputDir, `${slug}.webp`);
|
|
69
|
+
|
|
70
|
+
if (existsSync(outputPath)) {
|
|
71
|
+
const [srcStat, outStat] = await Promise.all([stat(sourcePath), stat(outputPath)]);
|
|
72
|
+
if (outStat.mtimeMs >= srcStat.mtimeMs) {
|
|
73
|
+
skipped++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fit = height ? 'cover' : 'inside';
|
|
79
|
+
await sharp(sourcePath)
|
|
80
|
+
.resize(width, height, { fit, withoutEnlargement: true, position: 'center' })
|
|
81
|
+
.webp({ quality, effort: 6 })
|
|
82
|
+
.toFile(outputPath);
|
|
83
|
+
console.log(`✓ ${slug}.webp (from ${parse(sourcePath).base}, q=${quality})`);
|
|
84
|
+
processed++;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log(
|
|
88
|
+
`autoBlogImages: ${processed} processed, ${skipped} up-to-date, ${missing.length} missing source.`
|
|
89
|
+
);
|
|
90
|
+
if (missing.length > 0) {
|
|
91
|
+
console.warn(' Missing source images for slugs:');
|
|
92
|
+
for (const slug of missing) console.warn(` - ${slug}`);
|
|
93
|
+
console.warn(
|
|
94
|
+
` Place an image named <slug>.{png,jpg,jpeg,webp} in ${sourceImageDir} to fix.`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function collectSlugs(dir) {
|
|
100
|
+
const slugs = [];
|
|
101
|
+
async function walk(d) {
|
|
102
|
+
for (const entry of await readdir(d, { withFileTypes: true })) {
|
|
103
|
+
const full = join(d, entry.name);
|
|
104
|
+
if (entry.isDirectory()) await walk(full);
|
|
105
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
106
|
+
slugs.push(entry.name.replace(/\.md$/, ''));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
await walk(dir);
|
|
111
|
+
return slugs;
|
|
112
|
+
}
|
|
@@ -1,21 +1,6 @@
|
|
|
1
|
-
export
|
|
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
|
-
}
|
|
1
|
+
export type { ImagesConfig, SpecialRule } from '../../config/src/types.js';
|
|
19
2
|
|
|
20
3
|
// @ts-expect-error - .mjs file resolved by Node at runtime
|
|
21
4
|
export { optimizeImages } from './optimize.mjs';
|
|
5
|
+
// @ts-expect-error - .mjs file resolved by Node at runtime
|
|
6
|
+
export { autoBlogImages } from './auto-blog.mjs';
|
|
@@ -4,26 +4,25 @@ import { existsSync } from 'fs';
|
|
|
4
4
|
import { join, dirname, parse, relative } from 'path';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* General recursive PNG/JPG → WebP optimizer.
|
|
8
|
+
*
|
|
9
|
+
* Walks `sourceDir` recursively. For every .jpg/.jpeg/.png, emits a .webp sibling.
|
|
10
|
+
* Per-file overrides via `specialRules`. Optional thumbnail generation.
|
|
10
11
|
*
|
|
11
12
|
* @param {object} config
|
|
12
|
-
* @param {string} config.sourceDir
|
|
13
|
-
* @param {string} config.thumbDir
|
|
13
|
+
* @param {string} config.sourceDir
|
|
14
|
+
* @param {string|null} [config.thumbDir] Null = no thumbs.
|
|
14
15
|
* @param {number} [config.defaultQuality=75]
|
|
15
16
|
* @param {number} [config.defaultMaxSize=1200]
|
|
16
17
|
* @param {number} [config.thumbSize=400]
|
|
17
18
|
* @param {number} [config.thumbQuality=70]
|
|
18
19
|
* @param {Record<string, SpecialRule>} [config.specialRules]
|
|
19
20
|
* Key = path relative to sourceDir without extension (e.g. 'logos/logo').
|
|
20
|
-
* Each rule: { maxWidth?, maxHeight?, quality?, smartSubsample?,
|
|
21
|
-
* generateAvif?, optimizePng? }.
|
|
22
21
|
*/
|
|
23
22
|
export async function optimizeImages(config) {
|
|
24
23
|
const {
|
|
25
24
|
sourceDir,
|
|
26
|
-
thumbDir,
|
|
25
|
+
thumbDir = null,
|
|
27
26
|
defaultQuality = 75,
|
|
28
27
|
defaultMaxSize = 1200,
|
|
29
28
|
thumbSize = 400,
|
|
@@ -109,5 +108,4 @@ export async function optimizeImages(config) {
|
|
|
109
108
|
}
|
|
110
109
|
|
|
111
110
|
await processDir(sourceDir);
|
|
112
|
-
console.log('\nDone.');
|
|
113
111
|
}
|