Skill: chirukinbb/wordpress-api-gutenberg (version 1.0.0)

--- name: wordpress-api-gutenberg description: Create, edit, and publish WordPress posts via REST API with full Gutenberg block support. Use when Codex needs to automate WordPress content publishing, generate articles programmatically, or manage posts through API calls. Includes authentication methods (JWT, Application Passwords), Gutenberg block serialization, featured images, categories, tags, and draft/publish workflows. --- # WordPress API with Gutenberg ## Overview This skill provides comprehensive guidance for interacting with WordPress REST API to create and manage posts using Gutenberg block editor format. It covers authentication, block serialization, media upload, and publishing workflows. ## Quick Start Before using the API, ensure you have: 1. **WordPress site** with REST API enabled (default) 2. **Authentication credentials**: - Application Password (WordPress 5.6+): Generate at `/wp-admin/admin.php?page=application-passwords` - JWT Authentication plugin installed (alternative) - Username/password for Basic Auth (not recommended for production) 3. **Base URL**: `https://your-site.com/wp-json/wp/v2` ## Authentication ### Application Password (Recommended) ```bash # Set environment variables export WP_URL="https://your-site.com" export WP_USERNAME="admin" export WP_APPLICATION_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx" ``` ```python import requests import os wp_url = os.environ.get('WP_URL') username = os.environ.get('WP_USERNAME') [REDACTED_SECRET]'WP_APPLICATION_PASSWORD') auth = (username, password) ``` ### JWT Authentication If using JWT plugin, obtain token first: ```python import requests wp_url = "https://your-site.com" username = "admin" [REDACTED_SECRET]" # Get token resp = requests.post(f"{wp_url}/wp-json/jwt-auth/v1/token", json={"username": username, "password": password}) [REDACTED_SECRET])['token'] headers = {"Authorization": f"Bearer {token}"} ``` ## Creating Posts with Gutenberg Blocks WordPress REST API expects posts in Gutenberg's serialized block format. The content field should contain block comments and HTML. ### Basic Block Structure ```python def create_gutenberg_post(title, content_blocks): """ Create a post with Gutenberg blocks. Args: title: Post title content_blocks: List of block dictionaries with 'blockName' and 'attrs' Returns: JSON data for POST request """ # Serialize blocks to Gutenberg format block_html = [] for block in content_blocks: block_name = block.get('blockName', 'core/paragraph') attrs = block.get('attrs', {}) inner_html = block.get('innerHTML', '') # Create block comment attrs_json = json.dumps(attrs) if attrs else '' block_comment = f'<!-- wp:{block_name} {attrs_json} -->' block_html.append(f'{block_comment}{inner_html}<!-- /wp:{block_name} -->') content = '\n\n'.join(block_html) return { "title": title, "content": content, "status": "draft", # or "publish" "format": "standard" } ``` ### Common Block Examples See [references/common_blocks.md](references/common_blocks.md) for detailed examples of: - Paragraph blocks with formatting - Heading blocks (h2-h4) - Image blocks with captions and alignment - List blocks (ordered/unordered) - Quote blocks - Code blocks - Custom HTML blocks - Columns and layout blocks ## Complete Workflow ### Step 1: Prepare Authentication Set up credentials as environment variables or in a configuration file. ### Step 2: Create Post Data Define title, content blocks, categories, tags, featured image. ### Step 3: Upload Media (if needed) ```python def upload_image(image_path, post_id=None): """Upload image to WordPress media library.""" with open(image_path, 'rb') as f: files = {'file': f} data = {} if post_id: data['post'] = post_id response = requests.post(f"{wp_url}/wp-json/wp/v2/media", files=files, data=data, auth=auth) return response.json() ``` ### Step 4: Create Post ```python def create_post(post_data): """Create new WordPress post.""" response = requests.post(f"{wp_url}/wp-json/wp/v2/posts", json=post_data, auth=auth) return response.json() ``` ### Step 5: Update Status Change from draft to publish: ```python def publish_post(post_id): """Publish a draft post.""" response = requests.post(f"{wp_url}/wp-json/wp/v2/posts/{post_id}", json={"status": "publish"}, auth=auth) return response.json() ``` ## Advanced Features ### Categories and Tags ```python # Get or create category def ensure_category(name, slug=None): categories = requests.get(f"{wp_url}/wp-json/wp/v2/categories", params={"search": name}, auth=auth).json() if categories: return categories[0]['id'] else: new_cat = requests.post(f"{wp_url}/wp-json/wp/v2/categories", json={"name": name, "slug": slug or name.lower()}, auth=auth).json() return new_cat['id'] ``` ### Featured Image ```python # Upload image first, then set as featured image_data = upload_image("path/to/image.jpg") post_data["featured_media"] = image_data['id'] ``` ### Custom Fields (ACF) If using Advanced Custom Fields plugin: ```python post_data["meta"] = { "your_field_name": "field_value" } ``` ## Error Handling Always check responses: ```python response = requests.post(...) if response.status_code in [200, 201]: print("Success!") else: print(f"Error {response.status_code}: {response.text}") ``` Common issues: - 401: Authentication failed - 403: User lacks permission - 404: Endpoint not found (check WordPress version) - 500: Server error (check PHP error logs) ## Scripts The `scripts/` directory contains helper utilities: - `wp_publish.py`: Complete publishing pipeline - `block_generator.py`: Generate Gutenberg block HTML from markdown - `media_uploader.py`: Batch upload images ## References - [common_blocks.md](references/common_blocks.md): Detailed block examples - [api_reference.md](references/api_reference.md): Complete WordPress REST API reference - [troubleshooting.md](references/troubleshooting.md): Common issues and solutions ## Assets - `templates/article_template.json`: JSON template for typical article structure - `block_samples/`: Example block HTML for various content types ## Example Request ```bash # Using curl with Application Password curl -X POST https://your-site.com/wp-json/wp/v2/posts \ -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" \ -H "Content-Type: application/json" \ -d '{ "title": "My New Post", "content": "<!-- wp:paragraph --><p>Hello World!</p><!-- /wp:paragraph -->", "status": "draft" }' ```

path: assets/block_samples/basic_blocks.html
content: <!-- Basic Gutenberg Blocks -->

<!-- wp:paragraph -->
<p>This is a standard paragraph block. It contains regular text that forms the body of your content.</p>
<!-- /wp:paragraph -->

<!-- wp:paragraph {"align":"center"} -->
<p class="has-text-align-center">This paragraph is centered. Alignment can be set to left, center, right, or wide.</p>
<!-- /wp:paragraph -->

<!-- wp:heading -->
<h2>Heading Level 2</h2>
<!-- /wp:heading -->

<!-- wp:heading {"level":3} -->
<h3>Heading Level 3</h3>
<!-- /wp:heading -->

<!-- wp:heading {"level":4,"anchor":"custom-anchor"} -->
<h4 id="custom-anchor">Heading with Anchor</h4>
<!-- /wp:heading -->

<!-- wp:list -->
<ul>
<li>Unordered list item 1</li>
<li>Unordered list item 2</li>
<li>Unordered list item 3</li>
</ul>
<!-- /wp:list -->

<!-- wp:list {"ordered":true} -->
<ol>
<li>Ordered list item 1</li>
<li>Ordered list item 2</li>
<li>Ordered list item 3</li>
</ol>
<!-- /wp:list -->

<!-- wp:quote -->
<blockquote class="wp-block-quote">
<p>A quotation from a person, book, or other source.</p>
<cite>— Source Name</cite>
</blockquote>
<!-- /wp:quote -->

<!-- wp:quote {"className":"is-style-large"} -->
<blockquote class="wp-block-quote is-style-large">
<p>A large style quote for emphasis. This style is often used for pull quotes.</p>
</blockquote>
<!-- /wp:quote -->

<!-- wp:code -->
<pre class="wp-block-code"><code>// Code block example
function helloWorld() {
    console.log("Hello, World!");
}</code></pre>
<!-- /wp:code -->

<!-- wp:code {"language":"python"} -->
<pre class="wp-block-code language-python"><code>def calculate_sum(a, b):
    return a + b</code></pre>
<!-- /wp:code -->

<!-- wp:separator -->
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<!-- /wp:separator -->

<!-- wp:separator {"className":"is-style-dots"} -->
<hr class="wp-block-separator has-text-color has-alpha-channel-opacity has-background is-style-dots" style="background-color:#ccc;color:#ccc"/>
<!-- /wp:separator -->

<!-- wp:buttons -->
<div class="wp-block-buttons">
<!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://example.com">Primary Button</a></div>
<!-- /wp:button -->
<!-- wp:button {"textColor":"background","backgroundColor":"vivid-cyan-blue"} -->
<div class="wp-block-button"><a class="wp-block-button__link has-background-color has-vivid-cyan-blue-background-color has-text-color has-background wp-element-button" href="#features">Styled Button</a></div>
<!-- /wp:button -->
</div>
<!-- /wp:buttons -->

<!-- wp:preformatted -->
<pre class="wp-block-preformatted">Preformatted text
   preserves whitespace
      exactly as typed.
Useful for code, poetry, or ASCII art.</pre>
<!-- /wp:preformatted -->

<!-- wp:table -->
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Header Column 1</th>
<th>Header Column 2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Row 1, Cell 1</td>
<td>Row 1, Cell 2</td>
</tr>
<tr>
<td>Row 2, Cell 1</td>
<td>Row 2, Cell 2</td>
</tr>
</tbody>
</table>
</figure>
<!-- /wp:table -->

path: assets/templates/article_template.json
content: {
  "title": "Sample Blog Post Title",
  "content": "<!-- wp:paragraph --><p>This is the introduction paragraph of your blog post. It should capture the reader's attention and provide a brief overview of what the article will cover.</p><!-- /wp:paragraph -->\n\n<!-- wp:heading --><h2>Main Section Heading</h2><!-- /wp:heading -->\n\n<!-- wp:paragraph --><p>This is the first paragraph of your main section. Expand on your topic with detailed information, examples, and insights.</p><!-- /wp:paragraph -->\n\n<!-- wp:paragraph --><p>Continue with additional paragraphs as needed to fully develop your ideas. Use clear, concise language and break up long paragraphs for readability.</p><!-- /wp:paragraph -->\n\n<!-- wp:image {\"align\":\"center\"} --><figure class=\"wp-block-image aligncenter\"><img src=\"https://example.com/wp-content/uploads/2025/01/sample-image.jpg\" alt=\"Descriptive alt text for the image\"/><figcaption class=\"wp-element-caption\">Caption explaining the image</figcaption></figure><!-- /wp:image -->\n\n<!-- wp:heading {\"level\":3} --><h3>Subsection Heading</h3><!-- /wp:heading -->\n\n<!-- wp:paragraph --><p>Use subheadings to organize your content into logical sections. This improves readability and helps readers navigate your article.</p><!-- /wp:paragraph -->\n\n<!-- wp:list --><ul><li>Bullet point for key information</li><li>Another important point</li><li>Third item in the list</li></ul><!-- /wp:list -->\n\n<!-- wp:quote --><blockquote class=\"wp-block-quote\"><p>A relevant or inspiring quote can add interest to your article.</p><cite>— Source of the quote</cite></blockquote><!-- /wp:quote -->\n\n<!-- wp:heading --><h2>Conclusion</h2><!-- /wp:heading -->\n\n<!-- wp:paragraph --><p>Summarize the main points of your article and provide a clear takeaway for readers. End with a call to action if appropriate.</p><!-- /wp:paragraph -->",
  "excerpt": "A brief summary of the article that appears in post listings and search results. Keep it concise and compelling.",
  "status": "draft",
  "format": "standard",
  "categories": ["Technology", "Tutorials"],
  "tags": ["WordPress", "API", "Gutenberg", "Automation"],
  "featured_image": {
    "path": "/path/to/featured-image.jpg",
    "title": "Featured Image Title",
    "alt_text": "Description of featured image",
    "caption": "Featured image caption"
  },
  "meta": {
    "_custom_field": "Custom value",
    "_read_time": "5 min"
  },
  "publish_immediately": false,
  "notes": "Replace placeholder content with your actual article. Update image URLs, categories, tags, and metadata as needed."
}

path: scripts/block_generator.py
content: #!/usr/bin/env python3
"""
Gutenberg Block Generator

Convert markdown or plain text to WordPress Gutenberg block format.
Supports common blocks: paragraphs, headings, lists, code blocks, images.

Usage:
    python block_generator.py input.md --output blocks.html
    python block_generator.py --text "Hello world" --format html
"""

import argparse
import re
import json
from typing import List, Dict, Any, Optional


class BlockGenerator:
    def __init__(self, add_paragraph_spacing: bool = True):
        """
        Initialize block generator.
        
        Args:
            add_paragraph_spacing: Add double newlines between blocks
        """
        self.add_spacing = add_paragraph_spacing
        
    def markdown_to_blocks(self, markdown: str) -> str:
        """
        Convert markdown text to Gutenberg blocks.
        
        Args:
            markdown: Markdown text
            
        Returns:
            Gutenberg block HTML
        """
        lines = markdown.split('\n')
        blocks = []
        i = 0
        
        while i < len(lines):
            line = lines[i].strip()
            
            # Empty line
            if not line:
                i += 1
                continue
            
            # Heading
            if line.startswith('#'):
                level = len(line.split(' ')[0])
                if level > 6:
                    level = 6
                text = line[level:].strip()
                blocks.append(self.heading_block(text, level))
                i += 1
                continue
            
            # List items
            if line.startswith('- ') or line.startswith('* ') or re.match(r'^\d+\. ', line):
                list_items = []
                list_type = 'unordered' if line.startswith(('- ', '* ')) else 'ordered'
                
                while i < len(lines) and (lines[i].startswith('- ') or 
                                         lines[i].startswith('* ') or 
                                         re.match(r'^\d+\. ', lines[i])):
                    item = lines[i].strip()
                    # Remove bullet/number
                    if list_type == 'unordered':
                        item = re.sub(r'^[-*]\s+', '', item)
                    else:
                        item = re.sub(r'^\d+\.\s+', '', item)
                    list_items.append(item)
                    i += 1
                
                blocks.append(self.list_block(list_items, list_type))
                continue
            
            # Code block (triple backticks)
            if line.startswith('```'):
                language = line[3:].strip() or 'plaintext'
                code_lines = []
                i += 1
                
                while i < len(lines) and not lines[i].strip().startswith('```'):
                    code_lines.append(lines[i])
                    i += 1
                
                if i < len(lines) and lines[i].strip().startswith('```'):
                    i += 1
                
                code = '\n'.join(code_lines)
                blocks.append(self.code_block(code, language))
                continue
            
            # Blockquote
            if line.startswith('> '):
                quote_lines = []
                while i < len(lines) and lines[i].startswith('> '):
                    quote_lines.append(lines[i][2:].strip())
                    i += 1
                quote_text = ' '.join(quote_lines)
                blocks.append(self.quote_block(quote_text))
                continue
            
            # Image (markdown format)
            img_match = re.match(r'^!\[([^\]]*)\]\(([^)]+)\)$', line)
            if img_match:
                alt_text = img_match.group(1)
                image_url = img_match.group(2)
                blocks.append(self.image_block(image_url, alt_text))
                i += 1
                continue
            
            # Link (markdown format)
            link_match = re.match(r'^\[([^\]]+)\]\(([^)]+)\)$', line)
            if link_match:
                # Convert to paragraph with link
                link_text = link_match.group(1)
                link_url = link_match.group(2)
                paragraph = f'<p><a href="{link_url}">{link_text}</a></p>'
                blocks.append(self.custom_block('core/paragraph', {}, paragraph))
                i += 1
                continue
            
            # Horizontal rule
            if line.strip() in ('---', '***', '___'):
                blocks.append(self.separator_block())
                i += 1
                continue
            
            # Regular paragraph (collect consecutive non-empty lines)
            paragraph_lines = []
            while i < len(lines) and lines[i].strip() and not self.is_special_markdown(lines[i]):
                paragraph_lines.append(lines[i].strip())
                i += 1
            
            if paragraph_lines:
                paragraph = ' '.join(paragraph_lines)
                # Convert inline markdown
                paragraph = self.convert_inline_markdown(paragraph)
                blocks.append(self.paragraph_block(paragraph))
                continue
            
            i += 1
        
        # Join blocks
        separator = '\n\n' if self.add_spacing else '\n'
        return separator.join(blocks)
    
    def is_special_markdown(self, line: str) -> bool:
        """Check if line starts with special markdown syntax."""
        line = line.strip()
        return (line.startswith('#') or
                line.startswith('- ') or
                line.startswith('* ') or
                re.match(r'^\d+\. ', line) or
                line.startswith('```') or
                line.startswith('> ') or
                re.match(r'^!\[.*\]\(.*\)$', line) or
                re.match(r'^\[.*\]\(.*\)$', line) or
                line in ('---', '***', '___'))
    
    def convert_inline_markdown(self, text: str) -> str:
        """Convert inline markdown to HTML."""
        # Bold: **text** or __text__
        text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
        text = re.sub(r'__(.+?)__', r'<strong>\1</strong>', text)
        
        # Italic: *text* or _text_
        text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
        text = re.sub(r'_(.+?)_', r'<em>\1</em>', text)
        
        # Code: `text`
        text = re.sub(r'`(.+?)`', r'<code>\1</code>', text)
        
        # Links: [text](url)
        text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
        
        return text
    
    def paragraph_block(self, text: str, align: str = None, 
                       text_color: str = None, 
                       background_color: str = None) -> str:
        """Create paragraph block."""
        attrs = {}
        class_names = []
        
        if align:
            attrs['align'] = align
            class_names.append(f'has-text-align-{align}')
        
        if text_color:
            attrs['textColor'] = text_color
            class_names.append(f'has-{text_color}-color')
        
        if background_color:
            attrs['backgroundColor'] = background_color
            class_names.append(f'has-{background_color}-background-color')
        
        # Build paragraph HTML
        class_attr = f' class="{" ".join(class_names)}"' if class_names else ''
        paragraph_html = f'<p{class_attr}>{text}</p>'
        
        return self.custom_block('core/paragraph', attrs, paragraph_html)
    
    def heading_block(self, text: str, level: int = 2, 
                     anchor: str = None) -> str:
        """Create heading block."""
        if level < 2:
            level = 2
        if level > 6:
            level = 6
        
        attrs = {'level': level}
        if anchor:
            attrs['anchor'] = anchor
        
        heading_html = f'<h{level}>{text}</h{level}>'
        if anchor:
            heading_html = f'<h{level} id="{anchor}">{text}</h{level}>'
        
        return self.custom_block('core/heading', attrs, heading_html)
    
    def list_block(self, items: List[str], list_type: str = 'unordered') -> str:
        """Create list block."""
        attrs = {'ordered': list_type == 'ordered'}
        
        list_tag = 'ol' if list_type == 'ordered' else 'ul'
        list_html = f'<{list_tag}>\n'
        for item in items:
            list_html += f'  <li>{item}</li>\n'
        list_html += f'</{list_tag}>'
        
        return self.custom_block('core/list', attrs, list_html)
    
    def code_block(self, code: str, language: str = 'plaintext') -> str:
        """Create code block."""
        attrs = {'language': language} if language != 'plaintext' else {}
        
        code_html = f'<pre class="wp-block-code"><code>{code}</code></pre>'
        if language != 'plaintext':
            code_html = f'<pre class="wp-block-code language-{language}"><code>{code}</code></pre>'
        
        return self.custom_block('core/code', attrs, code_html)
    
    def quote_block(self, text: str, citation: str = None) -> str:
        """Create quote block."""
        attrs = {}
        quote_html = f'<blockquote class="wp-block-quote"><p>{text}</p>'
        
        if citation:
            quote_html += f'<cite>{citation}</cite>'
            attrs['citation'] = citation
        
        quote_html += '</blockquote>'
        return self.custom_block('core/quote', attrs, quote_html)
    
    def image_block(self, url: str, alt_text: str = '', 
                   align: str = None, caption: str = None) -> str:
        """Create image block."""
        attrs = {}
        class_names = ['wp-block-image']
        
        if align:
            attrs['align'] = align
            class_names.append(f'align{align}')
        
        image_html = f'<figure class="{" ".join(class_names)}">'
        image_html += f'<img src="{url}" alt="{alt_text}"/>'
        
        if caption:
            image_html += f'<figcaption class="wp-element-caption">{caption}</figcaption>'
            attrs['caption'] = caption
        
        image_html += '</figure>'
        return self.custom_block('core/image', attrs, image_html)
    
    def separator_block(self, style: str = 'default') -> str:
        """Create separator block."""
        attrs = {}
        class_names = ['wp-block-separator', 'has-alpha-channel-opacity']
        
        if style != 'default':
            attrs['className'] = f'is-style-{style}'
            class_names.append(f'is-style-{style}')
        
        separator_html = f'<hr class="{" ".join(class_names)}"/>'
        return self.custom_block('core/separator', attrs, separator_html)
    
    def custom_block(self, block_name: str, attrs: Dict[str, Any], 
                    inner_html: str) -> str:
        """
        Create custom block with given attributes.
        
        Args:
            block_name: Block namespace (e.g., 'core/paragraph')
            attrs: Block attributes as dictionary
            inner_html: Inner HTML content
            
        Returns:
            Complete block HTML
        """
        # Format attributes JSON
        if attrs:
            # Convert to JSON, remove whitespace
            attrs_json = json.dumps(attrs, separators=(',', ':'))
            block_comment = f'<!-- wp:{block_name} {attrs_json} -->'
        else:
            block_comment = f'<!-- wp:{block_name} -->'
        
        closing_comment = f'<!-- /wp:{block_name} -->'
        
        return f'{block_comment}{inner_html}{closing_comment}'
    
    def wrap_in_group(self, blocks_html: str, 
                     background_color: str = None,
                     align: str = None) -> str:
        """
        Wrap blocks in a group block.
        
        Args:
            blocks_html: HTML of inner blocks
            background_color: Background color class
            align: Alignment
            
        Returns:
            Group block HTML
        """
        attrs = {}
        class_names = ['wp-block-group']
        
        if background_color:
            attrs['backgroundColor'] = background_color
            class_names.append(f'has-{background_color}-background-color')
            class_names.append('has-background')
        
        if align:
            attrs['align'] = align
        
        group_html = f'<div class="{" ".join(class_names)}">'
        group_html += blocks_html
        group_html += '</div>'
        
        return self.custom_block('core/group', attrs, group_html)
    
    def wrap_in_columns(self, columns_html: List[str], 
                       widths: List[str] = None) -> str:
        """
        Wrap content in columns block.
        
        Args:
            columns_html: List of column HTML strings
            widths: Optional list of width percentages (e.g., ["50%", "50%"])
            
        Returns:
            Columns block HTML
        """
        columns_block = []
        
        for i, column_html in enumerate(columns_html):
            attrs = {}
            if widths and i < len(widths):
                attrs['width'] = widths[i]
            
            column_inner = f'<div class="wp-block-column">'
            column_inner += column_html
            column_inner += '</div>'
            
            columns_block.append(self.custom_block('core/column', attrs, column_inner))
        
        columns_inner = '\n'.join(columns_block)
        columns_wrapper = f'<div class="wp-block-columns">{columns_inner}</div>'
        
        return self.custom_block('core/columns', {}, columns_wrapper)


def main():
    parser = argparse.ArgumentParser(description='Generate Gutenberg blocks from markdown/text')
    parser.add_argument('input', nargs='?', help='Input file (markdown)')
    parser.add_argument('--text', help='Input text')
    parser.add_argument('--output', '-o', help='Output file')
    parser.add_argument('--format', choices=['html', 'json'], default='html',
                       help='Output format')
    parser.add_argument('--spacing', action='store_true', default=True,
                       help='Add spacing between blocks (default: true)')
    parser.add_argument('--no-spacing', action='store_false', dest='spacing',
                       help='Remove spacing between blocks')
    
    args = parser.parse_args()
    
    # Read input
    input_text = ''
    if args.input:
        with open(args.input, 'r', encoding='utf-8') as f:
            input_text = f.read()
    elif args.text:
        input_text = args.text
    else:
        print("Error: No input provided. Use --text or provide input file.")
        sys.exit(1)
    
    # Generate blocks
    generator = BlockGenerator(add_paragraph_spacing=args.spacing)
    blocks_html = generator.markdown_to_blocks(input_text)
    
    # Output
    if args.format == 'json':
        output_data = {
            'content': blocks_html,
            'block_count': blocks_html.count('<!-- wp:'),
            'format': 'gutenberg'
        }
        output = json.dumps(output_data, indent=2)
    else:
        output = blocks_html
    
    if args.output:
        with open(args.output, 'w', encoding='utf-8') as f:
            f.write(output)
        print(f"✓ Generated {blocks_html.count('<!-- wp:')} blocks to {args.output}")
    else:
        print(output)


if __name__ == "__main__":
    import sys
    main()

path: scripts/wp_publish.py
content: #!/usr/bin/env python3
"""
WordPress Post Publisher

A complete script to create and publish WordPress posts via REST API.
Supports Gutenberg blocks, categories, tags, featured images, and custom fields.

Usage:
    python wp_publish.py --title "Post Title" --content "Content" [options]
    python wp_publish.py --config config.json

Environment variables:
    WP_URL: WordPress site URL (e.g., https://example.com)
    WP_USERNAME: WordPress username
    WP_APPLICATION_PASSWORD: Application password (24 words)
"""

import argparse
import json
import os
import sys
import time
import requests
from typing import Dict, Any, Optional, List


class WordPressPublisher:
    def __init__(self, base_url: str, username: str, password: str):
        """
        Initialize WordPress API client.
        
        Args:
            base_url: WordPress site URL (e.g., https://example.com)
            username: WordPress username
            [REDACTED_SECRET] password (24 words) or regular password
        """
        self.base_url = base_url.rstrip('/')
        self.api_url = f"{self.base_url}/wp-json/wp/v2"
        self.auth = (username, password)
        self.session = requests.Session()
        self.session.auth = self.auth
        
    def test_connection(self) -> bool:
        """Test API connection and authentication."""
        try:
            response = self.session.get(f"{self.api_url}/users/me", timeout=10)
            if response.status_code == 200:
                print(f"✓ Connected as: {response.json().get('name')}")
                return True
            else:
                print(f"✗ Connection failed: {response.status_code} {response.text}")
                return False
        except Exception as e:
            print(f"✗ Connection error: {e}")
            return False
    
    def upload_media(self, file_path: str, title: str = "", 
                     caption: str = "", alt_text: str = "") -> Optional[int]:
        """
        Upload media file to WordPress.
        
        Args:
            file_path: Path to image file
            title: Media title
            caption: Media caption
            alt_text: Alt text for accessibility
            
        Returns:
            Media ID or None if failed
        """
        if not os.path.exists(file_path):
            print(f"✗ File not found: {file_path}")
            return None
            
        try:
            with open(file_path, 'rb') as f:
                files = {'file': f}
                data = {}
                if title:
                    data['title'] = title
                if caption:
                    data['caption'] = caption
                if alt_text:
                    data['alt_text'] = alt_text
                    
                response = self.session.post(
                    f"{self.api_url}/media",
                    files=files,
                    data=data,
                    timeout=30
                )
                
                if response.status_code == 201:
                    media_id = response.json()['id']
                    print(f"✓ Uploaded media: {file_path} (ID: {media_id})")
                    return media_id
                else:
                    print(f"✗ Upload failed: {response.status_code} {response.text}")
                    return None
                    
        except Exception as e:
            print(f"✗ Upload error: {e}")
            return None
    
    def get_or_create_category(self, category_name: str, 
                               parent_id: int = 0) -> Optional[int]:
        """
        Get existing category ID or create new category.
        
        Args:
            category_name: Category name
            parent_id: Parent category ID (0 for none)
            
        Returns:
            Category ID or None if failed
        """
        # Search for existing category
        response = self.session.get(
            f"{self.api_url}/categories",
            params={"search": category_name, "per_page": 5}
        )
        
        if response.status_code == 200:
            categories = response.json()
            for cat in categories:
                if cat['name'].lower() == category_name.lower():
                    print(f"✓ Found existing category: {category_name} (ID: {cat['id']})")
                    return cat['id']
        
        # Create new category
        data = {
            "name": category_name,
            "slug": category_name.lower().replace(' ', '-'),
            "parent": parent_id
        }
        
        response = self.session.post(f"{self.api_url}/categories", json=data)
        
        if response.status_code == 201:
            cat_id = response.json()['id']
            print(f"✓ Created category: {category_name} (ID: {cat_id})")
            return cat_id
        else:
            print(f"✗ Category creation failed: {response.status_code} {response.text}")
            return None
    
    def get_or_create_tag(self, tag_name: str) -> Optional[int]:
        """
        Get existing tag ID or create new tag.
        
        Args:
            tag_name: Tag name
            
        Returns:
            Tag ID or None if failed
        """
        # Search for existing tag
        response = self.session.get(
            f"{self.api_url}/tags",
            params={"search": tag_name, "per_page": 5}
        )
        
        if response.status_code == 200:
            tags = response.json()
            for tag in tags:
                if tag['name'].lower() == tag_name.lower():
                    print(f"✓ Found existing tag: {tag_name} (ID: {tag['id']})")
                    return tag['id']
        
        # Create new tag
        data = {
            "name": tag_name,
            "slug": tag_name.lower().replace(' ', '-')
        }
        
        response = self.session.post(f"{self.api_url}/tags", json=data)
        
        if response.status_code == 201:
            tag_id = response.json()['id']
            print(f"✓ Created tag: {tag_name} (ID: {tag_id})")
            return tag_id
        else:
            print(f"✗ Tag creation failed: {response.status_code} {response.text}")
            return None
    
    def create_post(self, post_data: Dict[str, Any]) -> Optional[int]:
        """
        Create WordPress post.
        
        Args:
            post_data: Post data dictionary
            
        Returns:
            Post ID or None if failed
        """
        response = self.session.post(f"{self.api_url}/posts", json=post_data)
        
        if response.status_code == 201:
            post_id = response.json()['id']
            print(f"✓ Created post: {post_data.get('title', 'Untitled')} (ID: {post_id})")
            return post_id
        else:
            print(f"✗ Post creation failed: {response.status_code} {response.text}")
            return None
    
    def publish_post(self, post_id: int) -> bool:
        """
        Publish a draft post.
        
        Args:
            post_id: Post ID to publish
            
        Returns:
            True if successful
        """
        data = {"status": "publish"}
        response = self.session.post(f"{self.api_url}/posts/{post_id}", json=data)
        
        if response.status_code == 200:
            print(f"✓ Published post ID: {post_id}")
            return True
        else:
            print(f"✗ Publishing failed: {response.status_code} {response.text}")
            return False
    
    def create_post_from_config(self, config: Dict[str, Any]) -> Optional[int]:
        """
        Create post from configuration dictionary.
        
        Args:
            config: Configuration dictionary with post details
            
        Returns:
            Post ID or None if failed
        """
        # Process categories
        category_ids = []
        if 'categories' in config:
            for cat in config['categories']:
                if isinstance(cat, str):
                    cat_id = self.get_or_create_category(cat)
                    if cat_id:
                        category_ids.append(cat_id)
                elif isinstance(cat, dict):
                    cat_id = self.get_or_create_category(
                        cat.get('name'),
                        cat.get('parent', 0)
                    )
                    if cat_id:
                        category_ids.append(cat_id)
        
        # Process tags
        tag_ids = []
        if 'tags' in config:
            for tag in config['tags']:
                if isinstance(tag, str):
                    tag_id = self.get_or_create_tag(tag)
                    if tag_id:
                        tag_ids.append(tag_id)
        
        # Upload featured image
        featured_media = None
        if 'featured_image' in config:
            image_config = config['featured_image']
            if isinstance(image_config, str):
                featured_media = self.upload_media(image_config)
            elif isinstance(image_config, dict):
                featured_media = self.upload_media(
                    image_config.get('path', ''),
                    image_config.get('title', ''),
                    image_config.get('caption', ''),
                    image_config.get('alt_text', '')
                )
        
        # Prepare post data
        post_data = {
            "title": config.get('title', 'Untitled Post'),
            "content": config.get('content', ''),
            "excerpt": config.get('excerpt', ''),
            "status": config.get('status', 'draft'),
            "format": config.get('format', 'standard')
        }
        
        if category_ids:
            post_data["categories"] = category_ids
        if tag_ids:
            post_data["tags"] = tag_ids
        if featured_media:
            post_data["featured_media"] = featured_media
        
        if 'meta' in config:
            post_data["meta"] = config['meta']
        
        # Create post
        post_id = self.create_post(post_data)
        
        # Publish if requested
        if post_id and config.get('publish_immediately', False):
            time.sleep(2)  # Brief delay
            self.publish_post(post_id)
        
        return post_id


def load_config(config_file: str) -> Dict[str, Any]:
    """Load configuration from JSON file."""
    with open(config_file, 'r', encoding='utf-8') as f:
        return json.load(f)


def main():
    parser = argparse.ArgumentParser(description='Publish WordPress posts via REST API')
    parser.add_argument('--title', help='Post title')
    parser.add_argument('--content', help='Post content (Gutenberg blocks)')
    parser.add_argument('--content-file', help='File containing post content')
    parser.add_argument('--excerpt', help='Post excerpt')
    parser.add_argument('--status', default='draft', 
                       choices=['draft', 'publish', 'pending', 'private'],
                       help='Post status')
    parser.add_argument('--categories', nargs='+', help='Category names')
    parser.add_argument('--tags', nargs='+', help='Tag names')
    parser.add_argument('--featured-image', help='Path to featured image')
    parser.add_argument('--config', help='JSON configuration file')
    parser.add_argument('--publish', action='store_true', 
                       help='Publish immediately (default: save as draft)')
    parser.add_argument('--test', action='store_true', 
                       help='Test connection only')
    
    args = parser.parse_args()
    
    # Load environment variables
    wp_url = os.environ.get('WP_URL')
    wp_username = os.environ.get('WP_USERNAME')
    wp_password = os.environ.get('WP_APPLICATION_PASSWORD')
    
    if not all([wp_url, wp_username, wp_password]):
        print("Error: Environment variables not set.")
        print("Please set: WP_URL, WP_USERNAME, WP_APPLICATION_PASSWORD")
        sys.exit(1)
    
    # Initialize publisher
    publisher = WordPressPublisher(wp_url, wp_username, wp_password)
    
    # Test connection
    if not publisher.test_connection():
        sys.exit(1)
    
    if args.test:
        print("✓ Connection test successful")
        sys.exit(0)
    
    # Load content from file if specified
    content = args.content
    if args.content_file and os.path.exists(args.content_file):
        with open(args.content_file, 'r', encoding='utf-8') as f:
            content = f.read()
    
    # Prepare configuration
    if args.config:
        config = load_config(args.config)
    else:
        config = {
            "title": args.title or "New Post",
            "content": content or "<!-- wp:paragraph --><p>Post content</p><!-- /wp:paragraph -->",
            "excerpt": args.excerpt or "",
            "status": "publish" if args.publish else args.status,
            "categories": args.categories or [],
            "tags": args.tags or [],
            "publish_immediately": args.publish
        }
        
        if args.featured_image:
            config["featured_image"] = args.featured_image
    
    # Create post
    post_id = publisher.create_post_from_config(config)
    
    if post_id:
        print(f"\n✓ Success! Post created with ID: {post_id}")
        print(f"   View: {wp_url}/wp-admin/post.php?post={post_id}&action=edit")
        print(f"   Preview: {wp_url}/?p={post_id}&preview=true")
        sys.exit(0)
    else:
        print("\n✗ Failed to create post")
        sys.exit(1)


if __name__ == "__main__":
    main()

path: scripts/media_uploader.py
content: #!/usr/bin/env python3
"""
WordPress Media Uploader

Batch upload media files to WordPress with support for metadata,
alt text, captions, and post attachments.

Usage:
    python media_uploader.py image1.jpg image2.png
    python media_uploader.py --directory ./images --recursive
    python media_uploader.py --csv media_list.csv
"""

import argparse
import csv
import json
import os
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple

import requests


class MediaUploader:
    def __init__(self, base_url: str, username: str, password: str):
        """
        Initialize WordPress media uploader.
        
        Args:
            base_url: WordPress site URL
            username: WordPress username
            [REDACTED_SECRET] password
        """
        self.base_url = base_url.rstrip('/')
        self.api_url = f"{self.base_url}/wp-json/wp/v2"
        self.auth = (username, password)
        self.session = requests.Session()
        self.session.auth = self.auth
        
    def upload_single_file(self, file_path: str, metadata: Optional[Dict] = None) -> Optional[Dict]:
        """
        Upload a single file to WordPress media library.
        
        Args:
            file_path: Path to file
            metadata: Optional metadata dict with keys:
                - title: Media title
                - caption: Media caption
                - alt_text: Alt text
                - description: Description
                - post: Post ID to attach to
                
        Returns:
            Media data dictionary or None if failed
        """
        if not os.path.exists(file_path):
            print(f"✗ File not found: {file_path}")
            return None
        
        metadata = metadata or {}
        file_name = os.path.basename(file_path)
        
        try:
            with open(file_path, 'rb') as f:
                files = {'file': f}
                data = {}
                
                # Add metadata if provided
                for key in ['title', 'caption', 'alt_text', 'description', 'post']:
                    if key in metadata:
                        data[key] = metadata[key]
                
                # If no title provided, use filename without extension
                if 'title' not in data:
                    data['title'] = os.path.splitext(file_name)[0]
                
                print(f"Uploading: {file_name} ({os.path.getsize(file_path)/1024:.1f} KB)...")
                
                response = self.session.post(
                    f"{self.api_url}/media",
                    files=files,
                    data=data,
                    timeout=60
                )
                
                if response.status_code == 201:
                    media_data = response.json()
                    print(f"  ✓ Uploaded: {media_data['source_url']} (ID: {media_data['id']})")
                    return media_data
                else:
                    print(f"  ✗ Failed: {response.status_code} {response.text[:200]}")
                    return None
                    
        except Exception as e:
            print(f"  ✗ Error: {e}")
            return None
    
    def upload_directory(self, directory: str, recursive: bool = False, 
                        extensions: List[str] = None) -> List[Dict]:
        """
        Upload all media files in a directory.
        
        Args:
            directory: Directory path
            recursive: Include subdirectories
            extensions: Allowed file extensions (default: image extensions)
            
        Returns:
            List of uploaded media data
        """
        if extensions is None:
            extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', 
                         '.bmp', '.tiff', '.svg', '.pdf', '.mp4', 
                         '.mov', '.avi', '.wmv', '.mp3', '.wav', '.ogg']
        
        uploaded = []
        dir_path = Path(directory)
        
        if not dir_path.exists():
            print(f"✗ Directory not found: {directory}")
            return uploaded
        
        # Collect files
        files = []
        if recursive:
            for ext in extensions:
                files.extend(dir_path.rglob(f'*{ext}'))
                files.extend(dir_path.rglob(f'*{ext.upper()}'))
        else:
            for ext in extensions:
                files.extend(dir_path.glob(f'*{ext}'))
                files.extend(dir_path.glob(f'*{ext.upper()}'))
        
        files = list(set(files))  # Remove duplicates
        files.sort()
        
        print(f"Found {len(files)} media files in {directory}")
        
        # Upload each file
        for i, file_path in enumerate(files, 1):
            print(f"\n[{i}/{len(files)}] ", end='')
            
            # Generate metadata from filename
            file_stem = file_path.stem
            # Convert underscores and hyphens to spaces, capitalize
            title = ' '.join(word.capitalize() for word in file_stem.replace('_', ' ').replace('-', ' ').split())
            
            metadata = {
                'title': title,
                'alt_text': file_stem.replace('_', ' ').replace('-', ' ')  # Simple alt text
            }
            
            media_data = self.upload_single_file(str(file_path), metadata)
            if media_data:
                uploaded.append(media_data)
            
            # Small delay to avoid overwhelming server
            if i < len(files):
                time.sleep(1)
        
        return uploaded
    
    def upload_from_csv(self, csv_file: str) -> List[Dict]:
        """
        Upload media files specified in CSV.
        
        CSV format should have columns:
        - file_path: Path to file (required)
        - title: Media title (optional)
        - caption: Caption (optional)
        - alt_text: Alt text (optional)
        - description: Description (optional)
        - post: Post ID to attach to (optional)
        
        Args:
            csv_file: Path to CSV file
            
        Returns:
            List of uploaded media data
        """
        uploaded = []
        
        if not os.path.exists(csv_file):
            print(f"✗ CSV file not found: {csv_file}")
            return uploaded
        
        try:
            with open(csv_file, 'r', encoding='utf-8') as f:
                reader = csv.DictReader(f)
                rows = list(reader)
                
                if not rows:
                    print("CSV file is empty")
                    return uploaded
                
                print(f"Found {len(rows)} entries in CSV")
                
                for i, row in enumerate(rows, 1):
                    file_path = row.get('file_path', '').strip()
                    if not file_path:
                        print(f"\n[{i}/{len(rows)}] ✗ Skipping row {i}: No file_path")
                        continue
                    
                    print(f"\n[{i}/{len(rows)}] ", end='')
                    
                    # Prepare metadata
                    metadata = {}
                    for key in ['title', 'caption', 'alt_text', 'description', 'post']:
                        if key in row and row[key].strip():
                            metadata[key] = row[key].strip()
                    
                    media_data = self.upload_single_file(file_path, metadata)
                    if media_data:
                        uploaded.append(media_data)
                    
                    if i < len(rows):
                        time.sleep(1)
                        
        except Exception as e:
            print(f"\n✗ Error reading CSV: {e}")
        
        return uploaded
    
    def generate_import_report(self, uploaded_media: List[Dict], 
                              output_file: Optional[str] = None) -> str:
        """
        Generate report of uploaded media.
        
        Args:
            uploaded_media: List of media data dictionaries
            output_file: Optional file to save report
            
        Returns:
            Report string
        """
        if not uploaded_media:
            report = "No media uploaded."
            if output_file:
                with open(output_file, 'w', encoding='utf-8') as f:
                    f.write(report)
            return report
        
        report_lines = [
            f"# WordPress Media Upload Report",
            f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}",
            f"Total uploaded: {len(uploaded_media)}",
            f"\n## Uploaded Media\n"
        ]
        
        for media in uploaded_media:
            report_lines.extend([
                f"### {media.get('title', 'Untitled')}",
                f"- ID: {media['id']}",
                f"- File: {media.get('source_url', 'N/A')}",
                f"- Size: {media.get('media_details', {}).get('filesize', 'N/A')} bytes",
                f"- Dimensions: {media.get('media_details', {}).get('width', 'N/A')}x{media.get('media_details', {}).get('height', 'N/A')}",
                f"- Date: {media.get('date', 'N/A')}",
                f"- URL: {media.get('link', 'N/A')}",
                f""
            ])
        
        # Add Gutenberg block examples
        report_lines.extend([
            f"\n## Gutenberg Block Examples\n",
            f"Use these IDs in your posts:\n"
        ])
        
        for media in uploaded_media:
            if 'image' in media.get('media_type', ''):
                report_lines.append(
                    f'<!-- wp:image {{"id":{media["id"]}}} -->'
                    f'<figure class="wp-block-image">'
                    f'<img src="{media.get("source_url")}" alt="{media.get("alt_text", "")}" class="wp-image-{media["id"]}"/>'
                    f'</figure><!-- /wp:image -->'
                )
        
        report = '\n'.join(report_lines)
        
        if output_file:
            with open(output_file, 'w', encoding='utf-8') as f:
                f.write(report)
            print(f"✓ Report saved to: {output_file}")
        
        return report
    
    def update_media_metadata(self, media_id: int, metadata: Dict) -> bool:
        """
        Update metadata of existing media item.
        
        Args:
            media_id: Media ID
            metadata: Dict with keys: title, caption, alt_text, description
            
        Returns:
            True if successful
        """
        try:
            response = self.session.post(
                f"{self.api_url}/media/{media_id}",
                json=metadata
            )
            
            if response.status_code == 200:
                print(f"✓ Updated metadata for media ID {media_id}")
                return True
            else:
                print(f"✗ Failed to update media {media_id}: {response.status_code} {response.text}")
                return False
                
        except Exception as e:
            print(f"✗ Error updating media {media_id}: {e}")
            return False


def parse_metadata_string(metadata_str: str) -> Dict:
    """
    Parse metadata from string in format "key1=value1;key2=value2".
    
    Args:
        metadata_str: Metadata string
        
    Returns:
        Dictionary of metadata
    """
    metadata = {}
    if not metadata_str:
        return metadata
    
    pairs = metadata_str.split(';')
    for pair in pairs:
        if '=' in pair:
            key, value = pair.split('=', 1)
            metadata[key.strip()] = value.strip()
    
    return metadata


def main():
    parser = argparse.ArgumentParser(description='Batch upload media to WordPress')
    parser.add_argument('files', nargs='*', help='Media files to upload')
    parser.add_argument('--directory', '-d', help='Upload all media files in directory')
    parser.add_argument('--recursive', '-r', action='store_true',
                       help='Include subdirectories when using --directory')
    parser.add_argument('--csv', help='CSV file with media list and metadata')
    parser.add_argument('--metadata', help='Metadata for all files (format: "title=My Title;alt_text=Description")')
    parser.add_argument('--output', '-o', help='Output report file')
    parser.add_argument('--extensions', default='jpg,jpeg,png,gif,webp,pdf,mp4',
                       help='Comma-separated file extensions (default: jpg,jpeg,png,gif,webp,pdf,mp4)')
    parser.add_argument('--test', action='store_true',
                       help='Test connection only')
    
    args = parser.parse_args()
    
    # Load environment variables
    wp_url = os.environ.get('WP_URL')
    wp_username = os.environ.get('WP_USERNAME')
    wp_password = os.environ.get('WP_APPLICATION_PASSWORD')
    
    if not all([wp_url, wp_username, wp_password]):
        print("Error: Environment variables not set.")
        print("Please set: WP_URL, WP_USERNAME, WP_APPLICATION_PASSWORD")
        sys.exit(1)
    
    # Initialize uploader
    uploader = MediaUploader(wp_url, wp_username, wp_password)
    
    # Test connection
    print("Testing WordPress connection...")
    try:
        response = uploader.session.get(f"{uploader.api_url}/users/me", timeout=10)
        if response.status_code == 200:
            print(f"✓ Connected to: {wp_url}")
            print(f"  User: {response.json().get('name')}")
        else:
            print(f"✗ Connection failed: {response.status_code}")
            sys.exit(1)
    except Exception as e:
        print(f"✗ Connection error: {e}")
        sys.exit(1)
    
    if args.test:
        print("✓ Connection test successful")
        sys.exit(0)
    
    # Parse metadata
    global_metadata = parse_metadata_string(args.metadata) if args.metadata else {}
    
    uploaded = []
    
    # Upload from CSV
    if args.csv:
        print(f"\nUploading from CSV: {args.csv}")
        uploaded.extend(uploader.upload_from_csv(args.csv))
    
    # Upload directory
    if args.directory:
        extensions = [f'.{ext.strip()}' for ext in args.extensions.split(',')]
        print(f"\nUploading directory: {args.directory}")
        if args.recursive:
            print("  (including subdirectories)")
        uploaded.extend(uploader.upload_directory(
            args.directory, args.recursive, extensions
        ))
    
    # Upload individual files
    if args.files:
        print(f"\nUploading {len(args.files)} individual files")
        for file_path in args.files:
            media_data = uploader.upload_single_file(file_path, global_metadata)
            if media_data:
                uploaded.append(media_data)
            time.sleep(1)  # Delay between uploads
    
    # Generate report
    if uploaded:
        print(f"\n{'='*50}")
        print(f"Successfully uploaded {len(uploaded)} media files")
        
        report = uploader.generate_import_report(uploaded, args.output)
        
        if not args.output:
            print("\n" + report[:1000] + ("..." if len(report) > 1000 else ""))
            
        # Save media IDs to file for reference
        ids_file = 'uploaded_media_ids.json'
        ids = [{'id': m['id'], 'title': m.get('title', ''), 'url': m.get('source_url', '')} 
               for m in uploaded]
        with open(ids_file, 'w', encoding='utf-8') as f:
            json.dump(ids, f, indent=2)
        print(f"\n✓ Media IDs saved to: {ids_file}")
        
    else:
        print("\nNo media files were uploaded.")
        sys.exit(1)


if __name__ == "__main__":
    main()

path: references/common_blocks.md
content: # Gutenberg Block Examples

This reference provides ready-to-use Gutenberg block HTML for common content elements. Use these templates when constructing post content for WordPress REST API.

## Block Format Basics

Each block is wrapped in HTML comments:

```
<!-- wp:block-name {json_attributes} -->
Block content (HTML)
<!-- /wp:block-name -->
```

Attributes are optional JSON. For blocks with inner blocks, nest them accordingly.

## Core Blocks

### Paragraph

Simple text paragraph:

```html
<!-- wp:paragraph -->
<p>This is a paragraph of text. It can contain <strong>bold</strong>, <em>italic</em>, and <a href="https://example.com">links</a>.</p>
<!-- /wp:paragraph -->
```

With alignment and text color:

```html
<!-- wp:paragraph {"align":"center","textColor":"vivid-red"} -->
<p class="has-text-align-center has-vivid-red-color">Centered red text</p>
<!-- /wp:paragraph -->
```

### Heading

```html
<!-- wp:heading -->
<h2>Level 2 Heading</h2>
<!-- /wp:heading -->
```

```html
<!-- wp:heading {"level":3} -->
<h3>Level 3 Heading</h3>
<!-- /wp:heading -->
```

With anchor for table of contents:

```html
<!-- wp:heading {"anchor":"section-1"} -->
<h2 id="section-1">Section with Anchor</h2>
<!-- /wp:heading -->
```

### Image

Basic image (media ID must exist):

```html
<!-- wp:image {"id":123,"sizeSlug":"large"} -->
<figure class="wp-block-image size-large"><img src="https://example.com/wp-content/uploads/2025/01/image.jpg" alt="Description" class="wp-image-123"/></figure>
<!-- /wp:image -->
```

With caption and alignment:

```html
<!-- wp:image {"id":123,"align":"center","className":"is-style-rounded"} -->
<figure class="wp-block-image aligncenter is-style-rounded"><img src="https://example.com/wp-content/uploads/2025/01/image.jpg" alt="Alt text" class="wp-image-123"/><figcaption class="wp-element-caption">Image caption text</figcaption></figure>
<!-- /wp:image -->
```

### List

Unordered list:

```html
<!-- wp:list -->
<ul>
<li>First item</li>
<li>Second item</li>
<li>Third item</li>
</ul>
<!-- /wp:list -->
```

Ordered list:

```html
<!-- wp:list {"ordered":true} -->
<ol>
<li>Step one</li>
<li>Step two</li>
<li>Step three</li>
</ol>
<!-- /wp:list -->
```

### Quote

```html
<!-- wp:quote -->
<blockquote class="wp-block-quote">
<p>This is a quote from someone important.</p>
<cite>— Source Name</cite>
</blockquote>
<!-- /wp:quote -->
```

Pullquote style:

```html
<!-- wp:quote {"className":"is-style-large"} -->
<blockquote class="wp-block-quote is-style-large">
<p>Large style quote for emphasis.</p>
</blockquote>
<!-- /wp:quote -->
```

### Code

```html
<!-- wp:code -->
<pre class="wp-block-code"><code>def hello_world():
    print("Hello, World!")</code></pre>
<!-- /wp:code -->
```

With language specification:

```html
<!-- wp:code {"language":"python"} -->
<pre class="wp-block-code language-python"><code>import requests

response = requests.get("https://api.example.com")</code></pre>
<!-- /wp:code -->
```

### Preformatted

```html
<!-- wp:preformatted -->
<pre class="wp-block-preformatted">This text
   preserves
      whitespace
exactly.</pre>
<!-- /wp:preformatted -->
```

### Table

```html
<!-- wp:table -->
<figure class="wp-block-table">
<table>
<thead>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Row 1, Col 1</td>
<td>Row 1, Col 2</td>
</tr>
<tr>
<td>Row 2, Col 1</td>
<td>Row 2, Col 2</td>
</tr>
</tbody>
</table>
</figure>
<!-- /wp:table -->
```

### Buttons

```html
<!-- wp:buttons -->
<div class="wp-block-buttons">
<!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://example.com">Click Me</a></div>
<!-- /wp:button -->
</div>
<!-- /wp:buttons -->
```

Multiple buttons:

```html
<!-- wp:buttons -->
<div class="wp-block-buttons">
<!-- wp:button {"textColor":"background","backgroundColor":"vivid-cyan-blue"} -->
<div class="wp-block-button"><a class="wp-block-button__link has-background-color has-vivid-cyan-blue-background-color has-text-color has-background wp-element-button" href="#features">Features</a></div>
<!-- /wp:button -->
<!-- wp:button {"textColor":"background","backgroundColor":"luminous-vivid-orange"} -->
<div class="wp-block-button"><a class="wp-block-button__link has-background-color has-luminous-vivid-orange-background-color has-text-color has-background wp-element-button" href="#pricing">Pricing</a></div>
<!-- /wp:button -->
</div>
<!-- /wp:buttons -->
```

## Layout Blocks

### Columns

Two columns:

```html
<!-- wp:columns -->
<div class="wp-block-columns">
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:paragraph -->
<p>Left column content</p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:paragraph -->
<p>Right column content</p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
```

With width percentages:

```html
<!-- wp:columns -->
<div class="wp-block-columns">
<!-- wp:column {"width":"66.66%"} -->
<div class="wp-block-column" style="flex-basis:66.66%">
<!-- wp:paragraph -->
<p>Main content (2/3)</p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:column -->
<!-- wp:column {"width":"33.33%"} -->
<div class="wp-block-column" style="flex-basis:33.33%">
<!-- wp:paragraph -->
<p>Sidebar (1/3)</p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
```

### Group

For grouping blocks with background:

```html
<!-- wp:group {"backgroundColor":"luminous-vivid-amber","layout":{"type":"constrained"}} -->
<div class="wp-block-group has-luminous-vivid-amber-background-color has-background">
<!-- wp:paragraph -->
<p>Content inside a group with yellow background.</p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:group -->
```

### Separator

```html
<!-- wp:separator -->
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<!-- /wp:separator -->
```

Dotted style:

```html
<!-- wp:separator {"style":{"color":{"background":"#ccc"}},"className":"is-style-dots"} -->
<hr class="wp-block-separator has-text-color has-alpha-channel-opacity has-background is-style-dots" style="background-color:#ccc;color:#ccc"/>
<!-- /wp:separator -->
```

## Media Blocks

### Gallery

```html
<!-- wp:gallery {"linkTo":"media"} -->
<figure class="wp-block-gallery has-nested-images columns-3 is-cropped">
<!-- wp:image {"id":123,"sizeSlug":"large","linkDestination":"media"} -->
<figure class="wp-block-image size-large"><a href="https://example.com/wp-content/uploads/2025/01/image1.jpg"><img src="https://example.com/wp-content/uploads/2025/01/image1.jpg" alt="" class="wp-image-123"/></a></figure>
<!-- /wp:image -->
<!-- wp:image {"id":124,"sizeSlug":"large","linkDestination":"media"} -->
<figure class="wp-block-image size-large"><a href="https://example.com/wp-content/uploads/2025/01/image2.jpg"><img src="https://example.com/wp-content/uploads/2025/01/image2.jpg" alt="" class="wp-image-124"/></a></figure>
<!-- /wp:image -->
</figure>
<!-- /wp:gallery -->
```

### Cover

Image with text overlay:

```html
<!-- wp:cover {"url":"https://example.com/wp-content/uploads/2025/01/hero.jpg","dimRatio":50,"overlayColor":"black"} -->
<div class="wp-block-cover"><span aria-hidden="true" class="wp-block-cover__background has-black-background-color has-background-dim-50 has-background-dim"></span><img class="wp-block-cover__image-background" alt="" src="https://example.com/wp-content/uploads/2025/01/hero.jpg" data-object-fit="cover"/>
<div class="wp-block-cover__inner-container">
<!-- wp:paragraph {"align":"center","textColor":"white","fontSize":"large"} -->
<p class="has-text-align-center has-white-color has-large-font-size">Hero text over image</p>
<!-- /wp:paragraph -->
</div>
</div>
<!-- /wp:cover -->
```

## Embed Blocks

### YouTube

```html
<!-- wp:embed {"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ","type":"video","providerNameSlug":"youtube","responsive":true} -->
<figure class="wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube"><div class="wp-block-embed__wrapper">
https://www.youtube.com/watch?v=dQw4w9WgXcQ
</div></figure>
<!-- /wp:embed -->
```

### Twitter

```html
<!-- wp:embed {"url":"https://twitter.com/WordPress/status/123456789","type":"rich","providerNameSlug":"twitter"} -->
<figure class="wp-block-embed is-type-rich is-provider-twitter wp-block-embed-twitter"><div class="wp-block-embed__wrapper">
https://twitter.com/WordPress/status/123456789
</div></figure>
<!-- /wp:embed -->
```

## Custom Block Examples

### Shortcode

```html
<!-- wp:shortcode -->
[my_custom_shortcode param="value"]
<!-- /wp:shortcode -->
```

### Custom HTML

```html
<!-- wp:html -->
<div class="custom-widget">
  <h3>Custom HTML</h3>
  <p>This can contain any HTML.</p>
</div>
<!-- /wp:html -->
```

## Block Combinations

### Article Introduction

```html
<!-- wp:heading -->
<h2>Article Title</h2>
<!-- /wp:heading -->

<!-- wp:paragraph -->
<p>This is the introduction paragraph that summarizes what the article is about.</p>
<!-- /wp:paragraph -->

<!-- wp:image {"id":123,"align":"center","sizeSlug":"large"} -->
<figure class="wp-block-image aligncenter size-large"><img src="https://example.com/image.jpg" alt="Relevant image" class="wp-image-123"/><figcaption class="wp-element-caption">Image caption explaining relevance</figcaption></figure>
<!-- /wp:image -->

<!-- wp:paragraph -->
<p>The article continues here...</p>
<!-- /wp:paragraph -->
```

### Feature List

```html
<!-- wp:columns -->
<div class="wp-block-columns">
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:heading {"level":3} -->
<h3>Feature One</h3>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>Description of first feature with benefits.</p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:heading {"level":3} -->
<h3>Feature Two</h3>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>Description of second feature with benefits.</p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:column -->
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:heading {"level":3} -->
<h3>Feature Three</h3>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>Description of third feature with benefits.</p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
```

path: references/troubleshooting.md
content: # Troubleshooting WordPress API Issues

Common problems and solutions when working with WordPress REST API and Gutenberg.

## Authentication Issues

### "rest_cookie_invalid_nonce" or 403 errors

**Problem**: Authentication failing despite correct credentials.

**Solutions**:
1. **Application Password format**: Ensure you're using the exact 24-character phrase with spaces. Don't modify it.
2. **HTTPS required**: WordPress may require HTTPS for authentication. Ensure your site uses SSL.
3. **User permissions**: The user must have `edit_posts` capability. Check user role.
4. **Basic Auth plugin**: If using Basic Auth, ensure the plugin is installed and activated.
   - Popular plugins: "Application Passwords" (built-in), "Basic Auth", "JWT Authentication"
5. **CORS issues**: If making requests from browser JavaScript, configure CORS headers.

### JWT Authentication fails

**Problem**: Token request returns 403 or "invalid_username".

**Solutions**:
1. Ensure JWT plugin is installed and configured.
2. Check secret key in WordPress configuration.
3. Verify token expiration (default 7 days).
4. Use `curl` to test:
   ```bash
   curl -X POST https://site.com/wp-json/jwt-auth/v1/token \
     -H "Content-Type: application/json" \
     -d '{"username":"admin","password":"password"}'
   ```

## Gutenberg Block Problems

### Blocks disappear or are stripped

**Problem**: Blocks are removed when saving via API.

**Causes and fixes**:
1. **Invalid block HTML**: Ensure block comments are properly formed.
   - Correct: `<!-- wp:paragraph --><p>Text</p><!-- /wp:paragraph -->`
   - Wrong: `<!-- wp:paragraph --><p>Text</p>` (missing closing comment)
2. **Unregistered block**: Custom blocks must be registered on the server.
   - Use only core blocks unless you've registered custom blocks.
   - Check block namespace: `wp:myplugin/my-block` requires plugin active.
3. **Block validation**: WordPress validates blocks. Test with simple paragraph first.
4. **Content sanitization**: WordPress may strip certain HTML tags.
   - Use `wp_kses_post` compatible tags only.
   - Avoid script tags unless user has `unfiltered_html` capability.

### Blocks render incorrectly

**Problem**: Blocks appear as raw HTML or misformatted.

**Solutions**:
1. **Check block alignment classes**: Ensure alignment classes match WordPress CSS.
2. **Missing CSS**: Some blocks require theme styles. Test with default theme.
3. **Image IDs**: When using `wp:image {"id":123}`, ensure media ID exists.
4. **Nested blocks**: Complex layouts (columns, groups) must have proper nesting.

## API Endpoint Issues

### 404 "No route was found"

**Problem**: API endpoint not accessible.

**Solutions**:
1. **Permalinks**: Ensure pretty permalinks are enabled (Settings → Permalinks).
2. **REST API disabled**: Some security plugins disable REST API. Check plugin settings.
3. **Custom endpoint**: Verify you're using correct namespace for custom post types.
4. **WordPress version**: Ensure WordPress 4.7+ (REST API v2).

### 400 "Invalid parameter"

**Problem**: Request fails with parameter validation error.

**Solutions**:
1. **Read error message**: WordPress provides specific parameter errors.
2. **Check data types**: Categories/tags should be arrays of IDs, not names.
3. **Status values**: Use only: `draft`, `publish`, `pending`, `private`, `future`.
4. **Date format**: Use ISO 8601: `2025-01-15T10:00:00`.

## Media Upload Issues

### "Sorry, this file type is not permitted"

**Problem**: File upload rejected.

**Solutions**:
1. **Check MIME types**: WordPress has allowed file types. Common images (jpg, png, gif) are allowed.
2. **File size limit**: Hosting may limit uploads. Check `upload_max_filesize` in PHP.
3. **File name**: Remove special characters, use lowercase extensions.
4. **Alternative**: Upload via WordPress admin first, then use existing media ID.

### "Missing attachment file"

**Problem**: Media created but file not attached.

**Solutions**:
1. **Use multipart/form-data**: Not `application/json`.
2. **Correct field name**: Use `file` as field name.
3. **Content-Type header**: Should match file type (`image/jpeg`, etc.).
4. **Test with curl**:
   ```bash
   curl -X POST https://site.com/wp-json/wp/v2/media \
     -u "user:pass" \
     -F "file=@image.jpg" \
     -F "title=My Image"
   ```

## Performance Problems

### Slow API responses

**Problem**: Requests take too long.

**Optimizations**:
1. **Reduce embed**: Add `?_embed=false` to requests.
2. **Limit fields**: Use `?_fields=id,title` to reduce payload.
3. **Cache responses**: Implement client-side caching.
4. **Pagination**: Request fewer items per page.
5. **Server-side**: Consider caching plugin (WP Rocket, W3 Total Cache).

### Timeouts during batch operations

**Problem**: Multiple requests cause timeouts.

**Solutions**:
1. **Add delays**: Sleep 1-2 seconds between requests.
2. **Batch operations**: Use WordPress's batch endpoint if available.
3. **Increase timeout**: Client-side timeout settings.
4. **Background processing**: Consider WordPress cron or queue.

## Cross-Origin (CORS) Issues

**Problem**: Browser blocks API requests.

**Solutions**:
1. **Install CORS plugin**: "WP REST API - CORS" or similar.
2. **Add headers manually**:
   ```php
   // In theme's functions.php
   add_action('rest_api_init', function() {
     remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
     add_filter('rest_pre_serve_request', function($value) {
       header('Access-Control-Allow-Origin: *');
       header('Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS');
       header('Access-Control-Allow-Credentials: true');
       return $value;
     });
   }, 15);
   ```
3. **Proxy requests**: Route through same-origin server.

## SSL/TLS Issues

**Problem**: Certificate verification fails.

**Solutions**:
1. **Self-signed certs**: Disable verification (not recommended for production).
   ```python
   # Python requests
   requests.post(url, auth=auth, verify=False)
   ```
2. **Missing intermediate certificates**: Install full certificate chain.
3. **Use HTTP**: Only for local development.

## Debugging Techniques

### Enable WordPress debug

Add to `wp-config.php`:
```php
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
```

Check `wp-content/debug.log` for errors.

### Log API requests

```python
import logging
logging.basicConfig(level=logging.DEBUG)

import requests
requests.get(url, auth=auth)  # Will log full request/response
```

### Test with Postman or curl

1. **Verify endpoint availability**:
   ```bash
   curl -I https://site.com/wp-json/
   ```

2. **Test authentication**:
   ```bash
   curl -u "user:pass" https://site.com/wp-json/wp/v2/users/me
   ```

3. **Test post creation**:
   ```bash
   curl -X POST https://site.com/wp-json/wp/v2/posts \
     -u "user:pass" \
     -H "Content-Type: application/json" \
     -d '{"title":"Test","status":"draft"}'
   ```

### Check REST API index

Visit `https://site.com/wp-json/` to see all available routes.

## Common Error Messages

### "rest_forbidden" / "rest_cannot_edit"
User lacks capability. Ensure user role has `edit_posts` permission.

### "rest_invalid_json"
Invalid JSON in request body. Check for trailing commas, unquoted keys.

### "rest_post_invalid_id"
Post ID doesn't exist or user cannot access.

### "rest_invalid_param"
Parameter validation failed. Check error details in response.

### "rest_upload_invalid_file_type"
File type not allowed. Check WordPress allowed mime types.

## WordPress.com Specific Issues

### OAuth2 authentication required
Register application at [WordPress.com Developer](https://developer.wordpress.com/apps/).

### Rate limiting
WordPress.com enforces rate limits (typically 120 requests/minute). Implement retry with exponential backoff.

### Different endpoint structure
Use `https://public-api.wordpress.com/wp/v2/sites/{site_id}/` as base URL.

## Getting Help

1. **WordPress REST API Handbook**: https://developer.wordpress.org/rest-api/
2. **Gutenberg Handbook**: https://developer.wordpress.org/block-editor/
3. **Stack Overflow**: Tag with `wordpress-rest-api`
4. **WordPress Support Forums**: https://wordpress.org/support/

path: references/api_reference.md
content: # WordPress REST API Reference

Complete reference for WordPress REST API endpoints relevant to content creation and management.

## Base URL

```
https://your-site.com/wp-json/wp/v2
```

## Authentication Methods

### Application Passwords (WordPress 5.6+)
- Generate at: `/wp-admin/admin.php?page=application-passwords`
- Format: `username:password` (password is space-separated words)
- Use with HTTP Basic Auth

### JWT Authentication
- Requires "JWT Authentication for WP REST API" plugin
- Get token: `POST /jwt-auth/v1/token`
- Use token: `Authorization: Bearer <token>`

### Cookie Authentication (for plugins/themes)
- Requires `wpnonce` and logged-in session
- Not recommended for external applications

## Core Endpoints

### Posts

**Create post**
```
POST /posts
```
Body:
```json
{
  "title": "Post Title",
  "content": "Gutenberg block HTML",
  "excerpt": "Short excerpt",
  "status": "draft|publish|pending|private",
  "format": "standard|aside|chat|gallery|link|image|quote|status|video|audio",
  "categories": [1, 2],
  "tags": [3, 4],
  "featured_media": 123,
  "meta": {"custom_field": "value"}
}
```

**Get post**
```
GET /posts/{id}
```

**Update post**
```
POST /posts/{id}
```
Supports partial updates (only include fields to change).

**Delete post**
```
DELETE /posts/{id}
```
Add `?force=true` to bypass trash.

**List posts**
```
GET /posts
```
Parameters:
- `page`, `per_page` (default 10, max 100)
- `search` - search in title and content
- `after`, `before` - date filtering
- `categories`, `tags` - filter by taxonomy
- `status` - filter by status

### Media

**Upload media**
```
POST /media
```
Content-Type: `multipart/form-data`
Form fields:
- `file` - binary file
- `title`, `caption`, `alt_text`, `description`
- `post` - attach to post ID

**Get media**
```
GET /media/{id}
```

**Update media**
```
POST /media/{id}
```

### Categories

**List categories**
```
GET /categories
```
Parameters: `search`, `page`, `per_page`

**Create category**
```
POST /categories
```
Body:
```json
{
  "name": "Category Name",
  "slug": "category-slug",
  "description": "Category description",
  "parent": 0
}
```

### Tags

Similar to categories, endpoint: `/tags`

### Users

**Get current user**
```
GET /users/me
```

**List users**
```
GET /users
```

## Gutenberg-Specific Considerations

### Block Validation
WordPress validates blocks on save. Invalid blocks may be stripped. Ensure:
- Block comments are properly closed
- Attribute JSON is valid
- Inner HTML matches block expectations

### Block Serialization
The REST API expects the same block format used in the database:
- Block comments with attributes
- No extra whitespace inside comments (though WordPress is tolerant)
- Nested blocks must be properly indented

### Raw vs Rendered Content
- `content.raw` - The stored block HTML
- `content.rendered` - HTML rendered for display
- Use `content.raw` when updating

## Custom Post Types

If using custom post types with REST API enabled:
```
GET /{namespace}/v1/{post_type}
```
Check `GET /` for available routes.

## Taxonomies

**Get registered taxonomies**
```
GET /taxonomies
```

**Get taxonomy terms**
```
GET /{taxonomy}  # e.g., /categories, /tags
```

## Meta Fields

**Register meta fields** via `register_meta` or ACF.

**Update post meta** via `meta` field in post update:
```json
{
  "meta": {
    "my_field": "value"
  }
}
```

## Error Responses

Common HTTP status codes:

- `200` - Success (GET)
- `201` - Created (POST)
- `400` - Bad request (invalid data)
- `401` - Authentication failed
- `403` - Permission denied
- `404` - Resource not found
- `500` - Server error

Error response format:
```json
{
  "code": "rest_invalid_param",
  "message": "Invalid parameter(s): status",
  "data": {
    "status": 400,
    "params": {
      "status": "status is not one of draft, publish, pending, private, future."
    }
  }
}
```

## Rate Limiting

WordPress doesn't enforce rate limiting by default. However:
- Hosting providers may impose limits
- Consider adding delays between requests (1-2 seconds)
- Batch operations when possible

## Performance Tips

1. **Use `_fields` parameter** to reduce response size:
   ```
   GET /posts?_fields=id,title,link
   ```

2. **Disable `_embed`** unless needed:
   ```
   GET /posts?_embed=false
   ```

3. **Cache responses** when appropriate

4. **Use pagination** for large collections

## WordPress.com vs Self-Hosted

- WordPress.com uses different authentication (OAuth2)
- API base: `https://public-api.wordpress.com/wp/v2/sites/{site_id}/`
- Requires application registration

## Testing with curl

```bash
# Create post with Application Password
curl -X POST https://site.com/wp-json/wp/v2/posts \
  -u "username:xxxx xxxx xxxx xxxx xxxx xxxx" \
  -H "Content-Type: application/json" \
  -d '{"title":"Test","content":"<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->","status":"draft"}'

# Get posts
curl -u "username:password" https://site.com/wp-json/wp/v2/posts

# Upload image
curl -X POST https://site.com/wp-json/wp/v2/media \
  -u "username:password" \
  -H "Content-Disposition: attachment; filename=test.jpg" \
  -H "Content-Type: image/jpeg" \
  --data-binary @test.jpg
```


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)