diff --git a/.blog-config.sh.example b/.blog-config.sh.example new file mode 100644 index 000000000..c2c512f15 --- /dev/null +++ b/.blog-config.sh.example @@ -0,0 +1,29 @@ +# Blog Image Workflow Configuration +# Copy this file to .blog-config.sh and fill in your API keys + +# OpenAI API key for DALL-E image generation +# Get your key from: https://platform.openai.com/api-keys +OPENAI_API_KEY="sk-your-api-key-here" + +# TinyPNG API key for image optimization (optional) +# Get your key from: https://tinypng.com/developers +# If not provided, will use ImageMagick for local optimization +TINYPNG_API_KEY="" + +# Path to your images repository +# This should point to the local clone of github.com/haacked/images +IMAGES_REPO_PATH="$HOME/dev/haacked/images" + +# Base URL for published images +IMAGE_BASE_URL="https://i.haacked.com" + +# Optional: Editor command to open new posts +# Examples: "code", "vim", "subl", etc. +# Leave empty to skip auto-opening +EDITOR="" + +# DALL-E image generation settings +DALLE_MODEL="dall-e-3" +DALLE_SIZE="1024x1024" # Options: 1024x1024, 1792x1024, 1024x1792 +DALLE_QUALITY="standard" # Options: standard, hd +DALLE_STYLE="vivid" # Options: vivid, natural diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..ec82a24a5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: Test Blog Scripts + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install BATS + run: | + sudo apt-get update + sudo apt-get install -y bats + + - name: Run tests + run: bin/test diff --git a/.gitignore b/.gitignore index 177adef8b..bc6321867 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,9 @@ $RECYCLE.BIN/ .sass-cache/ .jekyll-metadata + +# Draft images (published separately to images repo) +.draft-images/ + +# Local blog configuration (contains API keys) +.blog-config.sh diff --git a/bin/test b/bin/test new file mode 100755 index 000000000..c3144ba6b --- /dev/null +++ b/bin/test @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Run all tests for blog workflow scripts + +set -euo pipefail + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Colors +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${GREEN}Running blog workflow tests...${NC}\n" + +# Check if bats is installed +if ! command -v bats > /dev/null 2>&1; then + echo -e "${RED}Error: bats is not installed${NC}" + echo "Install with: brew install bats-core" + exit 1 +fi + +# Run unit tests +echo -e "${YELLOW}=== Unit Tests ===${NC}" +if bats "$ROOT_DIR/tests/unit/"*.bats; then + echo -e "\n${GREEN}✓ All unit tests passed!${NC}\n" +else + echo -e "\n${RED}✗ Some unit tests failed${NC}\n" + exit 1 +fi + +# Run integration tests if they exist +if [ -d "$ROOT_DIR/tests/integration" ] && [ -n "$(ls -A "$ROOT_DIR/tests/integration"/*.bats 2> /dev/null)" ]; then + echo -e "${YELLOW}=== Integration Tests ===${NC}" + if bats "$ROOT_DIR/tests/integration/"*.bats; then + echo -e "\n${GREEN}✓ All integration tests passed!${NC}\n" + else + echo -e "\n${RED}✗ Some integration tests failed${NC}\n" + exit 1 + fi +fi + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}All tests passed! ✓${NC}" +echo -e "${GREEN}========================================${NC}" diff --git a/docs/IMAGE-WORKFLOW.md b/docs/IMAGE-WORKFLOW.md new file mode 100644 index 000000000..43bd2931c --- /dev/null +++ b/docs/IMAGE-WORKFLOW.md @@ -0,0 +1,349 @@ +# Blog Image Workflow + +An automated workflow for creating, generating, and publishing blog post images with AI assistance. + +## Overview + +This workflow provides three scripts that streamline the process of adding images to blog posts: + +1. **`script/new-post`** - Create a new blog post draft with image directory +2. **`script/generate-images`** - Interactively generate or select images using AI +3. **`script/publish-images`** - Optimize and publish images to the images repository + +## Setup + +### 1. Install Dependencies + +The workflow scripts are written in bash and require the following command-line tools: + +```bash +# macOS +brew install jq imagemagick + +# Linux (Debian/Ubuntu) +sudo apt-get install jq imagemagick + +# Linux (Fedora/RHEL) +sudo dnf install jq ImageMagick +``` + +**Required tools:** + +- `jq` - JSON parsing for API responses +- `curl` - HTTP requests (usually pre-installed) +- `git` - Version control (usually pre-installed) + +**Optional tools:** + +- `imagemagick` (`magick` command) - Image optimization fallback when TinyPNG API is not available + +### 2. Configure API Keys + +Copy the example configuration: + +```bash +cp .blog-config.sh.example .blog-config.sh +``` + +Edit `.blog-config.sh` and add your API keys: + +```bash +OPENAI_API_KEY="sk-your-actual-api-key" +TINYPNG_API_KEY="your-tinypng-key" # Optional +IMAGES_REPO_PATH="$HOME/dev/haacked/images" +IMAGE_BASE_URL="https://i.haacked.com" +``` + +**Getting API keys:** + +- OpenAI: +- TinyPNG: (optional, will use ImageMagick as fallback) + +## Complete Workflow + +### Step 1: Create a New Post + +```bash +./script/new-post +``` + +You'll be prompted for: + +- **Title**: The post title (e.g., "My Amazing Post") +- **Slug**: URL-friendly identifier (auto-generated if you press Enter) + +This creates: + +- `_posts/YYYY/YYYY-MM-DD-slug.md` - Your post file +- `.draft-images/YYYY-MM-DD-slug/` - Directory for draft images + +### Step 2: Write Your Draft + +Edit your post and add image placeholders: + +```markdown +--- +title: "My Amazing Post" +excerpt_image: "" # Will be set after generating images +--- + +[image1: A vibrant sunset over mountains with a developer working on a laptop] + +Here's the main content… + +[image2: Screenshot of the application dashboard] + +More content… + +[image3: Architecture diagram showing microservices] +``` + +**Placeholder format:** `[imageN: description]` + +- Use sequential numbers: `image1`, `image2`, etc. +- Descriptions are used as default AI prompts +- Add any existing images (screenshots, diagrams) to `.draft-images/YYYY-MM-DD-slug/` + +### Step 3: Generate/Select Images + +#### Interactive Mode (Default) + +```bash +./script/generate-images _posts/YYYY/YYYY-MM-DD-slug.md +``` + +For each image placeholder, you'll be prompted to: + +#### Option 1: Use an existing file + +- Lists files in `.draft-images/YYYY-MM-DD-slug/` +- Select which file to use + +#### Option 2: Generate with AI + +- Edit the prompt (or press Enter to use default) +- Choose style: Vivid (dramatic) or Natural (subdued) +- Preview the generated image URL +- Approve or regenerate with modifications + +The script: + +- Generates/selects images +- Saves to `.draft-images/YYYY-MM-DD-slug/imageN.png` +- Updates your post with local references: + + ```markdown + ![Description](./.draft-images/YYYY-MM-DD-slug/image1.png) + ``` + +#### Batch Mode (Non-Interactive) + +For automated workflows or CI/CD pipelines: + +```bash +./script/generate-images --all _posts/YYYY/YYYY-MM-DD-slug.md +``` + +This mode: + +- Keeps all existing images without prompting +- Generates missing images with default settings (vivid style, placeholder description as prompt) +- Never asks for user input (safe for automation) +- Uses `DALLE_STYLE` from `.blog-config.sh` if set, otherwise defaults to "vivid" + +**Use cases:** + +- Automated content pipelines +- CI/CD environments +- Generating multiple posts at once +- When you trust the placeholder descriptions + +### Step 4: Preview Locally + +```bash +jekyll serve +``` + +Visit to preview your post with local images. + +### Step 5: Publish Images + +When you're satisfied with all images: + +```bash +./script/publish-images _posts/YYYY/YYYY-MM-DD-slug.md +``` + +This script: + +1. **Optimizes** each image (using TinyPNG or ImageMagick) +2. **Copies** to `~/dev/haacked/images/blog/YYYY-MM-DD-slug/` +3. **Commits and pushes** to the images repository +4. **Updates** your post with final URLs: + + ```markdown + ![Description](https://i.haacked.com/blog/YYYY-MM-DD-slug/image1.png) + ``` + +### Step 6: Publish Your Post + +```bash +# Preview with live URLs +jekyll serve + +# Commit and create PR +git add _posts/YYYY/YYYY-MM-DD-slug.md +git commit -m "Add post about XYZ" +git push +gh pr create +``` + +## Tips and Tricks + +### AI Image Generation + +**Prompt tips:** + +- Be specific about style, colors, mood +- Mention "minimalist", "photorealistic", "illustration", etc. +- Reference composition: "overhead view", "close-up", "wide angle" +- Example: "Minimalist illustration of a git tree with terminal windows as leaves, autumn colors, digital art style" + +**Iterating:** + +- If you don't like the result, choose "Modify prompt and regenerate" +- Try different styles (Vivid vs Natural) +- Save iterations by downloading multiple versions to `.draft-images/` first + +### Manual Images + +You can always add images manually: + +1. Save to `.draft-images/YYYY-MM-DD-slug/` +2. When running `generate-images`, choose "Use existing file" +3. Continue with the publish workflow as normal + +### Skipping Images + +If you want to skip an image placeholder: + +- In `generate-images`, choose "Skip this image" +- The placeholder remains in your post unchanged +- You can run the script again later to process it + +### Re-running Scripts + +All scripts are idempotent: + +- `generate-images` - Will ask what to do with existing images +- `publish-images` - Will re-optimize and overwrite if needed + +## Troubleshooting + +### "Configuration file not found" + +Make sure you've created `.blog-config.sh` from the example: + +```bash +cp .blog-config.sh.example .blog-config.sh +``` + +### "Images repository not found" + +Check that `images_repo_path` in `.blog-config.sh` points to your local clone of the images repository: + +```bash +ls ~/dev/haacked/images # Should exist +``` + +### TinyPNG API limit exceeded + +The free tier has limits. The scripts will automatically fall back to ImageMagick. To use ImageMagick only, leave `tinypng_api_key` empty in your config. + +### Git push fails + +Make sure you have write access to the images repository and are authenticated: + +```bash +cd ~/dev/haacked/images +git remote -v +git push # Test push access +``` + +## File Structure + +```text +haacked.com/ +├── .blog-config.sh # Your API keys (gitignored) +├── .blog-config.sh.example # Template +├── .draft-images/ # Draft images (gitignored) +│ └── 2025-11-21-my-post/ +│ ├── image1.png +│ ├── image2.png +│ └── screenshot.png +├── _posts/ +│ └── 2025/ +│ └── 2025-11-21-my-post.md +└── script/ + ├── new-post # Create new post + ├── generate-images # Generate/select images + └── publish-images # Publish to images repo +``` + +## Advanced Usage + +### Batch Processing + +Process multiple posts: + +```bash +for post in _posts/2025/*.md; do + ./script/publish-images "$post" +done +``` + +### Custom DALL-E Settings + +Edit `.blog-config.sh`: + +```bash +DALLE_MODEL="dall-e-3" +DALLE_SIZE="1792x1024" # Wide format +DALLE_QUALITY="hd" # Higher quality +DALLE_STYLE="natural" # Default style +``` + +### Auto-open in Editor + +Set your preferred editor in `.blog-config.sh`: + +```bash +EDITOR="code" # VS Code +# EDITOR="vim" +# EDITOR="subl" +``` + +The `new-post` script will automatically open the post in your editor. + +## Comparison with Old Workflow + +| Step | Old Workflow | New Workflow | +| ----------------------- | --------------------------------------------- | ------------------------------- | +| Create post | Manual file creation | `./script/new-post` | +| Generate image | ChatGPT web → download | Interactive AI prompt | +| Optimize | Upload to tinypng.com → download | Automatic | +| Add to images repo | Manual copy to directory → commit → push | Automatic | +| Update post URLs | Manual URL construction and replacement | Automatic | +| **Total manual steps** | **8-10 steps per image** | **2 commands for all images** | +| **Time per image** | **~3-5 minutes** | **~1 minute** | + +## Future Enhancements + +Potential additions to the workflow: + +- Support for other AI image providers (Midjourney, Stable Diffusion) +- Automatic alt text generation for accessibility +- Image format conversion (WEBP, AVIF) +- Bulk image operations +- Integration with screenshot tools +- Automated testing of image links diff --git a/script/generate-images b/script/generate-images new file mode 100755 index 000000000..5c8a69c27 --- /dev/null +++ b/script/generate-images @@ -0,0 +1,475 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Load shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BLOG_ROOT="$(dirname "$SCRIPT_DIR")" + +# shellcheck source=script/lib/config-loader.sh +source "$SCRIPT_DIR/lib/config-loader.sh" + +# Load configuration (required for generate-images) +CONFIG_FILE="$BLOG_ROOT/.blog-config.sh" +if ! load_blog_config "$CONFIG_FILE" true; then + exit 1 +fi + +# Check for required dependencies +if ! check_required_dependencies jq curl; then + exit 1 +fi + +# Functions +find_image_placeholders() { + local content="$1" + grep -oE '\[image[0-9]+:[^]]+\]' <<< "$content" || true +} + +extract_placeholder_id() { + local placeholder="$1" + sed -E 's/\[([^:]+):.*/\1/' <<< "$placeholder" +} + +extract_placeholder_description() { + local placeholder="$1" + sed -E 's/\[[^:]+: *([^]]+)\]/\1/' <<< "$placeholder" +} + +generate_dalle_image() { + local prompt="$1" + local style="${2:-${DALLE_STYLE:-vivid}}" + + local payload + payload=$(jq -n \ + --arg model "${DALLE_MODEL:-dall-e-3}" \ + --arg prompt "$prompt" \ + --arg size "${DALLE_SIZE:-1024x1024}" \ + --arg quality "${DALLE_QUALITY:-standard}" \ + --arg style "$style" \ + '{ + model: $model, + prompt: $prompt, + n: 1, + size: $size, + quality: $quality, + style: $style + }') + + # Use curl config to hide API key from process list + local curl_config + curl_config=$(mktemp) + trap 'rm -f "$curl_config"' RETURN + + cat > "$curl_config" < /dev/null 2>&1; then + local error_msg + error_msg=$(echo "$response" | jq -r '.error.message') + echo "Error: OpenAI API returned an error: $error_msg" >&2 + echo "This could be due to:" >&2 + echo " - Invalid API key in .blog-config.sh" >&2 + echo " - Rate limit exceeded" >&2 + echo " - Network connectivity issues" >&2 + echo " - Invalid prompt or parameters" >&2 + return 1 + fi + + # Validate response contains expected data + if ! echo "$response" | jq -e '.data[0].url' > /dev/null 2>&1; then + echo "Error: Unexpected response from OpenAI API" >&2 + return 1 + fi + + local image_url + image_url=$(echo "$response" | jq -r '.data[0].url') + + # Validate URL is from OpenAI blob storage domain + # OpenAI DALL-E images are served from Azure blob storage + if [[ ! "$image_url" =~ ^https://oaidalleapiprodscus\.blob\.core\.windows\.net/ ]]; then + echo "Error: Image URL is not from expected OpenAI blob storage domain: $image_url" >&2 + return 1 + fi + + echo "$image_url" +} + +download_image() { + local url="$1" + local output_path="$2" + + # Validate output path is safe + local safe_path + if ! safe_path=$(validate_path_safety "$output_path" "$BLOG_ROOT/.draft-images" "image output path"); then + return 1 + fi + + # Download image + if ! curl -s -f -o "$safe_path" "$url"; then + echo "Error: Failed to download image from $url" >&2 + return 1 + fi + + # Validate downloaded file is an image + # Use case-insensitive match since file output varies across systems + if command -v file &> /dev/null; then + if ! file "$safe_path" | grep -qiE "^\S+:\s+(PNG|JPEG|GIF|WebP) image"; then + echo "Error: Downloaded file is not a valid image format" >&2 + rm -f "$safe_path" + return 1 + fi + fi + + # Check file size is reasonable (not empty, not too large) + local file_size + file_size=$(get_file_size "$safe_path") + + if (( file_size == 0 )); then + echo "Error: Downloaded file is empty" >&2 + rm -f "$safe_path" + return 1 + fi + + if (( file_size > 20971520 )); then # 20MB limit + echo "Error: Downloaded file is too large ($(( file_size / 1048576 ))MB > 20MB)" >&2 + rm -f "$safe_path" + return 1 + fi + + return 0 +} + +# Helper function: Select an existing file to use +select_existing_file() { + local draft_images_dir="$1" + local target_path="$2" + + existing_files=("$draft_images_dir"/*) + if [[ ${#existing_files[@]} -eq 0 ]] || [[ ! -f "${existing_files[0]}" ]]; then + echo "none" + return 1 + fi + + echo "" + echo "Select file:" + select selected_file in "${existing_files[@]}"; do + if [[ -n "$selected_file" ]]; then + # Validate paths are safe + local safe_source safe_dest + if safe_source=$(validate_path_safety "$selected_file" "$BLOG_ROOT/.draft-images" "source file") && \ + safe_dest=$(validate_path_safety "$target_path" "$BLOG_ROOT/.draft-images" "destination file"); then + cp "$safe_source" "$safe_dest" + echo "✓ Using $(basename "$selected_file")" + echo "selected" + return 0 + else + echo "Error: Path validation failed" >&2 + echo "failed" + return 1 + fi + fi + done + + echo "failed" + return 1 +} + +# Helper function: Handle AI image generation workflow +generate_ai_image_workflow() { + local description="$1" + local image_path="$2" + local image_id="$3" + + local satisfied=false + + while [[ "$satisfied" == false ]]; do + echo "" + echo "Default prompt: $description" + read -rp "Enter custom prompt (or press Enter to use default): " custom_prompt + + if [[ -z "$custom_prompt" ]]; then + custom_prompt="$description" + fi + + echo "" + echo "Select style:" + select style in "Vivid (hyper-real, dramatic)" "Natural (more subdued, natural)"; do + case $style in + "Vivid"*) + style_value="vivid" + break + ;; + "Natural"*) + style_value="natural" + break + ;; + esac + done + + echo "" + echo "⏳ Generating image…" + + if image_url=$(generate_dalle_image "$custom_prompt" "$style_value"); then + echo "✓ Generated image" + echo "" + echo "Preview URL: $image_url" + echo "" + + read -rp "Are you satisfied with this image? (y/n): " answer + if [[ "$answer" =~ ^[Yy] ]]; then + echo "⏳ Downloading image…" + if download_image "$image_url" "$image_path"; then + local file_size + file_size=$(get_file_size "$image_path") + echo "✓ Saved to $image_path ($(format_size "$file_size"))" + satisfied=true + echo "generated" + return 0 + else + echo "✗ Failed to download image" >&2 + read -rp "Would you like to retry? (y/n): " retry + if [[ ! "$retry" =~ ^[Yy] ]]; then + echo "⊘ Skipped $image_id" + echo "skipped" + return 1 + fi + fi + else + echo "" + echo "What would you like to do?" + select regen_choice in "Modify prompt and regenerate" "Regenerate with same prompt" "Skip this image"; do + case $regen_choice in + "Modify prompt and regenerate") + break + ;; + "Regenerate with same prompt") + # Set custom_prompt to force regeneration + break + ;; + "Skip this image") + echo "⊘ Skipped $image_id" + satisfied=true + echo "skipped" + return 1 + ;; + esac + done + fi + else + echo "✗ Error generating image" + read -rp "Would you like to retry? (y/n): " retry + if [[ ! "$retry" =~ ^[Yy] ]]; then + echo "⊘ Skipped $image_id" + echo "skipped" + return 1 + fi + fi + done +} + +# Main script +batch_mode=false +if [[ $# -eq 0 ]]; then + echo "Usage: $0 [--all] POST_FILE" >&2 + echo "" >&2 + echo "Options:" >&2 + echo " --all Non-interactive mode: generate all missing images with default settings" >&2 + echo "" >&2 + echo "Examples:" >&2 + echo " $0 _posts/2025/2025-11-21-my-post.md" >&2 + echo " $0 --all _posts/2025/2025-11-21-my-post.md" >&2 + exit 1 +fi + +# Parse arguments +if [[ "$1" == "--all" ]]; then + batch_mode=true + shift +fi + +post_path="$1" + +if [[ ! -f "$post_path" ]]; then + echo "Error: Post file not found: $post_path" >&2 + exit 1 +fi + +content=$(cat "$post_path") +placeholders=$(find_image_placeholders "$content") + +if [[ -z "$placeholders" ]]; then + echo "No image placeholders found in post." >&2 + echo "Add placeholders like: [image1: Description of image]" >&2 + exit 0 +fi + +date_slug=$(extract_date_slug "$post_path") +draft_images_dir="$BLOG_ROOT/.draft-images/$date_slug" +mkdir -p "$draft_images_dir" + +placeholder_count=$(echo "$placeholders" | wc -l | tr -d ' ') +echo "" +echo "Found $placeholder_count image placeholder(s)" +echo "" + +updated_content="$content" +counter=1 + +while IFS= read -r placeholder; do + [[ -z "$placeholder" ]] && continue + + image_id=$(extract_placeholder_id "$placeholder") + description=$(extract_placeholder_description "$placeholder") + + echo "━━━ Image $counter/$placeholder_count ━━━" + echo "Placeholder: $placeholder" + echo "" + + image_filename="${image_id}.png" + image_path="$draft_images_dir/$image_filename" + + # Determine action based on whether image exists + choice="" + if [[ "$batch_mode" == true ]]; then + # Batch mode: keep existing images, generate missing ones + if [[ -f "$image_path" ]]; then + echo "✓ Keeping existing $image_filename" + choice="Keep existing image" + else + echo "⏳ Will generate with AI (batch mode)" + choice="Generate with AI" + fi + elif [[ -f "$image_path" ]]; then + echo "Image already exists: $image_path" + echo "" + echo "What would you like to do?" + select choice in "Keep existing image" "Use different existing file" "Regenerate with AI" "Skip"; do + case $choice in + "Keep existing image") + echo "✓ Keeping existing $image_filename" + break + ;; + "Use different existing file") + result=$(select_existing_file "$draft_images_dir" "$image_path") + if [[ "$result" == "selected" ]]; then + break + elif [[ "$result" == "none" ]]; then + echo "No existing files found." + choice="Regenerate with AI" + else + choice="Skip" + break + fi + ;; + "Regenerate with AI") + break + ;; + "Skip") + echo "⊘ Skipped $image_id" + ((counter++)) + continue 2 + ;; + esac + done + else + # Check for existing files to use + existing_files=("$draft_images_dir"/*) + if [[ -f "${existing_files[0]}" ]]; then + echo "Choose an option:" + select choice in "Use existing file" "Generate with AI"; do + case $choice in + "Use existing file") + result=$(select_existing_file "$draft_images_dir" "$image_path") + if [[ "$result" == "selected" ]]; then + break + else + choice="Generate with AI" + break + fi + ;; + "Generate with AI") + break + ;; + esac + done + else + choice="Generate with AI" + fi + fi + + # Handle AI generation if selected + if [[ "$choice" == "Regenerate with AI" ]] || [[ "$choice" == "Generate with AI" ]]; then + if [[ "$batch_mode" == true ]]; then + # Batch mode: generate with default settings + echo "" + echo "⏳ Generating image with default settings…" + if image_url=$(generate_dalle_image "$description" "${DALLE_STYLE:-vivid}"); then + echo "✓ Generated image" + echo "⏳ Downloading image…" + if download_image "$image_url" "$image_path"; then + local file_size + file_size=$(get_file_size "$image_path") + echo "✓ Saved to $image_path ($(format_size "$file_size"))" + else + echo "✗ Failed to download image" >&2 + echo "⊘ Skipped $image_id" + fi + else + echo "✗ Error generating image" >&2 + echo "⊘ Skipped $image_id" + fi + else + # Interactive mode: use full workflow + generate_ai_image_workflow "$description" "$image_path" "$image_id" + fi + fi + + # Update content with local reference if image exists + if [[ -f "$image_path" ]]; then + local_ref="./.draft-images/$date_slug/$image_filename" + # Escape special characters for sed (properly formed character class) + escaped_placeholder=$(echo "$placeholder" | sed 's/[][\\/$*.^]/\\&/g') + updated_content=$(echo "$updated_content" | sed "s|$escaped_placeholder|![${description}](${local_ref})|") + fi + + echo "" + ((counter++)) +done <<< "$placeholders" + +# Write updated post with backup +backup_path="${post_path}.backup-$(date +%s)" +cp "$post_path" "$backup_path" + +echo "$updated_content" > "$post_path" + +# Verify the update looks reasonable +original_lines=$(wc -l < "$backup_path") +updated_lines=$(wc -l < "$post_path") + +# If the file shrunk dramatically (>50%), warn and restore +if (( updated_lines < original_lines / 2 )); then + echo "Warning: Post file appears corrupted after update (too few lines)" >&2 + echo "Original: $original_lines lines, Updated: $updated_lines lines" >&2 + echo "Restoring from backup…" >&2 + mv "$backup_path" "$post_path" + exit 1 +fi + +# Clean up backup on success +rm -f "$backup_path" + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "✓ All images processed!" +echo "✓ Updated post with local references" +echo "" +echo "Next steps:" +echo " 1. Preview locally: jekyll serve" +echo " 2. When ready to publish: ./script/publish-images $post_path" diff --git a/script/lib/config-loader.sh b/script/lib/config-loader.sh new file mode 100644 index 000000000..7a6d676e4 --- /dev/null +++ b/script/lib/config-loader.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Safe configuration loader for blog workflow scripts +# Prevents command injection by parsing config as data, not code + +load_blog_config() { + local config_file="$1" + local required="${2:-true}" + + if [[ ! -f "$config_file" ]]; then + if [[ "$required" == "true" ]]; then + echo "Error: Configuration file not found at $config_file" >&2 + echo "Copy .blog-config.sh.example to .blog-config.sh and configure it." >&2 + return 1 + else + return 0 + fi + fi + + # Validate file permissions (should not be world-writable) + if [[ -n "$(find "$config_file" -perm -002)" ]]; then + echo "Warning: Config file is world-writable. Fix with: chmod 600 $config_file" >&2 + fi + + # Parse config safely - extract key=value pairs without executing + while IFS= read -r line; do + # Skip comments and empty lines + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ -z "${line// }" ]] && continue + + # Extract key=value pairs + if [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)=\"([^\"]*)\"$ ]] || \ + [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)=\'([^\']*)\'$ ]] || \ + [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)=([^[:space:]]+)$ ]]; then + local key="${BASH_REMATCH[1]}" + local value="${BASH_REMATCH[2]}" + + # Only set recognized configuration variables + case "$key" in + OPENAI_API_KEY|TINYPNG_API_KEY|IMAGES_REPO_PATH|IMAGE_BASE_URL|\ + EDITOR|DALLE_MODEL|DALLE_SIZE|DALLE_QUALITY|DALLE_STYLE) + # Expand $HOME in paths + value="${value//\$HOME/$HOME}" + value="${value//\~/$HOME}" + + # Export the variable + export "$key=$value" + ;; + *) + echo "Warning: Unknown configuration key '$key' in $config_file" >&2 + ;; + esac + fi + done < "$config_file" + + return 0 +} + +validate_editor() { + local editor="$1" + + # Allow empty editor + [[ -z "$editor" ]] && return 0 + + # Validate editor is a safe command (alphanumeric, dash, underscore, slash only) + if [[ ! "$editor" =~ ^[a-zA-Z0-9/_-]+$ ]]; then + echo "Warning: EDITOR contains potentially unsafe characters, ignoring" >&2 + return 1 + fi + + # Check if editor exists + if ! command -v "$editor" &> /dev/null; then + echo "Warning: Editor '$editor' not found in PATH" >&2 + return 1 + fi + + return 0 +} + +validate_path_safety() { + local path="$1" + local base_dir="$2" + local description="${3:-path}" + + # Resolve to absolute path (handle both macOS and Linux) + local real_path + # Try GNU realpath -m first (handles non-existent paths), fall back to BSD realpath + if ! real_path=$(realpath -m "$path" 2>/dev/null || realpath "$path" 2>/dev/null); then + # If realpath fails, manually resolve the path + # Convert to absolute path if relative + if [[ "$path" = /* ]]; then + real_path="$path" + else + real_path="$(pwd)/$path" + fi + # Normalize by removing ./ and collapsing ../ (loop until no more changes) + real_path=$(echo "$real_path" | sed -E 's|/\./|/|g' | sed -E 's|^/\.\./|/|' | sed -E 's|/+|/|g') + local prev_path="" + while [[ "$real_path" != "$prev_path" ]]; do + prev_path="$real_path" + real_path=$(echo "$real_path" | sed -E 's|/[^/]+/\.\./|/|g') + done + fi + + # Ensure path is within base directory (prevent path traversal) + local real_base + # Resolve base directory (it should always exist) + if ! real_base=$(realpath "$base_dir" 2>/dev/null); then + echo "Error: Base directory does not exist: $base_dir" >&2 + return 1 + fi + + # Check for exact match or ensure it's a subdirectory with proper boundary + # Ensure trailing slash check so /blog won't match /blog-evil/ + if [[ "$real_path" != "$real_base" ]] && [[ ! "$real_path" =~ ^"$real_base"/ ]]; then + echo "Error: Path traversal detected in $description" >&2 + echo " Path: $path" >&2 + echo " Resolved: $real_path" >&2 + echo " Expected under: $real_base" >&2 + return 1 + fi + + echo "$real_path" + return 0 +} + +get_file_size() { + local file_path="$1" + # Cross-platform file size (macOS uses -f, Linux uses -c) + stat -f%z "$file_path" 2>/dev/null || stat -c%s "$file_path" 2>/dev/null +} + +extract_date_slug() { + local post_path="$1" + basename "$post_path" .md +} + +format_size() { + local bytes="$1" + if (( bytes < 1024 )); then + echo "${bytes}B" + elif (( bytes < 1048576 )); then + echo "$(( bytes / 1024 ))KB" + else + echo "$(( bytes / 1048576 ))MB" + fi +} + +check_required_dependencies() { + local missing=() + + # Check for required commands + local cmd + for cmd in "$@"; do + if ! command -v "$cmd" &> /dev/null; then + missing+=("$cmd") + fi + done + + if [[ ${#missing[@]} -gt 0 ]]; then + echo "Error: Missing required dependencies: ${missing[*]}" >&2 + echo "" >&2 + echo "Install with:" >&2 + for cmd in "${missing[@]}"; do + case "$cmd" in + jq) + echo " brew install jq # macOS" >&2 + echo " apt-get install jq # Linux" >&2 + ;; + curl) + echo " brew install curl # macOS" >&2 + echo " apt-get install curl # Linux" >&2 + ;; + magick) + echo " brew install imagemagick # macOS" >&2 + echo " apt-get install imagemagick # Linux" >&2 + ;; + git) + echo " brew install git # macOS" >&2 + echo " apt-get install git # Linux" >&2 + ;; + esac + done + return 1 + fi + + return 0 +} diff --git a/script/new-post b/script/new-post new file mode 100755 index 000000000..a98066415 --- /dev/null +++ b/script/new-post @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Load shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BLOG_ROOT="$(dirname "$SCRIPT_DIR")" + +# shellcheck source=script/lib/config-loader.sh +source "$SCRIPT_DIR/lib/config-loader.sh" + +# Load configuration (optional for new-post) +CONFIG_FILE="$BLOG_ROOT/.blog-config.sh" +load_blog_config "$CONFIG_FILE" false + +# Prompt for post details +echo "Create a new blog post" +echo "" +read -rp "Title: " title + +if [[ -z "$title" ]]; then + echo "Error: Title cannot be empty" >&2 + exit 1 +fi + +read -rp "Slug (press Enter to auto-generate): " slug + +# Auto-generate slug if not provided +if [[ -z "$slug" ]]; then + slug=$(echo "$title" | tr '[:upper:]' '[:lower:]' | \ + sed -E 's/[^a-z0-9 -]//g' | \ + sed -E 's/ +/-/g' | \ + sed -E 's/-+/-/g' | \ + sed -E 's/^-|-$//g') + + # Validate slug is not empty + if [[ -z "$slug" ]]; then + echo "Error: Could not generate a valid slug from title" >&2 + echo "Title contains only special characters. Please provide a slug manually." >&2 + read -rp "Enter slug: " slug + + if [[ -z "$slug" ]]; then + echo "Error: Slug cannot be empty" >&2 + exit 1 + fi + fi +fi + +# Generate date and filename +date_str=$(date +%Y-%m-%d) +year=$(date +%Y) +filename="${date_str}-${slug}.md" + +# Create paths +posts_dir="$BLOG_ROOT/_posts/$year" +post_path="$posts_dir/$filename" +draft_images_dir="$BLOG_ROOT/.draft-images/${date_str}-${slug}" + +# Check if post already exists +if [[ -f "$post_path" ]]; then + echo "Error: Post already exists at $post_path" >&2 + exit 1 +fi + +# Create directories +mkdir -p "$posts_dir" +mkdir -p "$draft_images_dir" + +# Create post with front matter +cat > "$post_path" < +EOF + +echo "" +echo "✓ Created post: $post_path" +echo "✓ Created draft images directory: $draft_images_dir" +echo "" +echo "Next steps:" +echo " 1. Edit your post and add image placeholders: [image1: description]" +echo " 2. Add any existing images to: .draft-images/${date_str}-${slug}/" +echo " 3. Run: ./script/generate-images _posts/$year/$filename" + +# Open in editor if configured +if [[ -n "${EDITOR:-}" ]]; then + if validate_editor "$EDITOR"; then + exec "$EDITOR" "$post_path" + fi +fi diff --git a/script/publish-images b/script/publish-images new file mode 100755 index 000000000..a48caff3e --- /dev/null +++ b/script/publish-images @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Load shared functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BLOG_ROOT="$(dirname "$SCRIPT_DIR")" + +# shellcheck source=script/lib/config-loader.sh +source "$SCRIPT_DIR/lib/config-loader.sh" + +# Load configuration (required for publish-images) +CONFIG_FILE="$BLOG_ROOT/.blog-config.sh" +if ! load_blog_config "$CONFIG_FILE" true; then + exit 1 +fi + +# Check for required dependencies +if ! check_required_dependencies git grep sed; then + exit 1 +fi + +# Functions +find_local_images() { + local content="$1" + grep -oE '!\[[^]]*\]\(\./\.draft-images/[^)]+\)' <<< "$content" | \ + sed -E 's/!\[[^]]*\]\(([^)]+)\)/\1/' || true +} + +optimize_with_tinypng() { + local input_path="$1" + local output_path="$2" + local api_key="$3" + + local original_size + original_size=$(get_file_size "$input_path") + + # Upload to TinyPNG + local response + response=$(curl -s --user "api:$api_key" \ + --data-binary @"$input_path" \ + -i "https://api.tinify.com/shrink") + + # Extract output URL from Location header + local output_url + output_url=$(echo "$response" | grep -i "^Location:" | sed 's/Location: //' | tr -d '\r') + + if [[ -z "$output_url" ]]; then + # Check for error message in response + local error_msg + error_msg=$(echo "$response" | grep -i "^{" | head -1) + echo "Error: TinyPNG API failed for $(basename "$input_path")" >&2 + if [[ -n "$error_msg" ]]; then + echo "API response: $error_msg" >&2 + fi + echo "This could be due to:" >&2 + echo " - API rate limit exceeded (500/month on free tier)" >&2 + echo " - Invalid API key in .blog-config.sh" >&2 + echo " - Network connectivity issues" >&2 + return 1 + fi + + # Download optimized image + curl -s -o "$output_path" "$output_url" + + local optimized_size + optimized_size=$(get_file_size "$output_path") + + local reduction=0 + if (( original_size > 0 )); then + reduction=$(( (original_size - optimized_size) * 100 / original_size )) + fi + + echo "$(format_size "$original_size") → $(format_size "$optimized_size") (${reduction}% reduction)" +} + +optimize_with_imagemagick() { + local input_path="$1" + local output_path="$2" + + local original_size + original_size=$(get_file_size "$input_path") + + # Use ImageMagick to optimize + magick "$input_path" -strip -quality 85 "$output_path" + + local optimized_size + optimized_size=$(get_file_size "$output_path") + + local reduction=0 + if (( original_size > 0 )); then + reduction=$(( (original_size - optimized_size) * 100 / original_size )) + fi + + echo "$(format_size "$original_size") → $(format_size "$optimized_size") (${reduction}% reduction)" +} + +optimize_image() { + local input_path="$1" + local output_path="$2" + + local basename_file + basename_file=$(basename "$input_path") + + # Try TinyPNG if API key is provided + if [[ -n "${TINYPNG_API_KEY:-}" ]]; then + if result=$(optimize_with_tinypng "$input_path" "$output_path" "$TINYPNG_API_KEY" 2>&1); then + echo " ✓ $basename_file: $result" + return 0 + else + echo " ⚠ TinyPNG failed for $basename_file, trying ImageMagick…" + fi + fi + + # Fallback to ImageMagick + if command -v magick &> /dev/null; then + if result=$(optimize_with_imagemagick "$input_path" "$output_path" 2>&1); then + echo " ✓ $basename_file: $result" + return 0 + else + echo " ⚠ ImageMagick failed for $basename_file: $result" + fi + fi + + # Final fallback: just copy + cp "$input_path" "$output_path" + echo " ⊘ $basename_file: copied without optimization" +} + +# Main script +if [[ $# -eq 0 ]]; then + echo "Usage: $0 POST_FILE" >&2 + echo "" >&2 + echo "Example:" >&2 + echo " $0 _posts/2025/2025-11-21-my-post.md" >&2 + exit 1 +fi + +post_path="$1" + +if [[ ! -f "$post_path" ]]; then + echo "Error: Post file not found: $post_path" >&2 + exit 1 +fi + +content=$(cat "$post_path") +local_images=$(find_local_images "$content") + +if [[ -z "$local_images" ]]; then + echo "No local images found in post." >&2 + echo "Local images should be referenced like: ![alt](./.draft-images/...)" >&2 + exit 0 +fi + +date_slug=$(extract_date_slug "$post_path") +images_repo_path="${IMAGES_REPO_PATH/#\~/$HOME}" +image_base_url="${IMAGE_BASE_URL}" + +if [[ ! -d "$images_repo_path" ]]; then + echo "Error: Images repository not found at $images_repo_path" >&2 + echo "Check your .blog-config.sh configuration." >&2 + exit 1 +fi + +# Create target directory in images repo +target_dir="$images_repo_path/blog/$date_slug" +mkdir -p "$target_dir" + +image_count=$(echo "$local_images" | wc -l | tr -d ' ') +echo "" +echo "⏳ Optimizing and copying $image_count image(s)…" +echo "" + +# Process images in parallel for better performance +declare -A url_mappings +declare -a pids=() +declare -a temp_files=() + +# Cleanup function for temp files +cleanup_temp_files() { + for temp_file in "${temp_files[@]}"; do + rm -f "$temp_file" + done +} +trap cleanup_temp_files EXIT + +# Function to process a single image (runs in background) +process_single_image() { + local local_path="$1" + local temp_output="$2" + + # Resolve full path and validate safety + local full_local_path="$BLOG_ROOT/${local_path#./}" + + # Validate path is safe (prevent path traversal) + local safe_source + if ! safe_source=$(validate_path_safety "$full_local_path" "$BLOG_ROOT" "image source" 2>&1); then + echo "SKIP|$local_path|unsafe path" > "$temp_output" + return 1 + fi + + if [[ ! -f "$safe_source" ]]; then + echo "SKIP|$local_path|not found" > "$temp_output" + return 1 + fi + + local filename + filename=$(basename "$safe_source") + local target_path="$target_dir/$filename" + + # Validate target path is safe + local safe_target + if ! safe_target=$(validate_path_safety "$target_path" "$images_repo_path" "image target" 2>&1); then + echo "SKIP|$local_path|unsafe target" > "$temp_output" + return 1 + fi + + # Optimize and copy + local result + result=$(optimize_image "$safe_source" "$safe_target" 2>&1) + + # Build final URL + local final_url="$image_base_url/blog/$date_slug/$filename" + + # Write result to temp file + echo "OK|$local_path|$final_url|$result" > "$temp_output" +} + +export -f process_single_image +export -f optimize_image +export -f optimize_with_tinypng +export -f optimize_with_imagemagick +export -f format_size +export -f validate_path_safety +export -f get_file_size +export BLOG_ROOT target_dir images_repo_path image_base_url date_slug TINYPNG_API_KEY + +# Process each image in parallel +counter=1 +while IFS= read -r local_path; do + [[ -z "$local_path" ]] && continue + + temp_file=$(mktemp) + temp_files+=("$temp_file") + + process_single_image "$local_path" "$temp_file" & + pids+=($!) + + ((counter++)) +done <<< "$local_images" + +# Wait for all background processes to complete +for pid in "${pids[@]}"; do + wait "$pid" || true # Don't fail if one image fails +done + +# Collect results from temp files +for temp_file in "${temp_files[@]}"; do + if [[ -f "$temp_file" ]] && [[ -s "$temp_file" ]]; then + IFS='|' read -r status local_path data message < "$temp_file" + + case "$status" in + OK) + final_url="$data" + url_mappings["$local_path"]="$final_url" + echo " $message" + ;; + SKIP) + echo " ⚠ $local_path: $data" + ;; + esac + fi + + rm -f "$temp_file" +done + +echo "" +echo "⏳ Committing to images repository…" + +# Git operations (with error propagation) +if ! ( + set -euo pipefail + cd "$images_repo_path" + git add "blog/$date_slug" + + # Check if there are changes + if git diff --cached --quiet; then + echo " ⊘ No changes to commit" + else + git commit --only -m "Add images for $date_slug" -- "blog/$date_slug" + echo " ⏳ Pushing to remote…" + git push + echo " ✓ Pushed to remote" + fi +); then + echo "Error: Failed to publish images to repository" >&2 + echo "Images were optimized but not pushed. Check git status in:" >&2 + echo " $images_repo_path" >&2 + exit 1 +fi + +echo "" +echo "⏳ Updating post with final URLs…" + +# Create backup before modifying +backup_path="${post_path}.backup-$(date +%s)" +cp "$post_path" "$backup_path" + +updated_content="$content" +for local_path in "${!url_mappings[@]}"; do + final_url="${url_mappings[$local_path]}" + # Escape special characters for sed + escaped_local=$(echo "$local_path" | sed 's/[\/&]/\\&/g') + escaped_final=$(echo "$final_url" | sed 's/[\/&]/\\&/g') + updated_content=$(echo "$updated_content" | sed "s|$escaped_local|$escaped_final|g") +done + +echo "$updated_content" > "$post_path" + +# Verify the update looks reasonable +original_lines=$(wc -l < "$backup_path") +updated_lines=$(wc -l < "$post_path") + +# If the file shrunk dramatically (>50%), warn and restore +if (( updated_lines < original_lines / 2 )); then + echo "Warning: Post file appears corrupted after update (too few lines)" >&2 + echo "Original: $original_lines lines, Updated: $updated_lines lines" >&2 + echo "Restoring from backup…" >&2 + mv "$backup_path" "$post_path" + exit 1 +fi + +# Clean up backup on success +rm -f "$backup_path" + +echo " ✓ Updated $post_path" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "✓ Images published successfully!" +echo "" +echo "Next steps:" +echo " 1. Preview with live URLs: jekyll serve" +echo " 2. Commit your post: git add $post_path" +echo " 3. Create PR: git commit && git push && gh pr create" diff --git a/tests/unit/test-config-loader.bats b/tests/unit/test-config-loader.bats new file mode 100644 index 000000000..792e938d4 --- /dev/null +++ b/tests/unit/test-config-loader.bats @@ -0,0 +1,254 @@ +#!/usr/bin/env bats +# Tests for script/lib/config-loader.sh + +setup() { + # Get paths + TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + PROJECT_ROOT="$(cd "$TEST_DIR/../.." && pwd)" + SCRIPT="$PROJECT_ROOT/script/lib/config-loader.sh" + + # Source the script + source "$SCRIPT" + + # Create temporary directory for test configs + TEST_TEMP_DIR="$(mktemp -d)" + export BLOG_ROOT="$TEST_TEMP_DIR" +} + +teardown() { + # Clean up temp directory + [ -d "$TEST_TEMP_DIR" ] && rm -rf "$TEST_TEMP_DIR" +} + +# === load_blog_config tests === + +@test "load_blog_config: loads valid config file" { + cat > "$TEST_TEMP_DIR/config.yml" << 'EOF' +OPENAI_API_KEY="sk-test-key" +TINYPNG_API_KEY="tinypng-key" +IMAGES_REPO_PATH="$HOME/images" +IMAGE_BASE_URL="https://test.com" +EDITOR="vim" +EOF + + load_blog_config "$TEST_TEMP_DIR/config.yml" true + + [ "$OPENAI_API_KEY" = "sk-test-key" ] + [ "$TINYPNG_API_KEY" = "tinypng-key" ] + [ "$IMAGE_BASE_URL" = "https://test.com" ] + [ "$EDITOR" = "vim" ] +} + +@test "load_blog_config: expands \$HOME in paths" { + cat > "$TEST_TEMP_DIR/config.yml" << 'EOF' +IMAGES_REPO_PATH="$HOME/images" +EOF + + load_blog_config "$TEST_TEMP_DIR/config.yml" true + + [ "$IMAGES_REPO_PATH" = "$HOME/images" ] +} + +@test "load_blog_config: expands tilde in paths" { + cat > "$TEST_TEMP_DIR/config.yml" << 'EOF' +IMAGES_REPO_PATH="~/images" +EOF + + load_blog_config "$TEST_TEMP_DIR/config.yml" true + + [[ "$IMAGES_REPO_PATH" =~ ^/.*images$ ]] +} + +@test "load_blog_config: ignores comments" { + cat > "$TEST_TEMP_DIR/config.yml" << 'EOF' +# This is a comment +OPENAI_API_KEY="sk-test-key" +# Another comment +EOF + + load_blog_config "$TEST_TEMP_DIR/config.yml" true + + [ "$OPENAI_API_KEY" = "sk-test-key" ] +} + +@test "load_blog_config: ignores empty lines" { + cat > "$TEST_TEMP_DIR/config.yml" << 'EOF' + +OPENAI_API_KEY="sk-test-key" + + +EOF + + load_blog_config "$TEST_TEMP_DIR/config.yml" true + + [ "$OPENAI_API_KEY" = "sk-test-key" ] +} + +@test "load_blog_config: warns on unknown keys" { + cat > "$TEST_TEMP_DIR/config.yml" << 'EOF' +UNKNOWN_KEY="value" +EOF + + run load_blog_config "$TEST_TEMP_DIR/config.yml" true + + [[ "$output" =~ "Unknown configuration key" ]] +} + +@test "load_blog_config: fails when required file missing" { + run load_blog_config "$TEST_TEMP_DIR/nonexistent.yml" true + + [ "$status" -eq 1 ] + [[ "$output" =~ "Configuration file not found" ]] +} + +@test "load_blog_config: succeeds when optional file missing" { + run load_blog_config "$TEST_TEMP_DIR/nonexistent.yml" false + + [ "$status" -eq 0 ] +} + +@test "load_blog_config: handles single quotes" { + cat > "$TEST_TEMP_DIR/config.yml" << EOF +OPENAI_API_KEY='sk-test-key' +EOF + + load_blog_config "$TEST_TEMP_DIR/config.yml" true + + [ "$OPENAI_API_KEY" = "sk-test-key" ] +} + +@test "load_blog_config: handles values without quotes" { + cat > "$TEST_TEMP_DIR/config.yml" << 'EOF' +DALLE_MODEL=dall-e-3 +EOF + + load_blog_config "$TEST_TEMP_DIR/config.yml" true + + [ "$DALLE_MODEL" = "dall-e-3" ] +} + +# === validate_editor tests === + +@test "validate_editor: accepts valid editor names" { + # Test with editors that exist on both macOS and Linux + run validate_editor "vi" + [ "$status" -eq 0 ] + + # Test with absolute path using command that definitely exists + local cat_path + cat_path=$(command -v cat) + run validate_editor "$cat_path" + [ "$status" -eq 0 ] +} + +@test "validate_editor: rejects editor with special characters" { + run validate_editor "vim; rm -rf /" + [ "$status" -eq 1 ] + [[ "$output" =~ "unsafe characters" ]] +} + +@test "validate_editor: allows empty editor" { + run validate_editor "" + [ "$status" -eq 0 ] +} + +@test "validate_editor: warns if editor not in PATH" { + run validate_editor "nonexistent-editor-xyz" + [ "$status" -eq 1 ] + [[ "$output" =~ "not found in PATH" ]] +} + +# === validate_path_safety tests === + +@test "validate_path_safety: allows paths within base directory" { + mkdir -p "$TEST_TEMP_DIR/blog/images" + touch "$TEST_TEMP_DIR/blog/images/test.png" + + result=$(validate_path_safety "$TEST_TEMP_DIR/blog/images/test.png" "$TEST_TEMP_DIR/blog" "test") + + [[ "$result" =~ /blog/images/test.png$ ]] +} + +@test "validate_path_safety: rejects path traversal attempts" { + mkdir -p "$TEST_TEMP_DIR/blog" + + run validate_path_safety "$TEST_TEMP_DIR/blog/../../etc/passwd" "$TEST_TEMP_DIR/blog" "test" + + [ "$status" -eq 1 ] + [[ "$output" =~ "Path traversal detected" ]] +} + +@test "validate_path_safety: prevents blog-evil bypass" { + mkdir -p "$TEST_TEMP_DIR/blog" + mkdir -p "$TEST_TEMP_DIR/blog-evil" + + # This should fail - blog-evil is NOT under blog/ + run validate_path_safety "$TEST_TEMP_DIR/blog-evil/file.txt" "$TEST_TEMP_DIR/blog" "test" + + [ "$status" -eq 1 ] + [[ "$output" =~ "Path traversal detected" ]] +} + +@test "validate_path_safety: allows exact base directory match" { + mkdir -p "$TEST_TEMP_DIR/blog" + + result=$(validate_path_safety "$TEST_TEMP_DIR/blog" "$TEST_TEMP_DIR/blog" "test") + + [[ "$result" =~ /blog$ ]] +} + +@test "validate_path_safety: requires proper directory boundary" { + mkdir -p "$TEST_TEMP_DIR/blog" + mkdir -p "$TEST_TEMP_DIR/blogosphere" + + # blogosphere is NOT under blog/, should fail + run validate_path_safety "$TEST_TEMP_DIR/blogosphere/file.txt" "$TEST_TEMP_DIR/blog" "test" + + [ "$status" -eq 1 ] +} + +# === get_file_size tests === + +@test "get_file_size: returns size of existing file" { + echo "test content" > "$TEST_TEMP_DIR/test.txt" + + size=$(get_file_size "$TEST_TEMP_DIR/test.txt") + + [ "$size" -gt 0 ] +} + +@test "get_file_size: works on empty file" { + touch "$TEST_TEMP_DIR/empty.txt" + + size=$(get_file_size "$TEST_TEMP_DIR/empty.txt") + + [ "$size" -eq 0 ] +} + +# === check_required_dependencies tests === + +@test "check_required_dependencies: succeeds with installed commands" { + run check_required_dependencies bash cat ls + + [ "$status" -eq 0 ] +} + +@test "check_required_dependencies: fails with missing commands" { + run check_required_dependencies nonexistent-command-xyz + + [ "$status" -eq 1 ] + [[ "$output" =~ "Missing required dependencies" ]] +} + +@test "check_required_dependencies: function has install instructions for known tools" { + # This test verifies that the function contains install instructions + # We can't easily test the output without mocking, so we verify the function + # contains the expected case statements for known tools + + # Read the function and check it has install instructions + func_body=$(declare -f check_required_dependencies) + + # Check that function contains install instructions for jq + [[ "$func_body" =~ "brew install jq" ]] + [[ "$func_body" =~ "apt-get install jq" ]] +} diff --git a/tests/unit/test-slug-generation.bats b/tests/unit/test-slug-generation.bats new file mode 100644 index 000000000..c42f66707 --- /dev/null +++ b/tests/unit/test-slug-generation.bats @@ -0,0 +1,164 @@ +#!/usr/bin/env bats +# Tests for slug generation in new-post script + +setup() { + # Get paths + TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + PROJECT_ROOT="$(cd "$TEST_DIR/../.." && pwd)" + + # Create temp directory + TEST_TEMP_DIR="$(mktemp -d)" +} + +teardown() { + # Clean up + [ -d "$TEST_TEMP_DIR" ] && rm -rf "$TEST_TEMP_DIR" +} + +# Helper function to simulate slug generation +generate_slug() { + local title="$1" + echo "$title" | tr '[:upper:]' '[:lower:]' | \ + sed -E 's/[^a-z0-9 -]//g' | \ + sed -E 's/ +/-/g' | \ + sed -E 's/-+/-/g' | \ + sed -E 's/^-|-$//g' +} + +# === Slug generation tests === + +@test "generate_slug: basic title to slug" { + result=$(generate_slug "My Amazing Post") + + [ "$result" = "my-amazing-post" ] +} + +@test "generate_slug: removes special characters" { + result=$(generate_slug "Hello! World?") + + [ "$result" = "hello-world" ] +} + +@test "generate_slug: collapses multiple spaces" { + result=$(generate_slug "Too Many Spaces") + + [ "$result" = "too-many-spaces" ] +} + +@test "generate_slug: collapses multiple hyphens" { + result=$(generate_slug "Too---Many---Hyphens") + + [ "$result" = "too-many-hyphens" ] +} + +@test "generate_slug: removes leading hyphen" { + result=$(generate_slug "-Leading Hyphen") + + [ "$result" = "leading-hyphen" ] +} + +@test "generate_slug: removes trailing hyphen" { + result=$(generate_slug "Trailing Hyphen-") + + [ "$result" = "trailing-hyphen" ] +} + +@test "generate_slug: handles all special characters" { + result=$(generate_slug "!@#\$%^&*()") + + [ -z "$result" ] +} + +@test "generate_slug: preserves existing hyphens" { + result=$(generate_slug "Pre-Existing-Hyphens") + + [ "$result" = "pre-existing-hyphens" ] +} + +@test "generate_slug: handles numbers" { + result=$(generate_slug "Post Number 42") + + [ "$result" = "post-number-42" ] +} + +@test "generate_slug: handles mixed case" { + result=$(generate_slug "CamelCaseTitle") + + [ "$result" = "camelcasetitle" ] +} + +@test "generate_slug: handles unicode characters removal" { + result=$(generate_slug "Café Señor") + + # Unicode should be removed, leaving just caf and seor + [ "$result" = "caf-seor" ] +} + +@test "generate_slug: empty string from special chars only" { + result=$(generate_slug "!@#\$%^&*(){}[]<>?/|\\") + + [ -z "$result" ] +} + +@test "generate_slug: handles spaces and hyphens combination" { + result=$(generate_slug "Spaced - Out - Title") + + [ "$result" = "spaced-out-title" ] +} + +@test "generate_slug: real world example 1" { + result=$(generate_slug "How to Deploy Your App to Production") + + [ "$result" = "how-to-deploy-your-app-to-production" ] +} + +@test "generate_slug: real world example 2" { + result=$(generate_slug "Building a REST API with Node.js") + + [ "$result" = "building-a-rest-api-with-nodejs" ] +} + +@test "generate_slug: real world example 3" { + result=$(generate_slug "10 Tips for Better Code Reviews") + + [ "$result" = "10-tips-for-better-code-reviews" ] +} + +# === Edge cases === + +@test "edge case: very long title" { + long_title="This Is A Very Long Title With Many Words That Goes On And On And On And Should Still Be Processed Correctly" + result=$(generate_slug "$long_title") + + [[ "$result" =~ ^this-is-a-very-long-title ]] +} + +@test "edge case: single word" { + result=$(generate_slug "Hello") + + [ "$result" = "hello" ] +} + +@test "edge case: single character" { + result=$(generate_slug "a") + + [ "$result" = "a" ] +} + +@test "edge case: numbers only" { + result=$(generate_slug "123 456 789") + + [ "$result" = "123-456-789" ] +} + +@test "edge case: hyphens only" { + result=$(generate_slug "---") + + [ -z "$result" ] +} + +@test "edge case: mixed valid and invalid chars" { + result=$(generate_slug "valid!!! @@@chars###") + + [ "$result" = "valid-chars" ] +} diff --git a/tests/unit/test-text-processing.bats b/tests/unit/test-text-processing.bats new file mode 100644 index 000000000..01d74c767 --- /dev/null +++ b/tests/unit/test-text-processing.bats @@ -0,0 +1,186 @@ +#!/usr/bin/env bats +# Tests for text processing functions in generate-images + +setup() { + # Get paths + TEST_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + PROJECT_ROOT="$(cd "$TEST_DIR/../.." && pwd)" + + # Source just the functions we need (not the whole script) + # Extract functions into a testable module + eval "$(sed -n '/^find_image_placeholders()/,/^}/p' "$PROJECT_ROOT/script/generate-images")" + eval "$(sed -n '/^extract_placeholder_id()/,/^}/p' "$PROJECT_ROOT/script/generate-images")" + eval "$(sed -n '/^extract_placeholder_description()/,/^}/p' "$PROJECT_ROOT/script/generate-images")" + # extract_date_slug is now in config-loader.sh + eval "$(sed -n '/^extract_date_slug()/,/^}/p' "$PROJECT_ROOT/script/lib/config-loader.sh")" +} + +# === find_image_placeholders tests === + +@test "find_image_placeholders: finds single placeholder" { + content="Here is [image1: A sunset] some text" + + result=$(find_image_placeholders "$content") + + [ "$result" = "[image1: A sunset]" ] +} + +@test "find_image_placeholders: finds multiple placeholders" { + content="[image1: First] text [image2: Second]" + + result=$(find_image_placeholders "$content") + + [ "$(echo "$result" | wc -l)" -eq 2 ] +} + +@test "find_image_placeholders: handles no placeholders" { + content="No placeholders here" + + result=$(find_image_placeholders "$content") + + [ -z "$result" ] +} + +@test "find_image_placeholders: handles colons in description" { + content="[image1: URL: https://example.com]" + + result=$(find_image_placeholders "$content") + + [ "$result" = "[image1: URL: https://example.com]" ] +} + +@test "find_image_placeholders: handles multiline content" { + content="Line 1 +[image1: First image] +Line 2 +[image2: Second image]" + + result=$(find_image_placeholders "$content") + + [ "$(echo "$result" | wc -l)" -eq 2 ] +} + +@test "find_image_placeholders: requires image prefix with number" { + content="[notimage: Wrong format]" + + result=$(find_image_placeholders "$content") + + [ -z "$result" ] +} + +@test "find_image_placeholders: handles sequential numbers" { + content="[image1: First] [image2: Second] [image99: Last]" + + result=$(find_image_placeholders "$content") + + [ "$(echo "$result" | wc -l)" -eq 3 ] +} + +# === extract_placeholder_id tests === + +@test "extract_placeholder_id: extracts simple ID" { + result=$(extract_placeholder_id "[image1: Description]") + + [ "$result" = "image1" ] +} + +@test "extract_placeholder_id: handles different numbers" { + result=$(extract_placeholder_id "[image42: Description]") + + [ "$result" = "image42" ] +} + +@test "extract_placeholder_id: works with large numbers" { + result=$(extract_placeholder_id "[image999: Description]") + + [ "$result" = "image999" ] +} + +# === extract_placeholder_description tests === + +@test "extract_placeholder_description: extracts simple description" { + result=$(extract_placeholder_description "[image1: A sunset]") + + [ "$result" = "A sunset" ] +} + +@test "extract_placeholder_description: handles colons in description" { + result=$(extract_placeholder_description "[image1: URL: https://example.com]") + + [ "$result" = "URL: https://example.com" ] +} + +@test "extract_placeholder_description: trims leading spaces" { + result=$(extract_placeholder_description "[image1: Extra spaces]") + + [ "$result" = "Extra spaces" ] +} + +@test "extract_placeholder_description: handles long descriptions" { + input="[image1: This is a very long description with many words and details]" + result=$(extract_placeholder_description "$input") + + [ "$result" = "This is a very long description with many words and details" ] +} + +@test "extract_placeholder_description: handles special characters" { + result=$(extract_placeholder_description "[image1: Test with \"quotes\" and 'apostrophes']") + + [[ "$result" =~ quotes ]] +} + +# === extract_date_slug tests === + +@test "extract_date_slug: extracts from standard filename" { + result=$(extract_date_slug "_posts/2025/2025-11-21-my-post.md") + + [ "$result" = "2025-11-21-my-post" ] +} + +@test "extract_date_slug: handles different years" { + result=$(extract_date_slug "_posts/2024/2024-01-15-test.md") + + [ "$result" = "2024-01-15-test" ] +} + +@test "extract_date_slug: handles long slugs" { + result=$(extract_date_slug "_posts/2025/2025-11-21-this-is-a-very-long-slug-name.md") + + [ "$result" = "2025-11-21-this-is-a-very-long-slug-name" ] +} + +@test "extract_date_slug: handles paths without year directory" { + result=$(extract_date_slug "_posts/2025-11-21-my-post.md") + + [ "$result" = "2025-11-21-my-post" ] +} + +# === Integration: Full workflow === + +@test "full workflow: parse placeholder and extract parts" { + content="Here is [image1: A beautiful sunset over mountains] in the post" + + # Find placeholder + placeholder=$(find_image_placeholders "$content") + + # Extract parts + id=$(extract_placeholder_id "$placeholder") + desc=$(extract_placeholder_description "$placeholder") + + [ "$id" = "image1" ] + [ "$desc" = "A beautiful sunset over mountains" ] +} + +@test "full workflow: multiple placeholders preserve order" { + content="[image1: First] then [image2: Second] then [image3: Third]" + + placeholders=$(find_image_placeholders "$content") + + first=$(echo "$placeholders" | head -1) + second=$(echo "$placeholders" | head -2 | tail -1) + third=$(echo "$placeholders" | tail -1) + + [ "$(extract_placeholder_id "$first")" = "image1" ] + [ "$(extract_placeholder_id "$second")" = "image2" ] + [ "$(extract_placeholder_id "$third")" = "image3" ] +}