@ibalzam/codejitsu-core 0.11.0 → 0.12.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/bin/codejitsu.mjs +13 -0
- package/modules/audit/src/groups/blog-quality.mjs +47 -0
- package/modules/blog-writer/BLOG_BATCH.md +34 -12
- package/modules/blog-writer/BLOG_WRITING.md +158 -32
- package/modules/blog-writer/CLAUDE.md +21 -0
- package/modules/cli/src/blog-init.mjs +37 -5
- package/modules/cli/src/blog-selftest.mjs +231 -0
- package/modules/config/src/types.d.ts +57 -13
- package/modules/config/src/types.ts +57 -13
- package/package.json +1 -1
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`);
|
|
@@ -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,
|
|
@@ -10,19 +10,28 @@
|
|
|
10
10
|
|
|
11
11
|
## Step 0 — Read site config
|
|
12
12
|
|
|
13
|
-
Same as `BLOG_WRITING.md` Step 0: load `codejitsu.config.ts.blogWriter`.
|
|
13
|
+
Same as `BLOG_WRITING.md` Step 0: load `codejitsu.config.ts.blogWriter`. If
|
|
14
|
+
missing, STOP with the same error message.
|
|
14
15
|
|
|
15
16
|
Also read:
|
|
16
|
-
- `src/content/blog/` — existing posts (find
|
|
17
|
+
- `src/content/blog/` — existing posts (find latest `pubDate`)
|
|
17
18
|
- `src/content.config.ts` — confirm the schema shape
|
|
18
19
|
|
|
19
20
|
## Step 1 — Decide cadence
|
|
20
21
|
|
|
21
|
-
Default cadence is
|
|
22
|
-
veteran). If existing posts have a different rhythm, infer from the last
|
|
23
|
-
10 and match it.
|
|
22
|
+
Default cadence is **`blogWriter.cadenceDays` from config** (kit default: 4).
|
|
24
23
|
|
|
25
|
-
|
|
24
|
+
Cadence inference rules (in order):
|
|
25
|
+
1. If `blogWriter.cadenceDays` is set in config → use it (don't infer).
|
|
26
|
+
2. Else if `≥ 5` existing posts with future `pubDate` exist → measure gaps
|
|
27
|
+
between their consecutive dates. If the standard deviation of gaps is
|
|
28
|
+
`≤ 30%` of the median gap → use the median.
|
|
29
|
+
3. Else (irregular spacing, e.g. WordPress import dates) → fall back to **4 days**.
|
|
30
|
+
|
|
31
|
+
Starting date = `max(latest existing pubDate, today) + cadence`.
|
|
32
|
+
|
|
33
|
+
If you fall back to default because of irregular existing dates, **mention it
|
|
34
|
+
to the user** before continuing — they may want to override.
|
|
26
35
|
|
|
27
36
|
## Step 2 — Generate N topics
|
|
28
37
|
|
|
@@ -134,9 +143,22 @@ say stop: leave the file and exit. They can run `/blog` row by row whenever.
|
|
|
134
143
|
|
|
135
144
|
## Verify
|
|
136
145
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
-
|
|
141
|
-
-
|
|
142
|
-
-
|
|
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.
|
|
@@ -8,24 +8,70 @@
|
|
|
8
8
|
## Step 0 — Read site config
|
|
9
9
|
|
|
10
10
|
Open `codejitsu.config.ts` at the site root and locate the `blogWriter` block.
|
|
11
|
+
|
|
12
|
+
**If `blogWriter` is missing or empty**, STOP and tell the user:
|
|
13
|
+
|
|
14
|
+
> "The blogWriter config block isn't set up yet. Add this to your codejitsu.config.ts:
|
|
15
|
+
>
|
|
16
|
+
> ```ts
|
|
17
|
+
> blogWriter: {
|
|
18
|
+
> tone: '<one-line voice description>',
|
|
19
|
+
> about: '<what the company does + who it serves>',
|
|
20
|
+
> audience: '<primary reader, e.g. BC homeowners planning HVAC>',
|
|
21
|
+
> services: ['Service A', 'Service B'],
|
|
22
|
+
> locations: ['City A', 'City B'],
|
|
23
|
+
> },
|
|
24
|
+
> ```
|
|
25
|
+
>
|
|
26
|
+
> The other fields (approvedTags, wordCount, imageStyle, pricing, seasonalRules,
|
|
27
|
+
> bannedPhrases) have sensible kit defaults. See `modules/blog-writer/CLAUDE.md`
|
|
28
|
+
> for the full shape."
|
|
29
|
+
|
|
30
|
+
Do not proceed without the block. Don't invent values.
|
|
31
|
+
|
|
11
32
|
Everything in there is **site-specific input** for what you're about to write:
|
|
12
33
|
|
|
13
|
-
- `tone` — voice + register
|
|
14
|
-
- `about` — what the company does, who it serves
|
|
15
|
-
- `audience` — primary reader
|
|
16
|
-
- `services` — names that map to `/services/<slug>/`
|
|
17
|
-
- `locations` — names that map to `/service-areas/<slug>/`
|
|
18
|
-
- `approvedTags` —
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- `
|
|
23
|
-
- `
|
|
24
|
-
- `
|
|
25
|
-
- `
|
|
34
|
+
- `tone` — voice + register (REQUIRED)
|
|
35
|
+
- `about` — what the company does, who it serves (REQUIRED)
|
|
36
|
+
- `audience` — primary reader (REQUIRED)
|
|
37
|
+
- `services` — names that map to `/services/<slug>/` (REQUIRED)
|
|
38
|
+
- `locations` — names that map to `/service-areas/<slug>/` (REQUIRED)
|
|
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.
|
|
43
|
+
- `wordCount` — `{ min, max, default }`. Kit default `{ 1200, 2500, 1800 }`
|
|
44
|
+
- `faqs` — `{ min, max }`. Kit default `{ 5, 8 }`
|
|
45
|
+
- `internalLinks` — `{ min, max }` per post. Kit default `{ 3, 6 }`
|
|
46
|
+
- `pricing` — `'brackets-only' | 'allowed' | 'never-mention'`. Kit default `'brackets-only'` for service businesses
|
|
47
|
+
- `seasonalRules` — free text; current date should respect this. Default: none
|
|
48
|
+
- `bannedPhrases` — refuse to write these. Kit default includes "In today's fast-paced world", "When it comes to", "Look no further", "In conclusion"
|
|
49
|
+
- `authorDefault` — frontmatter `author`. Default: `site.defaultAuthor` or `site.name`
|
|
50
|
+
- `cadenceDays` — for /blog-batch. Kit default `4`
|
|
26
51
|
|
|
27
52
|
Also detect the blog's frontmatter shape by reading `src/content.config.ts` and
|
|
28
|
-
ONE existing post in `src/content/blog/`.
|
|
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.
|
|
29
75
|
|
|
30
76
|
## Step 1 — Gather inputs (interactive)
|
|
31
77
|
|
|
@@ -57,7 +103,38 @@ options:
|
|
|
57
103
|
- "XL (2500+ words)" — Definitive guide
|
|
58
104
|
```
|
|
59
105
|
|
|
60
|
-
|
|
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.
|
|
61
138
|
|
|
62
139
|
### Question 3 — Primary service + city focus
|
|
63
140
|
|
|
@@ -130,20 +207,47 @@ frontmatter shape exactly (detected in Step 0).
|
|
|
130
207
|
(default 5-8). Each FAQ = real question a reader would search; answer is
|
|
131
208
|
2-4 sentences, concise and complete.
|
|
132
209
|
6. **Internal links** — `internalLinks.min` to `internalLinks.max` from config
|
|
133
|
-
(default 3-6).
|
|
134
|
-
|
|
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>/`.
|
|
135
216
|
7. **Banned phrases** — if `bannedPhrases` is set in config, refuse them.
|
|
136
217
|
Common offenders: "In today's fast-paced world…", "When it comes to…",
|
|
137
218
|
"Look no further…", "In conclusion…".
|
|
138
|
-
8. **Pricing** —
|
|
139
|
-
|
|
140
|
-
|
|
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.
|
|
141
230
|
9. **CTA** — closing paragraph includes a call-to-action. Two patterns:
|
|
142
231
|
- Modal: `[text](#contact)` or trigger-class link
|
|
143
232
|
- Phone: `[text](tel:+1...)` — use the actual number from `site.business.telephone`
|
|
144
233
|
Best is one paragraph with both.
|
|
145
|
-
10. **Approved tags only
|
|
146
|
-
|
|
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.
|
|
147
251
|
11. **Image placeholder.** Frontmatter `image` field points to where the image
|
|
148
252
|
WILL live: `<imageStyle.outputDir>/<slug>.webp`. The file doesn't exist
|
|
149
253
|
yet; that's fine. Run `/blog-images` to generate prompts later.
|
|
@@ -175,16 +279,38 @@ exactly. Examples:
|
|
|
175
279
|
|
|
176
280
|
## Step 4 — Verify after writing
|
|
177
281
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
-
|
|
182
|
-
|
|
183
|
-
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
-
|
|
282
|
+
After writing the file, **actively run these checks** by reading the file back
|
|
283
|
+
and counting. Don't skip. If any fail, fix in place before reporting Step 5.
|
|
284
|
+
|
|
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.
|
|
305
|
+
- **No em dashes**: grep body for `—` and `–` — neither should appear
|
|
306
|
+
- **No H1 in body**: first non-frontmatter line is NOT `# ...`
|
|
307
|
+
- **No `bannedPhrases`**: grep body for each banned phrase
|
|
308
|
+
- **Pricing policy**: if `'brackets-only'`, grep for standalone `$<digits>` not in a range
|
|
309
|
+
- **Frontmatter shape**: matches existing posts in `src/content/blog/`
|
|
310
|
+
- **At least one list**: body contains `- `, `1. `, OR `|` (markdown table)
|
|
311
|
+
|
|
312
|
+
If any check fails, **fix the file then re-verify**. Surface unfixable issues
|
|
313
|
+
to the user before reporting Step 5.
|
|
188
314
|
|
|
189
315
|
## Step 5 — Report back
|
|
190
316
|
|
|
@@ -102,6 +102,27 @@ prompt. Claude reads BLOG_WRITING.md from the installed package and follows
|
|
|
102
102
|
that playbook. **The actual writing rules live in the package**, not in the
|
|
103
103
|
site's repo — that's the robust part.
|
|
104
104
|
|
|
105
|
+
## Conflict with existing site blog instructions
|
|
106
|
+
|
|
107
|
+
Some Codejitsu sites already have `.claude/BLOG_INSTRUCTIONS.md` (pearl,
|
|
108
|
+
workzen) — older bespoke instructions written before the kit existed.
|
|
109
|
+
After running `blog:init`, the site has BOTH:
|
|
110
|
+
- The kit's `/blog` command → reads BLOG_WRITING.md in the package
|
|
111
|
+
- The site's `.claude/BLOG_INSTRUCTIONS.md` → may be referenced by Claude
|
|
112
|
+
ambiently when the site is open
|
|
113
|
+
|
|
114
|
+
**Resolution when adopting the kit on such a site:**
|
|
115
|
+
1. Read the existing `.claude/BLOG_INSTRUCTIONS.md`. Identify what's
|
|
116
|
+
genuinely site-specific (specific tone phrasings, special pricing
|
|
117
|
+
rules, internal link patterns).
|
|
118
|
+
2. Move those site-specific bits into `codejitsu.config.ts.blogWriter`
|
|
119
|
+
(or `seasonalRules` / `bannedPhrases` fields).
|
|
120
|
+
3. Delete or rename the old `.claude/BLOG_INSTRUCTIONS.md` to
|
|
121
|
+
`.claude/BLOG_INSTRUCTIONS.archived.md` so it doesn't get auto-loaded.
|
|
122
|
+
4. Keep only the kit's slash command files in `.claude/commands/`.
|
|
123
|
+
|
|
124
|
+
This avoids the "two contradicting instruction sources" problem.
|
|
125
|
+
|
|
105
126
|
## What must NOT be done
|
|
106
127
|
|
|
107
128
|
- **Don't copy the playbook contents into the site.** The slash command
|
|
@@ -9,9 +9,9 @@ const PACKAGE_ROOT = path.resolve(
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* `codejitsu blog:init` — copies the blog-writer slash command templates
|
|
12
|
-
* into the site's `.claude/commands
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* into the site's `.claude/commands/`. The templates are thin references
|
|
13
|
+
* to playbooks that live in the package; updates flow via `npm update`
|
|
14
|
+
* without re-running this command.
|
|
15
15
|
*/
|
|
16
16
|
export async function runBlogInit() {
|
|
17
17
|
const cwd = process.cwd();
|
|
@@ -49,11 +49,43 @@ export async function runBlogInit() {
|
|
|
49
49
|
console.log('');
|
|
50
50
|
console.log(`${written} created, ${skipped} skipped.`);
|
|
51
51
|
console.log('');
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
|
|
53
|
+
// Detect whether blogWriter is already in the config.
|
|
54
|
+
const configCandidates = ['codejitsu.config.ts', 'codejitsu.config.mjs', 'codejitsu.config.json'];
|
|
55
|
+
let hasBlogWriter = false;
|
|
56
|
+
for (const name of configCandidates) {
|
|
57
|
+
const p = path.join(cwd, name);
|
|
58
|
+
if (fs.existsSync(p) && /blogWriter\s*:/.test(fs.readFileSync(p, 'utf8'))) {
|
|
59
|
+
hasBlogWriter = true;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (hasBlogWriter) {
|
|
65
|
+
console.log(c.green('✓') + ' `blogWriter` block detected in codejitsu.config — ready to use.');
|
|
66
|
+
} else {
|
|
67
|
+
console.log(c.yellow('!') + ' No `blogWriter` block in codejitsu.config yet.');
|
|
68
|
+
console.log('');
|
|
69
|
+
console.log(c.gray('Add this minimal block to your codejitsu.config.ts:'));
|
|
70
|
+
console.log('');
|
|
71
|
+
console.log(c.gray(' blogWriter: {'));
|
|
72
|
+
console.log(c.gray(" tone: 'professional, plain-spoken, confident not boastful',"));
|
|
73
|
+
console.log(c.gray(" about: '<what the company does + who it serves>',"));
|
|
74
|
+
console.log(c.gray(" audience: '<primary reader, e.g. BC homeowners ...>',"));
|
|
75
|
+
console.log(c.gray(" services: ['Service A', 'Service B'],"));
|
|
76
|
+
console.log(c.gray(" locations: ['City A', 'City B'],"));
|
|
77
|
+
console.log(c.gray(' // Optional with kit defaults: approvedTags, wordCount, faqs,'));
|
|
78
|
+
console.log(c.gray(' // internalLinks, pricing, seasonalRules, bannedPhrases,'));
|
|
79
|
+
console.log(c.gray(' // authorDefault, cadenceDays, imageStyle'));
|
|
80
|
+
console.log(c.gray(' },'));
|
|
81
|
+
console.log('');
|
|
82
|
+
console.log(c.gray('See node_modules/@ibalzam/codejitsu-core/modules/blog-writer/CLAUDE.md for the full shape.'));
|
|
83
|
+
}
|
|
84
|
+
|
|
54
85
|
console.log('');
|
|
55
86
|
console.log('Then in Claude Code:');
|
|
56
87
|
console.log(' /blog single post, interactive');
|
|
57
88
|
console.log(' /blog-batch 20 schedule + outline 20 future posts');
|
|
58
89
|
console.log(' /blog-images image prompts for pending posts');
|
|
90
|
+
console.log('');
|
|
59
91
|
}
|
|
@@ -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
|
+
}
|
|
@@ -18,43 +18,87 @@ export interface CodejitsuConfig {
|
|
|
18
18
|
audit?: AuditConfig;
|
|
19
19
|
blogWriter?: BlogWriterConfig | false;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The minimum to enable blog writing: tone, about, audience, services,
|
|
23
|
+
* locations. Everything else has kit defaults that suit most sites.
|
|
24
|
+
*
|
|
25
|
+
* @example minimal
|
|
26
|
+
* ```ts
|
|
27
|
+
* blogWriter: {
|
|
28
|
+
* tone: 'professional, plain-spoken, confident not boastful',
|
|
29
|
+
* about: 'Veteran is a BC HVAC contractor serving the Lower Mainland',
|
|
30
|
+
* audience: 'BC homeowners planning HVAC upgrades',
|
|
31
|
+
* services: ['Heat Pump Installation', 'Furnace Installation'],
|
|
32
|
+
* locations: ['Vancouver', 'Burnaby', 'Surrey'],
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
21
36
|
export interface BlogWriterConfig {
|
|
22
37
|
enabled?: boolean;
|
|
23
|
-
/** Voice + register, free text. e.g. "professional but friendly, confident not boastful". */
|
|
38
|
+
/** REQUIRED. Voice + register, free text. e.g. "professional but friendly, confident not boastful". */
|
|
24
39
|
tone: string;
|
|
25
|
-
/** What the company does + who it serves.
|
|
40
|
+
/** REQUIRED. What the company does + who it serves. Grounds the writer in context. */
|
|
26
41
|
about: string;
|
|
27
|
-
/** Primary reader. e.g. "BC Lower Mainland homeowners planning HVAC upgrades". */
|
|
42
|
+
/** REQUIRED. Primary reader. e.g. "BC Lower Mainland homeowners planning HVAC upgrades". */
|
|
28
43
|
audience: string;
|
|
29
|
-
/** Service names
|
|
44
|
+
/** REQUIRED. Service names mapping to /services/<slug>/. Used for internal-link planning. */
|
|
30
45
|
services: string[];
|
|
31
|
-
/** Location names
|
|
46
|
+
/** REQUIRED. Location names mapping to /service-areas/<slug>/. */
|
|
32
47
|
locations: string[];
|
|
33
|
-
/** Exhaustive tag list. The writer refuses to invent new tags. */
|
|
34
|
-
approvedTags
|
|
35
|
-
|
|
48
|
+
/** Exhaustive tag list. The writer refuses to invent new tags; if nothing fits it asks the user. Default: derives from `blog.categories` in config, or asks at first /blog use. */
|
|
49
|
+
approvedTags?: string[];
|
|
50
|
+
/** Default: { min: 1200, max: 2500, default: 1800 } (the "Long" tier). */
|
|
51
|
+
wordCount?: {
|
|
36
52
|
min: number;
|
|
37
53
|
max: number;
|
|
38
54
|
default: number;
|
|
39
55
|
};
|
|
56
|
+
/** Default: { min: 5, max: 8 }. */
|
|
40
57
|
faqs?: {
|
|
41
58
|
min: number;
|
|
42
59
|
max: number;
|
|
43
60
|
};
|
|
61
|
+
/** Default: { min: 3, max: 6 }. */
|
|
44
62
|
internalLinks?: {
|
|
45
63
|
min: number;
|
|
46
64
|
max: number;
|
|
47
65
|
};
|
|
48
|
-
/**
|
|
66
|
+
/** Default: 'brackets-only' for service businesses; 'allowed' otherwise. */
|
|
49
67
|
pricing?: 'brackets-only' | 'allowed' | 'never-mention';
|
|
50
|
-
/** Free-text seasonal rules. e.g. "May-Sep: outdoor + AC; Oct-Nov: pre-winter prep". */
|
|
68
|
+
/** Free-text seasonal rules. e.g. "May-Sep: outdoor + AC; Oct-Nov: pre-winter prep". Default: none. */
|
|
51
69
|
seasonalRules?: string;
|
|
52
|
-
/** Phrases the writer must NOT produce.
|
|
70
|
+
/** Phrases the writer must NOT produce. Default kit list includes "In today's fast-paced world", "When it comes to", "Look no further", "In conclusion". */
|
|
53
71
|
bannedPhrases?: string[];
|
|
54
|
-
/** Frontmatter `author` default
|
|
72
|
+
/** Frontmatter `author` default. Default: `site.defaultAuthor` or `site.name`. */
|
|
55
73
|
authorDefault?: string;
|
|
56
|
-
|
|
74
|
+
/** Cadence for /blog-batch, in days. Default: 4. */
|
|
75
|
+
cadenceDays?: number;
|
|
76
|
+
/** Image generation config. Required only if you'll use /blog-images. */
|
|
77
|
+
imageStyle?: BlogImageStyle;
|
|
57
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* @example photorealistic-architecture
|
|
81
|
+
* ```ts
|
|
82
|
+
* imageStyle: {
|
|
83
|
+
* description: 'Photorealistic real-estate / architectural photography of the actual space the post is about. Wide landscape framing, eye-level. Bright natural daylight, modern desert-contemporary palette. Clean composition, lightly staged, no clutter, no text overlays.',
|
|
84
|
+
* branding: 'Logo small in bottom-right corner. No other brand marks.',
|
|
85
|
+
* outputDir: 'public/assets/images/blog',
|
|
86
|
+
* maxWords: 60,
|
|
87
|
+
* realism: 'photorealistic',
|
|
88
|
+
* }
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* @example sloth-mascot-cartoon
|
|
92
|
+
* ```ts
|
|
93
|
+
* imageStyle: {
|
|
94
|
+
* description: 'Cartoon sloth-mascot character relevant to the post topic, flat friendly colors, with a white rounded text box at the bottom containing a SHORT version of the title.',
|
|
95
|
+
* branding: 'No watermarks, just the in-image character',
|
|
96
|
+
* outputDir: 'public/images/blog/posts',
|
|
97
|
+
* maxWords: 50,
|
|
98
|
+
* realism: 'cartoon',
|
|
99
|
+
* }
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
58
102
|
export interface BlogImageStyle {
|
|
59
103
|
/** Full prompt-style description of the visual style: palette, framing, materials, mood. */
|
|
60
104
|
description: string;
|
|
@@ -20,34 +20,78 @@ export interface CodejitsuConfig {
|
|
|
20
20
|
blogWriter?: BlogWriterConfig | false;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The minimum to enable blog writing: tone, about, audience, services,
|
|
25
|
+
* locations. Everything else has kit defaults that suit most sites.
|
|
26
|
+
*
|
|
27
|
+
* @example minimal
|
|
28
|
+
* ```ts
|
|
29
|
+
* blogWriter: {
|
|
30
|
+
* tone: 'professional, plain-spoken, confident not boastful',
|
|
31
|
+
* about: 'Veteran is a BC HVAC contractor serving the Lower Mainland',
|
|
32
|
+
* audience: 'BC homeowners planning HVAC upgrades',
|
|
33
|
+
* services: ['Heat Pump Installation', 'Furnace Installation'],
|
|
34
|
+
* locations: ['Vancouver', 'Burnaby', 'Surrey'],
|
|
35
|
+
* }
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
23
38
|
export interface BlogWriterConfig {
|
|
24
39
|
enabled?: boolean;
|
|
25
|
-
/** Voice + register, free text. e.g. "professional but friendly, confident not boastful". */
|
|
40
|
+
/** REQUIRED. Voice + register, free text. e.g. "professional but friendly, confident not boastful". */
|
|
26
41
|
tone: string;
|
|
27
|
-
/** What the company does + who it serves.
|
|
42
|
+
/** REQUIRED. What the company does + who it serves. Grounds the writer in context. */
|
|
28
43
|
about: string;
|
|
29
|
-
/** Primary reader. e.g. "BC Lower Mainland homeowners planning HVAC upgrades". */
|
|
44
|
+
/** REQUIRED. Primary reader. e.g. "BC Lower Mainland homeowners planning HVAC upgrades". */
|
|
30
45
|
audience: string;
|
|
31
|
-
/** Service names
|
|
46
|
+
/** REQUIRED. Service names mapping to /services/<slug>/. Used for internal-link planning. */
|
|
32
47
|
services: string[];
|
|
33
|
-
/** Location names
|
|
48
|
+
/** REQUIRED. Location names mapping to /service-areas/<slug>/. */
|
|
34
49
|
locations: string[];
|
|
35
|
-
/** Exhaustive tag list. The writer refuses to invent new tags. */
|
|
36
|
-
approvedTags
|
|
37
|
-
|
|
50
|
+
/** Exhaustive tag list. The writer refuses to invent new tags; if nothing fits it asks the user. Default: derives from `blog.categories` in config, or asks at first /blog use. */
|
|
51
|
+
approvedTags?: string[];
|
|
52
|
+
/** Default: { min: 1200, max: 2500, default: 1800 } (the "Long" tier). */
|
|
53
|
+
wordCount?: { min: number; max: number; default: number };
|
|
54
|
+
/** Default: { min: 5, max: 8 }. */
|
|
38
55
|
faqs?: { min: number; max: number };
|
|
56
|
+
/** Default: { min: 3, max: 6 }. */
|
|
39
57
|
internalLinks?: { min: number; max: number };
|
|
40
|
-
/**
|
|
58
|
+
/** Default: 'brackets-only' for service businesses; 'allowed' otherwise. */
|
|
41
59
|
pricing?: 'brackets-only' | 'allowed' | 'never-mention';
|
|
42
|
-
/** Free-text seasonal rules. e.g. "May-Sep: outdoor + AC; Oct-Nov: pre-winter prep". */
|
|
60
|
+
/** Free-text seasonal rules. e.g. "May-Sep: outdoor + AC; Oct-Nov: pre-winter prep". Default: none. */
|
|
43
61
|
seasonalRules?: string;
|
|
44
|
-
/** Phrases the writer must NOT produce.
|
|
62
|
+
/** Phrases the writer must NOT produce. Default kit list includes "In today's fast-paced world", "When it comes to", "Look no further", "In conclusion". */
|
|
45
63
|
bannedPhrases?: string[];
|
|
46
|
-
/** Frontmatter `author` default
|
|
64
|
+
/** Frontmatter `author` default. Default: `site.defaultAuthor` or `site.name`. */
|
|
47
65
|
authorDefault?: string;
|
|
48
|
-
|
|
66
|
+
/** Cadence for /blog-batch, in days. Default: 4. */
|
|
67
|
+
cadenceDays?: number;
|
|
68
|
+
/** Image generation config. Required only if you'll use /blog-images. */
|
|
69
|
+
imageStyle?: BlogImageStyle;
|
|
49
70
|
}
|
|
50
71
|
|
|
72
|
+
/**
|
|
73
|
+
* @example photorealistic-architecture
|
|
74
|
+
* ```ts
|
|
75
|
+
* imageStyle: {
|
|
76
|
+
* description: 'Photorealistic real-estate / architectural photography of the actual space the post is about. Wide landscape framing, eye-level. Bright natural daylight, modern desert-contemporary palette. Clean composition, lightly staged, no clutter, no text overlays.',
|
|
77
|
+
* branding: 'Logo small in bottom-right corner. No other brand marks.',
|
|
78
|
+
* outputDir: 'public/assets/images/blog',
|
|
79
|
+
* maxWords: 60,
|
|
80
|
+
* realism: 'photorealistic',
|
|
81
|
+
* }
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* @example sloth-mascot-cartoon
|
|
85
|
+
* ```ts
|
|
86
|
+
* imageStyle: {
|
|
87
|
+
* description: 'Cartoon sloth-mascot character relevant to the post topic, flat friendly colors, with a white rounded text box at the bottom containing a SHORT version of the title.',
|
|
88
|
+
* branding: 'No watermarks, just the in-image character',
|
|
89
|
+
* outputDir: 'public/images/blog/posts',
|
|
90
|
+
* maxWords: 50,
|
|
91
|
+
* realism: 'cartoon',
|
|
92
|
+
* }
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
51
95
|
export interface BlogImageStyle {
|
|
52
96
|
/** Full prompt-style description of the visual style: palette, framing, materials, mood. */
|
|
53
97
|
description: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ibalzam/codejitsu-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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": [
|