--- name: mm-publish-content description: > Publish Stage: Publishes approved articles and social posts to multiple platforms. Supports Astro (git push), WordPress (REST API), Facebook (Page API), X/Twitter (xurl CLI), and Instagram (Graph API). Reads clients-config.json for credentials. Marks published status in frontmatter. Use when: "publish article", "publish บทความ", "ส่งบทความ", "ลงบทความ", "publish to astro", "publish to wordpress", "post to social". --- # mm-publish-content Publishes approved content to websites and social media platforms. ## When This Must Trigger - "publish article", "publish บทความ", "ส่งบทความ", "ลงบทความ" - "publish to astro", "publish to wordpress" - "post to facebook", "post to x", "post to instagram" - "ลง social", "publish social" ## Prerequisites - Article must have `status: approved` in frontmatter - Social posts must exist (created by mm-content-writer) - Credentials must be configured in `~/vault/99_System/clients-config.json` ## Process ### Step 1: Find content to publish ```bash # List articles ready to publish # Look for articles with status: approved in 60_Articles// VAULT="$HOME/vault" ``` Search for articles with `status: approved` in per-client folders: ```python import os, re vault = os.path.expanduser("~/vault/60_Articles") ready = [] for root, dirs, files in os.walk(vault): for f in files: if f != 'article.md': continue path = os.path.join(root, f) content = open(path).read() if 'status: approved' in content[:500]: # Extract title and slug title_match = re.search(r'^title:\s*["\']?(.+?)["\']?\s*$', content, re.MULTILINE) title = title_match.group(1) if title_match else f slug = os.path.basename(root) ready.append({'path': path, 'title': title, 'slug': slug, 'dir': root}) ``` If no approved articles found: ``` ไม่พบบทความที่พร้อม publish บทความต้องมี status: approved ก่อน ใช้ mm-content-writer skill เพื่อเขียนและอนุมัติบทความ ``` If multiple found, ask which one to publish (or publish all). ### Step 2: Determine publish targets Read the article's frontmatter: - `related_client` — look up in clients-config.json - `published_to` — already published platforms (skip these) If `related_client` is set, load client config: ```python import json config_path = os.path.expanduser("~/vault/99_System/clients-config.json") config = json.load(open(config_path)) client = None for c in config.get('clients', []): if c['id'] == related_client: client = c break ``` If no `related_client` or client not found: ``` บทความนี้ยังไม่ได้ระบุ client ต้องการ publish ไปที่ไหน? 1. Astro website (specify repo) 2. WordPress website (specify URL) 3. Social media only (Facebook, X, IG) 4. ระบุ client จาก clients-config.json ``` If client is found, show available platforms: ``` 📋 Client: [display_name] 🌐 Website: - [type]: [url/repo] ✅ 📱 Social: - Facebook: [page_id] ✅/❌ - Instagram: [user_id] ✅/❌ - X: [username] ✅/❌ เผยแพร่ไปช่องทางไหนบ้าง? (เลือกหมายเลข หรือ "all"): 1. Website ([type]) 2. Facebook 3. Instagram 4. X/Twitter 5. All available ``` ### Step 3: Publish to Website #### 3a. Astro (git push) If website type is `astro`: ```bash # 1. Clone or pull the repo REPO_URL="" BRANCH="" ARTICLE_DIR="" # 2. Convert article.md to Astro blog format # - Move to src/content/blog//index.md # - Convert frontmatter to Astro format # - Copy images to same directory # - Update any wikilinks to regular links # 3. Git commit and push git add . git commit -m "publish:
" git push origin $BRANCH ``` Conversion rules for Astro: - Keep `title`, `description`, `pubDate`, `slug` in frontmatter - Add `heroImage` pointing to featured image - Remove vault-specific fields (`type`, `status`, `related_client`) - Convert `published_to` to include `astro` - Copy images to the article directory #### Capture published_url After successful website publish, capture the URL: **For Astro**: Construct from site config + slug ``` PUBLISHED_URL="/blog/" ``` **For WordPress**: Get from API response ``` PUBLISHED_URL="" ``` #### Save published_url to frontmatter Update the article's frontmatter with the published URL: ```python import re article_path = "" content = open(article_path).read() # Add published_url after status: published content = content.replace( "status: published", f"status: published\npublished_url: \"{published_url}\"" ) open(article_path, 'w').write(content) ``` Also update `published_to` to include the website platform: ```python # Add to published_to array if "published_to: []" in content: content = content.replace("published_to: []", f"published_to: [astro]") ``` Confirm to user: ``` ✅ บทความ published: URL: [published_url] Frontmatter updated: published_url, published_to ``` #### Update existing social posts with URL If social posts already exist (from a previous run), update them with the published_url: ```python import os, re article_dir = "" published_url = "" for platform in ['facebook', 'x', 'ig']: post_path = os.path.join(article_dir, f"post-{platform}.md") if not os.path.exists(post_path): continue content = open(post_path).read() # Add published_url to frontmatter if 'published_url' not in content: content = re.sub( r'^(related_article:.*)$', f'\\1\npublished_url: "{published_url}"', content, flags=re.MULTILINE ) else: content = re.sub( r'^(published_url:\s*)["\']?.*?["\']?\s*$', f'\\1"{published_url}"', content, flags=re.MULTILINE ) # Replace URL placeholders in body content = content.replace('[ARTICLE_URL]', published_url) content = content.replace('[article_url]', published_url) open(post_path, 'w').write(content) ``` Or use the helper script: ```bash python3 ~/Gitea/moreminimore-service-system/scripts/mm_update_url.py \ --dir ~/vault/60_Articles// \ --url [published_url] ``` | `meta_description` | `description` | First 160 chars of description or article | | `created` | `pubDate` | Use source date, NOT today | | — | `category` | Look up from client config or ask user. Must match one of client's blog categories exactly | | — | `heroImage` | `/images/blog/-.png` — the ASTRO path, not the vault path | | — | `draft: false` | Always add for publication | | `type`, `status`, `related_client` | (omit) | Strip vault-internal fields | | `published_to` | (omit from frontmatter) | Track separately | **Image relocation:** - Vault featured: `60_Articles//images/featured.png` → Astro: `public/images/blog/-featured.png` - Vault inline: `60_Articles//attachments/.png` → Astro: `public/images/blog/-.png` - Every image reference in markdown: must rewrite vault path to Astro path **Example output:** ```markdown --- title: "Fintech มองไม่เห็นโดย AI Agents — แล้วจะเปลี่ยนยังไง?" description: "ธุรกิจการเงินพร้อมรับ AI Agents หรือยัง? วิเคราะห์ 3 แนวทางสร้าง visibility" pubDate: 2026-06-26 category: "SEO" heroImage: "/images/blog/2026-06-26-fintech-invisible-to-ai-agents-featured.png" draft: false --- # Fintech มองไม่เห็นโดย AI Agents — แล้วจะเปลี่ยนยังไง? ![AI Agent interaction diagram](/images/blog/2026-06-26-fintech-invisible-to-ai-agents-solution-diagram.png) ``` **Path conventions:** - Always use absolute paths from root: `/images/blog/-.png` - Never use relative paths (`images/...` or `../images/...`) - Prefix inline image names with the article slug to avoid collisions across articles **Post-conversion verification (MANDATORY):** ```bash # 1. Verify all Astro blog files exist cd ~/Gitea/moreminimore-astroreal ls src/content/blog/2026-*.md | wc -l # 2. Verify all image references exist in public/ grep -oP '/images/blog/[^)\"\\s]+' src/content/blog/2026-*.md | sort -u | while IFS= read -r img; do [ -f "public$img" ] || echo "MISSING: public$img" done # 3. Build check npm run build 2>&1 | tail -5 # Must show "[N] page(s) built" without errors ``` **Git commit + push:** ```bash git add -A git commit -m "feat: publish [article title]" git push origin main ``` **UPDATED `clients-config.json`:** After publishing, update the client entry with: - `website.categories` — the full list of categories used (for content planning) - `website.last_published` — ISO date of latest publish batch - Keep `website.repo` pointing to the Astro repo path #### 3b. WordPress (REST API) If website type is `wordpress`: ```bash # WordPress REST API WP_URL="" WP_USER="" WP_APP_PASSWORD="" # Create post curl -X POST "$WP_URL/wp-json/wp/v2/posts" \ -u "$WP_USER:$WP_APP_PASSWORD" \ -H "Content-Type: application/json" \ -d '{ "title": "", "content": "<html content>", "status": "draft", "slug": "<slug>", "categories": [<category_ids>], "featured_media": <media_id> }' ``` Steps: 1. Convert markdown to WordPress HTML (handle headings, lists, images) 2. Upload featured image to WordPress Media Library → get media_id 3. Upload inline images → get URLs 4. Create post with content + featured_media 5. Set status to "draft" first (user can review in WP before publishing) WordPress image upload: ```bash curl -X POST "$WP_URL/wp-json/wp/v2/media" \ -u "$WP_USER:$WP_APP_PASSWORD" \ -H "Content-Disposition: attachment; filename=\"featured.png\"" \ -H "Content-Type: image/png" \ --data-binary @featured.png ``` ### Step 4: Publish to Social Media #### 4a. Facebook Page Requires: `page_id` and `page_token` from client config. ```bash # Post to Facebook Page PAGE_ID="<from config>" PAGE_TOKEN="<from config>" # Text post curl -X POST "https://graph.facebook.com/v21.0/$PAGE_ID/feed" \ -d "message=<post content>" \ -d "access_token=$PAGE_TOKEN" # Post with image # 1. Upload photo first curl -X POST "https://graph.facebook.com/v21.0/$PAGE_ID/photos" \ -F "source=@image.png" \ -F "message=<caption>" \ -F "access_token=$PAGE_TOKEN" ``` Read `post-facebook.md` from the article directory. Post content = body of post-facebook.md. #### 4b. X/Twitter (xurl CLI) Requires: xurl configured with the client's app. ```bash # Check xurl auth xurl auth status # Post single tweet xurl post "<tweet content>" # Post with image xurl media upload images/featured.png xurl post "<tweet content>" --media-id <MEDIA_ID> # Post thread (if post-x.md is a thread) xurl post "<tweet 1>" # Get tweet ID from response xurl reply <TWEET_ID> "<tweet 2>" xurl reply <TWEET_ID_2> "<tweet 3>" ``` Read `post-x.md` from the article directory. Determine if single post or thread based on frontmatter `format` field. #### 4c. Instagram (Graph API) Requires: `user_id` and `token` from client config (Facebook Graph API). ```bash IG_USER_ID="<from config>" IG_TOKEN="<from config>" # 1. Create media container CONTAINER_ID=$(curl -s -X POST "https://graph.facebook.com/v21.0/$IG_USER_ID/media" \ -d "image_url=<public image url>" \ -d "caption=<caption>" \ -d "access_token=$IG_TOKEN" | jq -r '.id') # 2. Publish container curl -X POST "https://graph.facebook.com/v21.0/$IG_USER_ID/media_publish" \ -d "creation_id=$CONTAINER_ID" \ -d "access_token=$IG_TOKEN" ``` **Important**: Instagram requires images to be publicly accessible URLs. Options: 1. Upload image to a public CDN first 2. Use the WordPress media URL if already uploaded 3. Use a temporary image hosting service Read `post-ig.md` from the article directory. ### Step 5: Update frontmatter After successful publishing, update the article's frontmatter: ```yaml --- ...existing... status: published published_to: [astro, facebook, x, ig] # append new platforms published_date: YYYY-MM-DD --- ``` Also update each social post's frontmatter: ```yaml --- ...existing... status: published published_to: [facebook] published_date: YYYY-MM-DD post_id: "<platform post ID>" --- ``` Use `patch` to update frontmatter fields. ### Step 6: Error handling For each platform, if publishing fails: ``` ❌ [Platform]: [error message] สาเหตุที่เป็นไปได้: - Token หมดอายุ → ต้อง renew - credentials ไม่ถูกต้อง → ตรวจสอบ clients-config.json - API quota หมด → รอและลองใหม่ - [platform-specific troubleshooting] ต้องการ: 1. ข้าม platform นี้ → publish ช่องทางอื่นต่อ 2. หยุด → แก้ไขปัญหาก่อน 3. ลองใหม่ ``` ### Step 7: Summary **CRITICAL: Full article verification.** The user expects a per-article status report (not a summary). Generate a table showing EVERY article's status: ```bash # Per-article verification for f in src/content/blog/2026-*.md; do slug=$(basename "$f" .md) title=$(head -5 "$f" | grep "title:" | sed 's/title: "//' | sed 's/"$//') cat=$(grep "category:" "$f" | head -1 | sed 's/category: "//' | sed 's/"$//') hero=$(grep "heroImage:" "$f" | head -1) # Check if hero image exists if [ -n "$hero" ]; then img_path=$(echo "$hero" | grep -oP '/images/blog/[^"'"'"']+') [ -f "public$img_path" ] && img="✅" || img="❌ MISSING" else img="⚠️ no heroImage" fi echo "$slug | $title | $cat | $img" done ``` ### Step 7: Summary ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Published: [Article Title] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🌐 Website: ✅ [Astro/WordPress]: [published_url] 📱 Social: ✅ Facebook: [post url or "posted"] ✅ X/Twitter: [tweet url or "posted"] ✅ Instagram: [posted] ❌ [Failed platform]: [reason] 📋 Status updated in vault frontmatter 📋 published_url saved: [url] ``` ### Step 8: Archive check (after all publishing) After publishing, check if ALL content in the article directory is fully published. Only archive when the entire bundle is complete. ```python def can_archive(article_dir): """Check if ALL content in article dir is published""" import os # Files that must be checked required_files = ['article.md'] optional_posts = ['post-facebook.md', 'post-x.md', 'post-ig.md'] # Article must be published article_path = os.path.join(article_dir, 'article.md') if not os.path.exists(article_path): return False content = open(article_path).read()[:500] if 'status: published' not in content: return False # All existing posts must also be published for f in optional_posts: path = os.path.join(article_dir, f) if not os.path.exists(path): continue # post doesn't exist = skip content = open(path).read()[:500] if 'status: published' not in content: return False return True ``` If `can_archive()` returns True: ``` ✅ ทั้ง article และ posts publish ครบแล้ว — ย้ายไป Archive ย้าย folder ไป Archive/? (yes/no) ``` If user confirms (or auto-confirm): ```bash ARTICLE_DIR="<path>" CLIENT_ID="<client-id>" ARCHIVE_DIR="$HOME/vault/60_Articles/$CLIENT_ID/Archive" mkdir -p "$ARCHIVE_DIR" mv "$ARTICLE_DIR" "$ARCHIVE_DIR/$(basename $ARTICLE_DIR)" ``` If `can_archive()` returns False: ``` ⚠️ ยังไม่ครบ — ไม่ย้ายไป Archive สถานะ: ✅ article.md: published ✅ post-facebook.md: published ❌ post-x.md: draft (ยังไม่ publish) ✅ post-ig.md: published ต้อง publish ทุกส่วนก่อนจึงจะย้ายไป Archive ได้ ``` **Important**: NEVER move to Archive unless ALL existing content files are published. The archive represents "fully complete" work only. ## Config Lookup Credentials are read from `~/vault/99_System/clients-config.json`. Structure: ```json { "global": { "google": { ... }, "meta": { ... } }, "clients": [ { "id": "client-id", "display_name": "Client Name", "website": { "type": "wordpress|astro", "url": "...", "wp_user": "...", "wp_app_password": "..." }, "social": { "facebook": { "page_id": "...", "page_token": "..." }, "instagram": { "user_id": "...", "token": "..." }, "x": { "username": "...", "app": "..." } } } ] } ``` If credentials are missing: 1. Tell the user which credential is needed 2. Explain how to obtain it 3. Offer to save it to clients-config.json once provided ## Notes - Always publish to website FIRST, then social (social posts should link to the article) - WordPress posts are created as "draft" by default — user reviews before publishing - Astro posts are pushed to git — may trigger CI/CD deployment - Instagram requires publicly accessible image URLs — plan accordingly - X/Twitter uses xurl CLI — must be installed and authenticated - Mark `status: published` only after ALL selected platforms succeed - If partial success (some platforms fail), mark only successful platforms in `published_to`