Publish Pipeline Implementation Plan
Publish Pipeline Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: One command that publishes an Obsidian draft to Jekyll — copies assets, rewrites embeds, moves to _posts/ — and a newsletter fix so file-referenced SVGs get converted to PNG in emails.
Architecture: A new publish.sh script replaces the manual workflow. It calls a rewritten convert_obsidian.py (scoped to drafts) for embed rewriting + asset copying, then moves the post. newsletter/send.js gets a second conversion pass for <img src="...svg"> tags.
Tech Stack: Python 3 (convert), Bash (publish), Node.js/sharp (newsletter SVG→PNG)
File Structure
| File | Action | Responsibility |
|---|---|---|
publish.sh |
Create | Single entry point: picks draft, runs convert, moves to _posts/, stages git |
convert_obsidian.py |
Modify | Rewrite to: scan _drafts/ for embeds, search Obsidian assets/svg/ and assets/images/ for source files, copy to assets/images/, rewrite ![[]] →  |
newsletter/send.js |
Modify | Add handler for <img src="...svg">: read SVG from disk, convert via sharp, save PNG to assets/email/, replace src |
Task 1: Rewrite convert_obsidian.py to handle drafts and asset resolution
Files:
- Modify:
convert_obsidian.py(full rewrite ofconvert_obsidian_embedsfunction andmain)
The current script searches for *.md in the Obsidian root and uses rglob from the file’s parent dir. This doesn’t work because:
- Drafts are in
Xitnode/drafts/(symlink to_drafts/), not the Obsidian root rglobfrom_drafts/never finds files inXitnode/assets/svg/orXitnode/assets/images/
The rewrite makes the script work on a single draft file, searching the correct Obsidian asset directories.
- Step 1: Rewrite
convert_obsidian.py
Replace the entire file with:
#!/usr/bin/env python3
"""
Convert Obsidian embeds in a draft to Jekyll markdown and copy assets.
Usage: python convert_obsidian.py <draft-file.md> [--dry-run]
"""
import os
import re
import shutil
import sys
from pathlib import Path
# Project root (where this script lives)
PROJECT_ROOT = Path(__file__).resolve().parent
ASSETS_DEST = PROJECT_ROOT / "assets" / "images"
# Obsidian asset source directories
OBSIDIAN_ROOT = Path(
os.environ.get(
"OBSIDIAN_ROOT",
os.path.expanduser("~/dev/Projects/obsidian/Obsidian Vault/Xitnode"),
)
)
OBSIDIAN_ASSET_DIRS = [
OBSIDIAN_ROOT / "assets" / "svg",
OBSIDIAN_ROOT / "assets" / "images",
]
IMAGE_EXTENSIONS = {".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"}
def find_asset(filename):
"""Search Obsidian asset directories for a file."""
for asset_dir in OBSIDIAN_ASSET_DIRS:
candidate = asset_dir / filename
if candidate.exists():
return candidate
return None
def convert_embeds(content, dry_run=False):
"""Convert ![[file]] embeds to  and copy assets."""
copied = []
missing = []
def replace_embed(match):
raw_name = match.group(1)
# Strip .excalidraw suffix if present
clean_name = re.sub(r"\.excalidraw(\.\w+)$", r"\1", raw_name)
ext = Path(clean_name).suffix.lower()
if ext not in IMAGE_EXTENSIONS:
return match.group(0)
source = find_asset(raw_name) or find_asset(clean_name)
if source is None:
missing.append(raw_name)
print(f" WARNING: asset not found: {raw_name}")
# Still convert the syntax so Jekyll doesn't break
elif not dry_run:
dest = ASSETS_DEST / clean_name
ASSETS_DEST.mkdir(parents=True, exist_ok=True)
if not dest.exists() or source.stat().st_mtime > dest.stat().st_mtime:
shutil.copy2(source, dest)
copied.append(clean_name)
print(f" Copied: {raw_name} -> assets/images/{clean_name}")
else:
print(f" Up to date: assets/images/{clean_name}")
else:
print(f" Would copy: {raw_name} -> assets/images/{clean_name}")
alt_text = Path(clean_name).stem.replace("_", " ").replace("-", " ").title()
return f""
converted = re.sub(r"!\[\[([^\]]+)\]\]", replace_embed, content)
return converted, copied, missing
def main():
if len(sys.argv) < 2:
print("Usage: python convert_obsidian.py <draft-file.md> [--dry-run]")
sys.exit(1)
draft_path = Path(sys.argv[1]).resolve()
dry_run = "--dry-run" in sys.argv
if not draft_path.exists():
print(f"File not found: {draft_path}")
sys.exit(1)
print(f"Processing: {draft_path.name}")
content = draft_path.read_text(encoding="utf-8")
converted, copied, missing = convert_embeds(content, dry_run=dry_run)
if converted != content and not dry_run:
draft_path.write_text(converted, encoding="utf-8")
print(f"Rewrote embeds in {draft_path.name}")
elif converted == content:
print("No embeds to convert.")
if missing:
print(f"\nWARNING: {len(missing)} asset(s) not found:")
for m in missing:
print(f" - {m}")
if dry_run:
print("\nDry run complete. No files modified.")
if __name__ == "__main__":
main()
- Step 2: Test with dry run on the existing draft
Run: cd /Users/stefanodellapietra/dev/Projects/xitnode && python convert_obsidian.py _drafts/2026-04-06-dove-si-chiude-il-loop.md --dry-run
Expected: Script runs, reports “No embeds to convert.” (this draft has no ![[]] embeds), exits cleanly.
- Step 3: Test with a synthetic embed
Create a temporary test:
echo '![[test.svg]]' > /tmp/test-embed.md
cd /Users/stefanodellapietra/dev/Projects/xitnode && python convert_obsidian.py /tmp/test-embed.md --dry-run
Expected: Reports “WARNING: asset not found: test.svg” and shows the converted markdown syntax.
- Step 4: Commit
git add convert_obsidian.py
git commit -m "refactor: rewrite convert_obsidian.py for draft-based asset pipeline"
Task 2: Create publish.sh — single publish command
Files:
- Create:
publish.sh
This script is the one command you run to publish. It:
- Lists drafts and lets you pick one (or accepts a filename argument)
- Runs
convert_obsidian.pyto handle embeds + copy assets - Moves the draft from
_drafts/to_posts/
- Step 1: Create
publish.sh
#!/bin/bash
# Publish an Obsidian draft to _posts/
# Usage: ./publish.sh [draft-filename.md]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DRAFTS_DIR="$SCRIPT_DIR/_drafts"
POSTS_DIR="$SCRIPT_DIR/_posts"
# Pick draft
if [ -n "$1" ]; then
DRAFT="$DRAFTS_DIR/$1"
else
# List available drafts
DRAFTS=($(ls "$DRAFTS_DIR"/*.md 2>/dev/null | xargs -I{} basename {}))
if [ ${#DRAFTS[@]} -eq 0 ]; then
echo "No drafts found in _drafts/"
exit 1
fi
echo "Available drafts:"
for i in "${!DRAFTS[@]}"; do
echo " $((i+1)). ${DRAFTS[$i]}"
done
read -p "Pick a draft [1-${#DRAFTS[@]}]: " CHOICE
if [ -z "$CHOICE" ] || [ "$CHOICE" -lt 1 ] || [ "$CHOICE" -gt ${#DRAFTS[@]} ]; then
echo "Invalid choice."
exit 1
fi
DRAFT="$DRAFTS_DIR/${DRAFTS[$((CHOICE-1))]}"
fi
if [ ! -f "$DRAFT" ]; then
echo "Draft not found: $DRAFT"
exit 1
fi
BASENAME=$(basename "$DRAFT")
echo "Publishing: $BASENAME"
# Convert Obsidian embeds and copy assets
python3 "$SCRIPT_DIR/convert_obsidian.py" "$DRAFT"
# Move to _posts
mkdir -p "$POSTS_DIR"
mv "$DRAFT" "$POSTS_DIR/$BASENAME"
echo "Moved to _posts/$BASENAME"
# Stage changes
cd "$SCRIPT_DIR"
git add "_posts/$BASENAME" assets/images/
echo ""
echo "Done. Review and push:"
echo " git status"
echo " git commit -m 'publish: $BASENAME'"
echo " git push"
- Step 2: Make executable
chmod +x /Users/stefanodellapietra/dev/Projects/xitnode/publish.sh
- Step 3: Test dry run (read the output, don’t actually publish the real draft)
cd /Users/stefanodellapietra/dev/Projects/xitnode
# Just verify the script parses and lists drafts
bash -x publish.sh nonexistent.md 2>&1 | head -20
Expected: “Draft not found” error — confirms the script runs and path logic works.
- Step 4: Commit
git add publish.sh
git commit -m "feat: add publish.sh — single command to publish drafts"
Task 3: Fix newsletter/send.js — handle file-referenced SVGs
Files:
- Modify:
newsletter/send.js:101-144(theconvertSvgsForEmailfunction)
The current function only matches inline <svg> tags. After marked renders  to <img src="/assets/images/file.svg">, the SVG regex doesn’t match. We need a second pass that:
- Finds
<img src="...svg">tags - Reads the SVG file from disk (
assets/images/) - Converts to PNG via sharp
- Saves to
assets/email/ - Replaces the src with the PNG URL
- Step 1: Add
convertSvgImgsForEmailfunction after the existingconvertSvgsForEmailfunction
Add this new function at line 145 (after convertSvgsForEmail closes):
async function convertSvgImgsForEmail(html, postUrl) {
fs.mkdirSync(EMAIL_ASSETS_DIR, { recursive: true });
const imgRegex = /<img\s+src="(\/assets\/images\/[^"]+\.svg)"[^>]*>/gi;
const matches = [...html.matchAll(imgRegex)];
if (matches.length === 0) return { html, generatedFiles: [] };
const generatedFiles = [];
for (let i = 0; i < matches.length; i++) {
const imgTag = matches[i][0];
const svgPath = matches[i][1]; // e.g. /assets/images/diagram.svg
const localPath = path.join(__dirname, "..", svgPath);
if (!fs.existsSync(localPath)) {
console.warn(` SVG file not found: ${localPath}, using fallback link`);
const fallback = `<p style="padding:16px;background:#f5f5f5;border:1px solid #ddd;border-radius:4px;text-align:center;color:#666;font-size:14px;">[Diagramma — <a href="${postUrl}" style="color:#007acc;">vedi sul sito</a>]</p>`;
html = html.replace(imgTag, fallback);
continue;
}
const svgContent = fs.readFileSync(localPath, "utf-8");
const hash = crypto.createHash("md5").update(svgContent).digest("hex").slice(0, 10);
const filename = `diagram-${hash}.png`;
const filepath = path.join(EMAIL_ASSETS_DIR, filename);
try {
const pngBuffer = await sharp(Buffer.from(svgContent))
.png()
.resize({ width: 1080, withoutEnlargement: true })
.toBuffer();
fs.writeFileSync(filepath, pngBuffer);
generatedFiles.push(filepath);
const imgUrl = `${SITE_URL}/assets/email/${filename}`;
const newImgTag = `<img src="${imgUrl}" alt="Diagramma" style="max-width:100%;height:auto;border:1px solid #eee;border-radius:4px;" />`;
html = html.replace(imgTag, newImgTag);
console.log(` SVG img ${i + 1}/${matches.length} → ${filename} (${(pngBuffer.length / 1024).toFixed(1)}KB)`);
} catch (err) {
console.warn(` SVG img ${i + 1}/${matches.length} conversion failed:`, err.message);
const fallback = `<p style="padding:16px;background:#f5f5f5;border:1px solid #ddd;border-radius:4px;text-align:center;color:#666;font-size:14px;">[Diagramma — <a href="${postUrl}" style="color:#007acc;">vedi sul sito</a>]</p>`;
html = html.replace(imgTag, fallback);
}
}
return { html, generatedFiles };
}
- Step 2: Update
renderEmailto call both conversion functions
In renderEmail (line 146-155), change:
const { html: content, generatedFiles } = await convertSvgsForEmail(post.content, post.postUrl);
to:
const { html: inlineConverted, generatedFiles: inlineFiles } = await convertSvgsForEmail(post.content, post.postUrl);
const { html: content, generatedFiles: imgFiles } = await convertSvgImgsForEmail(inlineConverted, post.postUrl);
const generatedFiles = [...inlineFiles, ...imgFiles];
- Step 3: Test with dry run on the latest post (which has file-referenced SVGs)
cd /Users/stefanodellapietra/dev/Projects/xitnode/newsletter
npm install
node send.js --slug non-stai-facendo-bi --dry-run
Expected output should show:
SVG img 1/3 → diagram-HASH.png (XXX.XKB)
SVG img 2/3 → diagram-HASH.png (XXX.XKB)
SVG img 3/3 → diagram-HASH.png (XXX.XKB)
Generated 3 PNG(s) in assets/email/
Verify PNGs were created:
ls -la ../assets/email/
- Step 4: Commit
git add newsletter/send.js
git commit -m "fix: convert file-referenced SVGs to PNG in newsletter emails"
Task 4: End-to-end test with a real draft
Files: No new files — integration test of the full pipeline.
- Step 1: Create a test SVG in Obsidian assets
cat > "/Users/stefanodellapietra/dev/Projects/obsidian/Obsidian Vault/Xitnode/assets/svg/test-pipeline.svg" << 'EOF'
<svg viewBox="0 0 200 100" xmlns="http://www.w3.org/2000/svg" style="font-family:system-ui,sans-serif">
<rect width="200" height="100" fill="#f5f5f5" stroke="#333" rx="8"/>
<text x="100" y="55" text-anchor="middle" font-size="16" fill="#333">Pipeline Test</text>
</svg>
EOF
- Step 2: Create a test draft with an Obsidian embed
cat > "/Users/stefanodellapietra/dev/Projects/xitnode/_drafts/2026-04-07-test-pipeline.md" << 'EOF'
---
layout: post
title: "Test Pipeline"
date: 2026-04-07
categories: [xitnode]
tags: [test]
---
Test post with SVG embed:
![[test-pipeline.svg]]
End of test.
EOF
- Step 3: Run publish.sh on the test draft
cd /Users/stefanodellapietra/dev/Projects/xitnode
./publish.sh 2026-04-07-test-pipeline.md
Expected:
test-pipeline.svgcopied toassets/images/test-pipeline.svg- Embed rewritten to
 - File moved to
_posts/2026-04-07-test-pipeline.md
Verify:
grep "assets/images/test-pipeline.svg" _posts/2026-04-07-test-pipeline.md
ls assets/images/test-pipeline.svg
- Step 4: Run newsletter dry run on the test post
cd newsletter && node send.js --slug test-pipeline --dry-run
Expected: SVG img converted to PNG, dry run output shows the conversion.
- Step 5: Clean up test artifacts
cd /Users/stefanodellapietra/dev/Projects/xitnode
rm _posts/2026-04-07-test-pipeline.md
rm assets/images/test-pipeline.svg
rm -f assets/email/diagram-*.png # only new test ones — check hash first
rm "/Users/stefanodellapietra/dev/Projects/obsidian/Obsidian Vault/Xitnode/assets/svg/test-pipeline.svg"
- Step 6: Final commit of any remaining changes
git add -A
git diff --staged --quiet || git commit -m "chore: pipeline integration verified"