@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.
Files changed (40) hide show
  1. package/CLAUDE.md +67 -0
  2. package/LICENSE +21 -0
  3. package/MIGRATIONS/README.md +30 -0
  4. package/README.md +35 -0
  5. package/checklist/bin/run.mjs +189 -0
  6. package/checklist/core.md +55 -0
  7. package/modules/blog/CLAUDE.md +87 -0
  8. package/modules/blog/checklist.md +36 -0
  9. package/modules/blog/src/components/index.ts +1 -0
  10. package/modules/blog/src/index.ts +201 -0
  11. package/modules/blog/templates/content/_sample-post.md +18 -0
  12. package/modules/blog/templates/lib/blog.ts +17 -0
  13. package/modules/blog/templates/pages/blog/[...slug].astro +53 -0
  14. package/modules/blog/templates/pages/blog/category/[category].astro +40 -0
  15. package/modules/blog/templates/pages/blog/index.astro +30 -0
  16. package/modules/blog/templates/pages/blog/tag/[tag].astro +35 -0
  17. package/modules/deploy/CLAUDE.md +58 -0
  18. package/modules/deploy/checklist.md +9 -0
  19. package/modules/deploy/templates/daily-deploy.yml +29 -0
  20. package/modules/deploy/templates/wrangler.toml +2 -0
  21. package/modules/images/CLAUDE.md +77 -0
  22. package/modules/images/bin/optimize.mjs +44 -0
  23. package/modules/images/checklist.md +23 -0
  24. package/modules/images/src/index.ts +21 -0
  25. package/modules/images/src/optimize.mjs +113 -0
  26. package/modules/images/templates/codejitsu-images.config.mjs +18 -0
  27. package/modules/llms/CLAUDE.md +57 -0
  28. package/modules/llms/bin/generate.mjs +35 -0
  29. package/modules/llms/checklist.md +10 -0
  30. package/modules/llms/src/generate.mjs +179 -0
  31. package/modules/llms/templates/codejitsu-llms.config.mjs +39 -0
  32. package/modules/seo/CLAUDE.md +95 -0
  33. package/modules/seo/checklist.md +30 -0
  34. package/modules/seo/src/index.ts +2 -0
  35. package/modules/seo/src/schema.ts +203 -0
  36. package/modules/seo/src/sitemap.ts +109 -0
  37. package/modules/seo/templates/Head.astro +53 -0
  38. package/modules/seo/templates/robots.txt +5 -0
  39. package/package.json +73 -0
  40. package/src/index.ts +1 -0
@@ -0,0 +1,113 @@
1
+ import sharp from 'sharp';
2
+ import { readdir, mkdir } from 'fs/promises';
3
+ import { existsSync } from 'fs';
4
+ import { join, dirname, parse, relative } from 'path';
5
+
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).
10
+ *
11
+ * @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).
14
+ * @param {number} [config.defaultQuality=75]
15
+ * @param {number} [config.defaultMaxSize=1200]
16
+ * @param {number} [config.thumbSize=400]
17
+ * @param {number} [config.thumbQuality=70]
18
+ * @param {Record<string, SpecialRule>} [config.specialRules]
19
+ * Key = path relative to sourceDir without extension (e.g. 'logos/logo').
20
+ * Each rule: { maxWidth?, maxHeight?, quality?, smartSubsample?,
21
+ * generateAvif?, optimizePng? }.
22
+ */
23
+ export async function optimizeImages(config) {
24
+ const {
25
+ sourceDir,
26
+ thumbDir,
27
+ defaultQuality = 75,
28
+ defaultMaxSize = 1200,
29
+ thumbSize = 400,
30
+ thumbQuality = 70,
31
+ specialRules = {},
32
+ } = config;
33
+
34
+ if (!existsSync(sourceDir)) {
35
+ console.log(`No source dir at ${sourceDir} — nothing to optimize.`);
36
+ return;
37
+ }
38
+
39
+ const generateThumbs = thumbDir && thumbDir !== sourceDir;
40
+ if (generateThumbs && !existsSync(thumbDir)) {
41
+ await mkdir(thumbDir, { recursive: true });
42
+ }
43
+
44
+ async function processDir(dir) {
45
+ const entries = await readdir(dir, { withFileTypes: true });
46
+ for (const entry of entries) {
47
+ const fullPath = join(dir, entry.name);
48
+ if (entry.isDirectory()) {
49
+ if (generateThumbs && fullPath === thumbDir) continue;
50
+ await processDir(fullPath);
51
+ continue;
52
+ }
53
+ if (!entry.isFile()) continue;
54
+ const lower = entry.name.toLowerCase();
55
+ if (!/\.(jpe?g|png)$/.test(lower)) continue;
56
+
57
+ const { name } = parse(entry.name);
58
+ const relPath = relative(sourceDir, fullPath);
59
+ const ruleKey = relPath.replace(/\.(jpe?g|png)$/i, '');
60
+ const rule = specialRules[ruleKey];
61
+
62
+ const outputDir = dirname(fullPath);
63
+ const webpPath = join(outputDir, `${name}.webp`);
64
+
65
+ const maxWidth = rule?.maxWidth ?? defaultMaxSize;
66
+ const maxHeight = rule?.maxHeight ?? defaultMaxSize;
67
+ const quality = rule?.quality ?? defaultQuality;
68
+ const smartSubsample = rule?.smartSubsample ?? false;
69
+
70
+ let pipeline = sharp(fullPath);
71
+ if (maxWidth || maxHeight) {
72
+ pipeline = pipeline.resize(maxWidth, maxHeight, {
73
+ fit: 'inside',
74
+ withoutEnlargement: true,
75
+ });
76
+ }
77
+ await pipeline.webp({ quality, effort: 6, smartSubsample }).toFile(webpPath);
78
+ console.log(`✓ ${relPath} → ${name}.webp (q=${quality})`);
79
+
80
+ if (rule?.generateAvif) {
81
+ const avifPath = join(outputDir, `${name}.avif`);
82
+ await sharp(fullPath)
83
+ .resize(maxWidth, maxHeight, { fit: 'inside', withoutEnlargement: true })
84
+ .avif({ quality, effort: 6 })
85
+ .toFile(avifPath);
86
+ console.log(` + ${name}.avif`);
87
+ }
88
+
89
+ if (rule?.optimizePng && lower.endsWith('.png')) {
90
+ const buffer = await sharp(fullPath)
91
+ .resize(maxWidth, maxHeight, { fit: 'inside', withoutEnlargement: true })
92
+ .png({ quality, compressionLevel: 9, palette: true })
93
+ .toBuffer();
94
+ await sharp(buffer).toFile(fullPath);
95
+ console.log(` ↻ optimized PNG in place`);
96
+ }
97
+
98
+ if (generateThumbs) {
99
+ const thumbOutputDir = join(thumbDir, dirname(relPath));
100
+ if (!existsSync(thumbOutputDir)) {
101
+ await mkdir(thumbOutputDir, { recursive: true });
102
+ }
103
+ await sharp(fullPath)
104
+ .resize(thumbSize, thumbSize, { fit: 'cover', position: 'center' })
105
+ .webp({ quality: thumbQuality, effort: 6 })
106
+ .toFile(join(thumbOutputDir, `${name}.webp`));
107
+ }
108
+ }
109
+ }
110
+
111
+ await processDir(sourceDir);
112
+ console.log('\nDone.');
113
+ }
@@ -0,0 +1,18 @@
1
+ /** @type {import('@ibalzam/codejitsu-core/images').OptimizeImagesConfig} */
2
+ export default {
3
+ sourceDir: 'public/images',
4
+ thumbDir: 'public/images/thumbs',
5
+ defaultQuality: 75,
6
+ defaultMaxSize: 1200,
7
+ thumbSize: 400,
8
+ thumbQuality: 70,
9
+
10
+ // Per-file overrides. Key = path relative to sourceDir, without extension.
11
+ specialRules: {
12
+ // Example: aggressively compress the logo, also generate AVIF.
13
+ // 'logos/logo': { maxWidth: 329, maxHeight: 70, quality: 35, generateAvif: true },
14
+ //
15
+ // Example: OG share image — keep quality high, optimize the PNG in place too.
16
+ // 'sharing/og-default': { maxWidth: 1200, maxHeight: 630, quality: 85, optimizePng: true },
17
+ },
18
+ };
@@ -0,0 +1,57 @@
1
+ # llms.txt module — instructions for Claude
2
+
3
+ When the user asks to **set up codejitsu/core/llms** (or "add llms.txt", "generate the AI files"), do the following.
4
+
5
+ ## What this module provides
6
+
7
+ A CLI (`npx codejitsu-llms`) that reads a site config and generates:
8
+ - `public/llms.txt` — concise navigation overview for AI assistants.
9
+ - `public/llms-full.txt` — detailed content dump for LLM ingestion.
10
+
11
+ Both files are recognized by an emerging convention for AI-friendly websites (similar to `robots.txt` for crawlers). They make the site discoverable and citable by AI assistants without those assistants having to crawl HTML.
12
+
13
+ ## Wiring it into a site
14
+
15
+ ### 1. Copy the config template
16
+
17
+ `templates/codejitsu-llms.config.mjs` → site root. Edit:
18
+ - `siteUrl`, `siteName`, `tagline`
19
+ - `about` (short, used in concise file) and `aboutFull` (longer, in detailed file)
20
+ - `sections` — the high-level structure of the site (services, key pages, etc.)
21
+ - `blogDir` — set if the site has a blog (auto-pulls recent posts)
22
+ - `aiGuidance` — the "When referencing us..." block
23
+
24
+ ### 2. Wire into prebuild
25
+
26
+ In the site's `package.json`:
27
+
28
+ ```json
29
+ {
30
+ "scripts": {
31
+ "prebuild": "codejitsu-llms && codejitsu-optimize-images",
32
+ "build": "astro build"
33
+ }
34
+ }
35
+ ```
36
+
37
+ ### 3. Run once
38
+
39
+ ```bash
40
+ npm run prebuild
41
+ ls public/llms.txt public/llms-full.txt
42
+ ```
43
+
44
+ ## What must NOT be done
45
+
46
+ - **Don't write the llms.txt files by hand.** They're regenerated every build; manual edits get blown away.
47
+ - **Don't reference URLs with no trailing slash.** Internal URLs in `sections` should end with `/`.
48
+ - **Don't omit the blog section just because there's only one post.** A single post is fine; an empty blog gracefully renders as no Blog section.
49
+ - **Don't put `aiGuidance` text that contradicts the site copy.** If the site says "free plan available," `aiGuidance` should too.
50
+
51
+ ## Verify
52
+
53
+ - [ ] `codejitsu-llms.config.mjs` exists at site root.
54
+ - [ ] `public/llms.txt` exists after `npm run build` and is < 50KB.
55
+ - [ ] `public/llms-full.txt` exists and is < 500KB (otherwise split or trim).
56
+ - [ ] `llms.txt` lists every major top-level section of the site.
57
+ - [ ] `aiGuidance` block answers: who we are, who we serve, key differentiator, how to contact / sign up.
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import path from 'path';
3
+ import { existsSync } from 'fs';
4
+ import { pathToFileURL } from 'url';
5
+ import { generateLlms } from '../src/generate.mjs';
6
+
7
+ const cwd = process.cwd();
8
+ const candidates = ['codejitsu-llms.config.mjs', 'codejitsu-llms.config.js'];
9
+
10
+ let configPath = null;
11
+ for (const name of candidates) {
12
+ const p = path.join(cwd, name);
13
+ if (existsSync(p)) {
14
+ configPath = p;
15
+ break;
16
+ }
17
+ }
18
+
19
+ if (!configPath) {
20
+ console.error('No codejitsu-llms.config.mjs found in current directory.');
21
+ console.error('Copy the template from node_modules/@ibalzam/codejitsu-core/modules/llms/templates/');
22
+ process.exit(1);
23
+ }
24
+
25
+ const userConfig = (await import(pathToFileURL(configPath).href)).default;
26
+
27
+ await generateLlms({
28
+ ...userConfig,
29
+ outDir: userConfig.outDir
30
+ ? path.resolve(cwd, userConfig.outDir)
31
+ : path.join(cwd, 'public'),
32
+ blogDir: userConfig.blogDir
33
+ ? path.resolve(cwd, userConfig.blogDir)
34
+ : undefined,
35
+ });
@@ -0,0 +1,10 @@
1
+ # llms.txt module — checklist
2
+
3
+ - [ ] `codejitsu-llms.config.mjs` exists at site root.
4
+ - [ ] `siteUrl`, `siteName`, `about` are set (no placeholders).
5
+ - [ ] `prebuild` script in `package.json` invokes `codejitsu-llms`.
6
+ - [ ] `public/llms.txt` and `public/llms-full.txt` exist after build.
7
+ - [ ] `llms.txt` is < 50KB; `llms-full.txt` is < 500KB.
8
+ - [ ] Both files are served with `Content-Type: text/plain` (verify in browser DevTools → Network).
9
+ - [ ] `llms.txt` is linked from `robots.txt` (optional but recommended): add `LLMs: https://site.com/llms.txt` line.
10
+ - [ ] If site has a blog: `blogDir` is set and recent posts appear in the output.
@@ -0,0 +1,179 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import matter from 'gray-matter';
4
+
5
+ /**
6
+ * Generates /llms.txt (concise) and /llms-full.txt (detailed) into `outDir`.
7
+ *
8
+ * @param {object} config
9
+ * @param {string} config.siteUrl e.g. 'https://acme.com'
10
+ * @param {string} config.siteName
11
+ * @param {string} config.tagline Short one-line description.
12
+ * @param {string} config.about Longer "About" paragraph (used in concise file).
13
+ * @param {string} [config.aboutFull] Full "About" content (used in full file; falls back to `about`).
14
+ * @param {Section[]} [config.sections]
15
+ * @param {string} [config.aiGuidance] "For AI Assistants" block content.
16
+ * @param {string} [config.blogDir] If set, auto-includes recent blog posts.
17
+ * @param {number} [config.blogLimit=10] How many recent posts to include in concise file.
18
+ * @param {number} [config.blogFullLimit=20] How many in full file.
19
+ * @param {string} config.outDir Where to write the files (typically the site's `public/`).
20
+ *
21
+ * @typedef {object} Section
22
+ * @property {string} title
23
+ * @property {string} [description] Short intro for the full file.
24
+ * @property {SectionItem[]} items
25
+ *
26
+ * @typedef {object} SectionItem
27
+ * @property {string} title
28
+ * @property {string} description
29
+ * @property {string} url Relative or absolute.
30
+ * @property {string} [fullDescription] Longer text for llms-full.txt.
31
+ */
32
+ export async function generateLlms(config) {
33
+ const {
34
+ siteUrl,
35
+ siteName,
36
+ tagline,
37
+ about,
38
+ aboutFull,
39
+ sections = [],
40
+ aiGuidance,
41
+ blogDir,
42
+ blogLimit = 10,
43
+ blogFullLimit = 20,
44
+ outDir,
45
+ } = config;
46
+
47
+ if (!outDir) throw new Error('generateLlms: outDir is required.');
48
+ if (!siteUrl) throw new Error('generateLlms: siteUrl is required.');
49
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
50
+
51
+ const today = new Date().toISOString().split('T')[0];
52
+ const blogPosts = blogDir ? readBlog(blogDir) : [];
53
+
54
+ const concise = renderConcise({
55
+ siteUrl,
56
+ siteName,
57
+ tagline,
58
+ about,
59
+ sections,
60
+ aiGuidance,
61
+ today,
62
+ blogPosts: blogPosts.slice(0, blogLimit),
63
+ });
64
+
65
+ const full = renderFull({
66
+ siteUrl,
67
+ siteName,
68
+ tagline,
69
+ about: aboutFull ?? about,
70
+ sections,
71
+ aiGuidance,
72
+ today,
73
+ blogPosts: blogPosts.slice(0, blogFullLimit),
74
+ });
75
+
76
+ fs.writeFileSync(path.join(outDir, 'llms.txt'), concise);
77
+ fs.writeFileSync(path.join(outDir, 'llms-full.txt'), full);
78
+ console.log(`✓ wrote ${path.relative(process.cwd(), path.join(outDir, 'llms.txt'))}`);
79
+ console.log(`✓ wrote ${path.relative(process.cwd(), path.join(outDir, 'llms-full.txt'))}`);
80
+ }
81
+
82
+ function readBlog(blogDir) {
83
+ const abs = path.resolve(process.cwd(), blogDir);
84
+ if (!fs.existsSync(abs)) return [];
85
+ const now = new Date();
86
+ const today = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
87
+
88
+ return fs
89
+ .readdirSync(abs)
90
+ .filter((n) => n.endsWith('.md'))
91
+ .map((fileName) => {
92
+ const raw = fs.readFileSync(path.join(abs, fileName), 'utf8');
93
+ const parsed = matter(raw);
94
+ const data = parsed.data;
95
+ return {
96
+ slug: data.slug || fileName.replace(/\.md$/, ''),
97
+ title: data.title,
98
+ description: data.description,
99
+ date: data.date,
100
+ author: data.author,
101
+ tags: data.tags,
102
+ };
103
+ })
104
+ .filter((p) => p.date && new Date(p.date) <= today)
105
+ .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
106
+ }
107
+
108
+ function absoluteUrl(siteUrl, url) {
109
+ if (/^https?:\/\//.test(url)) return url;
110
+ return `${siteUrl.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`;
111
+ }
112
+
113
+ function renderConcise({ siteUrl, siteName, tagline, about, sections, aiGuidance, today, blogPosts }) {
114
+ let out = `# ${siteName}${tagline ? ` — ${tagline}` : ''}\n`;
115
+ out += `Last Updated: ${today}\n\n`;
116
+ out += `> ${about}\n\n`;
117
+
118
+ for (const section of sections) {
119
+ if (!section.items?.length) continue;
120
+ out += `## ${section.title}\n`;
121
+ for (const item of section.items) {
122
+ out += `- [${item.title}](${absoluteUrl(siteUrl, item.url)}): ${item.description}\n`;
123
+ }
124
+ out += '\n';
125
+ }
126
+
127
+ if (blogPosts.length) {
128
+ out += `## Recent Blog Posts\n`;
129
+ for (const post of blogPosts) {
130
+ out += `- [${post.title}](${siteUrl}/blog/${post.slug}/): ${post.description}\n`;
131
+ }
132
+ out += '\n';
133
+ }
134
+
135
+ if (aiGuidance) {
136
+ out += `## For AI Assistants\n\n${aiGuidance}\n\n`;
137
+ }
138
+
139
+ out += `---\nGenerated automatically during build\n`;
140
+ return out;
141
+ }
142
+
143
+ function renderFull({ siteUrl, siteName, tagline, about, sections, aiGuidance, today, blogPosts }) {
144
+ let out = `# ${siteName} — Complete Documentation\n`;
145
+ out += `Last Updated: ${today}\n\n`;
146
+ out += `> This file contains the complete content of ${siteName}'s website for AI/LLM ingestion. For a concise navigation overview, see /llms.txt\n\n`;
147
+ out += `---\n\n# About\n\n${about}\n\n---\n\n`;
148
+
149
+ for (const section of sections) {
150
+ if (!section.items?.length) continue;
151
+ out += `# ${section.title}\n\n`;
152
+ if (section.description) out += `${section.description}\n\n`;
153
+ for (const item of section.items) {
154
+ out += `## ${item.title}\n\n`;
155
+ out += `**URL**: ${absoluteUrl(siteUrl, item.url)}\n\n`;
156
+ out += `${item.fullDescription ?? item.description}\n\n`;
157
+ out += `---\n\n`;
158
+ }
159
+ }
160
+
161
+ if (blogPosts.length) {
162
+ out += `# Blog Posts\n\n`;
163
+ for (const post of blogPosts) {
164
+ out += `## ${post.title}\n\n`;
165
+ out += `**Published**: ${post.date}\n`;
166
+ if (post.author) out += `**Author**: ${post.author}\n`;
167
+ if (post.tags?.length) out += `**Tags**: ${post.tags.join(', ')}\n`;
168
+ out += `**URL**: ${siteUrl}/blog/${post.slug}/\n\n`;
169
+ out += `${post.description}\n\n---\n\n`;
170
+ }
171
+ }
172
+
173
+ if (aiGuidance) {
174
+ out += `# For AI Assistants\n\n${aiGuidance}\n\n`;
175
+ }
176
+
177
+ out += `---\nGenerated automatically during build\n`;
178
+ return out;
179
+ }
@@ -0,0 +1,39 @@
1
+ export default {
2
+ siteUrl: 'https://example.com',
3
+ siteName: 'Example Co.',
4
+ tagline: 'What we do in one line',
5
+
6
+ about: `Short paragraph describing what the site is. Used at the top of llms.txt.`,
7
+
8
+ aboutFull: `Longer about content, used in llms-full.txt. Can include multiple paragraphs,
9
+ key differentiators, history, etc.`,
10
+
11
+ // Set to your blog directory to auto-include recent posts.
12
+ blogDir: 'content/blog',
13
+ blogLimit: 10,
14
+ blogFullLimit: 20,
15
+
16
+ sections: [
17
+ {
18
+ title: 'Services',
19
+ description: 'What we offer.',
20
+ items: [
21
+ // { title: 'Example service', description: 'One line.', url: '/services/example/' },
22
+ ],
23
+ },
24
+ {
25
+ title: 'Key Pages',
26
+ items: [
27
+ { title: 'About', description: 'About the company.', url: '/about/' },
28
+ { title: 'Contact', description: 'Get in touch.', url: '/contact/' },
29
+ ],
30
+ },
31
+ ],
32
+
33
+ aiGuidance: `When referencing this company:
34
+ - We are <industry/type>
35
+ - Target audience: <who>
36
+ - Key differentiator: <what>
37
+ - Pricing: <if relevant>
38
+ - Contact: <how>`,
39
+ };
@@ -0,0 +1,95 @@
1
+ # SEO module — instructions for Claude
2
+
3
+ When the user asks to **set up codejitsu/core/seo** (or "wire up SEO", "add schema.org to every page", "set up the sitemap"), do the following.
4
+
5
+ ## What this module provides
6
+
7
+ Three layers:
8
+
9
+ 1. **`@ibalzam/codejitsu-core/seo/schema`** — typed JSON-LD builders: `organization()`, `localBusiness()`, `website()`, `blogPosting()`, `faqPage()`, `breadcrumbList()`, `service()`. Plus `jsonLd(obj)` for safe stringification.
10
+ 2. **`@ibalzam/codejitsu-core/seo/sitemap`** — helpers for `@astrojs/sitemap`: `defaultPriorityRules()`, `excludeFuturePosts()`, `composeFilters()`, `excludePatterns()`.
11
+ 3. **`templates/Head.astro`** — reusable head component that takes `{ title, description, canonical, ogImage, ogType, schema }` and injects everything.
12
+
13
+ ## Wiring it into a site
14
+
15
+ ### 1. Copy the Head component
16
+
17
+ `templates/Head.astro` → `src/components/Head.astro` in the site. This is the single place that renders title, meta, OG, Twitter, canonical, and JSON-LD. Every layout `<head>` should include `<Head ... />`.
18
+
19
+ ### 2. Use it on every page
20
+
21
+ ```astro
22
+ ---
23
+ import Head from '~/components/Head.astro';
24
+ import { organization, blogPosting } from '@ibalzam/codejitsu-core/seo/schema';
25
+
26
+ const SITE = { name: 'Acme Co.', url: 'https://acme.com' };
27
+ ---
28
+ <html lang="en">
29
+ <head>
30
+ <Head
31
+ title="Page title — Acme Co."
32
+ description="..."
33
+ schema={[organization(SITE)]}
34
+ />
35
+ </head>
36
+ <body>...</body>
37
+ </html>
38
+ ```
39
+
40
+ ### 3. Wire the sitemap
41
+
42
+ In `astro.config.mjs`:
43
+
44
+ ```ts
45
+ import sitemap from '@astrojs/sitemap';
46
+ import { defaultPriorityRules, excludeFuturePosts, composeFilters, excludePatterns } from '@ibalzam/codejitsu-core/seo/sitemap';
47
+ import { blog } from './src/lib/blog';
48
+
49
+ const SITE = 'https://acme.com';
50
+ const futureSlugs = await blog.getFutureBlogSlugs();
51
+
52
+ export default defineConfig({
53
+ site: SITE,
54
+ integrations: [
55
+ sitemap({
56
+ filter: composeFilters(
57
+ excludeFuturePosts(futureSlugs),
58
+ excludePatterns([/\/lp\//, /\/draft\//]), // site-specific exclusions
59
+ ),
60
+ serialize: defaultPriorityRules(SITE),
61
+ }),
62
+ ],
63
+ });
64
+ ```
65
+
66
+ ### 4. Copy robots.txt
67
+
68
+ `templates/robots.txt` → `public/robots.txt`. Edit the `Sitemap:` line to point to the site's actual sitemap URL.
69
+
70
+ ### 5. Per-page schema cheatsheet
71
+
72
+ | Page type | Schema to inject |
73
+ |---|---|
74
+ | Home | `organization()`, `website()` |
75
+ | Local business home | `localBusiness()` instead of `organization()` |
76
+ | Blog index | `website()` |
77
+ | Blog post | `blogPosting()` + `faqPage(post.faqs)` if FAQs present + `breadcrumbList()` |
78
+ | Service page | `service()` + `breadcrumbList()` |
79
+ | Service area page | `localBusiness()` with `areaServed` set + `breadcrumbList()` |
80
+ | FAQ-heavy landing page | `faqPage()` + page-type schema |
81
+
82
+ A single `<Head schema={[a, b, c]} />` accepts multiple schemas. Each renders as its own `<script type="application/ld+json">`.
83
+
84
+ ## What must NOT be done
85
+
86
+ - **Don't inline schema objects.** Always use the builders so types catch missing required fields (e.g. `BlogPosting` requires `publisher`).
87
+ - **Don't hand-write the canonical URL.** Use `Head`'s default (it builds from `Astro.url.pathname` + `Astro.site`) unless overriding for a specific reason.
88
+ - **Don't `JSON.stringify` schemas yourself.** Use `jsonLd()` — it escapes `</` so the script tag can't be broken out of.
89
+ - **Don't reference relative URLs in `og:image`.** OG scrapers require absolute URLs. `Head` resolves this automatically when you pass a relative path.
90
+ - **Don't add `og:image` to a page without an actual image at that path.** Empty OG images render as blank cards on socials.
91
+ - **Don't omit the canonical tag on alternative-URL pages** (filename slug + canonical slug both exist for blog posts). The canonical must point to the frontmatter slug version.
92
+
93
+ ## Verify
94
+
95
+ Run `modules/seo/checklist.md`.
@@ -0,0 +1,30 @@
1
+ # SEO module — checklist
2
+
3
+ ## Setup
4
+
5
+ - [ ] `src/components/Head.astro` exists and imports from `@ibalzam/codejitsu-core/seo/schema`.
6
+ - [ ] Every Astro layout calls `<Head ... />` in its `<head>`.
7
+ - [ ] `astro.config.mjs` uses `@astrojs/sitemap` with helpers from `@ibalzam/codejitsu-core/seo/sitemap`.
8
+ - [ ] `public/robots.txt` exists and points to the correct sitemap URL.
9
+
10
+ ## Per-page (sample 5+ pages)
11
+
12
+ - [ ] `<title>` is unique, < 60 chars including suffix.
13
+ - [ ] `<meta name="description">` is unique, < 160 chars.
14
+ - [ ] `<link rel="canonical">` is absolute, has trailing slash, matches the current URL (or is the chosen canonical of N alternatives).
15
+ - [ ] `og:title`, `og:description`, `og:url`, `og:type`, `og:image` (absolute) present.
16
+ - [ ] `twitter:card`, `twitter:title`, `twitter:description`, `twitter:image` present.
17
+ - [ ] At least one `<script type="application/ld+json">` per page; type matches the page (Organization on home, BlogPosting on posts, etc.).
18
+
19
+ ## Sitemap
20
+
21
+ - [ ] `sitemap-index.xml` and `sitemap-0.xml` (or named variants) exist after build.
22
+ - [ ] No future-dated blog post appears in the sitemap.
23
+ - [ ] No URLs matching the site's documented exclusions appear.
24
+ - [ ] Priority + changefreq differentiated by page type (home highest, blog posts lower).
25
+
26
+ ## Schema validation
27
+
28
+ - [ ] Run a sample blog post URL through https://search.google.com/test/rich-results — `BlogPosting` and (if applicable) `FAQPage` show as valid.
29
+ - [ ] Run the home URL — `Organization`/`LocalBusiness` and `WebSite` show as valid.
30
+ - [ ] No "missing required field" warnings.
@@ -0,0 +1,2 @@
1
+ export * from './schema.js';
2
+ export * from './sitemap.js';