@ibalzam/codejitsu-core 0.11.1 → 0.13.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/SETUP.md CHANGED
@@ -393,7 +393,11 @@ npx codejitsu deploy:setup
393
393
  Interactive wizard:
394
394
  - Copies `.github/workflows/daily-deploy.yml` + `wrangler.toml` from
395
395
  package templates if missing.
396
- - Prompts for the Cloudflare deploy hook URL.
396
+ - Prompts for the Cloudflare deploy hook URL(s). For a multi-site monorepo
397
+ (one repo → several Pages projects), enter every project's hook URL
398
+ comma-separated; they're stored in `CLOUDFLARE_DEPLOY_HOOK_URLS` and the
399
+ workflow pings each. A single site uses `CLOUDFLARE_DEPLOY_HOOK_URL`.
400
+ See `modules/deploy/CLAUDE.md` → "Multi-site monorepos".
397
401
  - Stores it as a GH Actions secret via `gh secret set`.
398
402
  - Optionally triggers a test run.
399
403
 
package/bin/codejitsu.mjs CHANGED
@@ -4,6 +4,7 @@ import { runBlog } from '../modules/cli/src/blog.mjs';
4
4
  import { runDeploySetup, runDeployTrigger } from '../modules/cli/src/deploy.mjs';
5
5
  import { runDoctor } from '../modules/cli/src/doctor.mjs';
6
6
  import { runBlogInit } from '../modules/cli/src/blog-init.mjs';
7
+ import { runBlogSelftest } from '../modules/cli/src/blog-selftest.mjs';
7
8
  import { runAudit } from '../modules/audit/src/run.mjs';
8
9
 
9
10
  const subcommand = process.argv[2];
@@ -13,6 +14,17 @@ const COMMANDS = {
13
14
  'blog:list': () => runBlog('blog:list'),
14
15
  'blog:drafts': () => runBlog('blog:drafts'),
15
16
  'blog:init': () => runBlogInit(),
17
+ 'blog:selftest': () => {
18
+ const { values } = parseArgs({
19
+ args: rest,
20
+ options: {
21
+ topic: { type: 'string' },
22
+ model: { type: 'string' },
23
+ },
24
+ allowPositionals: true,
25
+ });
26
+ return runBlogSelftest({ topic: values.topic, model: values.model });
27
+ },
16
28
  'deploy:setup': () => runDeploySetup(),
17
29
  'deploy:run': () => runDeployTrigger(),
18
30
  doctor: () => runDoctor(),
@@ -59,6 +71,7 @@ function printHelp() {
59
71
  console.log(` blog:list List every non-draft post with URL + image check`);
60
72
  console.log(` blog:drafts List future-dated (pending) posts only`);
61
73
  console.log(` blog:init Install /blog, /blog-batch, /blog-images slash commands`);
74
+ console.log(` blog:selftest Cold-Claude write a throwaway post + grade it. Flags: --topic, --model`);
62
75
  console.log(``);
63
76
  console.log(` deploy:setup Wire up daily Cloudflare deploy (prompts for hook URL)`);
64
77
  console.log(` deploy:run Trigger the Daily Deploy workflow once now`);
package/checklist/core.md CHANGED
@@ -9,7 +9,7 @@ The runner covers the easy stuff (file presence, meta tags, canonical, schema sc
9
9
  - [ ] `npm run build` exits 0 with no warnings about missing pages, unresolved imports, or broken links.
10
10
  - [ ] `dist/` contains static HTML for every expected route. No `.html` route is missing.
11
11
  - [ ] `wrangler.toml` is present and points to `dist`.
12
- - [ ] `.github/workflows/daily-deploy.yml` exists; the `CLOUDFLARE_DEPLOY_HOOK_URL` secret is set in the repo (skip if site has no scheduled content).
12
+ - [ ] `.github/workflows/daily-deploy.yml` exists; the deploy-hook secret is set in the repo — `CLOUDFLARE_DEPLOY_HOOK_URL` (single site) or `CLOUDFLARE_DEPLOY_HOOK_URLS` (comma-separated, one Deploy Hook per Pages project, for a multi-site monorepo). Skip if the site has no scheduled content.
13
13
 
14
14
  ## URLs + routing
15
15
 
@@ -31,10 +31,17 @@ export async function runBlogQuality(ctx) {
31
31
  const staleCutoff = new Date(today);
32
32
  staleCutoff.setUTCMonth(staleCutoff.getUTCMonth() - staleMonths);
33
33
 
34
+ // Tag governance: approvedTags is the controlled vocabulary (cap 12).
35
+ const blogWriter = config.blogWriter && typeof config.blogWriter === 'object' ? config.blogWriter : null;
36
+ const approvedTags = blogWriter?.approvedTags ?? null;
37
+ const approvedSet = approvedTags ? new Set(approvedTags) : null;
38
+
34
39
  const stalePosts = [];
35
40
  const missingDescription = [];
36
41
  const missingImage = [];
37
42
  const shortBody = [];
43
+ const htmlBodyPosts = [];
44
+ const unapprovedTagPosts = [];
38
45
  const dupTitle = new Map();
39
46
  const dupDesc = new Map();
40
47
 
@@ -44,6 +51,22 @@ export async function runBlogQuality(ctx) {
44
51
  if (draftField && data[draftField]) continue;
45
52
 
46
53
  const slug = fileName.replace(/\.md$/, '');
54
+
55
+ // Tag governance: collect category + tags, flag any not in approvedTags.
56
+ if (approvedSet) {
57
+ const postTags = [];
58
+ if (typeof data.category === 'string') postTags.push(data.category);
59
+ if (Array.isArray(data.tags)) postTags.push(...data.tags);
60
+ const bad = postTags.filter((t) => !approvedSet.has(t));
61
+ if (bad.length > 0) {
62
+ unapprovedTagPosts.push(`${slug}: ${[...new Set(bad)].join(', ')}`);
63
+ }
64
+ }
65
+
66
+ // Body format: flag posts whose body is raw HTML (legacy WordPress imports).
67
+ if (/^\s*<(p|h[1-6]|div|ul|ol|table)[\s>]/i.test(content.trimStart())) {
68
+ htmlBodyPosts.push(slug);
69
+ }
47
70
  const dateVal = data[dateField];
48
71
  const postDate = dateVal instanceof Date ? dateVal : (typeof dateVal === 'string' ? new Date(dateVal) : null);
49
72
 
@@ -69,6 +92,30 @@ export async function runBlogQuality(ctx) {
69
92
  }
70
93
 
71
94
  results.push(info(`${files.length} blog posts indexed`));
95
+
96
+ // Tag governance results.
97
+ if (!approvedTags) {
98
+ results.push(info('No blogWriter.approvedTags configured — tag governance not enforced'));
99
+ } else {
100
+ results.push(
101
+ approvedTags.length <= 12
102
+ ? pass(`approvedTags within cap (${approvedTags.length}/12)`)
103
+ : fail(`approvedTags exceeds cap (${approvedTags.length}/12)`,
104
+ 'Trim to 12 or fewer. The cap keeps the taxonomy tight.')
105
+ );
106
+ results.push(summarize(
107
+ 'All post tags + categories are in approvedTags',
108
+ unapprovedTagPosts,
109
+ 'fail'
110
+ ));
111
+ }
112
+
113
+ results.push(summarize(
114
+ 'Post bodies are markdown (not legacy HTML)',
115
+ htmlBodyPosts,
116
+ 'warn'
117
+ ));
118
+
72
119
  results.push(summarize(
73
120
  `No posts older than ${staleMonths} months`,
74
121
  stalePosts,
@@ -143,9 +143,22 @@ say stop: leave the file and exit. They can run `/blog` row by row whenever.
143
143
 
144
144
  ## Verify
145
145
 
146
- - [ ] N rows, all unique slugs
147
- - [ ] Dates respect cadence; no collisions with existing posts
148
- - [ ] Tags balanced across `approvedTags`
149
- - [ ] Service + city distribution covers most of the config lists
150
- - [ ] Season fits each `pubDate`'s month
151
- - [ ] No duplicate or near-duplicate topics
146
+ After generating the schedule, **actively count** these distributions and
147
+ rebalance the draft if any fail. Don't just eyeball.
148
+
149
+ - N rows, all unique slugs (grep the slug column for duplicates)
150
+ - Dates respect cadence; no collisions with existing post `pubDate`s
151
+ - **Tag distribution**: no single primary tag exceeds 40% of the batch.
152
+ (For N=20, no tag appears as primary more than 8 times.)
153
+ - **Topic-format distribution**: count comparison / how-to / troubleshooting /
154
+ seasonal across the batch. No single format exceeds ~40%. Roughly
155
+ 30/30/20/20 is the target.
156
+ - **City coverage**: cover most of `blogWriter.locations` before any city
157
+ repeats. For N ≤ locations.length, every city should be distinct.
158
+ - **Service coverage**: spread across `blogWriter.services`; don't anchor half
159
+ the batch to one service.
160
+ - Season fits each `pubDate`'s month per `seasonalRules`
161
+ - No duplicate or near-duplicate topics (compare titles for keyword overlap)
162
+
163
+ If any distribution check fails, **revise the batch before writing the file** —
164
+ swap the over-represented topics for under-covered service/city/format combos.
@@ -36,7 +36,10 @@ Everything in there is **site-specific input** for what you're about to write:
36
36
  - `audience` — primary reader (REQUIRED)
37
37
  - `services` — names that map to `/services/<slug>/` (REQUIRED)
38
38
  - `locations` — names that map to `/service-areas/<slug>/` (REQUIRED)
39
- - `approvedTags` — exhaustive. If unset, derive from `blog.categories` in config OR ask the user once
39
+ - `approvedTags` — **REQUIRED for writing; the single controlled vocabulary**
40
+ (hard cap: 12 per site). Governs BOTH `category` and `tags` fields. If unset,
41
+ STOP and ask the user to define up to 12 (or derive from the `category` enum
42
+ in `src/content.config.ts`). Never write a post with a tag outside this list.
40
43
  - `wordCount` — `{ min, max, default }`. Kit default `{ 1200, 2500, 1800 }`
41
44
  - `faqs` — `{ min, max }`. Kit default `{ 5, 8 }`
42
45
  - `internalLinks` — `{ min, max }` per post. Kit default `{ 3, 6 }`
@@ -47,7 +50,28 @@ Everything in there is **site-specific input** for what you're about to write:
47
50
  - `cadenceDays` — for /blog-batch. Kit default `4`
48
51
 
49
52
  Also detect the blog's frontmatter shape by reading `src/content.config.ts` and
50
- ONE existing post in `src/content/blog/`. Use that exact shape for the new post.
53
+ ONE existing post in `src/content/blog/`. **Match the frontmatter field SHAPE
54
+ (which fields exist), but NOT the body format and NOT the field VALUES.**
55
+
56
+ **Body format: always write clean markdown.** Use `## H2`, `- bullets`,
57
+ `1. numbered`, `| markdown | tables |`, `**bold**`. Do this EVEN IF existing
58
+ posts have HTML in the body. Some sites (veteran) were WordPress-imported with
59
+ raw HTML bodies inside their `.md` files. That is legacy, not the target.
60
+ Astro renders markdown and inline-HTML identically, so a new markdown post sits
61
+ fine alongside old HTML ones. Never copy `<p>`, `<h2>`, `<table>` from an
62
+ existing post — write markdown.
63
+
64
+ Critical distinction for taxonomy fields (`category`, `tags`):
65
+ - A schema may have `category` (an enum — constrained) AND/OR `tags` (a free
66
+ array). Both are taxonomy.
67
+ - `blogWriter.approvedTags` governs **every taxonomy field**. Put approved
68
+ values in `category` AND in `tags`.
69
+ - **Do NOT copy free-form tag values from existing posts.** Many sites were
70
+ WordPress-imported with proliferated tags (every city, every appliance as a
71
+ tag). That sprawl is exactly what `approvedTags` exists to stop. Match the
72
+ field structure of old posts, ignore their messy values.
73
+ - If `category` is an enum, pick the one approved value that fits. If `tags`
74
+ is a free array, fill it from `approvedTags` only — not invented descriptors.
51
75
 
52
76
  ## Step 1 — Gather inputs (interactive)
53
77
 
@@ -79,7 +103,38 @@ options:
79
103
  - "XL (2500+ words)" — Definitive guide
80
104
  ```
81
105
 
82
- Default = the `wordCount.default` from config.
106
+ **Match the tier to the topic's natural scope — don't rubber-stamp the config
107
+ default.** A focused 2-option comparison or a single-question explainer is
108
+ usually Medium. A multi-angle definitive guide is Long or XL. Defaulting every
109
+ post to "Long" produces either thin Long posts or padded ones.
110
+
111
+ Rough mapping:
112
+ - Comparison / "X vs Y" (2-3 options) → Medium (800-1200), occasionally Long
113
+ - Single troubleshooting walkthrough → Medium (800-1200)
114
+ - How-to / multi-step guide → Long (1500-2500)
115
+ - Industry/buying guide covering many sub-topics → Long (1500-2500)
116
+ - Definitive "everything about X" → XL (2500+)
117
+
118
+ **The real quality bar is structural density + depth, NOT raw word count.**
119
+ A post passes if every H2 has 2-3 real paragraphs, there's at least one
120
+ list/table, and nothing reads thin. Never pad to hit a number — efficient,
121
+ complete writing at 1200 words beats 1800 words of filler every time.
122
+
123
+ **To make Long / XL posts genuinely long, add DEPTH, not words:**
124
+ - At least one worked example or named scenario ("a Surrey homeowner switching
125
+ from a 15-year-old gas furnace stacked CleanBC + BC Hydro to land at roughly
126
+ X after rebates") — concrete, not abstract.
127
+ - Real specifics: model families, actual process steps, real numbers inside
128
+ narratives, real local detail.
129
+ - Granular sub-sections (H3s) where a topic has genuine sub-parts.
130
+ - Show the concept applied, don't just define it.
131
+
132
+ If a Long post comes in short, the fix is "what concrete example or applied
133
+ detail is missing?" — never "add more adjectives." If after adding genuine
134
+ depth the post is still ~1200 words and complete, it was a Medium topic;
135
+ relabel it and move on. Do not pad.
136
+
137
+ `wordCount` in config is a loose sanity band, not a target to chase.
83
138
 
84
139
  ### Question 3 — Primary service + city focus
85
140
 
@@ -152,25 +207,47 @@ frontmatter shape exactly (detected in Step 0).
152
207
  (default 5-8). Each FAQ = real question a reader would search; answer is
153
208
  2-4 sentences, concise and complete.
154
209
  6. **Internal links** — `internalLinks.min` to `internalLinks.max` from config
155
- (default 3-6). Link to `/services/<slug>/`, `/service-areas/<slug>/`, and
156
- `/services/<slug>/<city>/` patterns. Don't link the same URL twice.
210
+ (default 3-6). **Target the MIDDLE of the range, not the floor.** For a
211
+ 3-6 range, aim for 4-5. Link the FIRST mention of each distinct city and
212
+ service in the body. A post that names a city six times but links it once
213
+ is under-linked — link the first occurrence. Don't link the same URL twice;
214
+ don't stuff. Patterns: `/services/<slug>/`, `/service-areas/<slug>/`,
215
+ `/services/<slug>/<city>/`.
157
216
  7. **Banned phrases** — if `bannedPhrases` is set in config, refuse them.
158
217
  Common offenders: "In today's fast-paced world…", "When it comes to…",
159
218
  "Look no further…", "In conclusion…".
160
- 8. **Pricing** — if `pricing: 'brackets-only'`, every price reference is a
161
- range with context. Never a single dollar figure. If `pricing: 'never-mention'`,
162
- omit pricing entirely.
219
+ 8. **Pricing** — applies only to `pricing: 'brackets-only'`:
220
+ - **Service-cost CLAIMS must be bracket ranges with context.** What a
221
+ job/install/repair/service costs is always a range:
222
+ "a furnace replacement typically runs $6,500 - $10,000 depending on
223
+ venting and efficiency." Never a single figure for a cost claim.
224
+ - **Narrative examples may be specific.** A hypothetical scenario, a
225
+ story about one homeowner's invoice, or a comparison walkthrough can use
226
+ a specific number because it references a *scenario*, not a price claim:
227
+ "the technician quoted $1,400 for the control board" is fine inside a
228
+ decision-making narrative. When in doubt, bracket it.
229
+ - If `pricing: 'never-mention'`, omit all pricing.
163
230
  9. **CTA** — closing paragraph includes a call-to-action. Two patterns:
164
231
  - Modal: `[text](#contact)` or trigger-class link
165
232
  - Phone: `[text](tel:+1...)` — use the actual number from `site.business.telephone`
166
233
  Best is one paragraph with both.
167
- 10. **Approved tags only.** Pick 2-3 from `blogWriter.approvedTags`. Primary
168
- tag first. **Never invent.** If nothing in `approvedTags` fits the topic,
169
- STOP and ask the user:
170
- > "None of your approved tags fit '<topic>'. Options: pick the closest fit
171
- > from <list>, OR add a new tag to `blogWriter.approvedTags` in
172
- > codejitsu.config.ts. Which?"
173
- Don't auto-add tags to the config.
234
+ 10. **Approved tags only for ALL taxonomy fields, hard cap 12.** `approvedTags`
235
+ is the single controlled vocabulary, max 12 per site. It governs both
236
+ `category` (if the schema has an enum) and `tags` (if the schema has a free
237
+ array). Put `approvedTags` values in both. Pick 2-3, primary first.
238
+ **Never invent, even if existing posts use free-form tags.** WordPress
239
+ imports often have proliferated tags (cities, appliance types) — that
240
+ sprawl is exactly what `approvedTags` prevents. Match the field *shape* of
241
+ old posts, not their values.
242
+
243
+ **If nothing in `approvedTags` fits the topic**, STOP and ask:
244
+ > "None of your approved tags fit '<topic>'. Either: (a) pick the closest
245
+ > from <list>, or (b) add a new approved tag. You currently have <N>/12.
246
+ > [If N < 12:] I can add '<proposed>' to blogWriter.approvedTags. [If N = 12:]
247
+ > You're at the 12-tag cap — to add one, tell me which existing tag to remove."
248
+
249
+ Only add a tag to the config with explicit user approval, and never exceed
250
+ 12. Don't auto-add.
174
251
  11. **Image placeholder.** Frontmatter `image` field points to where the image
175
252
  WILL live: `<imageStyle.outputDir>/<slug>.webp`. The file doesn't exist
176
253
  yet; that's fine. Run `/blog-images` to generate prompts later.
@@ -205,10 +282,26 @@ exactly. Examples:
205
282
  After writing the file, **actively run these checks** by reading the file back
206
283
  and counting. Don't skip. If any fail, fix in place before reporting Step 5.
207
284
 
208
- - Word count is within `wordCount` target (count after stripping frontmatter)
209
- - FAQ count in frontmatter is within `faqs.min`-`faqs.max`
210
- - Internal links count: grep for `](/services/` and `](/service-areas/` in body
211
- - Tags are all from `approvedTags`
285
+ - **Structural density (the real bar):** every H2 has 2-3 paragraphs; at least
286
+ one list or table; no section reads thin. This matters more than word count.
287
+ - Word count is a SOFT signal, not a gate. Do not pad to hit it. If a post
288
+ intended as Long lands short, ask "what concrete example / applied detail
289
+ is missing?" and add depth — OR accept it was a Medium topic and relabel.
290
+ Never add filler to chase a number. A dense, complete 1200-word post passes.
291
+ - FAQ count in frontmatter is within `faqs.min`-`faqs.max`.
292
+ - **Internal links — mechanical check, do this explicitly:**
293
+ 1. List every distinct service AND city you named in the body.
294
+ 2. You should have linked the FIRST mention of each (up to `internalLinks.max`).
295
+ 3. Count your actual `](/services/` + `](/service-areas/` links.
296
+ 4. If the count is below the range midpoint AND you have named-but-unlinked
297
+ services/cities, add those links now. Don't ship at the floor when you
298
+ mentioned linkable things and skipped them.
299
+ Example failure: post discusses heat pumps, furnaces, AND air conditioning
300
+ but only links two of them — link the third.
301
+ - **Every taxonomy field uses `approvedTags`**: check BOTH `category` and
302
+ `tags` in the frontmatter. If `tags` contains any value not in
303
+ `approvedTags` (e.g. a city name or appliance copied from old posts),
304
+ that's a fail — replace with approved values.
212
305
  - **No em dashes**: grep body for `—` and `–` — neither should appear
213
306
  - **No H1 in body**: first non-frontmatter line is NOT `# ...`
214
307
  - **No `bannedPhrases`**: grep body for each banned phrase
@@ -0,0 +1,231 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { spawn } from 'child_process';
4
+ import matter from 'gray-matter';
5
+ import { loadConfig, isModuleEnabled } from '../../config/src/load.mjs';
6
+ import { c } from './format.mjs';
7
+
8
+ const PACKAGE_PLAYBOOK = '@ibalzam/codejitsu-core/modules/blog-writer/BLOG_WRITING.md';
9
+
10
+ /**
11
+ * `codejitsu blog:selftest [--topic "..."] [--model sonnet]`
12
+ *
13
+ * Spawns a COLD `claude -p` session (no memory of the kit's design), points
14
+ * it at the blog-writer playbook, has it write a throwaway post, then grades
15
+ * the output against the playbook's own rules. Reports pass/fail and deletes
16
+ * the test post. Report-only: exits 1 on failure (CI-friendly) but blocks
17
+ * nothing by default.
18
+ *
19
+ * This is the antidote to self-test bias — a fresh session reveals whether
20
+ * the playbook actually drives behaviour, not whether the author follows
21
+ * rules they already know.
22
+ */
23
+ export async function runBlogSelftest({ topic, model = 'sonnet' } = {}) {
24
+ const cwd = process.cwd();
25
+
26
+ let config;
27
+ try { config = await loadConfig(cwd); }
28
+ catch (err) {
29
+ console.error(c.red(`✗ ${err.message}`));
30
+ process.exit(1);
31
+ }
32
+
33
+ if (!isModuleEnabled(config, 'blogWriter')) {
34
+ console.error(c.red('✗ blogWriter not configured in codejitsu.config.'));
35
+ console.error(' Run `codejitsu blog:init` and add the blogWriter block first.');
36
+ process.exit(1);
37
+ }
38
+ const bw = config.blogWriter;
39
+
40
+ if (!(await commandExists('claude'))) {
41
+ console.error(c.red('✗ `claude` CLI not in PATH. Install Claude Code to run selftest.'));
42
+ process.exit(1);
43
+ }
44
+
45
+ // Default topic: weave service[0] + location[0] so internal links are possible.
46
+ const svc = bw.services?.[0] ?? 'your main service';
47
+ const loc = bw.locations?.[0] ?? 'your main city';
48
+ const testTopic = topic ?? `${svc} considerations for ${loc} homeowners this season`;
49
+
50
+ const slug = `__selftest-${Date.now()}`;
51
+ const contentDir = path.resolve(cwd, (typeof config.blog === 'object' && config.blog.contentDir) || 'src/content/blog');
52
+ const postPath = path.join(contentDir, `${slug}.md`);
53
+
54
+ console.log(c.bold(`\nCodejitsu blog:selftest\n`));
55
+ console.log(`Model: ${model}`);
56
+ console.log(`Topic: ${testTopic}`);
57
+ console.log(`Output: ${path.relative(cwd, postPath)} (deleted after grading)`);
58
+ console.log(c.gray('\nSpawning a cold claude -p session… (~$0.80 on sonnet, 2-6 minutes)\n'));
59
+
60
+ const prompt = buildPrompt({ testTopic, slug });
61
+
62
+ let envelope;
63
+ try {
64
+ const out = await runCmd('claude', [
65
+ '-p', prompt,
66
+ '--allowedTools', 'Read Glob Grep Write Edit Bash',
67
+ '--model', model,
68
+ '--output-format', 'json',
69
+ ], 600_000);
70
+ if (out.code !== 0) {
71
+ console.error(c.red(`✗ claude exited ${out.code}`));
72
+ console.error(out.stderr.slice(0, 400));
73
+ cleanup(postPath);
74
+ process.exit(1);
75
+ }
76
+ envelope = JSON.parse(out.stdout);
77
+ } catch (err) {
78
+ console.error(c.red(`✗ Could not run claude: ${err.message}`));
79
+ cleanup(postPath);
80
+ process.exit(1);
81
+ }
82
+
83
+ if (envelope.total_cost_usd) {
84
+ console.log(c.gray(`Cost: ~$${Number(envelope.total_cost_usd).toFixed(3)}\n`));
85
+ }
86
+
87
+ if (!fs.existsSync(postPath)) {
88
+ console.error(c.red(`✗ Cold Claude did not write the expected file: ${path.relative(cwd, postPath)}`));
89
+ console.error(c.gray('Its response was:'));
90
+ console.error(c.gray((envelope.result ?? '').slice(0, 500)));
91
+ process.exit(1);
92
+ }
93
+
94
+ const results = gradePost(postPath, bw);
95
+ printGrade(results);
96
+ cleanup(postPath);
97
+
98
+ const fails = results.filter((r) => r.status === 'fail').length;
99
+ console.log('');
100
+ console.log(fails === 0
101
+ ? c.green('Selftest passed — the playbook drove a clean post from a cold session.')
102
+ : c.red(`Selftest found ${fails} failure(s) — tighten the playbook and re-run.`));
103
+ process.exit(fails > 0 ? 1 : 0);
104
+ }
105
+
106
+ function buildPrompt({ testTopic, slug }) {
107
+ return `You are running the /blog command for this Codejitsu site. Open ${PACKAGE_PLAYBOOK} (in node_modules) and follow it top-down to write ONE blog post.
108
+
109
+ This is a NON-INTERACTIVE selftest run. SKIP the AskUserQuestion step and use:
110
+ - Topic: ${testTopic}
111
+ - Pick the post type + length tier that genuinely fit the topic
112
+ - Use the next sensible future publish date
113
+ - IMPORTANT: write the file to exactly this slug so it can be graded + cleaned up:
114
+ src/content/blog/${slug}.md
115
+
116
+ Read codejitsu.config.ts for tone, services, locations, approvedTags, and all rules. Read src/content.config.ts for the taxonomy fields. Read ONE existing post to match the frontmatter SHAPE (but write the body in clean markdown, and use only approvedTags for category + tags). Run the playbook's Step 4 verify checks and fix any failures before finishing.`;
117
+ }
118
+
119
+ function gradePost(postPath, bw) {
120
+ const raw = fs.readFileSync(postPath, 'utf8');
121
+ let data, body;
122
+ try {
123
+ const parsed = matter(raw);
124
+ data = parsed.data;
125
+ body = parsed.content;
126
+ } catch (err) {
127
+ return [{ status: 'fail', label: 'Frontmatter parses', detail: err.message }];
128
+ }
129
+
130
+ const results = [];
131
+ const add = (ok, label, detail) =>
132
+ results.push({ status: ok ? 'pass' : 'fail', label, detail: ok ? undefined : detail });
133
+ const addWarn = (ok, label, detail) =>
134
+ results.push({ status: ok ? 'pass' : 'warn', label, detail: ok ? undefined : detail });
135
+
136
+ // Em dashes
137
+ const emDashes = (body.match(/[—–]/g) ?? []).length;
138
+ add(emDashes === 0, 'No em dashes', `found ${emDashes}`);
139
+
140
+ // No H1 in body
141
+ const firstLine = body.split('\n').find((l) => l.trim().length > 0) ?? '';
142
+ add(!/^#\s/.test(firstLine.trim()), 'No H1 in body', `body starts with: ${firstLine.slice(0, 50)}`);
143
+
144
+ // Markdown body, not HTML
145
+ add(!/^\s*<(p|h[1-6]|div|ul|ol|table)[\s>]/i.test(body.trimStart()),
146
+ 'Body is markdown (not HTML)', 'body starts with an HTML block tag');
147
+
148
+ // FAQ count
149
+ const faqMin = bw.faqs?.min ?? 5;
150
+ const faqMax = bw.faqs?.max ?? 8;
151
+ const faqCount = Array.isArray(data.faqs) ? data.faqs.length : 0;
152
+ add(faqCount >= faqMin && faqCount <= faqMax,
153
+ `FAQ count in ${faqMin}-${faqMax}`, `got ${faqCount}`);
154
+
155
+ // Tags governance
156
+ const approved = new Set(bw.approvedTags ?? []);
157
+ if (approved.size > 0) {
158
+ const used = [];
159
+ if (typeof data.category === 'string') used.push(data.category);
160
+ if (Array.isArray(data.tags)) used.push(...data.tags);
161
+ const bad = [...new Set(used.filter((t) => !approved.has(t)))];
162
+ add(bad.length === 0, 'All tags/category in approvedTags',
163
+ `not approved: ${bad.join(', ')}`);
164
+ } else {
165
+ results.push({ status: 'warn', label: 'approvedTags configured', detail: 'none set' });
166
+ }
167
+
168
+ // Internal links
169
+ const linkMin = bw.internalLinks?.min ?? 3;
170
+ const links = (body.match(/\]\(\/(services|service-areas)\/[^)]+\)/g) ?? []).length;
171
+ addWarn(links >= linkMin, `Internal links ≥ ${linkMin}`, `got ${links}`);
172
+
173
+ // Banned phrases
174
+ const banned = bw.bannedPhrases ?? ["In today's fast-paced world", 'When it comes to', 'Look no further', 'In conclusion'];
175
+ const hit = banned.filter((p) => body.toLowerCase().includes(p.toLowerCase()));
176
+ add(hit.length === 0, 'No banned phrases', `found: ${hit.join('; ')}`);
177
+
178
+ // At least one list or table
179
+ const hasList = /^[-*]\s/m.test(body) || /^\d+\.\s/m.test(body) || /^\|.+\|/m.test(body);
180
+ add(hasList, 'At least one list or table', 'none found');
181
+
182
+ // Word count sane (not absurdly thin)
183
+ const words = body.split(/\s+/).filter(Boolean).length;
184
+ addWarn(words >= 700, 'Body ≥ 700 words (not thin)', `got ${words}`);
185
+ results.push({ status: 'info', label: `Body word count: ${words}` });
186
+
187
+ // Pricing brackets (only if brackets-only)
188
+ if (bw.pricing === 'brackets-only') {
189
+ // Heuristic: flag standalone $N that is NOT part of a "$N - $N" range or a narrative.
190
+ // We only hard-check the count; narrative examples are allowed, so this is a warn.
191
+ const standalone = (body.match(/\$[0-9][0-9,]*(?!\s*-\s*\$)/g) ?? []).length;
192
+ results.push({ status: 'info', label: `Standalone $ figures: ${standalone} (narrative examples allowed; service-cost claims should be ranges)` });
193
+ }
194
+
195
+ return results;
196
+ }
197
+
198
+ function printGrade(results) {
199
+ console.log(c.bold('Grade:'));
200
+ for (const r of results) {
201
+ const icon = r.status === 'pass' ? c.green('✓')
202
+ : r.status === 'warn' ? c.yellow('!')
203
+ : r.status === 'info' ? c.gray('i')
204
+ : c.red('✗');
205
+ console.log(` ${icon} ${r.label}${r.detail ? c.gray(' — ' + r.detail) : ''}`);
206
+ }
207
+ }
208
+
209
+ function cleanup(postPath) {
210
+ try { if (fs.existsSync(postPath)) fs.unlinkSync(postPath); } catch {}
211
+ }
212
+
213
+ function commandExists(cmd) {
214
+ return new Promise((resolve) => {
215
+ const p = spawn('which', [cmd], { stdio: 'ignore' });
216
+ p.on('close', (code) => resolve(code === 0));
217
+ p.on('error', () => resolve(false));
218
+ });
219
+ }
220
+
221
+ function runCmd(cmd, args, timeoutMs) {
222
+ return new Promise((resolve, reject) => {
223
+ const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
224
+ let stdout = '', stderr = '';
225
+ proc.stdout.on('data', (d) => { stdout += d; });
226
+ proc.stderr.on('data', (d) => { stderr += d; });
227
+ const t = setTimeout(() => { proc.kill(); reject(new Error('timed out')); }, timeoutMs);
228
+ proc.on('error', (e) => { clearTimeout(t); reject(e); });
229
+ proc.on('close', (code) => { clearTimeout(t); resolve({ code, stdout, stderr }); });
230
+ });
231
+ }
@@ -5,7 +5,7 @@ When the user asks to **set up codejitsu/core/deploy** (or "wire up the Cloudfla
5
5
  ## What this module provides
6
6
 
7
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.
8
+ - `templates/daily-deploy.yml` — GitHub Action that pings one or more Cloudflare deploy hooks every morning so scheduled blog posts (or any time-gated content) graduate from hidden to public on their publish date. Handles both single-site repos and multi-site monorepos (see below).
9
9
 
10
10
  ## Wiring it into a site
11
11
 
@@ -30,11 +30,34 @@ gh secret set CLOUDFLARE_DEPLOY_HOOK_URL --body "<paste URL>"
30
30
 
31
31
  Or via the GitHub UI: Settings → Secrets and variables → Actions → New repository secret, named `CLOUDFLARE_DEPLOY_HOOK_URL`.
32
32
 
33
+ > Multi-site monorepo? Skip this step and follow **Multi-site monorepos** below instead.
34
+
33
35
  ### 4. Verify
34
36
 
35
37
  - Trigger the workflow manually: `gh workflow run "Daily Deploy"` (or via the GH UI).
36
38
  - A Cloudflare Pages deployment should kick off within seconds.
37
39
 
40
+ ## Multi-site monorepos
41
+
42
+ A single repo can host several sites (e.g. `sites/www`, `sites/kamloops`, `sites/kelowna`), each deployed as its **own Cloudflare Pages project**. There is still only **one** `.github/workflows/daily-deploy.yml` for the repo, but it must rebuild **every** project — otherwise only one site graduates its scheduled content each morning and the rest stay stale.
43
+
44
+ The template already handles this. To wire it up:
45
+
46
+ 1. **Create one deploy hook per Pages project** (Cloudflare dashboard → each project → Settings → Builds & deployments → Deploy hooks, branch `main`). For garagedoorpros that's three: `garagedoorpros-ca`, `gdp-kamloops`, `gdp-kelowna`.
47
+ 2. **Store all the URLs in a single plural secret, comma-separated** (one line — easiest to set and paste; Cloudflare hook URLs never contain commas):
48
+
49
+ ```bash
50
+ gh secret set CLOUDFLARE_DEPLOY_HOOK_URLS \
51
+ --body "https://api.cloudflare.com/.../www-hook,https://api.cloudflare.com/.../kamloops-hook,https://api.cloudflare.com/.../kelowna-hook"
52
+ ```
53
+
54
+ (Newline- or space-separated values also work — the workflow splits on commas, newlines, and spaces — but commas keep it a single line, which avoids the multi-line `--body` / paste pitfalls.)
55
+ 3. The workflow prefers `CLOUDFLARE_DEPLOY_HOOK_URLS` when present, splits it, and pings each URL. It only falls back to the singular `CLOUDFLARE_DEPLOY_HOOK_URL` when the plural secret is absent — so single-site repos need no change.
56
+
57
+ **Adding a site later:** create its deploy hook, append `,<new-url>` to `CLOUDFLARE_DEPLOY_HOOK_URLS`, done. No workflow edit needed.
58
+
59
+ **Verify:** `gh workflow run "Daily Deploy"` then confirm a deployment kicks off in *every* Pages project, not just one.
60
+
38
61
  ## Build command (for Cloudflare Pages git integration)
39
62
 
40
63
  If using Cloudflare's git integration (preferred for push-driven deploys):
@@ -45,7 +68,8 @@ If using Cloudflare's git integration (preferred for push-driven deploys):
45
68
 
46
69
  ## What must NOT be done
47
70
 
48
- - **Don't commit the deploy hook URL.** It belongs in `CLOUDFLARE_DEPLOY_HOOK_URL` secret only.
71
+ - **Don't commit the deploy hook URL(s).** They belong in the `CLOUDFLARE_DEPLOY_HOOK_URL` / `CLOUDFLARE_DEPLOY_HOOK_URLS` secret only.
72
+ - **Don't add a second daily-deploy workflow for a monorepo's extra sites.** One workflow pings all hooks via `CLOUDFLARE_DEPLOY_HOOK_URLS` — see **Multi-site monorepos**. Duplicate workflows mean duplicate crons and drift.
49
73
  - **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
74
  - **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
75
  - **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.
@@ -54,5 +78,5 @@ If using Cloudflare's git integration (preferred for push-driven deploys):
54
78
 
55
79
  - [ ] `wrangler.toml` present at site root with correct `name`.
56
80
  - [ ] `.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.
81
+ - [ ] Deploy-hook secret set (check with `gh secret list`): `CLOUDFLARE_DEPLOY_HOOK_URL` for a single site, or `CLOUDFLARE_DEPLOY_HOOK_URLS` (one URL per line) for a multi-site monorepo.
82
+ - [ ] A Cloudflare Pages project exists for **each** site and is connected to the git repo, and every project has a hook in the secret.
@@ -3,7 +3,7 @@
3
3
  - [ ] `wrangler.toml` exists at site root with the correct Cloudflare Pages project `name`.
4
4
  - [ ] `pages_build_output_dir = "dist"` in `wrangler.toml`.
5
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.
6
+ - [ ] Deploy-hook secret is set in the repo (`gh secret list`): `CLOUDFLARE_DEPLOY_HOOK_URL` (single site) or `CLOUDFLARE_DEPLOY_HOOK_URLS` (multi-site monorepo, one URL per line).
7
+ - [ ] A Cloudflare Pages project exists for **each** site and is connected to the GitHub repo (git integration); every project's hook is in the secret.
8
+ - [ ] Each Pages project's build command is `npm run build` (for its own site/workspace), output dir is `dist`, Node version is 20.
9
+ - [ ] Manual run of `gh workflow run "Daily Deploy"` triggers a Cloudflare deployment in **every** Pages project within seconds.
@@ -3,9 +3,14 @@ name: Daily Deploy
3
3
  # Triggers a Cloudflare Pages rebuild each morning so any scheduled content
4
4
  # whose publish date has arrived graduates from hidden to public.
5
5
  #
6
- # Requires a repository secret named CLOUDFLARE_DEPLOY_HOOK_URL holding
7
- # the Deploy Hook URL from Cloudflare Pages (Project settings -> Builds &
6
+ # Single-site repo: set the secret CLOUDFLARE_DEPLOY_HOOK_URL to the one
7
+ # Deploy Hook URL from Cloudflare Pages (Project settings -> Builds &
8
8
  # deployments -> Deploy hooks).
9
+ #
10
+ # Multi-site monorepo (one repo -> several Pages projects): set the secret
11
+ # CLOUDFLARE_DEPLOY_HOOK_URLS to ALL the projects' Deploy Hook URLs, separated
12
+ # by commas (newlines also work). The job pings every URL so each site
13
+ # rebuilds. If both secrets are set, the plural one wins.
9
14
 
10
15
  on:
11
16
  schedule:
@@ -18,12 +23,23 @@ jobs:
18
23
  runs-on: ubuntu-latest
19
24
  timeout-minutes: 5
20
25
  steps:
21
- - name: Ping Cloudflare deploy hook
26
+ - name: Ping Cloudflare deploy hook(s)
22
27
  env:
28
+ HOOKS: ${{ secrets.CLOUDFLARE_DEPLOY_HOOK_URLS }}
23
29
  HOOK: ${{ secrets.CLOUDFLARE_DEPLOY_HOOK_URL }}
24
30
  run: |
25
- if [ -z "$HOOK" ]; then
26
- echo "CLOUDFLARE_DEPLOY_HOOK_URL secret is not set"
31
+ # Prefer the plural (multi-URL) secret; fall back to the singular one.
32
+ urls="$HOOKS"
33
+ [ -z "$urls" ] && urls="$HOOK"
34
+ if [ -z "$urls" ]; then
35
+ echo "Neither CLOUDFLARE_DEPLOY_HOOK_URLS nor CLOUDFLARE_DEPLOY_HOOK_URL is set"
27
36
  exit 1
28
37
  fi
29
- curl --fail-with-body -X POST "$HOOK"
38
+ # Accept commas, newlines, or spaces as separators between URLs.
39
+ failed=0
40
+ for url in $(echo "$urls" | tr ',\n' ' '); do
41
+ [ -z "$url" ] && continue
42
+ echo "Pinging deploy hook: ${url%%\?*}"
43
+ curl --fail-with-body -X POST "$url" || failed=1
44
+ done
45
+ exit $failed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibalzam/codejitsu-core",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "Shared core for Codejitsu Astro sites — reusable code and Claude-facing instructions for blog, SEO, images, deploy, and llms.txt.",
6
6
  "keywords": [