Complete implementation of OurDigital skills with dual-platform support (Claude Desktop + Claude Code) following standardized structure. Skills created: - 01-ourdigital-brand-guide: Brand reference & style guidelines - 02-ourdigital-blog: Korean blog drafts (blog.ourdigital.org) - 03-ourdigital-journal: English essays (journal.ourdigital.org) - 04-ourdigital-research: Research prompts & workflows - 05-ourdigital-document: Notion-to-presentation pipeline - 06-ourdigital-designer: Visual/image prompt generation - 07-ourdigital-ad-manager: Ad copywriting & keyword research - 08-ourdigital-trainer: Training materials & workshop planning - 09-ourdigital-backoffice: Quotes, proposals, cost analysis - 10-ourdigital-skill-creator: Meta skill for creating new skills Features: - YAML frontmatter with "ourdigital" or "our" prefix triggers - Standardized directory structure (code/, desktop/, shared/, docs/) - Shared environment setup (_ourdigital-shared/) - Comprehensive reference documentation - Cross-skill integration support Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
167 lines
4.7 KiB
Python
167 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Ghost CMS Publisher for OurDigital Blog
|
|
|
|
Publishes markdown content to Ghost CMS via Admin API.
|
|
|
|
Usage:
|
|
python ghost_publish.py --file post.md --draft
|
|
python ghost_publish.py --file post.md --publish
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import jwt
|
|
import time
|
|
import requests
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from dotenv import load_dotenv
|
|
|
|
# Load environment variables
|
|
load_dotenv(os.path.expanduser("~/.env.ourdigital"))
|
|
|
|
|
|
def create_ghost_token(admin_key: str) -> str:
|
|
"""Create JWT token for Ghost Admin API."""
|
|
key_id, secret = admin_key.split(":")
|
|
|
|
iat = int(time.time())
|
|
header = {"alg": "HS256", "typ": "JWT", "kid": key_id}
|
|
payload = {
|
|
"iat": iat,
|
|
"exp": iat + 5 * 60, # 5 minutes
|
|
"aud": "/admin/"
|
|
}
|
|
|
|
token = jwt.encode(payload, bytes.fromhex(secret), algorithm="HS256", headers=header)
|
|
return token
|
|
|
|
|
|
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
|
"""Parse YAML frontmatter from markdown content."""
|
|
import yaml
|
|
|
|
pattern = r'^---\s*\n(.*?)\n---\s*\n(.*)$'
|
|
match = re.match(pattern, content, re.DOTALL)
|
|
|
|
if match:
|
|
frontmatter = yaml.safe_load(match.group(1))
|
|
body = match.group(2)
|
|
return frontmatter, body
|
|
|
|
return {}, content
|
|
|
|
|
|
def publish_to_ghost(
|
|
file_path: str,
|
|
ghost_url: str,
|
|
admin_key: str,
|
|
draft: bool = True
|
|
) -> dict:
|
|
"""Publish markdown file to Ghost CMS."""
|
|
|
|
# Read and parse file
|
|
content = Path(file_path).read_text(encoding="utf-8")
|
|
frontmatter, body = parse_frontmatter(content)
|
|
|
|
# Create JWT token
|
|
token = create_ghost_token(admin_key)
|
|
|
|
# Prepare post data
|
|
post_data = {
|
|
"posts": [{
|
|
"title": frontmatter.get("title", "Untitled"),
|
|
"slug": frontmatter.get("slug"),
|
|
"html": markdown_to_html(body),
|
|
"meta_description": frontmatter.get("meta_description", ""),
|
|
"tags": [{"name": tag} for tag in frontmatter.get("tags", [])],
|
|
"status": "draft" if draft else "published",
|
|
"authors": [{"email": "andrew.yim@ourdigital.org"}]
|
|
}]
|
|
}
|
|
|
|
# Add feature image if provided
|
|
if frontmatter.get("featured_image"):
|
|
post_data["posts"][0]["feature_image"] = frontmatter["featured_image"]
|
|
|
|
# Make API request
|
|
api_url = f"{ghost_url}/ghost/api/admin/posts/"
|
|
headers = {
|
|
"Authorization": f"Ghost {token}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
response = requests.post(api_url, json=post_data, headers=headers)
|
|
response.raise_for_status()
|
|
|
|
return response.json()
|
|
|
|
|
|
def markdown_to_html(markdown_text: str) -> str:
|
|
"""Convert markdown to HTML."""
|
|
try:
|
|
import markdown
|
|
return markdown.markdown(
|
|
markdown_text,
|
|
extensions=["tables", "fenced_code", "codehilite"]
|
|
)
|
|
except ImportError:
|
|
# Basic conversion if markdown library not available
|
|
html = markdown_text.replace("\n\n", "</p><p>")
|
|
html = f"<p>{html}</p>"
|
|
return html
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Publish to Ghost CMS")
|
|
parser.add_argument("--file", required=True, help="Markdown file to publish")
|
|
parser.add_argument("--draft", action="store_true", help="Publish as draft")
|
|
parser.add_argument("--publish", action="store_true", help="Publish immediately")
|
|
parser.add_argument("--channel", default="blog",
|
|
choices=["blog", "journal", "ourstory"],
|
|
help="Ghost channel to publish to")
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Get credentials based on channel
|
|
channel_map = {
|
|
"blog": ("GHOST_BLOG_URL", "GHOST_BLOG_ADMIN_KEY"),
|
|
"journal": ("GHOST_JOURNAL_URL", "GHOST_JOURNAL_ADMIN_KEY"),
|
|
"ourstory": ("GHOST_OURSTORY_URL", "GHOST_OURSTORY_ADMIN_KEY")
|
|
}
|
|
|
|
url_var, key_var = channel_map[args.channel]
|
|
ghost_url = os.getenv(url_var)
|
|
admin_key = os.getenv(key_var)
|
|
|
|
if not ghost_url or not admin_key:
|
|
print(f"Error: Missing credentials for {args.channel}")
|
|
print(f"Set {url_var} and {key_var} in ~/.env.ourdigital")
|
|
return 1
|
|
|
|
try:
|
|
result = publish_to_ghost(
|
|
args.file,
|
|
ghost_url,
|
|
admin_key,
|
|
draft=not args.publish
|
|
)
|
|
|
|
post = result["posts"][0]
|
|
status = "Draft" if not args.publish else "Published"
|
|
print(f"{status}: {post['title']}")
|
|
print(f"URL: {ghost_url}/{post['slug']}/")
|
|
print(f"Edit: {ghost_url}/ghost/#/editor/post/{post['id']}")
|
|
|
|
return 0
|
|
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
exit(main())
|