#!/usr/bin/env nu # Content Image Copier # # Copies images/ directories from page-bundle posts to the public static tree # so they are served over HTTP. # # Page-bundle layout (source): # {content_root}/{type}/{lang}/{category}/{slug}/ # ├── index.md # ├── index.ncl # └── images/ # └── *.{png,jpg,webp,svg,gif,avif} # # After copy (destination): # {public_root}/content/{type}/{lang}/{category}/{slug}/images/ # # Markdown references use absolute paths: # ![alt](/content/{type}/{lang}/{category}/{slug}/images/file.png) # # Usage: # nu copy-content-images.nu [--content-dir PATH] [--public-dir PATH] # [--type TYPE] [--lang LANG] [--dry-run] [--verbose] # # Environment: # SITE_CONTENT_PATH — overrides --content-dir default # SITE_PUBLIC_PATH — overrides --public-dir default def main [ --content-dir: string = "" # Content root (default: site/content) --public-dir: string = "" # Public assets root (default: site/public) --type: string = "" # Filter to one content type --lang: string = "" # Filter to one language --dry-run # Print what would be copied without writing --verbose # Print per-file details ] { let content_root = resolve_env "SITE_CONTENT_PATH" $content_dir "site/content" let public_root = resolve_env "SITE_PUBLIC_PATH" $public_dir "site/public" if not ($content_root | path exists) { error make { msg: $"Content directory not found: ($content_root)" } } print $"(ansi cyan)Content Image Copier(ansi reset)" print $" content : ($content_root)" print $" public : ($public_root)/content" if $dry_run { print $"(ansi yellow)[dry-run](ansi reset)" } let types = discover_dirs $content_root $type if ($types | is-empty) { print "No content types found."; return } mut total_copied = 0 mut total_skipped = 0 mut total_errors = 0 for ct in $types { let langs = discover_dirs $"($content_root)/($ct)" $lang for language in $langs { let lang_dir = $"($content_root)/($ct)/($language)" let subdirs = discover_dirs $lang_dir "" for sub in $subdirs { let sub_dir = $"($lang_dir)/($sub)" # Check if this subdir is itself a page-bundle (flat layout: no category) if ($"($sub_dir)/index.md" | path exists) and ($"($sub_dir)/images" | path exists) { # Flat page-bundle: treat as cat="" slug=$sub let result = copy_category_images $lang_dir $ct $language "" $public_root $dry_run $verbose $total_copied = $total_copied + $result.copied $total_skipped = $total_skipped + $result.skipped $total_errors = $total_errors + $result.errors # Flat bundles handled at lang_dir level — break to avoid duplicate processing break } else { # Category layout: subdir contains page-bundles let result = copy_category_images $sub_dir $ct $language $sub $public_root $dry_run $verbose $total_copied = $total_copied + $result.copied $total_skipped = $total_skipped + $result.skipped $total_errors = $total_errors + $result.errors } } } # Copy language-neutral shared images from {ct}/_images/ let shared = copy_shared_images $ct $content_root $public_root $dry_run $verbose $total_copied = $total_copied + $shared.copied $total_skipped = $total_skipped + $shared.skipped $total_errors = $total_errors + $shared.errors } print "" print $"(ansi green)Done(ansi reset) — copied: ($total_copied) skipped: ($total_skipped) errors: ($total_errors)" if $total_errors > 0 { exit 1 } } # Copy images/ from one category directory def copy_category_images [ cat_dir: string, ct: string, lang: string, cat: string, public_root: string, dry_run: bool, verbose: bool, ] { if not ($cat_dir | path exists) { return { copied: 0, skipped: 0, errors: 0 } } # Find slug dirs that have an images/ subdir. # Two layouts: # page-bundle: {slug}/index.md exists (projects, activities) # flat-file: {slug}.md exists in cat_dir (blog, recipes) let bundles = (ls $cat_dir) | where type == "dir" | get name | each { |p| $p | path basename } | where { |d| not ($d | str starts-with ".") } | where { |d| ($"($cat_dir)/($d)/images" | path exists) and ( ($"($cat_dir)/($d)/index.md" | path exists) or ($"($cat_dir)/($d).md" | path exists) ) } if ($bundles | is-empty) { return { copied: 0, skipped: 0, errors: 0 } } mut copied = 0 mut skipped = 0 mut errors = 0 for slug in $bundles { let src_images = $"($cat_dir)/($slug)/images" let dst_images = if $cat == "" { $"($public_root)/content/($ct)/($lang)/($slug)/images" } else { $"($public_root)/content/($ct)/($lang)/($cat)/($slug)/images" } # Collect image files let img_files = try { (ls $src_images) | where type == "file" | where { |e| let ext = ($e.name | path parse | get extension | str downcase) ($ext in ["png", "jpg", "jpeg", "webp", "svg", "gif", "avif"]) } | get name } catch { [] } if ($img_files | is-empty) { if $verbose { print $" skip ($ct)/($lang)/($cat)/($slug)/images — no image files" } continue } if $verbose { print $" ($ct)/($lang)/($cat)/($slug): ($img_files | length) images" } for img in $img_files { let dst_file = $"($dst_images)/($img | path basename)" # Skip if destination is identical (same size) if ($dst_file | path exists) { let src_size = (ls $img | get size | first) let dst_size = (ls $dst_file | get size | first) if $src_size == $dst_size { if $verbose { print $" skip ($img | path basename) — unchanged" } $skipped = $skipped + 1 continue } } if $dry_run { print $" [dry-run] ($img | path basename) → ($dst_file)" $copied = $copied + 1 continue } let result = try { mkdir $dst_images cp $img $dst_file "ok" } catch { |e| print $" (ansi red)ERROR(ansi reset) ($img | path basename): ($e.msg)" "error" } if $result == "ok" { if $verbose { print $" (ansi green)copy(ansi reset) ($img | path basename)" } $copied = $copied + 1 } else { $errors = $errors + 1 } } } { copied: $copied, skipped: $skipped, errors: $errors } } # Copy {ct}/_images/ tree to public — language-neutral shared images def copy_shared_images [ ct: string, content_root: string, public_root: string, dry_run: bool, verbose: bool, ] { let src_root = $"($content_root)/($ct)/_images" let dst_root = $"($public_root)/content/($ct)/_images" if not ($src_root | path exists) { return { copied: 0, skipped: 0, errors: 0 } } mut copied = 0; mut skipped = 0; mut errors = 0 # Walk cat/slug dirs let cats = (ls $src_root) | where type == "dir" | get name | each { |p| $p | path basename } for cat in $cats { let cat_dir = $"($src_root)/($cat)" let slugs = (ls $cat_dir) | where type == "dir" | get name | each { |p| $p | path basename } | where { |d| $d != "pending" } for slug in $slugs { let slug_dir = $"($cat_dir)/($slug)" let dst_slug = $"($dst_root)/($cat)/($slug)" let img_files = try { (ls $slug_dir) | where type == "file" | where { |e| let ext = ($e.name | path parse | get extension | str downcase) ($ext in ["png", "jpg", "jpeg", "webp", "svg", "gif", "avif"]) } | get name } catch { [] } for img in $img_files { let dst_file = $"($dst_slug)/($img | path basename)" if ($dst_file | path exists) { let ss = (ls $img | get size | first) let ds = (ls $dst_file | get size | first) if $ss == $ds { $skipped = $skipped + 1; continue } } if $dry_run { if $verbose { print $" [dry-run] ($img | path basename) → ($dst_file)" } $copied = $copied + 1; continue } let result = try { mkdir $dst_slug; cp $img $dst_file; "ok" } catch { |e| print $" (ansi red)ERROR(ansi reset) ($img | path basename): ($e.msg)"; "error" } if $result == "ok" { if $verbose { print $" (ansi green)copy(ansi reset) ($img | path basename)" } $copied = $copied + 1 } else { $errors = $errors + 1 } } } } { copied: $copied, skipped: $skipped, errors: $errors } } def resolve_env [var_name: string, cli_arg: string, fallback: string] { if $cli_arg != "" { return $cli_arg } try { $env | get $var_name } catch { $fallback } } def discover_dirs [parent: string, filter: string] { if not ($parent | path exists) { return [] } let all = (ls $parent) | where type == "dir" | get name | each { |p| $p | path basename } | where { |d| not ($d | str starts-with ".") } | where { |d| not ($d | str starts-with "_") } # exclude _images, _shared, etc. if $filter != "" { $all | where { |d| $d == $filter } } else { $all } }