@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.
@@ -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
+ }
@@ -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
- Two complementary layers:
7
+ Three layers — use whichever you need:
8
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).
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
- 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
+ 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
- export default defineConfig({
22
- // ...
23
- image: {
24
- service: { entrypoint: 'astro/assets/services/sharp' },
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. Copy the optimizer config
28
+ ### 2. Configure the CLI in `codejitsu.config.ts`
31
29
 
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).
30
+ ```ts
31
+ import { defineConfig } from '@ibalzam/codejitsu-core/config';
33
32
 
34
- ### 3. Wire the pre-pass into the build
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
- In the site's `package.json`:
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
- 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.
70
+ ### 4. Workflow for new blog images (with autoBlogImages)
56
71
 
57
- ## How to reference images in templates
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
- - **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).
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 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/`.
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
- - [ ] `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.
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 { existsSync } from 'fs';
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
- 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
- };
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
- 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
- }
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 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
- };
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
- await optimizeImages(config);
52
+ console.log('[codejitsu-optimize-images] Done.');
@@ -1,23 +1,31 @@
1
1
  # Images module — checklist
2
2
 
3
- ## Astro
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
- ## Pre-pass
10
+ ## General optimization
9
11
 
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.
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 have both PNG (for legacy scrapers) and WebP variants; the `og:image` meta tag points to the PNG (more compatible).
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/images/` is > 500KB. If so, it has a `specialRules` entry tuning quality/dimensions.
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 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
- }
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
- * Recursively converts JPG/JPEG/PNG → WebP under `sourceDir`, writing
8
- * matching thumbnails under `thumbDir`. Per-file rules can override
9
- * defaults (quality, dimensions, AVIF generation, in-place PNG optimization).
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 Absolute path to source images.
13
- * @param {string} config.thumbDir Absolute path to write thumbnails (skipped if same as sourceDir).
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
  }