Adwave Content Publishing Guide
strategy/publishing-guide.md · strategy · 2 versions
Adwave Content Publishing Guide
Note: For quick reference and workflow overview, see CLAUDE.md (workspace instructions) first. This guide provides detailed, step-by-step publishing instructions.
---
Complete, unambiguous workflow for publishing content from brief to Contentful.
This guide is designed to be followed from scratch without prior context. Every step is explicit.
---
Table of Contents
- Prerequisites
- Quick Reference Commands
- Complete Workflow
- Draft Format Requirements
- Contentful Configuration
- Troubleshooting
---
Prerequisites
1. Environment Setup
Required environment variables:
# Create .env.local file (one-time setup)
cat > .env.local << 'EOF'
CONTENTFUL_MANAGEMENT_ACCESS_TOKEN=your-token-here
GEMINI_API_KEY=your-gemini-key-here
EOF
Or set in your shell session:
export CONTENTFUL_MANAGEMENT_ACCESS_TOKEN="your-token-here"
export GEMINI_API_KEY="your-gemini-key-here"
How to get tokens:
- Contentful token: Contentful → Settings → API Keys → Personal Access Tokens
- Gemini API key: Google AI Studio → Get API Key
2. Install Dependencies
cd /Users/naffis/projects/adwave/content-writing
npm install
3. Verify MCP Tools Are Available
Before starting, verify these MCP tools respond:
| Tool | Purpose | Verification Command |
|---|---|---|
nano-banana | Image generation | get_configuration_status |
contentful | CMS operations | get_initial_context |
If tools don't respond: Check Cursor MCP settings and restart Cursor.
---
Quick Reference Commands
# === FULL WORKFLOW (copy/paste, replace [SLUG] and [TITLE]) ===
# 0. CHECK SLUG AVAILABILITY FIRST (critical!)
npm run check-slug -- "[SLUG]"
# 1. Create image directory
mkdir -p drafts/images/[SLUG]/
# 2. Generate images (use nano-banana MCP), then copy each:
cp "/Users/naffis/generated_imgs/generated-*.png" "drafts/images/[SLUG]/hero.png"
# 3. Upload images
npm run upload-images -- "drafts/images/[SLUG]/" "[TITLE]"
# 4. Pre-check tables are wrapped
grep -n "^|" drafts/[SLUG].md
# 5. Convert markdown to RichText
npx tsx scripts/markdown-to-richtext.ts "drafts/[SLUG].md" "drafts/images/[SLUG]/asset-ids.json" 2>/dev/null > /tmp/richtext.json
# 6. Create table entries (if any tables in the markdown)
# For each <!-- TABLE: id --> block:
# a. Use Contentful MCP `create_entry` with contentTypeId "table",
# fields: { title, tableMarkdown }
# b. `publish_entry` to publish it
# c. sed 's/__TABLE_PLACEHOLDER_<id>__/<NEW_ENTRY_ID>/g' /tmp/richtext.json > /tmp/final-richtext.json
# Verify 0 placeholders left: grep -c TABLE_PLACEHOLDER /tmp/final-richtext.json
# If no tables: cp /tmp/richtext.json /tmp/final-richtext.json
# 7. Create entry via MCP (get entry ID)
# 8. Update body: npm run update-entry-body -- "[ENTRY_ID]" /tmp/final-richtext.json
# 9. Publish via MCP
# 10. Update calendar
---
Complete Workflow
Step 0: Identify the Next Post
Open the editorial calendar:
calendar/editorial-calendar.md
Find the next unpublished post (status = 🔲) in chronological order.
Locate the corresponding brief:
briefs/YYYY-MM-DD-[slug].md
---
Step 1: Read the Brief
Read the brief file completely. It contains:
- Title and subtitle
- Target word count
- URL slug (use this for folder names)
- Category and resource type
- Article outline
- Adwave integration notes
- Keywords
Note these values from the brief:
Publish Date:YYYY-MM-DDURL Slug:the-article-slugCategory:Guides | Industries | Case Study | Insights | News | Using AdwaveIndustry Type:(only for Industries category)Subindustry Type:(only for Industries category)
---
Step 1b: Verify Slug is Available
CRITICAL: Always check if the slug is taken before proceeding.
npm run check-slug -- "the-article-slug"
If the slug is available:
✅ Slug "the-article-slug" is AVAILABLE
Proceed to Step 2.
If the slug is taken:
❌ Slug "the-article-slug" is TAKEN
Existing entry:
Entry ID: abc123
Title: Existing Article Title
...
💡 Suggestions:
- the-article-slug-guide
- the-article-slug-2025
When slug is taken, you must:
- Choose a different slug (script provides suggestions)
- Update the brief file with the new slug
- Update the article title if needed to match
- Re-run
npm run check-slugto verify the new slug is available
Common slug alternatives:
- Add
-guidesuffix - Add year (
-2025) - Make more specific (
tv-advertising-dentists→dental-practice-tv-advertising) - Add qualifier (
ctv-statistics→ctv-advertising-statistics-2025)
---
Step 2: Review Writing Guidelines
Writing and QC rules (voice, formatting, SEO, product accuracy, competitors, quality) are enforced by the workspace Guidelines (Library → Guidelines) on every draft.
Key rules to remember:
- Never use emdashes (—), use commas or parentheses
- Use contractions (you'll, it's, here's)
- No jargon without explanation
- Acknowledge challenges before solutions
- 2-3 natural Adwave mentions in body
- NEVER mention: MNTN, Vibe, Creatify, tvScientific
---
Step 3: Research Internal Links
⚠️ REQUIRED: Fetch Adwave sitemap for EVERY article:
Use firecrawl_map MCP tool (no limit - get ALL URLs):
firecrawl_map(url: "https://adwave.com")
Goal: Identify minimum 4 internal links to include in the article. More is better when relevant.
⚠️ IMPORTANT: Link Selection Strategy
DO NOT rely on a static list of links. Always fetch the sitemap fresh and select contextually relevant pages.
Priority order when selecting links from sitemap results:
- FIRST:
/resources/pages (other blog posts/guides)
- These provide variety and cross-promote our content - Match to article topic (e.g., cost article → /resources/local-tv-advertising-costs/) - Examples: /resources/what-is-connected-tv-advertising/, /resources/tv-ad-production-costs/, /resources/small-business-tv-ads-vs-social/
- SECOND:
/industries/pages (when discussing specific verticals)
- Match to article's industry context - Examples: /industries/food-beverage/tv-advertising-restaurants/
- THIRD: Core site pages (sparingly, only when directly relevant)
- /how-it-works/, /pricing/, /faq/, /channels/ - Avoid overusing these - don't link the same pages in every article
| Example Ads | /example-tv-ads |
|---|
Linking rules:
- Minimum 4 internal links per article
- Use descriptive anchor text (not "click here")
- Link early (within first 500 words)
- Never italicize links
- Spread links throughout naturally
- Match links to article context
---
Step 4: Write the Draft
Create the draft file:
touch drafts/YYYY-MM-DD-[slug].md
Draft structure:
# Article Title
**Subtitle goes here**
---
<!-- IMAGE: hero -->
**Placement:** Top of article
**Type:** Hero (Digital Scrapbook - [RECIPE TYPE])
**Description:** [Alt text description]
**Filename:** hero.png
**Style:** STRICT - Must follow Digital Scrapbook rules
<!-- /IMAGE -->
[Opening paragraph with hook - 50-100 words]
[Continue with article following the brief outline...]
## H2 Section Title
[Section content]
<!-- IMAGE: chart-name -->
**Placement:** After [section/context]
**Type:** Data chart (Editorial Clean)
**Description:** [Alt text description]
**Filename:** chart-name.png
**Style:** STRICT - Must follow Editorial Clean rules
<!-- /IMAGE -->
<!-- TABLE: unique-table-id -->
**Title:** Table Display Title
**Caption:** Optional caption
| Column 1 | Column 2 |
|----------|----------|
| Value 1 | Value 2 |
<!-- /TABLE -->
## Ready to Get Started?
[CTA section - 50-100 words]
[Link to Adwave](https://adwave.com) with call to action.
Image count minimums by category:
| Category | Minimum Images |
|---|---|
| Case Study | 3-5 |
| Guides | 3-5 |
| Industries | 4-5 |
| Insights | 3-6 |
| News | 1-2 |
| Using Adwave | 4-6 |
---
Step 4b: SEO Keyword Verification (REQUIRED)
Before generating images, verify all keywords from the brief are properly integrated.
Read the brief to identify:
- Primary keyword: The main search term the article targets
- Secondary keywords: 3-5 related terms that should also appear
Run keyword verification:
SLUG="your-article-slug" && DRAFT="drafts/*$SLUG*.md"
echo "=== KEYWORD VERIFICATION ==="
echo ""
echo "1. First 150 words (check for primary keyword):"
head -c 1200 $DRAFT | grep -v "^#\|^<!--\|^\*\*\|^---" | head -20
echo ""
echo "2. H2 headings (check for keyword inclusion):"
grep "^## " $DRAFT
echo ""
echo "3. SEO Description:"
grep "SEO Description" $DRAFT
SEO Keyword Checklist:
| Requirement | Location | How to Verify |
|---|---|---|
| Primary keyword in first 100 words | Opening paragraph | Manual check of first paragraph |
| Primary keyword in at least 1 H2 | Any H2 heading | grep "^## " drafts/slug.md |
| Primary keyword in SEO description | Meta description | Check SEO Description line |
| All secondary keywords used | Body text | Search for each keyword |
Common fixes for missing keywords:
- Primary keyword missing from opening:
- Rewrite first sentence to naturally include the primary keyword - Example: "TV advertising for dentists might sound expensive..." instead of "Most dental practices rely on referrals..."
- Secondary keywords not used:
- Add secondary keywords to H2 headings where natural - Work them into relevant body paragraphs - Don't force them—readability trumps keyword density
- H2 headings don't include keywords:
- Revise H2s to include primary or secondary keywords - Example: "Costs" → "TV Advertising Costs for Dentists"
Keyword density guidelines:
- Primary keyword: 3-5 times in body (excluding title/metadata)
- Each secondary keyword: At least once
- Natural usage only—never keyword stuff
---
Step 5: Generate Images
Create the image directory:
mkdir -p drafts/images/YYYY-MM-DD-[slug]/
Image style rules are enforced by the "Minimum 4 images" guideline (brand palette + digital-scrapbook style).
Image style rules (STRICT):
| Image Type | Style | Guide Section |
|---|---|---|
| Hero images | Digital Scrapbook | Part 1 |
| Charts/graphs | Editorial Clean | Part 2 |
| Body images | Flexible | Part 3 |
Generate images using nano-banana MCP:
Tool: generate_image
Prompt: [Use template from adwave-images.mdc for your content type]
IMPORTANT: After each generation, copy the file:
# nano-banana saves to: /Users/naffis/generated_imgs/generated-[timestamp]-[random].png
# Copy to your draft images folder with correct filename:
cp "/Users/naffis/generated_imgs/generated-XXXX.png" "drafts/images/YYYY-MM-DD-[slug]/hero.png"
⚠️ MANDATORY: Review each image for text errors before proceeding:
- Open the generated image at 100% zoom
- Read ALL text in the image carefully
- Check for: typos, misspellings, wrong numbers, garbled text
- If errors found: regenerate with LESS text (use icons instead) or use
continue_editing - Only proceed to upload after ALL images pass text review
Pro tip: To avoid typos, minimize text in images. Use icons, symbols, and color coding instead of text labels wherever possible. See adwave-images.mdc for low-text prompt templates.
Hero image recipe by content type:
- Platform Guides → "Technical Manual" recipe
- Case Studies → "Success Journal" recipe
- Comparison Posts → "Pro/Con List" recipe
- Industry Playbooks → "Industry Mood Board" recipe
- Statistics/Data → "Field Research Note" recipe
- How-To Guides → "DIY Project Plan" recipe
- Thought Leadership → "Vision Board" recipe
---
Step 5b: MANDATORY Image Count Checkpoint
⚠️ STOP: Before uploading, verify you have enough images.
# Count images in your draft
SLUG="your-article-slug"
echo "=== IMAGE COUNT CHECK ===" && \
echo "Images in draft:" && grep -c "!\[" drafts/*$SLUG*.md && \
echo "Images in folder:" && ls drafts/images/*$SLUG*/ | wc -l
Minimum image requirements:
| Category | Min Total | Min Body (non-hero) |
|---|---|---|
| Platform Guides | 4 | 3 |
| Case Studies | 4 | 3 |
| How-To Guides | 5 | 4 |
| Industries/Verticals | 5 | 4 |
| Statistics | 5 | 4 |
| Thought Leadership | 3 | 2 |
If count is below minimum:
- STOP - Do not proceed
- Identify sections that need visuals (after statistics, comparisons, processes)
- Generate additional images
- Update the draft with image references
- Re-run this checkpoint
Only proceed when image count meets minimum.
---
Step 6: Upload Images to Contentful
npm run upload-images -- "drafts/images/YYYY-MM-DD-[slug]/" "Article Title"
Expected output:
🖼️ Uploading X image(s) to Contentful...
📤 Uploading: hero.png...
✓ Published!
📤 Uploading: chart-name.png...
✓ Published!
💾 Asset IDs saved to: drafts/images/[slug]/asset-ids.json
Verify asset-ids.json was created:
cat drafts/images/YYYY-MM-DD-[slug]/asset-ids.json
---
Step 7: Pre-Check Tables
CRITICAL: All markdown tables MUST be wrapped in <!-- TABLE: --> placeholders.
✅ BLOCKING CHECK: The markdown-to-richtext script now fails automatically if unwrapped tables are detected. If you see this error, wrap your tables before proceeding.
# Manual check (optional - script does this automatically)
grep -n "^|" drafts/YYYY-MM-DD-[slug].md
# Count table placeholders
grep -c "<!-- TABLE:" drafts/YYYY-MM-DD-[slug].md
If conversion fails with "Unwrapped markdown tables detected" error:
- Find the table lines in the error output
- Wrap each table in
<!-- TABLE: unique-id -->...<!-- /TABLE -->placeholders - Re-run the conversion
---
Step 8: Convert Markdown to RichText
npx tsx scripts/markdown-to-richtext.ts \
"drafts/YYYY-MM-DD-[slug].md" \
"drafts/images/YYYY-MM-DD-[slug]/asset-ids.json" \
2>/dev/null > /tmp/richtext.json
Verify conversion:
head -c 100 /tmp/richtext.json # Should start with {
wc -c /tmp/richtext.json # Should be 20KB-100KB typically
---
Step 9: Create Table Entries (via Contentful MCP)
If your article has NO tables, skip to Step 10. (cp /tmp/richtext.json /tmp/final-richtext.json and move on.)
The markdown-to-richtext converter inserts __TABLE_PLACEHOLDER_<id>__ tokens anywhere a <!-- TABLE: id --> ... <!-- /TABLE --> block appears in the markdown. Each placeholder must be replaced with the ID of a published table content-type entry before the body can be attached.
For each <!-- TABLE: id --> block in the markdown:
- Create the table entry via Contentful MCP
create_entry:
{
"spaceId": "fia320z3blxi",
"environmentId": "master",
"contentTypeId": "table",
"fields": {
"title": { "en-US": "Human-readable table title" },
"tableMarkdown": { "en-US": "| Col | Col |\n|---|---|\n| ... |" }
}
}
- Publish the entry with
publish_entry. - Replace the placeholder in the richtext JSON with the new entry ID:
sed 's/__TABLE_PLACEHOLDER_<id>__/<NEW_ENTRY_ID>/g' /tmp/richtext.json > /tmp/final-richtext.json
For multiple tables, chain sed calls or pipe through each replacement in turn.
- Verify 0 placeholders remain:
grep -c TABLE_PLACEHOLDER /tmp/final-richtext.json # Must print 0
Note: npm run markdown-to-richtext prints 2–3 npm log lines to stdout before the JSON payload. Strip them with tail -n +4 (or locate the real start line with grep -n '^{' | head -1) before using the file.
Verify clean JSON:
head -c 50 /tmp/final-richtext.json # Should start with {
tail -c 50 /tmp/final-richtext.json # Should end with }
---
Step 10: Create the Contentful Entry
Use the Contentful MCP tool create_entry:
{
"spaceId": "fia320z3blxi",
"environmentId": "master",
"contentTypeId": "resource",
"fields": {
"title": { "en-US": "SEO Title (50-60 chars)" },
"h1": { "en-US": "Page Heading (H1) - Can Be Longer and More Descriptive" },
"subtitle": { "en-US": "Subtitle text" },
"slug": { "en-US": "article-slug" },
"shortDescription": { "en-US": "Brief description for cards (1-2 sentences)" },
"resourceDate": { "en-US": "YYYY-MM-DD" },
"resourceType": { "en-US": "Guides" },
"seoDescription": { "en-US": "Meta description for SEO (150-155 chars)" },
"headerImage": {
"en-US": {
"sys": { "type": "Link", "linkType": "Asset", "id": "HERO_ASSET_ID" }
}
}
}
}
Title vs H1 Guidelines:
title= SEO title (browser tab, search results) - Keep to 50-60 characters for SERP displayh1= Page heading (visible on page) - Can be longer, more descriptive, keyword-rich
Step 10a: Craft the H1 (Before Creating Entry)
⚠️ REQUIRED: Review title and body content to craft an effective H1.
The H1 should not just be a copy of the SEO title. Take time to craft it properly:
H1 Crafting Process:
- Review the SEO title - Note the primary keyword and any character constraints
- Scan the article body - Identify:
- The core problem being solved - The main value proposition - Key differentiators (e.g., "for small businesses", "in 2025", "step-by-step")
- Draft an H1 that:
- Expands on the SEO title with more context or specificity - Speaks to what the reader will learn or achieve - Feels natural as a page heading (not truncated or awkward) - Includes the primary keyword naturally
H1 Quality Checklist:
- [ ] Is it more descriptive than the SEO title?
- [ ] Does it include the primary keyword?
- [ ] Does it match the article's actual content and promise?
- [ ] Would a reader immediately understand what they'll get?
- [ ] Does it feel natural as a heading (not keyword-stuffed)?
H1 Patterns by Content Type:
| Content Type | H1 Pattern | Example |
|---|---|---|
| How-To Guide | "How to [Action]: [Expanded Benefit]" | "How to Advertise on Roku: Everything Small Businesses Need to Know" |
| Comprehensive Guide | "The Complete Guide to [Topic] [Context]" | "The Complete Guide to TV Advertising for Restaurants in 2025" |
| Cost/Pricing | "How Much Does [Topic] Cost? [Audience Context]" | "How Much Does CTV Advertising Cost? A Breakdown for Small Businesses" |
| Comparison | "[A] vs [B]: [Decision-Focused Question]" | "CTV vs. Social Media Ads: Which Delivers Better ROI for Local Businesses?" |
| Statistics | "[Number] [Topic] Statistics [Audience] [Year]" | "25 CTV Advertising Statistics Every Small Business Marketer Should Know in 2025" |
| Industry Playbook | "[Industry] [Topic]: [Comprehensive Descriptor]" | "Restaurant TV Advertising: The Complete Marketing Playbook" |
Examples of Good Title → H1 Expansion:
| SEO Title (50-60 chars) | H1 (expanded, reader-focused) |
|---|---|
| TV Advertising for Restaurants: Complete Guide | The Complete Guide to TV Advertising for Restaurants in 2025 |
| CTV Advertising Costs: What to Expect | How Much Does CTV Advertising Cost? A Complete Breakdown for Small Businesses |
| Roku Advertising for Small Business | How to Advertise on Roku: Everything Small Businesses Need to Know |
| Best Advertising for Plumbers | The Best Advertising Channels for Plumbers: A Data-Driven Guide |
| CTV vs Meta Ads Comparison | CTV vs. Meta Ads: Which Channel Delivers Better ROI for Small Businesses? |
**Field values by category:**
| Category | resourceType | industryType | subindustryType |
|----------|--------------|--------------|-----------------|
| Guides | "Guides" | - | - |
| Industries | "Industries" | Required | Required |
| Case Study | "Case Study" | - | - |
| Insights | "Insights" | - | - |
| News | "News" | - | - |
| Using Adwave | "Using Adwave" | - | - |
**Get the HERO_ASSET_ID from:**
cat drafts/images/YYYY-MM-DD-[slug]/asset-ids.json | grep hero
**Record the Entry ID** returned by create_entry (format: `XXXXXXXXXXXX`).
---
### Step 11: Update Entry Body Content
npm run update-entry-body -- "ENTRY_ID" /tmp/final-richtext.json
**Expected output:**
✅ Entry updated successfully! Entry ID: ENTRY_ID Version: 2
---
### Step 12: Publish the Entry
**Use the Contentful MCP tool `publish_entry`:**
{ "spaceId": "fia320z3blxi", "environmentId": "master", "entryId": "ENTRY_ID" }
---
### Step 13: Update Editorial Calendar
**Edit `calendar/editorial-calendar.md`:**
1. Add to Published Content Tracking table:
2. Update status in monthly schedule:
---
## Draft Format Requirements
### Image Placeholders
**⚠️ CRITICAL: Standard markdown images `` DO NOT WORK.**
The markdown-to-richtext script ONLY recognizes the placeholder format below. Any images using standard markdown syntax will be silently ignored.
**Image embedding requirements:**
1. **Filename must exactly match** the key in `asset-ids.json`
2. **All images must be uploaded** before running markdown-to-richtext
3. **asset-ids.json must exist** and contain all image filenames
4. **Hero images** are skipped in body (used for `headerImage` field instead)
### Table Placeholders
**Table rules:**
- Every markdown table MUST be wrapped
- Table ID must be unique within the article
- Title is REQUIRED
- Caption is optional
---
## Contentful Configuration
### Space and Environment
Space ID: fia320z3blxi Environment ID: master
### Content Type: resource
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| title | Symbol | Yes | SEO title (50-60 chars for SERP) |
| h1 | Symbol | Yes | Page heading (visible on page, can be longer) |
| subtitle | Symbol | No | Supporting headline |
| slug | Symbol | Yes | URL slug (no leading slash) |
| shortDescription | Symbol | No | Card description |
| headerImage | Asset Link | Yes | Hero image |
| headerVideo | Asset Link | No | Optional video |
| resourceDate | Date | Yes | YYYY-MM-DD format |
| resourceType | Symbol | Yes | See values below |
| industryType | Symbol | Conditional | Required for Industries |
| subindustryType | Symbol | Conditional | Required for Industries |
| bodyContent | RichText | Yes | Main content |
| seoImage | Asset Link | No | OG image |
| seoDescription | Symbol | No | Meta description |
**Title vs H1 Best Practices:**
- **title**: Optimized for search results. Front-load keywords. Keep under 60 chars.
- **h1**: Optimized for readers on the page. Can be more descriptive and engaging.
- They can be identical for simple cases, but separating them allows better optimization for both contexts.
### resourceType Values
- Case Study
- Guides
- Industries
- Insights
- News
- Using Adwave
### industryType Values
- Food & Beverage
- Professional Services
- Real Estate
- E-commerce
- Home Services
- Automotive
- Political
- Apps
- Retail
- Travel
- Hospitality
- Financial
- Legal
- Health and Wellness
---
## Troubleshooting
### "CONTENTFUL_MANAGEMENT_ACCESS_TOKEN not found"
Check if set
echo $CONTENTFUL_MANAGEMENT_ACCESS_TOKEN
Set it
export CONTENTFUL_MANAGEMENT_ACCESS_TOKEN="your-token"
Or create .env.local
echo "CONTENTFUL_MANAGEMENT_ACCESS_TOKEN=your-token" > .env.local
### "nano-banana tool not found"
1. Check Cursor MCP settings
2. Verify GEMINI_API_KEY is set
3. Restart Cursor
### Images Not Appearing in Body
**Most common causes:**
1. **Using wrong image syntax** - Standard markdown `` does NOT work
- Fix: Convert to `<!-- IMAGE: -->` placeholder format
2. **Missing asset-ids.json entry** - Image wasn't uploaded or JSON is stale
- Fix: Run `npm run upload-images` to re-upload all images
- Verify: `cat drafts/images/[slug]/asset-ids.json`
3. **Filename mismatch** - Placeholder filename doesn't match asset-ids.json key
- Check: `**Filename:** hero.png` must match `"hero.png": "assetId"` exactly
4. **Images uploaded after conversion** - RichText was generated before uploads
- Fix: Re-run markdown-to-richtext AFTER upload-images completes
**Verification command:**
Count embedded assets in generated RichText
python3 -c "import json; d=json.load(open('/tmp/final-richtext.json')); print(len([n for n in d['richText']['content'] if n.get('nodeType')=='embedded-asset-block']))"
**Expected:** Should match number of non-hero images in your draft
### Table Entry Creation Fails
1. Check table placeholder format is correct
2. Verify API token has write permissions
3. Check Contentful has `table` content type
### JSON Contains Log Lines
Use grep to find actual JSON start:
grep -n '^{' /tmp/tables-output.txt | head -1 tail -n +[LINE_NUMBER] /tmp/tables-output.txt > /tmp/clean.json
### Entry Version Conflict
Fetch the entry again to get current version. Scripts handle this automatically.
### Contentful PUT Replaces All Fields
**⚠️ CRITICAL:** When updating entries via the Contentful Management API with PUT, you must include ALL fields in the payload, not just the ones being changed. PUT replaces the entire entry rather than merging.
**Wrong (will delete all other fields):**
{"fields": {"bodyContent": {"en-US": ...}}}
**Correct (preserves all fields):**
{"fields": { "title": {"en-US": "..."}, "h1": {"en-US": "..."}, "slug": {"en-US": "..."}, "headerImage": {"en-US": {...}}, "resourceDate": {"en-US": "..."}, "resourceType": {"en-US": "..."}, "seoDescription": {"en-US": "..."}, "bodyContent": {"en-US": ...} }}
**Prevention:** Always fetch the current entry first, then merge your changes into the complete fields object before sending the PUT request.
---
## NPM Scripts Reference
| Script | Command | Purpose |
|--------|---------|---------|
| upload-images | `npm run upload-images -- <path> <title>` | Upload images to Contentful |
| markdown-to-richtext | `npm run markdown-to-richtext -- <md> <assets>` | Convert markdown to RichText |
| update-entry-body | `npm run update-entry-body -- <id> <json>` | Update entry bodyContent |
Table entries are created directly via Contentful MCP (`create_entry` + `publish_entry` with `contentTypeId: "table"`); see Step 9 above.
---
## Complete Checklist
### Pre-Writing
- [ ] Identified next post from calendar
- [ ] Read brief completely
- [ ] Reviewed the workspace Guidelines (Library → Guidelines)
- [ ] Fetched sitemap and identified 4+ internal link targets
### Writing
- [ ] Draft follows brief outline
- [ ] Voice matches style guide
- [ ] Minimum 4 internal links included
- [ ] 2-3 natural Adwave mentions
- [ ] All images have placeholders
- [ ] All tables wrapped in placeholders
- [ ] Word count in target range
- [ ] **Primary keyword in first 100 words**
- [ ] **Primary keyword in at least 1 H2**
- [ ] **All secondary keywords used**
### Images
- [ ] Image directory created
- [ ] Hero image generated (correct recipe)
- [ ] Charts generated (Editorial Clean)
- [ ] **All images reviewed for text errors at 100% zoom**
- [ ] **No typos or misspellings in any image**
- [ ] All images copied to draft folder
- [ ] Images uploaded to Contentful
- [ ] asset-ids.json created
### Publishing
- [ ] Tables pre-checked (grep test)
- [ ] Markdown converted to RichText
- [ ] Table entries created (if any)
- [ ] **H1 crafted (reviewed title + body content, not just copied from title)**
- [ ] Entry created in Contentful (with both title and h1)
- [ ] Body content updated
- [ ] Entry published
### Post-Publishing
- [ ] Entry ID recorded
- [ ] Calendar tracking table updated
- [ ] Calendar schedule status updated
- [ ] **Post-publish health check completed** (see below)
---
## Post-Publish Health Check (REQUIRED)
**⚠️ ALWAYS run this health check after publishing any article.**
### Step 1: Check for Unwrapped Tables
Check for raw table lines in the draft
grep -n "^|" drafts/YYYY-MM-DD-[slug].md
Count TABLE placeholders
grep -c "<!-- TABLE:" drafts/YYYY-MM-DD-[slug].md
**Expected result:**
- If tables exist, every `|` line should be inside a `<!-- TABLE: -->` placeholder
- Count of table lines should equal count inside placeholders × rows per table
**If tables are unwrapped:**
1. Edit the draft to wrap tables in `<!-- TABLE: -->` placeholders
2. Re-run `npm run markdown-to-richtext` (strip the npm log prefix with `tail -n +4`)
3. For each table, create + publish a `table` entry via Contentful MCP, then `sed` the `__TABLE_PLACEHOLDER_<id>__` token out of the richtext JSON (see Step 9)
4. Update entry body (`npm run update-entry-body`)
5. Re-publish via MCP
### Step 2: Verify Embedded Assets
Count embedded assets in the published RichText
python3 -c " import json data = json.load(open('/tmp/final-richtext.json')) content = data.get('richText', {}).get('content', []) assets = [n for n in content if n.get('nodeType') == 'embedded-asset-block'] tables = [n for n in content if n.get('nodeType') == 'embedded-entry-block'] print(f'Embedded images: {len(assets)}') print(f'Embedded tables: {len(tables)}') "
**Expected result:**
- Images should equal (total images - 1 for hero)
- Tables should equal number of `<!-- TABLE: -->` placeholders
### Step 3: Verify Image Count
Compare against minimums:
| Category | Min Body Images | Min Total |
|----------|-----------------|-----------|
| Case Study | 2 | 3 |
| Guides | 2 | 3 |
| Industries | 3 | 4 |
| Insights | 2 | 3 |
| News | 0 | 1 |
| Using Adwave | 3 | 4 |
### Step 4: Verify Internal Links
Count internal links to adwave.com
grep -o "https://adwave.com[^)]*" drafts/YYYY-MM-DD-[slug].md | wc -l
**Expected:** Minimum 4 internal links
### Step 5: Verify SEO Keywords
**Check the brief for keywords, then verify usage:**
Check for primary keyword in first 150 words
head -c 1200 drafts/$SLUG.md | grep -v "^#\|^<!--\|^\\" | head -15
Check H2 headings for keyword inclusion
grep "^## " drafts/$SLUG.md
**Expected result:**
- Primary keyword appears in first 100 words of body text
- Primary keyword appears in at least one H2
- All secondary keywords from brief are used at least once
**If keywords are missing:**
1. Edit draft to naturally incorporate keywords
2. Re-run `npm run markdown-to-richtext`
3. Update entry body and republish
### Health Check Summary
Run all checks with this one-liner:
SLUG="your-article-slug" && DRAFT="drafts/$SLUG.md" && \ echo "=== Tables ===" && \ echo "Raw table lines: $(grep -c "^|" $DRAFT 2>/dev/null || echo 0)" && \ echo "TABLE placeholders: $(grep -c "<!-- TABLE:" $DRAFT 2>/dev/null || echo 0)" && \ echo "" && \ echo "=== Internal Links ===" && \ echo "Adwave links: $(grep -o "https://adwave.com[^)]" $DRAFT 2>/dev/null | wc -l)" && \ echo "" && \ echo "=== SEO Keywords ===" && \ echo "First 100 words (manual check for primary keyword):" && \ head -c 800 $DRAFT | grep -v "^#\|^<!--\|^\\*" | head -10
**Checklist:**
- [ ] Tables: All `|` lines inside placeholders
- [ ] Links: Minimum 4 internal links to adwave.com
- [ ] Keywords: Primary keyword in first 100 words
- [ ] Keywords: Primary keyword in at least 1 H2
- [ ] Keywords: All secondary keywords used
---
## Updating Existing Content: Image Preservation
When rewriting or updating existing Contentful content, **ALWAYS preserve existing images**. Removing images breaks the visual design and data visualization of pages.
### How to Preserve Images
**Step 1: Extract Asset IDs from scraped content**
When scraping a page, identify all image URLs in this format:
https://media.adwave.com/fia320z3blxi/{ASSET_ID}/{hash}/{filename}.png
The **ASSET_ID** is the second path segment after the space ID.
**Step 2: Create asset-ids.json**
{ "hero.png": "7uZQUvHZ6CvKeM8llXFaEN", "chart-subscription-growth.png": "2SWqrVWeGLURjQgbzrffV8" }
**Step 3: Include IMAGE placeholders in markdown** for each existing image.
**Step 4: Position images correctly** in the same relative positions as original.
### Common Mistakes
- ❌ Creating empty asset-ids.json files for pages with images
- ❌ Skipping image analysis when scraping pages
- ❌ Rewriting content without checking for images first
- ✅ Extract all image URLs before rewriting
- ✅ Create complete asset-ids.json with all mappings
- ✅ Verify images render after updating
---
## Version History
| Date | Change |
|------|--------|
| 2025-12-08 | Added SEO keyword verification step (Step 4b) |
| 2025-12-08 | Added keyword check to post-publish health check |
| 2025-12-08 | Comprehensive rewrite addressing all documentation gaps |
| 2025-12-08 | Added explicit brief reading step |
| 2025-12-08 | Added nano-banana output location and copy instructions |
| 2025-12-08 | Corrected tail line calculation formula |
| 2025-12-08 | Added writing rules reference |
| 2025-12-08 | Added image count requirements |
| 2025-12-08 | Added tool verification section |
Latest change
# Adwave Content Publishing Guide
- > **Note:** For quick reference and workflow overview, see `strategy/agent-context.md` first. This guide provides detailed, step-by-step publishing instructions.
+ > **Note:** For quick reference and workflow overview, see `CLAUDE.md` (workspace instructions) first. This guide provides detailed, step-by-step publishing instructions.
---
**Complete, unambiguous workflow for publishing content from brief to Contentful.**
This guide is designed to be followed from scratch without prior context. Every step is explicit.
---
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Quick Reference Commands](#quick-reference-commands)
3. [Complete Workflow](#complete-workflow)
4. [Draft Format Requirements](#draft-format-requirements)
5. [Contentful Configuration](#contentful-configuration)
6. [Troubleshooting](#troubleshooting)
---
## Prerequisites
### 1. Environment Setup
**Required environment variables:**
```bash
# Create .env.local file (one-time setup)
cat > .env.local << 'EOF'
CONTENTFUL_MANAGEMENT_ACCESS_TOKEN=your-token-here
GEMINI_API_KEY=your-gemini-key-here
EOF
```
**Or set in your shell session:**
```bash
export CONTENTFUL_MANAGEMENT_ACCESS_TOKEN="your-token-here"
export GEMINI_API_KEY="your-gemini-key-here"
```
**How to get tokens:**
- **Contentful token:** Contentful → Settings → API Keys → Personal Access Tokens
- **Gemini API key:** Google AI Studio → Get API Key
### 2. Install Dependencies
```bash
cd /Users/naffis/projects/adwave/content-writing
npm install
```
### 3. Verify MCP Tools Are Available
Before starting, verify these MCP tools respond:
| Tool | Purpose | Verification Command |
|------|---------|---------------------|
| `nano-banana` | Image generation | `get_configuration_status` |
| `contentful` | CMS operations | `get_initial_context` |
**If tools don't respond:** Check Cursor MCP settings and restart Cursor.
---
## Quick Reference Commands
```bash
# === FULL WORKFLOW (copy/paste, replace [SLUG] and [TITLE]) ===
# 0. CHECK SLUG AVAILABILITY FIRST (critical!)
npm run check-slug -- "[SLUG]"
# 1. Create image directory
mkdir -p drafts/images/[SLUG]/
# 2. Generate images (use nano-banana MCP), then copy each:
cp "/Users/naffis/generated_imgs/generated-*.png" "drafts/images/[SLUG]/hero.png"
# 3. Upload images
npm run upload-images -- "drafts/images/[SLUG]/" "[TITLE]"
# 4. Pre-check tables are wrapped
grep -n "^|" drafts/[SLUG].md
# 5. Convert markdown to RichText
npx tsx scripts/markdown-to-richtext.ts "drafts/[SLUG].md" "drafts/images/[SLUG]/asset-ids.json" 2>/dev/null > /tmp/richtext.json
# 6. Create table entries (if any tables in the markdown)
# For each <!-- TABLE: id --> block:
# a. Use Contentful MCP `create_entry` with contentTypeId "table",
# fields: { title, tableMarkdown }
# b. `publish_entry` to publish it
# c. sed 's/__TABLE_PLACEHOLDER_<id>__/<NEW_ENTRY_ID>/g' /tmp/richtext.json > /tmp/final-richtext.json
# Verify 0 placeholders left: grep -c TABLE_PLACEHOLDER /tmp/final-richtext.json
# If no tables: cp /tmp/richtext.json /tmp/final-richtext.json
# 7. Create entry via MCP (get entry ID)
# 8. Update body: npm run update-entry-body -- "[ENTRY_ID]" /tmp/final-richtext.json
# 9. Publish via MCP
# 10. Update calendar
```
---
## Complete Workflow
### Step 0: Identify the Next Post
**Open the editorial calendar:**
```
calendar/editorial-calendar.md
```
**Find the next unpublished post** (status = 🔲) in chronological order.
**Locate the corresponding brief:**
```
briefs/YYYY-MM-DD-[slug].md
```
---
### Step 1: Read the Brief
**Read the brief file completely.** It contains:
- Title and subtitle
- Target word count
- URL slug (use this for folder names)
- Category and resource type
- Article outline
- Adwave integration notes
- Keywords
**Note these values from the brief:**
- `Publish Date:` YYYY-MM-DD
- `URL Slug:` the-article-slug
- `Category:` Guides | Industries | Case Study | Insights | News | Using Adwave
- `Industry Type:` (only for Industries category)
- `Subindustry Type:` (only for Industries category)
---
### Step 1b: Verify Slug is Available
**CRITICAL: Always check if the slug is taken before proceeding.**
```bash
npm run check-slug -- "the-article-slug"
```
**If the slug is available:**
```
✅ Slug "the-article-slug" is AVAILABLE
```
Proceed to Step 2.
**If the slug is taken:**
```
❌ Slug "the-article-slug" is TAKEN
Existing entry:
Entry ID: abc123
Title: Existing Article Title
...
💡 Suggestions:
- the-article-slug-guide
- the-article-slug-2025
```
**When slug is taken, you must:**
1. Choose a different slug (script provides suggestions)
2. Update the brief file with the new slug
3. Update the article title if needed to match
4. Re-run `npm run check-slug` to verify the new slug is available
**Common slug alternatives:**
- Add `-guide` suffix
- Add year (`-2025`)
- Make more specific (`tv-advertising-dentists` → `dental-practice-tv-advertising`)
- Add qualifier (`ctv-statistics` → `ctv-advertising-statistics-2025`)
---
### Step 2: Review Writing Guidelines
- **Before writing, review these style guides in `.cursor/rules/`:**
-
- | File | Contains |
- |------|----------|
- | `adwave-voice.mdc` | Voice, tone, personality, word choices to avoid |
- | `adwave-formatting.mdc` | Structure, headings, lists, image placement |
- | `adwave-seo.mdc` | Title, meta description, keyword usage |
- | `adwave-product.mdc` | Accurate Adwave features, pricing, capabilities |
- | `adwave-competitor-content.mdc` | Rules for mentioning competitors (STRICT) |
- | `adwave-quality.mdc` | Pre-publish quality checklist |
+ **Writing and QC rules (voice, formatting, SEO, product accuracy, competitors, quality) are enforced by the workspace Guidelines (Library → Guidelines) on every draft.**
**Key rules to remember:**
- Never use emdashes (—), use commas or parentheses
- Use contractions (you'll, it's, here's)
- No jargon without explanation
- Acknowledge challenges before solutions
- 2-3 natural Adwave mentions in body
- NEVER mention: MNTN, Vibe, Creatify, tvScientific
---
### Step 3: Research Internal Links
**⚠️ REQUIRED: Fetch Adwave sitemap for EVERY article:**
```
Use firecrawl_map MCP tool (no limit - get ALL URLs):
firecrawl_map(url: "https://adwave.com")
```
**Goal:** Identify **minimum 4 internal links** to include in the article. More is better when relevant.
**⚠️ IMPORTANT: Link Selection Strategy**
**DO NOT rely on a static list of links.** Always fetch the sitemap fresh and select contextually relevant pages.
**Priority order when selecting links from sitemap results:**
1. **FIRST: `/resources/` pages** (other blog posts/guides)
- These provide variety and cross-promote our content
- Match to article topic (e.g., cost article → `/resources/local-tv-advertising-costs/`)
- Examples: `/resources/what-is-connected-tv-advertising/`, `/resources/tv-ad-production-costs/`, `/resources/small-business-tv-ads-vs-social/`
2. **SECOND: `/industries/` pages** (when discussing specific verticals)
- Match to article's industry context
- Examples: `/industries/food-beverage/tv-advertising-restaurants/`
3. **THIRD: Core site pages** (sparingly, only when directly relevant)
- `/how-it-works/`, `/pricing/`, `/faq/`, `/channels/`
- **Avoid overusing these** - don't link the same pages in every article
| Example Ads | `/example-tv-ads` |
| What is CTV | `/resources/what-is-connected-tv-advertising/` |
**Linking rules:**
- **Minimum 4 internal links per article**
- Use descriptive anchor text (not "click here")
- Link early (within first 500 words)
- Never italicize links
- Spread links throughout naturally
- Match links to article context
---
### Step 4: Write the Draft
**Create the draft file:**
```bash
touch drafts/YYYY-MM-DD-[slug].md
```
**Draft structure:**
```markdown
# Article Title
**Subtitle goes here**
---
<!-- IMAGE: hero -->
**Placement:** Top of article
**Type:** Hero (Digital Scrapbook - [RECIPE TYPE])
**Description:** [Alt text description]
**Filename:** hero.png
**Style:** STRICT - Must follow Digital Scrapbook rules
<!-- /IMAGE -->
[Opening paragraph with hook - 50-100 words]
[Continue with article following the brief outline...]
## H2 Section Title
[Section content]
<!-- IMAGE: chart-name -->
**Placement:** After [section/context]
**Type:** Data chart (Editorial Clean)
**Description:** [Alt text description]
**Filename:** chart-name.png
**Style:** STRICT - Must follow Editorial Clean rules
<!-- /IMAGE -->
<!-- TABLE: unique-table-id -->
**Title:** Table Display Title
**Caption:** Optional caption
| Column 1 | Column 2 |
|----------|----------|
| Value 1 | Value 2 |
<!-- /TABLE -->
## Ready to Get Started?
[CTA section - 50-100 words]
[Link to Adwave](https://adwave.com) with call to action.
```
**Image count minimums by category:**
| Category | Minimum Images |
|----------|----------------|
| Case Study | 3-5 |
| Guides | 3-5 |
| Industries | 4-5 |
| Insights | 3-6 |
| News | 1-2 |
| Using Adwave | 4-6 |
---
### Step 4b: SEO Keyword Verification (REQUIRED)
**Before generating images, verify all keywords from the brief are properly integrated.**
**Read the brief to identify:**
- **Primary keyword:** The main search term the article targets
- **Secondary keywords:** 3-5 related terms that should also appear
**Run keyword verification:**
```bash
SLUG="your-article-slug" && DRAFT="drafts/*$SLUG*.md"
echo "=== KEYWORD VERIFICATION ==="
echo ""
echo "1. First 150 words (check for primary keyword):"
head -c 1200 $DRAFT | grep -v "^#\|^<!--\|^\*\*\|^---" | head -20
echo ""
echo "2. H2 headings (check for keyword inclusion):"
grep "^## " $DRAFT
echo ""
echo "3. SEO Description:"
grep "SEO Description" $DRAFT
```
**SEO Keyword Checklist:**
| Requirement | Location | How to Verify |
|-------------|----------|---------------|
| Primary keyword in first 100 words | Opening paragraph | Manual check of first paragraph |
| Primary keyword in at least 1 H2 | Any H2 heading | `grep "^## " drafts/*slug*.md` |
| Primary keyword in SEO description | Meta description | Check SEO Description line |
| All secondary keywords used | Body text | Search for each keyword |
**Common fixes for missing keywords:**
1. **Primary keyword missing from opening:**
- Rewrite first sentence to naturally include the primary keyword
- Example: "TV advertising for dentists might sound expensive..." instead of "Most dental practices rely on referrals..."
2. **Secondary keywords not used:**
- Add secondary keywords to H2 headings where natural
- Work them into relevant body paragraphs
- Don't force them—readability trumps keyword density
3. **H2 headings don't include keywords:**
- Revise H2s to include primary or secondary keywords
- Example: "Costs" → "TV Advertising Costs for Dentists"
**Keyword density guidelines:**
- Primary keyword: 3-5 times in body (excluding title/metadata)
- Each secondary keyword: At least once
- Natural usage only—never keyword stuff
---
### Step 5: Generate Images
**Create the image directory:**
```bash
mkdir -p drafts/images/YYYY-MM-DD-[slug]/
```
- **Image styles are in the auto-applied rules:**
- ```
- .cursor/rules/adwave-images.mdc
- ```
+ **Image style rules are enforced by the "Minimum 4 images" guideline (brand palette + digital-scrapbook style).**
**Image style rules (STRICT):**
| Image Type | Style | Guide Section |
|------------|-------|---------------|
| Hero images | Digital Scrapbook | Part 1 |
| Charts/graphs | Editorial Clean | Part 2 |
| Body images | Flexible | Part 3 |
**Generate images using nano-banana MCP:**
```
Tool: generate_image
Prompt: [Use template from adwave-images.mdc for your content type]
```
**IMPORTANT: After each generation, copy the file:**
```bash
# nano-banana saves to: /Users/naffis/generated_imgs/generated-[timestamp]-[random].png
# Copy to your draft images folder with correct filename:
cp "/Users/naffis/generated_imgs/generated-XXXX.png" "drafts/images/YYYY-MM-DD-[slug]/hero.png"
```
**⚠️ MANDATORY: Review each image for text errors before proceeding:**
1. Open the generated image at 100% zoom
2. Read ALL text in the image carefully
3. Check for: typos, misspellings, wrong numbers, garbled text
4. If errors found: regenerate with LESS text (use icons instead) or use `continue_editing`
5. Only proceed to upload after ALL images pass text review
**Pro tip:** To avoid typos, minimize text in images. Use icons, symbols, and color coding instead of text labels wherever possible. See `adwave-images.mdc` for low-text prompt templates.
**Hero image recipe by content type:**
- Platform Guides → "Technical Manual" recipe
- Case Studies → "Success Journal" recipe
- Comparison Posts → "Pro/Con List" recipe
- Industry Playbooks → "Industry Mood Board" recipe
- Statistics/Data → "Field Research Note" recipe
- How-To Guides → "DIY Project Plan" recipe
- Thought Leadership → "Vision Board" recipe
---
### Step 5b: MANDATORY Image Count Checkpoint
**⚠️ STOP: Before uploading, verify you have enough images.**
```bash
# Count images in your draft
SLUG="your-article-slug"
echo "=== IMAGE COUNT CHECK ===" && \
echo "Images in draft:" && grep -c "!\[" drafts/*$SLUG*.md && \
echo "Images in folder:" && ls drafts/images/*$SLUG*/ | wc -l
```
**Minimum image requirements:**
| Category | Min Total | Min Body (non-hero) |
|----------|-----------|---------------------|
| Platform Guides | 4 | 3 |
| Case Studies | 4 | 3 |
| How-To Guides | 5 | 4 |
| Industries/Verticals | 5 | 4 |
| Statistics | 5 | 4 |
| Thought Leadership | 3 | 2 |
**If count is below minimum:**
1. **STOP** - Do not proceed
2. Identify sections that need visuals (after statistics, comparisons, processes)
3. Generate additional images
4. Update the draft with image references
5. Re-run this checkpoint
**Only proceed when image count meets minimum.**
---
### Step 6: Upload Images to Contentful
```bash
npm run upload-images -- "drafts/images/YYYY-MM-DD-[slug]/" "Article Title"
```
**Expected output:**
```
🖼️ Uploading X image(s) to Contentful...
📤 Uploading: hero.png...
✓ Published!
📤 Uploading: chart-name.png...
✓ Published!
💾 Asset IDs saved to: drafts/images/[slug]/asset-ids.json
```
**Verify asset-ids.json was created:**
```bash
cat drafts/images/YYYY-MM-DD-[slug]/asset-ids.json
```
---
### Step 7: Pre-Check Tables
**CRITICAL:** All markdown tables MUST be wrapped in `<!-- TABLE: -->` placeholders.
**✅ BLOCKING CHECK:** The `markdown-to-richtext` script now **fails automatically** if unwrapped tables are detected. If you see this error, wrap your tables before proceeding.
```bash
# Manual check (optional - script does this automatically)
grep -n "^|" drafts/YYYY-MM-DD-[slug].md
# Count table placeholders
grep -c "<!-- TABLE:" drafts/YYYY-MM-DD-[slug].md
```
**If conversion fails with "Unwrapped markdown tables detected" error:**
1. Find the table lines in the error output
2. Wrap each table in `<!-- TABLE: unique-id -->` ... `<!-- /TABLE -->` placeholders
3. Re-run the conversion
---
### Step 8: Convert Markdown to RichText
```bash
npx tsx scripts/markdown-to-richtext.ts \
"drafts/YYYY-MM-DD-[slug].md" \
"drafts/images/YYYY-MM-DD-[slug]/asset-ids.json" \
2>/dev/null > /tmp/richtext.json
```
**Verify conversion:**
```bash
head -c 100 /tmp/richtext.json # Should start with {
wc -c /tmp/richtext.json # Should be 20KB-100KB typically
```
---
### Step 9: Create Table Entries (via Contentful MCP)
**If your article has NO tables, skip to Step 10.** (`cp /tmp/richtext.json /tmp/final-richtext.json` and move on.)
The `markdown-to-richtext` converter inserts `__TABLE_PLACEHOLDER_<id>__` tokens anywhere a `<!-- TABLE: id --> ... <!-- /TABLE -->` block appears in the markdown. Each placeholder must be replaced with the ID of a published `table` content-type entry before the body can be attached.
**For each `<!-- TABLE: id -->` block in the markdown:**
1. **Create the table entry via Contentful MCP `create_entry`:**
```json
{
"spaceId": "fia320z3blxi",
"environmentId": "master",
"contentTypeId": "table",
"fields": {
"title": { "en-US": "Human-readable table title" },
"tableMarkdown": { "en-US": "| Col | Col |\n|---|---|\n| ... |" }
}
}
```
2. **Publish the entry** with `publish_entry`.
3. **Replace the placeholder** in the richtext JSON with the new entry ID:
```bash
sed 's/__TABLE_PLACEHOLDER_<id>__/<NEW_ENTRY_ID>/g' /tmp/richtext.json > /tmp/final-richtext.json
```
For multiple tables, chain sed calls or pipe through each replacement in turn.
4. **Verify 0 placeholders remain:**
```bash
grep -c TABLE_PLACEHOLDER /tmp/final-richtext.json # Must print 0
```
**Note:** `npm run markdown-to-richtext` prints 2–3 npm log lines to stdout before the JSON payload. Strip them with `tail -n +4` (or locate the real start line with `grep -n '^{' | head -1`) before using the file.
**Verify clean JSON:**
```bash
head -c 50 /tmp/final-richtext.json # Should start with {
tail -c 50 /tmp/final-richtext.json # Should end with }
```
---
### Step 10: Create the Contentful Entry
**Use the Contentful MCP tool `create_entry`:**
```json
{
"spaceId": "fia320z3blxi",
"environmentId": "master",
"contentTypeId": "resource",
"fields": {
"title": { "en-US": "SEO Title (50-60 chars)" },
"h1": { "en-US": "Page Heading (H1) - Can Be Longer and More Descriptive" },
"subtitle": { "en-US": "Subtitle text" },
"slug": { "en-US": "article-slug" },
"shortDescription": { "en-US": "Brief description for cards (1-2 sentences)" },
"resourceDate": { "en-US": "YYYY-MM-DD" },
"resourceType": { "en-US": "Guides" },
"seoDescription": { "en-US": "Meta description for SEO (150-155 chars)" },
"headerImage": {
"en-US": {
"sys": { "type": "Link", "linkType": "Asset", "id": "HERO_ASSET_ID" }
}
}
}
}
```
**Title vs H1 Guidelines:**
- `title` = SEO title (browser tab, search results) - Keep to 50-60 characters for SERP display
- `h1` = Page heading (visible on page) - Can be longer, more descriptive, keyword-rich
### Step 10a: Craft the H1 (Before Creating Entry)
**⚠️ REQUIRED: Review title and body content to craft an effective H1.**
The H1 should not just be a copy of the SEO title. Take time to craft it properly:
**H1 Crafting Process:**
1. **Review the SEO title** - Note the primary keyword and any character constraints
2. **Scan the article body** - Identify:
- The core problem being solved
- The main value proposition
- Key differentiators (e.g., "for small businesses", "in 2025", "step-by-step")
3. **Draft an H1 that:**
- Expands on the SEO title with more context or specificity
- Speaks to what the reader will learn or achieve
- Feels natural as a page heading (not truncated or awkward)
- Includes the primary keyword naturally
**H1 Quality Checklist:**
- [ ] Is it more descriptive than the SEO title?
- [ ] Does it include the primary keyword?
- [ ] Does it match the article's actual content and promise?
- [ ] Would a reader immediately understand what they'll get?
- [ ] Does it feel natural as a heading (not keyword-stuffed)?
**H1 Patterns by Content Type:**
| Content Type | H1 Pattern | Example |
|--------------|------------|---------|
| How-To Guide | "How to [Action]: [Expanded Benefit]" | "How to Advertise on Roku: Everything Small Businesses Need to Know" |
| Comprehensive Guide | "The Complete Guide to [Topic] [Context]" | "The Complete Guide to TV Advertising for Restaurants in 2025" |
| Cost/Pricing | "How Much Does [Topic] Cost? [Audience Context]" | "How Much Does CTV Advertising Cost? A Breakdown for Small Businesses" |
| Comparison | "[A] vs [B]: [Decision-Focused Question]" | "CTV vs. Social Media Ads: Which Delivers Better ROI for Local Businesses?" |
| Statistics | "[Number] [Topic] Statistics [Audience] [Year]" | "25 CTV Advertising Statistics Every Small Business Marketer Should Know in 2025" |
| Industry Playbook | "[Industry] [Topic]: [Comprehensive Descriptor]" | "Restaurant TV Advertising: The Complete Marketing Playbook" |
**Examples of Good Title → H1 Expansion:**
| SEO Title (50-60 chars) | H1 (expanded, reader-focused) |
|-------------------------|-------------------------------|
| TV Advertising for Restaurants: Complete Guide | The Complete Guide to TV Advertising for Restaurants in 2025 |
| CTV Advertising Costs: What to Expect | How Much Does CTV Advertising Cost? A Complete Breakdown for Small Businesses |
| Roku Advertising for Small Business | How to Advertise on Roku: Everything Small Businesses Need to Know |
| Best Advertising for Plumbers | The Best Advertising Channels for Plumbers: A Data-Driven Guide |
| CTV vs Meta Ads Comparison | CTV vs. Meta Ads: Which Channel Delivers Better ROI for Small Businesses? |
```
**Field values by category:**
| Category | resourceType | industryType | subindustryType |
|----------|--------------|--------------|-----------------|
| Guides | "Guides" | - | - |
| Industries | "Industries" | Required | Required |
| Case Study | "Case Study" | - | - |
| Insights | "Insights" | - | - |
| News | "News" | - | - |
| Using Adwave | "Using Adwave" | - | - |
**Get the HERO_ASSET_ID from:**
```bash
cat drafts/images/YYYY-MM-DD-[slug]/asset-ids.json | grep hero
```
**Record the Entry ID** returned by create_entry (format: `XXXXXXXXXXXX`).
---
### Step 11: Update Entry Body Content
```bash
npm run update-entry-body -- "ENTRY_ID" /tmp/final-richtext.json
```
**Expected output:**
```
✅ Entry updated successfully!
Entry ID: ENTRY_ID
Version: 2
```
---
### Step 12: Publish the Entry
**Use the Contentful MCP tool `publish_entry`:**
```json
{
"spaceId": "fia320z3blxi",
"environmentId": "master",
"entryId": "ENTRY_ID"
}
```
---
### Step 13: Update Editorial Calendar
**Edit `calendar/editorial-calendar.md`:**
1. Add to Published Content Tracking table:
```markdown
| [Date] | [Title] | [Category] | `[ENTRY_ID]` |
```
2. Update status in monthly schedule:
```markdown
| [Date] | [Title] | [Category] | [Funnel] | [Strategy] | ✅ Published |
```
---
## Draft Format Requirements
### Image Placeholders
**⚠️ CRITICAL: Standard markdown images `` DO NOT WORK.**
The markdown-to-richtext script ONLY recognizes the placeholder format below. Any images using standard markdown syntax will be silently ignored.
```markdown
<!-- IMAGE: unique-id -->
**Placement:** [Where in article]
**Type:** [Hero | Data chart | Concept | etc.]
**Description:** [Alt text]
**Filename:** [name].png
**Style:** [STRICT - Digital Scrapbook | STRICT - Editorial Clean | Flexible]
<!-- /IMAGE -->
```
**Image embedding requirements:**
1. **Filename must exactly match** the key in `asset-ids.json`
2. **All images must be uploaded** before running markdown-to-richtext
3. **asset-ids.json must exist** and contain all image filenames
4. **Hero images** are skipped in body (used for `headerImage` field instead)
### Table Placeholders
```markdown
<!-- TABLE: unique-id -->
**Title:** Display Title for Table Entry
**Caption:** Optional caption text
| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Value | Value | Value |
<!-- /TABLE -->
```
**Table rules:**
- Every markdown table MUST be wrapped
- Table ID must be unique within the article
- Title is REQUIRED
- Caption is optional
---
## Contentful Configuration
### Space and Environment
```
Space ID: fia320z3blxi
Environment ID: master
```
### Content Type: resource
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| title | Symbol | Yes | SEO title (50-60 chars for SERP) |
| h1 | Symbol | Yes | Page heading (visible on page, can be longer) |
| subtitle | Symbol | No | Supporting headline |
| slug | Symbol | Yes | URL slug (no leading slash) |
| shortDescription | Symbol | No | Card description |
| headerImage | Asset Link | Yes | Hero image |
| headerVideo | Asset Link | No | Optional video |
| resourceDate | Date | Yes | YYYY-MM-DD format |
| resourceType | Symbol | Yes | See values below |
| industryType | Symbol | Conditional | Required for Industries |
| subindustryType | Symbol | Conditional | Required for Industries |
| bodyContent | RichText | Yes | Main content |
| seoImage | Asset Link | No | OG image |
| seoDescription | Symbol | No | Meta description |
**Title vs H1 Best Practices:**
- **title**: Optimized for search results. Front-load keywords. Keep under 60 chars.
- **h1**: Optimized for readers on the page. Can be more descriptive and engaging.
- They can be identical for simple cases, but separating them allows better optimization for both contexts.
### resourceType Values
- Case Study
- Guides
- Industries
- Insights
- News
- Using Adwave
### industryType Values
- Food & Beverage
- Professional Services
- Real Estate
- E-commerce
- Home Services
- Automotive
- Political
- Apps
- Retail
- Travel
- Hospitality
- Financial
- Legal
- Health and Wellness
---
## Troubleshooting
### "CONTENTFUL_MANAGEMENT_ACCESS_TOKEN not found"
```bash
# Check if set
echo $CONTENTFUL_MANAGEMENT_ACCESS_TOKEN
# Set it
export CONTENTFUL_MANAGEMENT_ACCESS_TOKEN="your-token"
# Or create .env.local
echo "CONTENTFUL_MANAGEMENT_ACCESS_TOKEN=your-token" > .env.local
```
### "nano-banana tool not found"
1. Check Cursor MCP settings
2. Verify GEMINI_API_KEY is set
3. Restart Cursor
### Images Not Appearing in Body
**Most common causes:**
1. **Using wrong image syntax** - Standard markdown `` does NOT work
- Fix: Convert to `<!-- IMAGE: -->` placeholder format
2. **Missing asset-ids.json entry** - Image wasn't uploaded or JSON is stale
- Fix: Run `npm run upload-images` to re-upload all images
- Verify: `cat drafts/images/[slug]/asset-ids.json`
3. **Filename mismatch** - Placeholder filename doesn't match asset-ids.json key
- Check: `**Filename:** hero.png` must match `"hero.png": "assetId"` exactly
4. **Images uploaded after conversion** - RichText was generated before uploads
- Fix: Re-run markdown-to-richtext AFTER upload-images completes
**Verification command:**
```bash
# Count embedded assets in generated RichText
python3 -c "import json; d=json.load(open('/tmp/final-richtext.json')); print(len([n for n in d['richText']['content'] if n.get('nodeType')=='embedded-asset-block']))"
```
**Expected:** Should match number of non-hero images in your draft
### Table Entry Creation Fails
1. Check table placeholder format is correct
2. Verify API token has write permissions
3. Check Contentful has `table` content type
### JSON Contains Log Lines
Use grep to find actual JSON start:
```bash
grep -n '^{' /tmp/tables-output.txt | head -1
tail -n +[LINE_NUMBER] /tmp/tables-output.txt > /tmp/clean.json
```
### Entry Version Conflict
Fetch the entry again to get current version. Scripts handle this automatically.
### Contentful PUT Replaces All Fields
**⚠️ CRITICAL:** When updating entries via the Contentful Management API with PUT, you must include ALL fields in the payload, not just the ones being changed. PUT replaces the entire entry rather than merging.
**Wrong (will delete all other fields):**
```json
{"fields": {"bodyContent": {"en-US": ...}}}
```
**Correct (preserves all fields):**
```json
{"fields": {
"title": {"en-US": "..."},
"h1": {"en-US": "..."},
"slug": {"en-US": "..."},
"headerImage": {"en-US": {...}},
"resourceDate": {"en-US": "..."},
"resourceType": {"en-US": "..."},
"seoDescription": {"en-US": "..."},
"bodyContent": {"en-US": ...}
}}
```
**Prevention:** Always fetch the current entry first, then merge your changes into the complete fields object before sending the PUT request.
---
## NPM Scripts Reference
| Script | Command | Purpose |
|--------|---------|---------|
| upload-images | `npm run upload-images -- <path> <title>` | Upload images to Contentful |
| markdown-to-richtext | `npm run markdown-to-richtext -- <md> <assets>` | Convert markdown to RichText |
| update-entry-body | `npm run update-entry-body -- <id> <json>` | Update entry bodyContent |
Table entries are created directly via Contentful MCP (`create_entry` + `publish_entry` with `contentTypeId: "table"`); see Step 9 above.
---
## Complete Checklist
### Pre-Writing
- [ ] Identified next post from calendar
- [ ] Read brief completely
- - [ ] Reviewed writing rules in `.cursor/rules/`
+ - [ ] Reviewed the workspace Guidelines (Library → Guidelines)
- [ ] Fetched sitemap and identified 4+ internal link targets
### Writing
- [ ] Draft follows brief outline
- [ ] Voice matches style guide
- [ ] Minimum 4 internal links included
- [ ] 2-3 natural Adwave mentions
- [ ] All images have placeholders
- [ ] All tables wrapped in placeholders
- [ ] Word count in target range
- [ ] **Primary keyword in first 100 words**
- [ ] **Primary keyword in at least 1 H2**
- [ ] **All secondary keywords used**
### Images
- [ ] Image directory created
- [ ] Hero image generated (correct recipe)
- [ ] Charts generated (Editorial Clean)
- [ ] **All images reviewed for text errors at 100% zoom**
- [ ] **No typos or misspellings in any image**
- [ ] All images copied to draft folder
- [ ] Images uploaded to Contentful
- [ ] asset-ids.json created
### Publishing
- [ ] Tables pre-checked (grep test)
- [ ] Markdown converted to RichText
- [ ] Table entries created (if any)
- [ ] **H1 crafted (reviewed title + body content, not just copied from title)**
- [ ] Entry created in Contentful (with both title and h1)
- [ ] Body content updated
- [ ] Entry published
### Post-Publishing
- [ ] Entry ID recorded
- [ ] Calendar tracking table updated
- [ ] Calendar schedule status updated
- [ ] **Post-publish health check completed** (see below)
---
## Post-Publish Health Check (REQUIRED)
**⚠️ ALWAYS run this health check after publishing any article.**
### Step 1: Check for Unwrapped Tables
```bash
# Check for raw table lines in the draft
grep -n "^|" drafts/YYYY-MM-DD-[slug].md
# Count TABLE placeholders
grep -c "<!-- TABLE:" drafts/YYYY-MM-DD-[slug].md
```
**Expected result:**
- If tables exist, every `|` line should be inside a `<!-- TABLE: -->` placeholder
- Count of table lines should equal count inside placeholders × rows per table
**If tables are unwrapped:**
1. Edit the draft to wrap tables in `<!-- TABLE: -->` placeholders
2. Re-run `npm run markdown-to-richtext` (strip the npm log prefix with `tail -n +4`)
3. For each table, create + publish a `table` entry via Contentful MCP, then `sed` the `__TABLE_PLACEHOLDER_<id>__` token out of the richtext JSON (see Step 9)
4. Update entry body (`npm run update-entry-body`)
5. Re-publish via MCP
### Step 2: Verify Embedded Assets
```bash
# Count embedded assets in the published RichText
python3 -c "
import json
data = json.load(open('/tmp/final-richtext.json'))
content = data.get('richText', {}).get('content', [])
assets = [n for n in content if n.get('nodeType') == 'embedded-asset-block']
tables = [n for n in content if n.get('nodeType') == 'embedded-entry-block']
print(f'Embedded images: {len(assets)}')
print(f'Embedded tables: {len(tables)}')
"
```
**Expected result:**
- Images should equal (total images - 1 for hero)
- Tables should equal number of `<!-- TABLE: -->` placeholders
### Step 3: Verify Image Count
Compare against minimums:
| Category | Min Body Images | Min Total |
|----------|-----------------|-----------|
| Case Study | 2 | 3 |
| Guides | 2 | 3 |
| Industries | 3 | 4 |
| Insights | 2 | 3 |
| News | 0 | 1 |
| Using Adwave | 3 | 4 |
### Step 4: Verify Internal Links
```bash
# Count internal links to adwave.com
grep -o "https://adwave.com[^)]*" drafts/YYYY-MM-DD-[slug].md | wc -l
```
**Expected:** Minimum 4 internal links
### Step 5: Verify SEO Keywords
**Check the brief for keywords, then verify usage:**
```bash
# Check for primary keyword in first 150 words
head -c 1200 drafts/*$SLUG*.md | grep -v "^#\|^<!--\|^\*\*" | head -15
# Check H2 headings for keyword inclusion
grep "^## " drafts/*$SLUG*.md
```
**Expected result:**
- Primary keyword appears in first 100 words of body text
- Primary keyword appears in at least one H2
- All secondary keywords from brief are used at least once
**If keywords are missing:**
1. Edit draft to naturally incorporate keywords
2. Re-run `npm run markdown-to-richtext`
3. Update entry body and republish
### Health Check Summary
Run all checks with this one-liner:
```bash
SLUG="your-article-slug" && DRAFT="drafts/*$SLUG*.md" && \
echo "=== Tables ===" && \
echo "Raw table lines: $(grep -c "^|" $DRAFT 2>/dev/null || echo 0)" && \
echo "TABLE placeholders: $(grep -c "<!-- TABLE:" $DRAFT 2>/dev/null || echo 0)" && \
echo "" && \
echo "=== Internal Links ===" && \
echo "Adwave links: $(grep -o "https://adwave.com[^)]*" $DRAFT 2>/dev/null | wc -l)" && \
echo "" && \
echo "=== SEO Keywords ===" && \
echo "First 100 words (manual check for primary keyword):" && \
head -c 800 $DRAFT | grep -v "^#\|^<!--\|^\*\*" | head -10
```
**Checklist:**
- [ ] Tables: All `|` lines inside placeholders
- [ ] Links: Minimum 4 internal links to adwave.com
- [ ] Keywords: Primary keyword in first 100 words
- [ ] Keywords: Primary keyword in at least 1 H2
- [ ] Keywords: All secondary keywords used
---
## Updating Existing Content: Image Preservation
When rewriting or updating existing Contentful content, **ALWAYS preserve existing images**. Removing images breaks the visual design and data visualization of pages.
### How to Preserve Images
**Step 1: Extract Asset IDs from scraped content**
When scraping a page, identify all image URLs in this format:
```
https://media.adwave.com/fia320z3blxi/{ASSET_ID}/{hash}/{filename}.png
```
The **ASSET_ID** is the second path segment after the space ID.
**Step 2: Create asset-ids.json**
```json
{
"hero.png": "7uZQUvHZ6CvKeM8llXFaEN",
"chart-subscription-growth.png": "2SWqrVWeGLURjQgbzrffV8"
}
```
**Step 3: Include IMAGE placeholders in markdown** for each existing image.
**Step 4: Position images correctly** in the same relative positions as original.
### Common Mistakes
- ❌ Creating empty asset-ids.json files for pages with images
- ❌ Skipping image analysis when scraping pages
- ❌ Rewriting content without checking for images first
- ✅ Extract all image URLs before rewriting
- ✅ Create complete asset-ids.json with all mappings
- ✅ Verify images render after updating
---
## Version History
| Date | Change |
|------|--------|
| 2025-12-08 | Added SEO keyword verification step (Step 4b) |
| 2025-12-08 | Added keyword check to post-publish health check |
| 2025-12-08 | Comprehensive rewrite addressing all documentation gaps |
| 2025-12-08 | Added explicit brief reading step |
| 2025-12-08 | Added nano-banana output location and copy instructions |
| 2025-12-08 | Corrected tail line calculation formula |
| 2025-12-08 | Added writing rules reference |
| 2025-12-08 | Added image count requirements |
| 2025-12-08 | Added tool verification section |
Version history
- v2 · 7/5/2026, 6:30:28 PM · Library consolidation: repointed references from merged/archived docs to their new homes
- v1 · 6/20/2026, 3:11:54 AM · migration import