
Publish X Article
- 412 installs
- 130 repo stars
- Updated June 19, 2026
- sugarforever/01coder-agent-skills
publish-x-article is an agent skill that drafts and publishes long-form X articles for developers who need to share technical write-ups, release notes, or product updates on the X platform.
About
publish-x-article is an agent skill from sugarforever/01coder-agent-skills that guides AI coding agents through formatting and publishing long-form articles on X (formerly Twitter). The skill encodes a repeatable workflow for turning markdown drafts, release summaries, or technical posts into X article posts without manual copy-paste across tabs. Developers reach for publish-x-article when a feature ships, a blog post is ready, or a changelog needs public distribution on X. The skill fits teams that treat X as a primary channel for developer relations, open-source announcements, and technical storytelling alongside their codebase.
- publish-x-article
Publish X Article by the numbers
- 412 all-time installs (skills.sh)
- +15 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,024 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sugarforever/01coder-agent-skills --skill publish-x-articleAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 412 |
|---|---|
| repo stars | ★ 130 |
| Last updated | June 19, 2026 |
| Repository | sugarforever/01coder-agent-skills ↗ |
How do you publish a long-form article on X?
Use publish-x-article for development tasks
Who is it for?
Developers and technical writers who regularly publish long-form posts on X after shipping features or writing technical content.
Skip if: Teams that only need short tweets, threads, or LinkedIn posts rather than X long-form articles.
When should I use this skill?
The user asks to publish, format, or post a long-form article, blog draft, or release write-up on X.
What you get
Formatted X article draft, published post link, and distribution-ready social copy.
- formatted X article
- published post link
Files
Publish X Article
Publish Markdown content to X (Twitter) Articles editor, preserving formatting with rich text conversion. Automatically handles X Premium limitations by converting unsupported elements to images.
Credits
This skill is inspired by and based on wshuyi/x-article-publisher-skill. Thank you to the original author for the foundational work.
Interactive Setup: Ask Subscription Type
IMPORTANT: Before processing the article, ask the user about their X subscription type if not already known.
Prompt the User
Before publishing, I need to know your X subscription type to handle formatting correctly:
1. **X Premium** - Basic tier ($8/month)
2. **X Premium+** - Plus tier ($16/month)
Which subscription do you have? (Premium / Premium+)For Chinese users:
在发布之前,我需要了解您的 X 订阅类型以正确处理格式:
1. **X Premium** - 基础版 ($8/月)
2. **X Premium+** - 高级版 ($16/月)
您使用的是哪个版本?(Premium / Premium+)Remember the Answer
Once the user answers, remember their subscription type for the rest of the session. Don't ask again unless they explicitly want to change it.
X Subscription Feature Comparison
| Feature | X Premium | X Premium+ |
|---|---|---|
H1 headers (#) | Title only | Title only |
H2 headers (##) | Yes | Yes |
H3+ headers (###, etc.) | No | Yes |
| Markdown tables | No | Yes |
| Mermaid diagrams | No | No (not supported by X) |
| Code blocks | Blockquotes | Blockquotes |
| Bold, italic, links | Yes | Yes |
| Lists | Yes | Yes |
| Blockquotes | Yes | Yes |
| Images | Yes | Yes |
Pre-Processing Required by Subscription
X Premium (Basic):
- Convert H3+ headers → H2 or bold
- Convert tables → PNG images
- Convert mermaid → PNG images
X Premium+ (Plus):
- Keep H3+ headers as-is
- Keep tables as-is (rendered natively)
- Convert mermaid → PNG images (still not supported)
Prerequisites
- Playwright MCP for browser automation
- User logged into X with Premium subscription
- Python 3.9+ with dependencies:
- macOS:
pip install Pillow pyobjc-framework-Cocoa markdown - Windows:
pip install Pillow pywin32 clip-util markdown - diagram-to-image skill (for converting tables/mermaid to PNG via diagramless.xyz API)
Scripts
Located in ~/.claude/skills/publish-x-article/scripts/:
parse_markdown.py
Parse Markdown and extract structured data:
python parse_markdown.py <markdown_file> [--output json|html] [--html-only]Returns JSON with: title, cover_image, content_images (with block_index for positioning), html, total_blocks
copy_to_clipboard.py
Copy image or HTML to system clipboard:
# Copy image (with optional compression)
python copy_to_clipboard.py image /path/to/image.jpg [--quality 80]
# Copy HTML for rich text paste
python copy_to_clipboard.py html --file /path/to/content.htmldiagram-to-image.mjs (from diagram-to-image skill)
Convert Mermaid diagrams and Markdown tables to PNG images via diagramless.xyz API:
node ~/.claude/skills/diagram-to-image/scripts/diagram-to-image.mjs /tmp/table.md -o /tmp/table.png
node ~/.claude/skills/diagram-to-image/scripts/diagram-to-image.mjs /tmp/diagram.mmd -o /tmp/diagram.png --theme oceanPre-Processing: Handle Unsupported Elements
Before publishing, scan the Markdown for unsupported elements and convert them to images.
Step 0: Analyze Content for Limitations
# Check the markdown file for unsupported elements
cat /path/to/article.mdLook for: 1. Deep headers (H3+): ###, ####, etc. 2. Markdown tables: Lines with | characters forming table structure 3. Mermaid code blocks: `mermaid
Converting Unsupported Elements to Images
1. Markdown Tables → PNG
When a table is detected:
# 1. Extract table to temp file
cat > /tmp/table.md << 'TABLE_EOF'
| Column 1 | Column 2 | Column 3 |
|----------|----------|----------|
| Data 1 | Data 2 | Data 3 |
TABLE_EOF
# 2. Convert to image via diagramless.xyz API
node ~/.claude/skills/diagram-to-image/scripts/diagram-to-image.mjs /tmp/table.md -o /tmp/table-001.png
# 3. Replace table in markdown with image reference
# 2. Mermaid Diagrams → PNG
When a mermaid block is detected:
# 1. Extract mermaid to temp file
cat > /tmp/diagram.mmd << 'MERMAID_EOF'
flowchart TD
A[Start] --> B[Process]
B --> C[End]
MERMAID_EOF
# 2. Convert to image via diagramless.xyz API
node ~/.claude/skills/diagram-to-image/scripts/diagram-to-image.mjs /tmp/diagram.mmd -o /tmp/diagram-001.png
# 3. Replace mermaid block in markdown with image reference
# 3. Deep Headers (H3+) → Simplified Structure
Goal: Preserve the article's logical structure and readability while working within X Premium's H2-only limitation.
Guidelines for the AI:
When you encounter H3, H4, or deeper headers in a Premium user's article, think about what the author intended:
- If the header introduces a distinct subtopic under a section, convert it to bold text as a paragraph opener. This maintains visual hierarchy without breaking X's formatting.
- If the header is a major section that happens to be H3, consider promoting it to H2 — but only if this doesn't create a flat, meaningless structure. The article should still flow logically.
- If there's a deep hierarchy (H2 → H3 → H4), flatten thoughtfully:
- Keep H2 as H2
- Convert H3 to bold paragraph
- Convert H4 to italic or just merge into the paragraph naturally
- Preserve meaning over structure. A header like
### Why This Mattersmight become a bold lead-in:**Why does this matter?**followed by the content. Use your judgment.
- Read the content. If an H3 header is just "Example" or "Note", it might work better as a blockquote or inline emphasis rather than a standalone bold line.
The goal is not mechanical conversion — it's creating an article that reads well on X while honoring the author's intent.
Pre-Processing Workflow
Before publishing, read through the article and prepare it for X:
1. Understand the article's structure — Read it first. What are the main sections? How does the author use headers to organize ideas?
2. Handle tables and mermaid diagrams — These need to become images. Extract each one, convert to PNG, and note where they should be inserted.
3. Adapt headers for the subscription tier:
- For Premium+ users: Keep headers as-is (H3+ supported)
- For Premium users: Thoughtfully restructure H3+ headers while preserving the article's flow and intent (see guidelines above)
4. Create the modified markdown — Save your adapted version to a temp file, ready for parsing.
The goal is an article that reads naturally on X, not a mechanically transformed document.
Main Workflow
Strategy: "先文后图后分割线" (Text First, Images Second, Dividers Last)
For articles with images and dividers, paste ALL text content first, then insert images and dividers at correct positions using block index.
1. Pre-process: Convert tables/mermaid to images, flatten deep headers 2. Parse modified Markdown with Python script → get title, images, dividers (all with block_index), HTML 3. Navigate to X Articles editor 4. Upload cover image (first image) 5. Fill title 6. Copy HTML to clipboard (Python) → Paste with Cmd+V 7. Insert content images at positions specified by block_index 8. Insert dividers at positions specified by block_index (via Insert > Divider menu) 9. Save as draft (NEVER auto-publish)
高效执行原则 (Efficiency Guidelines)
目标: 最小化操作之间的等待时间,实现流畅的自动化体验。
1. 避免不必要的 browser_snapshot
大多数浏览器操作(click, type, press_key 等)都会在返回结果中包含页面状态。不要在每次操作后单独调用 browser_snapshot,直接使用操作返回的页面状态即可。
❌ 错误做法:
browser_click → browser_snapshot → 分析 → browser_click → browser_snapshot → ...
✅ 正确做法:
browser_click → 从返回结果中获取页面状态 → browser_click → ...2. 避免不必要的 browser_wait_for
只在以下情况使用 browser_wait_for:
- 等待图片上传完成(
textGone="正在上传媒体") - 等待页面初始加载(极少数情况)
不要使用 browser_wait_for 来等待按钮或输入框出现 - 它们在页面加载完成后立即可用。
3. 并行执行独立操作
当两个操作没有依赖关系时,可以在同一个消息中并行调用多个工具:
✅ 可以并行:
- 填写标题 (browser_type) + 复制HTML到剪贴板 (Bash)
- 解析Markdown生成JSON + 生成HTML文件
❌ 不能并行(有依赖):
- 必须先点击create才能上传封面图
- 必须先粘贴内容才能插入图片4. 连续执行浏览器操作
每个浏览器操作返回的页面状态包含所有需要的元素引用。直接使用这些引用进行下一步操作:
# 理想流程(每步直接执行,不额外等待):
browser_navigate → 从返回状态找create按钮 → browser_click(create)
→ 从返回状态找上传按钮 → browser_click(上传) → browser_file_upload
→ 从返回状态找应用按钮 → browser_click(应用)
→ 从返回状态找标题框 → browser_type(标题)
→ 点击编辑器 → browser_press_key(Meta+v)
→ ...5. 准备工作前置
在开始浏览器操作之前,先完成所有准备工作: 1. 扫描不支持的元素(表格、Mermaid、深层标题) 2. 转换表格/Mermaid 为图片 3. 解析 Markdown 获取 JSON 数据 4. 生成 HTML 文件到 /tmp/ 5. 记录 title、cover_image、content_images 等信息
这样浏览器操作阶段可以连续执行,不需要中途停下来处理数据。
Step 0: Read and Adapt the Article
First, read the article. Understand what it's about, how it's structured, and what the author is trying to communicate.
Preserve the Original
IMPORTANT: Never modify the user's original file. If adaptations are needed:
1. Save the adapted version as a copy (e.g., /tmp/article_adapted.md or alongside the original as article_for_x.md) 2. Tell the user what was changed and where both files are:
I've adapted your article for X Premium. Here's what changed:
- Converted 2 tables to images
- Restructured 3 H3 headers to bold text for better flow
Original preserved: /path/to/article.md
Adapted version: /path/to/article_for_x.md3. Proceed with the adapted copy for publishing
This ensures the user can review the changes and keeps their original work intact.
Adaptation Based on Subscription
For Premium Users (Basic Tier)
Ask yourself:
- Are there tables? → Convert each to a PNG image
- Are there mermaid diagrams? → Convert each to a PNG image
- Are there H3+ headers? → Restructure them thoughtfully (see header guidelines above)
Create a modified version of the markdown that will work within Premium's limitations while preserving readability.
For Premium+ Users
Ask yourself:
- Are there mermaid diagrams? → Convert to PNG (still not supported on any tier)
- Tables and H3+ headers can stay as-is
Converting Elements to Images
# Tables → PNG (auto-detected as table)
node ~/.claude/skills/diagram-to-image/scripts/diagram-to-image.mjs /tmp/table-1.md -o /tmp/table-1.png
# Mermaid → PNG (auto-detected as mermaid)
node ~/.claude/skills/diagram-to-image/scripts/diagram-to-image.mjs /tmp/diagram-1.mmd -o /tmp/diagram-1.pngReplace these elements in the markdown with image references, positioning them where the original element was.
Step 1: Parse Markdown (Python)
Use parse_markdown.py to extract all structured data:
python ~/.claude/skills/publish-x-article/scripts/parse_markdown.py /path/to/modified_article.mdOutput JSON:
{
"title": "Article Title",
"cover_image": "/path/to/first-image.jpg",
"content_images": [
{"path": "/tmp/table-1.png", "block_index": 5, "after_text": "context..."},
{"path": "/tmp/mermaid-1.png", "block_index": 12, "after_text": "another context..."}
],
"dividers": [
{"block_index": 7, "after_text": "context before divider..."},
{"block_index": 15, "after_text": "another context..."}
],
"html": "<p>Content...</p><h2>Section</h2>...",
"total_blocks": 45
}Key fields:
block_index: The image/divider should be inserted AFTER block element at this index (0-indexed)total_blocks: Total number of block elements in the HTMLafter_text: Kept for reference/debugging only, NOT for positioningdividers: Array of divider positions (markdown---must be inserted via X's menu, not HTML<hr>)
Save HTML to temp file for clipboard:
python parse_markdown.py modified_article.md --html-only > /tmp/article_html.htmlStep 2: Open X Articles Editor
browser_navigate: https://x.com/compose/articles重要: 页面加载后会显示草稿列表,不是编辑器。需要:
1. 等待页面加载完成: 使用 browser_snapshot 检查页面状态 2. 立即点击 "create" 按钮: 不要等待 "添加标题" 等编辑器元素,它们只有点击 create 后才出现 3. 等待编辑器加载: 点击 create 后,等待编辑器元素出现
# 1. 导航到页面
browser_navigate: https://x.com/compose/articles
# 2. 获取页面快照,找到 create 按钮
browser_snapshot
# 3. 点击 create 按钮(通常 ref 类似 "create" 或带有 create 标签)
browser_click: element="create button", ref=<create_button_ref>
# 4. 现在编辑器应该打开了,可以继续上传封面图等操作注意: 不要使用 browser_wait_for text="添加标题" 来等待页面加载,因为这个文本只有在点击 create 后才出现,会导致超时。
If login needed, prompt user to log in manually.
Step 3: Upload Cover Image
1. Click "添加照片或视频" button 2. Use browser_file_upload with the cover image path (from JSON output) 3. Verify image uploaded
Step 4: Fill Title
- Find textbox with "添加标题" placeholder
- Use browser_type to input title (from JSON output)
Step 5: Paste Text Content (Python Clipboard)
Copy HTML to system clipboard using Python, then paste:
# Copy HTML to clipboard
python ~/.claude/skills/publish-x-article/scripts/copy_to_clipboard.py html --file /tmp/article_html.htmlThen in browser:
browser_click on editor textbox
browser_press_key: Meta+vThis preserves all rich text formatting (H2, bold, links, lists).
Step 6: Insert Content Images (Block Index Positioning)
关键改进: 使用 block_index 精确定位,而非依赖文字匹配。
定位原理
粘贴 HTML 后,编辑器中的内容结构为一系列块元素(段落、标题、引用等)。每张图片的 block_index 表示它应该插入在第 N 个块元素之后。
操作步骤
1. 获取所有块元素: 使用 browser_snapshot 获取编辑器内容,找到 textbox 下的所有子元素 2. 按索引定位: 根据 block_index 点击对应的块元素 3. 粘贴图片: 复制图片到剪贴板后粘贴
For each content image (from content_images array):
# 1. Copy image to clipboard (with compression)
python ~/.claude/skills/publish-x-article/scripts/copy_to_clipboard.py image /path/to/img.jpg --quality 85# 2. Click the block element at block_index
# Example: if block_index=5, click the 6th block element (0-indexed)
browser_click on the element at position block_index in the editor
# 3. Paste image
browser_press_key: Meta+v
# 4. Wait for upload (use short time, returns immediately when done)
browser_wait_for textGone="正在上传媒体" time=2反向插入
注意: 每插入一张图片后,后续图片的实际位置会偏移。建议按 block_index 从大到小的顺序插入图片。
如果有3张图片,block_index 分别为 5, 12, 27: 1. 先插入 block_index=27 的图片 2. 再插入 block_index=12 的图片 3. 最后插入 block_index=5 的图片
Step 6.5: Insert Dividers (Via Menu)
重要: Markdown 中的 --- 分割线不能通过 HTML <hr> 标签粘贴(X Articles 会忽略它)。必须通过 X Articles 的 Insert 菜单插入。
为什么需要特殊处理
X Articles 有自己的原生分割线元素,只能通过 Insert > Divider 菜单插入。HTML <hr> 标签会被完全忽略。
操作步骤
For each divider (from dividers array), in reverse order of block_index:
# 1. Click the block element at block_index position
browser_click on the element at position block_index in the editor
# 2. Open Insert menu
browser_click on "Insert" button (Add Media button)
# 3. Click Divider menu item
browser_click on "Divider" menuitem
# Divider is inserted at cursor position反向插入
和图片一样,按 block_index 从大到小的顺序插入分割线,避免位置偏移问题。
与图片的插入顺序
建议先插入所有图片,再插入所有分割线。两者都按 block_index 从大到小的顺序:
1. 插入所有图片(从最大 block_index 开始) 2. 插入所有分割线(从最大 block_index 开始)
Step 7: Save Draft
1. Verify content pasted (check word count indicator) 2. Draft auto-saves, or click Save button if needed 3. Click "预览" to verify formatting 4. Report: "Draft saved. Review and publish manually."
Critical Rules
1. NEVER publish - Only save draft 2. Pre-process first - Convert tables/mermaid/deep headers before parsing 3. First image = cover - Upload first image as cover image 4. Rich text conversion - Always convert Markdown to HTML before pasting 5. Use clipboard API - Paste via clipboard for proper formatting 6. Block index positioning - Use block_index for precise image/divider placement 7. Reverse order insertion - Insert images and dividers from highest to lowest block_index 8. H1 title handling - H1 is used as title only, not included in body 9. Dividers via menu - Markdown --- must be inserted via Insert > Divider menu (HTML <hr> is ignored)
Supported Formatting (After Pre-Processing)
| Element | Support | Notes |
|---|---|---|
H2 (##) | Native | Section headers |
Bold (**) | Native | Strong emphasis |
Italic (*) | Native | Emphasis |
Links ([](url)) | Native | Hyperlinks |
| Ordered lists | Native | 1. 2. 3. |
| Unordered lists | Native | - bullets |
Blockquotes (>) | Native | Quoted text |
| Code blocks | Converted | → Blockquotes |
| Tables | Converted | → PNG images |
| Mermaid | Converted | → PNG images |
| H3+ headers | Converted | → H2 or bold |
Dividers (---) | Menu insert | → Insert > Divider |
Example Flow
User: "Publish /path/to/article.md to X"
# Step 0: Analyze content
# Found: 1 table, 1 mermaid diagram, 2 H3 headers
# Step 0.1: Convert table to image
node ~/.claude/skills/diagram-to-image/scripts/diagram-to-image.mjs /tmp/table-1.md -o /tmp/table-1.png
# Step 0.2: Convert mermaid to image
node ~/.claude/skills/diagram-to-image/scripts/diagram-to-image.mjs /tmp/mermaid-1.mmd -o /tmp/mermaid-1.png
# Step 0.3: Create modified markdown with image refs and flattened headers
# Save to /tmp/article_modified.md
# Step 1: Parse modified markdown
python ~/.claude/skills/publish-x-article/scripts/parse_markdown.py /tmp/article_modified.md > /tmp/article.json
python ~/.claude/skills/publish-x-article/scripts/parse_markdown.py /tmp/article_modified.md --html-only > /tmp/article_html.html2. Navigate to https://x.com/compose/articles 3. Click create, upload cover image (browser_file_upload for cover only) 4. Fill title (from JSON: title) 5. Copy & paste HTML:
python ~/.claude/skills/publish-x-article/scripts/copy_to_clipboard.py html --file /tmp/article_html.htmlThen: browser_press_key Meta+v 6. For each content image (including converted table/mermaid PNGs), in reverse order of block_index:
python copy_to_clipboard.py image /path/to/img.jpg --quality 85- Click block element at
block_indexposition - browser_press_key Meta+v
- Wait until upload complete
7. Verify in preview 8. "Draft saved. Please review and publish manually."
Best Practices
为什么用 block_index 而非文字匹配?
1. 精确定位: 不依赖文字内容,即使多处文字相似也能正确定位 2. 可靠性: 索引是确定性的,不会因为文字相似而混淆 3. 调试方便: after_text 仍保留用于人工核验
为什么用 Python 而非浏览器内 JavaScript?
1. 本地处理更可靠: Python 直接操作系统剪贴板,不受浏览器沙盒限制 2. 图片压缩: 上传前压缩图片 (--quality 85),减少上传时间 3. 代码复用: 脚本固定不变,无需每次重新编写转换逻辑 4. 调试方便: 脚本可单独测试,问题易定位
等待策略
关键理解: browser_wait_for 的 textGone 参数会在文字消失时立即返回,time 只是最大等待时间,不是固定等待时间。
# 正确用法:短 time 值,条件满足立即返回
browser_wait_for textGone="正在上传媒体" time=2
# 错误用法:固定长时间等待
browser_wait_for time=5 # 无条件等待5秒,浪费时间封面图 vs 内容图
- 封面图: 使用 browser_file_upload(因为有专门的上传按钮)
- 内容图: 使用 Python 剪贴板 + 粘贴(更高效)
Troubleshooting
Table not rendering correctly
- Ensure Pillow is installed:
pip install pillow - Check table markdown syntax is valid
Mermaid conversion fails
- Check that diagramless.xyz is reachable
- Check mermaid syntax is valid
- Try with explicit type:
--type mermaid
Deep headers still showing
- Manually flatten
###→##or**bold** - Re-run pre-processing
Image upload timeout
- Compress images with
--quality 70 - Use shorter
browser_wait_for time=2
#!/usr/bin/env python3
"""
Copy image or HTML to system clipboard for X Articles publishing.
Supports:
- Image files (jpg, png, gif, webp) - copies as image data
- HTML content - copies as rich text for paste
- Optional image compression before copying
Usage:
# Copy image to clipboard
python copy_to_clipboard.py image /path/to/image.jpg
# Copy image with compression (quality 0-100)
python copy_to_clipboard.py image /path/to/image.jpg --quality 80
# Copy HTML to clipboard
python copy_to_clipboard.py html "<p>Hello</p>"
# Copy HTML from file
python copy_to_clipboard.py html --file /path/to/content.html
Requirements:
macOS: pip install Pillow pyobjc-framework-Cocoa
Windows: pip install Pillow pywin32 clip-util
"""
import argparse
import io
import os
import sys
from pathlib import Path
def compress_image(image_path: str, quality: int = 85, max_size: tuple = (2000, 2000)) -> bytes:
"""Compress image and return as bytes."""
from PIL import Image
img = Image.open(image_path)
# Convert to RGB if necessary (for JPEG)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
# Resize if too large
img.thumbnail(max_size, Image.Resampling.LANCZOS)
# Save to bytes
buffer = io.BytesIO()
img.save(buffer, format='JPEG', quality=quality, optimize=True)
return buffer.getvalue()
def copy_image_to_clipboard_macos(image_path: str, quality: int = None) -> bool:
"""Copy image to macOS clipboard using AppKit."""
try:
from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeTIFF
from Foundation import NSData
# Compress if quality specified, otherwise use original
if quality:
image_data = compress_image(image_path, quality)
else:
with open(image_path, 'rb') as f:
image_data = f.read()
# Create NSData from image bytes
ns_data = NSData.dataWithBytes_length_(image_data, len(image_data))
# Get pasteboard and clear it
pasteboard = NSPasteboard.generalPasteboard()
pasteboard.clearContents()
# Determine type based on file extension
ext = Path(image_path).suffix.lower()
if ext in ('.png',):
pasteboard.setData_forType_(ns_data, NSPasteboardTypePNG)
else:
# For JPEG and others, use TIFF (more compatible)
from PIL import Image
img = Image.open(io.BytesIO(image_data))
tiff_buffer = io.BytesIO()
img.save(tiff_buffer, format='TIFF')
tiff_data = NSData.dataWithBytes_length_(tiff_buffer.getvalue(), len(tiff_buffer.getvalue()))
pasteboard.setData_forType_(tiff_data, NSPasteboardTypeTIFF)
return True
except ImportError as e:
print(f"Error: Missing dependency: {e}", file=sys.stderr)
print("Install with: pip install Pillow pyobjc-framework-Cocoa", file=sys.stderr)
return False
except Exception as e:
print(f"Error copying image: {e}", file=sys.stderr)
return False
def copy_html_to_clipboard_macos(html: str) -> bool:
"""Copy HTML to macOS clipboard as rich text."""
try:
from AppKit import NSPasteboard, NSPasteboardTypeHTML, NSPasteboardTypeString
from Foundation import NSData
# Get pasteboard and clear it
pasteboard = NSPasteboard.generalPasteboard()
pasteboard.clearContents()
# Set HTML content
html_data = html.encode('utf-8')
ns_data = NSData.dataWithBytes_length_(html_data, len(html_data))
pasteboard.setData_forType_(ns_data, NSPasteboardTypeHTML)
# Also set plain text version
pasteboard.setString_forType_(html, NSPasteboardTypeString)
return True
except ImportError as e:
print(f"Error: Missing dependency: {e}", file=sys.stderr)
print("Install with: pip install pyobjc-framework-Cocoa", file=sys.stderr)
return False
except Exception as e:
print(f"Error copying HTML: {e}", file=sys.stderr)
return False
# ============================================================================
# Windows Implementation
# ============================================================================
def copy_image_to_clipboard_windows(image_path: str, quality: int = None) -> bool:
"""Copy image to Windows clipboard using CF_DIB format.
Uses pywin32's win32clipboard module to set image data in Device Independent
Bitmap (DIB) format, which is the standard Windows clipboard format for images.
"""
try:
import win32clipboard
from PIL import Image
# Load and optionally compress image
if quality:
image_data = compress_image(image_path, quality)
img = Image.open(io.BytesIO(image_data))
else:
img = Image.open(image_path)
# Convert to RGB (required for BMP format)
if img.mode in ('RGBA', 'P', 'LA'):
img = img.convert('RGB')
# Save as BMP to BytesIO, skip 14-byte BITMAPFILEHEADER
output = io.BytesIO()
img.save(output, format='BMP')
data = output.getvalue()[14:] # Skip BITMAPFILEHEADER
output.close()
# Copy to clipboard
win32clipboard.OpenClipboard()
try:
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardData(win32clipboard.CF_DIB, data)
finally:
win32clipboard.CloseClipboard()
return True
except ImportError as e:
print(f"Error: Missing dependency: {e}", file=sys.stderr)
print("Install with: pip install Pillow pywin32", file=sys.stderr)
return False
except Exception as e:
print(f"Error copying image: {e}", file=sys.stderr)
return False
def copy_html_to_clipboard_windows(html: str) -> bool:
"""Copy HTML to Windows clipboard using clip-util library."""
try:
from clipboard import Clipboard
with Clipboard() as clipboard:
clipboard["html"] = html
return True
except ImportError as e:
print(f"Error: Missing dependency: {e}", file=sys.stderr)
print("Install with: pip install clip-util", file=sys.stderr)
return False
except Exception as e:
print(f"Error copying HTML: {e}", file=sys.stderr)
return False
# ============================================================================
# Platform Detection and Function Selection
# ============================================================================
def copy_image_to_clipboard(image_path: str, quality: int = None) -> bool:
"""Copy image to clipboard (cross-platform)."""
if sys.platform == 'darwin':
return copy_image_to_clipboard_macos(image_path, quality)
elif sys.platform == 'win32':
return copy_image_to_clipboard_windows(image_path, quality)
else:
print(f"Error: Unsupported platform: {sys.platform}", file=sys.stderr)
return False
def copy_html_to_clipboard(html: str) -> bool:
"""Copy HTML to clipboard (cross-platform)."""
if sys.platform == 'darwin':
return copy_html_to_clipboard_macos(html)
elif sys.platform == 'win32':
return copy_html_to_clipboard_windows(html)
else:
print(f"Error: Unsupported platform: {sys.platform}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(description='Copy to clipboard for X Articles')
subparsers = parser.add_subparsers(dest='type', required=True)
# Image subcommand
img_parser = subparsers.add_parser('image', help='Copy image to clipboard')
img_parser.add_argument('path', help='Path to image file')
img_parser.add_argument('--quality', type=int, default=None,
help='JPEG quality (1-100), enables compression')
img_parser.add_argument('--max-width', type=int, default=2000,
help='Max width for resize')
img_parser.add_argument('--max-height', type=int, default=2000,
help='Max height for resize')
# HTML subcommand
html_parser = subparsers.add_parser('html', help='Copy HTML to clipboard')
html_parser.add_argument('content', nargs='?', help='HTML content')
html_parser.add_argument('--file', '-f', help='Read HTML from file')
args = parser.parse_args()
if args.type == 'image':
if not os.path.exists(args.path):
print(f"Error: Image not found: {args.path}", file=sys.stderr)
sys.exit(1)
success = copy_image_to_clipboard(args.path, args.quality)
if success:
print(f"Image copied to clipboard: {args.path}")
if args.quality:
print(f" (compressed with quality={args.quality})")
sys.exit(0 if success else 1)
elif args.type == 'html':
if args.file:
if not os.path.exists(args.file):
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(1)
with open(args.file, 'r', encoding='utf-8') as f:
html = f.read()
elif args.content:
html = args.content
else:
# Read from stdin
html = sys.stdin.read()
success = copy_html_to_clipboard(html)
if success:
print(f"HTML copied to clipboard ({len(html)} chars)")
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
Parse Markdown for X Articles publishing.
Extracts:
- Title (from first H1/H2 or first line)
- Cover image (first image)
- Content images with block index for precise positioning
- Dividers (---) with block index for precise positioning
- HTML content (images and dividers stripped)
Usage:
python parse_markdown.py <markdown_file> [--output json|html]
Output (JSON):
{
"title": "Article Title",
"cover_image": "/path/to/cover.jpg",
"content_images": [
{"path": "/path/to/img.jpg", "block_index": 3, "after_text": "context..."},
...
],
"dividers": [
{"block_index": 7, "after_text": "context..."},
...
],
"html": "<p>Content...</p><h2>Section</h2>...",
"total_blocks": 25
}
The block_index indicates which block element (0-indexed) the image/divider should follow.
This allows precise positioning without relying on text matching.
Note: Dividers must be inserted via X Articles' Insert > Divider menu, not HTML <hr> tags.
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
def split_into_blocks(markdown: str) -> list[str]:
"""Split markdown into logical blocks (paragraphs, headers, quotes, code blocks, etc.)."""
blocks = []
current_block = []
in_code_block = False
code_block_lines = []
lines = markdown.split('\n')
for line in lines:
stripped = line.strip()
# Handle code block boundaries
if stripped.startswith('```'):
if in_code_block:
# End of code block
in_code_block = False
if code_block_lines:
# Mark as code block with special prefix for later processing
# Use ___CODE_BLOCK_START___ and ___CODE_BLOCK_END___ to preserve content
blocks.append('___CODE_BLOCK_START___' + '\n'.join(code_block_lines) + '___CODE_BLOCK_END___')
code_block_lines = []
else:
# Start of code block
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
in_code_block = True
continue
# If inside code block, collect ALL lines (including empty lines)
if in_code_block:
code_block_lines.append(line)
continue
# Empty line signals end of block
if not stripped:
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
continue
# Horizontal rule (divider) is its own block
if re.match(r'^---+$', stripped):
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
blocks.append('___DIVIDER___')
continue
# Headers, blockquotes are their own blocks
if stripped.startswith(('#', '>')):
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
blocks.append(stripped)
continue
# Image on its own line is its own block
if re.match(r'^!\[.*\]\(.*\)$', stripped):
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
blocks.append(stripped)
continue
current_block.append(line)
if current_block:
blocks.append('\n'.join(current_block))
# Handle unclosed code block
if code_block_lines:
blocks.append('___CODE_BLOCK_START___' + '\n'.join(code_block_lines) + '___CODE_BLOCK_END___')
return blocks
def extract_images_and_dividers(markdown: str, base_path: Path) -> tuple[list[dict], list[dict], str, int]:
"""Extract images and dividers with their block index positions.
Returns:
(image_list, divider_list, markdown_without_images_and_dividers, total_blocks)
"""
blocks = split_into_blocks(markdown)
images = []
dividers = []
clean_blocks = []
img_pattern = re.compile(r'^!\[([^\]]*)\]\(([^)]+)\)$')
for i, block in enumerate(blocks):
block_stripped = block.strip()
# Check for divider
if block_stripped == '___DIVIDER___':
# block_index is the index in clean_blocks (without images/dividers)
block_index = len(clean_blocks)
# Get context from previous block for reference
after_text = ""
if clean_blocks:
prev_block = clean_blocks[-1].strip()
lines = [l for l in prev_block.split('\n') if l.strip()]
after_text = lines[-1][:80] if lines else ""
dividers.append({
"block_index": block_index,
"after_text": after_text # Keep for reference/debugging
})
continue
# Check for image
match = img_pattern.match(block_stripped)
if match:
alt_text = match.group(1)
img_path = match.group(2)
# Resolve relative paths
if not os.path.isabs(img_path):
full_path = str(base_path / img_path)
else:
full_path = img_path
# block_index is the index in clean_blocks (without images/dividers)
block_index = len(clean_blocks)
# Get context from previous block for reference
after_text = ""
if clean_blocks:
prev_block = clean_blocks[-1].strip()
lines = [l for l in prev_block.split('\n') if l.strip()]
after_text = lines[-1][:80] if lines else ""
images.append({
"path": full_path,
"alt": alt_text,
"block_index": block_index,
"after_text": after_text # Keep for reference/debugging
})
continue
# Regular block
clean_blocks.append(block)
clean_markdown = '\n\n'.join(clean_blocks)
return images, dividers, clean_markdown, len(clean_blocks)
def extract_title(markdown: str) -> tuple[str, str]:
"""Extract title from first H1, H2, or first non-empty line.
Returns:
(title, markdown_without_title): Title string and markdown with H1 title removed.
If title is from H1, it's removed from markdown to avoid duplication.
"""
lines = markdown.strip().split('\n')
title = "Untitled"
title_line_idx = None
for idx, line in enumerate(lines):
stripped = line.strip()
if not stripped:
continue
# H1 - use as title and mark for removal
if stripped.startswith('# '):
title = stripped[2:].strip()
title_line_idx = idx
break
# H2 - use as title but don't remove (it's a section header)
if stripped.startswith('## '):
title = stripped[3:].strip()
break
# First non-empty, non-image line
if not stripped.startswith('!['):
title = stripped[:100]
break
# Remove H1 title line from markdown to avoid duplication
if title_line_idx is not None:
lines.pop(title_line_idx)
markdown = '\n'.join(lines)
return title, markdown
def markdown_to_html(markdown: str) -> str:
"""Convert markdown to HTML for X Articles rich text paste."""
html = markdown
# Process code blocks first (marked with ___CODE_BLOCK_START___ and ___CODE_BLOCK_END___)
# Convert to blockquote format since X Articles doesn't support <pre><code>
def convert_code_block(match):
code_content = match.group(1)
lines = code_content.strip().split('\n')
# Join non-empty lines with <br> for display
formatted = '<br>'.join(line for line in lines if line.strip())
return f'<blockquote>{formatted}</blockquote>'
html = re.sub(r'___CODE_BLOCK_START___(.*?)___CODE_BLOCK_END___', convert_code_block, html, flags=re.DOTALL)
# Note: Dividers (---) are extracted separately and inserted via X Articles menu
# They are NOT converted to <hr> tags since X Articles strips them
# Headers (H2 only, H1 is title)
html = re.sub(r'^## (.+)$', r'<h2>\1</h2>', html, flags=re.MULTILINE)
html = re.sub(r'^### (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
# Bold
html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
# Italic
html = re.sub(r'\*([^*]+)\*', r'<em>\1</em>', html)
# Links
html = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', html)
# Blockquotes (regular markdown blockquotes, not code blocks)
html = re.sub(r'^> (.+)$', r'<blockquote>\1</blockquote>', html, flags=re.MULTILINE)
# Unordered lists
html = re.sub(r'^- (.+)$', r'<li>\1</li>', html, flags=re.MULTILINE)
# Ordered lists
html = re.sub(r'^\d+\. (.+)$', r'<li>\1</li>', html, flags=re.MULTILINE)
# Wrap consecutive <li> in <ul>
html = re.sub(r'((?:<li>.*?</li>\n?)+)', r'<ul>\1</ul>', html)
# Paragraphs - split by double newlines
parts = html.split('\n\n')
processed_parts = []
for part in parts:
part = part.strip()
if not part:
continue
# Skip if already a block element
if part.startswith(('<h2>', '<h3>', '<blockquote>', '<ul>', '<ol>')):
processed_parts.append(part)
else:
# Wrap in paragraph, convert single newlines to <br>
part = part.replace('\n', '<br>')
processed_parts.append(f'<p>{part}</p>')
return ''.join(processed_parts)
def parse_markdown_file(filepath: str) -> dict:
"""Parse a markdown file and return structured data."""
path = Path(filepath)
base_path = path.parent
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Skip YAML frontmatter if present
if content.startswith('---'):
end_marker = content.find('---', 3)
if end_marker != -1:
content = content[end_marker + 3:].strip()
# Extract title first (and remove H1 from markdown)
title, content = extract_title(content)
# Extract images and dividers with block indices
images, dividers, clean_markdown, total_blocks = extract_images_and_dividers(content, base_path)
# Convert to HTML
html = markdown_to_html(clean_markdown)
# Separate cover image from content images
cover_image = images[0]["path"] if images else None
content_images = images[1:] if len(images) > 1 else []
return {
"title": title,
"cover_image": cover_image,
"content_images": content_images,
"dividers": dividers,
"html": html,
"total_blocks": total_blocks,
"source_file": str(path.absolute())
}
def main():
parser = argparse.ArgumentParser(description='Parse Markdown for X Articles')
parser.add_argument('file', help='Markdown file to parse')
parser.add_argument('--output', choices=['json', 'html'], default='json',
help='Output format (default: json)')
parser.add_argument('--html-only', action='store_true',
help='Output only HTML content')
args = parser.parse_args()
if not os.path.exists(args.file):
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(1)
result = parse_markdown_file(args.file)
if args.html_only:
print(result['html'])
elif args.output == 'json':
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(result['html'])
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
Convert Markdown table to PNG image.
Usage: python3 table_to_image.py <input.md> <output.png> [--scale 2]
"""
import sys
import re
from pathlib import Path
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("Error: Pillow not installed. Run: pip install pillow")
sys.exit(1)
def parse_markdown_table(content: str) -> tuple[list[str], list[list[str]], list[str]]:
"""Parse markdown table into headers, rows, and alignments."""
lines = [line.strip() for line in content.strip().split('\n') if line.strip()]
if len(lines) < 2:
raise ValueError("Table must have at least 2 rows")
def parse_cells(line: str) -> list[str]:
line = line.strip()
if line.startswith('|'):
line = line[1:]
if line.endswith('|'):
line = line[:-1]
return [cell.strip() for cell in line.split('|')]
# Find separator line
separator_idx = -1
for i, line in enumerate(lines):
if re.match(r'^\|?[\s]*:?-{2,}:?[\s]*(\|[\s]*:?-{2,}:?[\s]*)*\|?$', line):
separator_idx = i
break
if separator_idx > 0:
# Has headers
headers = parse_cells(lines[separator_idx - 1])
sep_cells = parse_cells(lines[separator_idx])
alignments = []
for cell in sep_cells:
cell = cell.strip()
if cell.startswith(':') and cell.endswith(':'):
alignments.append('center')
elif cell.endswith(':'):
alignments.append('right')
else:
alignments.append('left')
rows = [parse_cells(line) for line in lines[separator_idx + 1:]]
else:
# No headers - all data rows
headers = []
rows = [parse_cells(line) for line in lines]
alignments = ['left'] * (len(rows[0]) if rows else 0)
return headers, rows, alignments
def get_font(size: int, bold: bool = False):
"""Get a font, falling back to default if system fonts unavailable."""
font_paths = [
# macOS
"/System/Library/Fonts/SFNSMono.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/Library/Fonts/Arial.ttf",
# Linux
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
# Windows
"C:/Windows/Fonts/arial.ttf",
]
for path in font_paths:
try:
return ImageFont.truetype(path, size)
except (OSError, IOError):
continue
# Fallback to default
return ImageFont.load_default()
def render_table_to_image(
headers: list[str],
rows: list[list[str]],
alignments: list[str],
scale: int = 2
) -> Image.Image:
"""Render table data to a PIL Image."""
# Configuration
base_font_size = 14
font_size = base_font_size * scale
padding_x = 16 * scale
padding_y = 12 * scale
border_radius = 8 * scale
margin = 20 * scale
# Colors
bg_color = (255, 255, 255)
header_bg = (249, 250, 251)
text_color = (55, 65, 81)
header_text_color = (17, 24, 39)
border_color = (229, 231, 235)
# Fonts
regular_font = get_font(font_size)
bold_font = get_font(font_size, bold=True)
# Calculate column widths
col_count = len(headers) if headers else (len(rows[0]) if rows else 0)
col_widths = [0] * col_count
# Create temp image for text measurement
temp_img = Image.new('RGB', (1, 1))
temp_draw = ImageDraw.Draw(temp_img)
# Measure headers
for i, header in enumerate(headers):
bbox = temp_draw.textbbox((0, 0), header, font=bold_font)
col_widths[i] = max(col_widths[i], bbox[2] - bbox[0])
# Measure data cells
for row in rows:
for i, cell in enumerate(row):
if i < col_count:
bbox = temp_draw.textbbox((0, 0), cell, font=regular_font)
col_widths[i] = max(col_widths[i], bbox[2] - bbox[0])
# Add padding to widths
col_widths = [w + padding_x * 2 for w in col_widths]
# Calculate dimensions
row_height = font_size + padding_y * 2
header_height = row_height if headers else 0
table_width = sum(col_widths)
table_height = header_height + len(rows) * row_height
img_width = table_width + margin * 2
img_height = table_height + margin * 2
# Create image
img = Image.new('RGB', (img_width, img_height), bg_color)
draw = ImageDraw.Draw(img)
# Draw table border (rounded rectangle)
x0, y0 = margin, margin
x1, y1 = margin + table_width, margin + table_height
draw.rounded_rectangle([x0, y0, x1, y1], radius=border_radius, outline=border_color, width=scale)
y = margin
# Draw header
if headers:
# Header background
draw.rounded_rectangle(
[x0, y0, x1, y0 + row_height],
radius=border_radius,
fill=header_bg
)
# Cover bottom corners of header (they should be square)
draw.rectangle([x0, y0 + row_height - border_radius, x1, y0 + row_height], fill=header_bg)
# Header border
draw.line([(x0, y + row_height), (x1, y + row_height)], fill=border_color, width=scale)
# Header text
x = margin
for i, header in enumerate(headers):
bbox = draw.textbbox((0, 0), header, font=bold_font)
text_width = bbox[2] - bbox[0]
if alignments[i] == 'center':
text_x = x + (col_widths[i] - text_width) // 2
elif alignments[i] == 'right':
text_x = x + col_widths[i] - text_width - padding_x
else:
text_x = x + padding_x
text_y = y + padding_y
draw.text((text_x, text_y), header, fill=header_text_color, font=bold_font)
x += col_widths[i]
y += row_height
# Draw data rows
for row_idx, row in enumerate(rows):
# Row border (except last row)
if row_idx < len(rows) - 1:
draw.line([(x0, y + row_height), (x1, y + row_height)], fill=border_color, width=scale)
# Cell text
x = margin
for i, cell in enumerate(row):
if i >= col_count:
break
bbox = draw.textbbox((0, 0), cell, font=regular_font)
text_width = bbox[2] - bbox[0]
if i < len(alignments):
if alignments[i] == 'center':
text_x = x + (col_widths[i] - text_width) // 2
elif alignments[i] == 'right':
text_x = x + col_widths[i] - text_width - padding_x
else:
text_x = x + padding_x
else:
text_x = x + padding_x
text_y = y + padding_y
draw.text((text_x, text_y), cell, fill=text_color, font=regular_font)
x += col_widths[i]
y += row_height
return img
def main():
if len(sys.argv) < 3:
print("Usage: python3 table_to_image.py <input.md> <output.png> [--scale N]")
sys.exit(1)
input_path = sys.argv[1]
output_path = sys.argv[2]
# Parse optional scale argument
scale = 2
if '--scale' in sys.argv:
scale_idx = sys.argv.index('--scale')
if scale_idx + 1 < len(sys.argv):
try:
scale = int(sys.argv[scale_idx + 1])
except ValueError:
pass
# Read input
content = Path(input_path).read_text()
# Parse table
try:
headers, rows, alignments = parse_markdown_table(content)
except Exception as e:
print(f"Error parsing table: {e}")
sys.exit(1)
if not rows:
print("Error: No data rows found in table")
sys.exit(1)
# Render image
img = render_table_to_image(headers, rows, alignments, scale)
# Save
output = Path(output_path)
img.save(output, 'PNG')
print(f"Saved: {output} ({img.width}x{img.height})")
if __name__ == '__main__':
main()
Related skills
How it compares
Choose publish-x-article over generic social-post skills when the deliverable is an X long-form article rather than a short tweet or thread.
FAQ
What does publish-x-article do?
publish-x-article is an agent skill that helps developers format and publish long-form articles on X. It guides agents through turning markdown drafts or release summaries into X article posts for technical and product announcements.
When should I use publish-x-article?
Use publish-x-article when a blog draft, changelog, or technical write-up is ready and needs distribution as an X long-form article. The skill fits post-ship announcements and developer relations content workflows.