1137 lines
44 KiB
Text
1137 lines
44 KiB
Text
#!/usr/bin/env nu
|
|
|
|
# AI Image Generation Pipeline
|
|
#
|
|
# Discovers content items missing images, constructs typed prompts via Tera
|
|
# templates, calls the configured image provider, presents each image for human
|
|
# approval via typedialog, injects approved paths into both metadata files
|
|
# (.md + .ncl), and publishes a NATS event to trigger the content sync pipeline.
|
|
#
|
|
# Provider-agnostic: the `provider` field in image-generation.ncl selects the
|
|
# backend. Both expose the uniform Result shape { ok, actual_cost, err }:
|
|
# - openai → dall-e-3 / gpt-image-1 (POST /v1/images/generations, b64_json)
|
|
# - gemini → gemini-2.5-flash-image (POST :generateContent, inlineData)
|
|
#
|
|
# Two-phase execution:
|
|
# Phase 1 — generate: provider calls for all discovered items → images/pending/
|
|
# Phase 2 — review: typedialog batch approval → finalize approved items
|
|
#
|
|
# Prerequisites (runtime):
|
|
# - nickel CLI (`nickel export`) for config loading
|
|
# - tera nu plugin for prompt rendering
|
|
# - typedialog CLI (`^typedialog select` or `^typedialog-tui`) for approval UI
|
|
# - nats CLI (`^nats pub`) for event publishing
|
|
#
|
|
# Usage:
|
|
# nu generate-images.nu [flags]
|
|
#
|
|
# Environment:
|
|
# OPENAI_API_KEY — required when provider = "openai"
|
|
# GEMINI_API_KEY — required when provider = "gemini"
|
|
# RUSTELO_IMAGE_PROVIDER — global default provider (overrides .ncl; --provider wins)
|
|
# RUSTELO_IMAGE_MODEL — global default model (overrides .ncl; --model wins)
|
|
# RUSTELO_IMAGE_QUALITY — global default quality (overrides .ncl; --quality wins)
|
|
# NATS_NAMESPACE — NATS subject prefix (default: "rustelo")
|
|
# SITE_CONTENT_PATH — overrides --content-dir
|
|
# SITE_PUBLIC_PATH — overrides --public-dir
|
|
|
|
source lib/frontmatter.nu
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Price table — updated 2025-05. Source: platform.openai.com/docs/pricing
|
|
# Keys: "{model}:{quality}:{size}"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def image_price_table [] {
|
|
{
|
|
# dall-e-3
|
|
"dall-e-3:standard:1024x1024": 0.040,
|
|
"dall-e-3:standard:1024x1792": 0.080,
|
|
"dall-e-3:standard:1792x1024": 0.080,
|
|
"dall-e-3:hd:1024x1024": 0.080,
|
|
"dall-e-3:hd:1024x1792": 0.120,
|
|
"dall-e-3:hd:1792x1024": 0.120,
|
|
# gpt-image-1 (flat per-image equivalents from official pricing)
|
|
"gpt-image-1:low:1024x1024": 0.011,
|
|
"gpt-image-1:low:1024x1792": 0.016,
|
|
"gpt-image-1:low:1792x1024": 0.016,
|
|
"gpt-image-1:medium:1024x1024": 0.042,
|
|
"gpt-image-1:medium:1024x1792": 0.063,
|
|
"gpt-image-1:medium:1792x1024": 0.063,
|
|
"gpt-image-1:high:1024x1024": 0.167,
|
|
"gpt-image-1:high:1024x1792": 0.250,
|
|
"gpt-image-1:high:1792x1024": 0.250,
|
|
# gemini-2.5-flash-image (nano-banana): flat ~1290 output tokens/image
|
|
# at $30/1M output → ~$0.039/image regardless of aspect ratio.
|
|
# Pre-flight estimate only; actual cost comes from usageMetadata.
|
|
"gemini-2.5-flash-image:standard:1024x1024": 0.039,
|
|
"gemini-2.5-flash-image:standard:1024x1792": 0.039,
|
|
"gemini-2.5-flash-image:standard:1792x1024": 0.039,
|
|
}
|
|
}
|
|
|
|
def cost_per_image [model: string, quality: string, size: string] {
|
|
let key = $"($model):($quality):($size)"
|
|
let table = image_price_table
|
|
$table | get --optional $key | default 0.0
|
|
}
|
|
|
|
# For gpt-image-1: compute actual cost from the usage field returned in the API response.
|
|
# Token prices are far more stable than per-image equivalents.
|
|
# Source: platform.openai.com/docs/pricing (updated 2025-05)
|
|
def cost_from_usage [usage: record] {
|
|
let input_price_per_token = 0.000005 # $5.00 / 1M input tokens
|
|
let output_price_per_token = 0.00004 # $40.00 / 1M output tokens
|
|
let input_tokens = $usage | get --optional input_tokens | default 0
|
|
let output_tokens = $usage | get --optional output_tokens | default 0
|
|
($input_tokens * $input_price_per_token) + ($output_tokens * $output_price_per_token)
|
|
}
|
|
|
|
# For gemini-2.5-flash-image: compute actual cost from usageMetadata.
|
|
# Image output is billed as output tokens (~1290/image) at the flash-image rate.
|
|
# Source: ai.google.dev/gemini-api/docs/pricing (gemini-2.5-flash-image)
|
|
def cost_from_gemini_usage [usage: record] {
|
|
let input_price_per_token = 0.0000003 # $0.30 / 1M input tokens
|
|
let output_price_per_token = 0.00003 # $30.00 / 1M output (image) tokens
|
|
let input_tokens = $usage | get --optional promptTokenCount | default 0
|
|
let output_tokens = $usage | get --optional candidatesTokenCount | default 0
|
|
($input_tokens * $input_price_per_token) + ($output_tokens * $output_price_per_token)
|
|
}
|
|
|
|
def append_spend_log [log_path: string, entry: record] {
|
|
$entry | to json --raw | save --append $log_path
|
|
"\n" | save --append $log_path
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main [
|
|
--content-dir: string = "" # Content root (default: $SITE_CONTENT_PATH or site/content)
|
|
--public-dir: string = "" # Public assets root (default: $SITE_PUBLIC_PATH or site/public)
|
|
--config: string = "" # Nickel config path (default: site/config/image-generation.ncl)
|
|
--provider: string = "" # Override config provider: openai | gemini
|
|
--model: string = "" # Override config model (e.g. gpt-image-1, gemini-2.5-flash-image)
|
|
--quality: string = "" # Override config quality (standard|hd | low|medium|high)
|
|
--type: string = "" # Filter: blog | recipes | projects | activities
|
|
--lang: string = "" # Filter: en | es | ...
|
|
--category: string = "" # Filter: specific category within type
|
|
--slug: string = "" # Target one specific item
|
|
--image-type: string = "both" # thumbnail | feature | both
|
|
--budget: float = 0.0 # Abort if estimated cost exceeds this (USD). 0 = no limit
|
|
--spend-log: string = "" # Path to JSONL spend log (default: scripts/content/.image-spend.jsonl)
|
|
--propagate-langs # Write the approved path to all other language variants
|
|
--insert-hero # Insert hero image as first paragraph in the markdown body
|
|
--force # Regenerate even if thumbnail already set
|
|
--dry-run # Print prompts; no API calls, no file writes
|
|
--no-nats # Skip NATS publish after approval
|
|
--verbose # Per-item detail
|
|
] {
|
|
let content_root = resolve_env "SITE_CONTENT_PATH" $content_dir "site/content"
|
|
let public_root = resolve_env "SITE_PUBLIC_PATH" $public_dir "site/public"
|
|
let config_path = if $config != "" { $config } else { "site/config/image-generation.ncl" }
|
|
|
|
if not ($content_root | path exists) {
|
|
error make { msg: $"Content directory not found: ($content_root)" }
|
|
}
|
|
if not ($config_path | path exists) {
|
|
error make { msg: $"Config file not found: ($config_path)" }
|
|
}
|
|
|
|
let log_path = if $spend_log != "" {
|
|
$spend_log
|
|
} else {
|
|
"scripts/content/.image-spend.jsonl"
|
|
}
|
|
|
|
print $"(ansi cyan)AI Image Generation Pipeline(ansi reset)"
|
|
print $" content : ($content_root)"
|
|
print $" config : ($config_path)"
|
|
if $dry_run { print $"(ansi yellow)[dry-run] No API calls or file writes(ansi reset)" }
|
|
|
|
let cfg_file = load_config $config_path
|
|
|
|
# Effective provider/model/quality with precedence:
|
|
# CLI flag > RUSTELO_IMAGE_* env var > site config (.ncl) > built-in
|
|
# The env layer is a global, file-free default to switch every site at once
|
|
# (e.g. export RUSTELO_IMAGE_PROVIDER=gemini); a --flag still wins per run.
|
|
let eff_provider = resolve_env "RUSTELO_IMAGE_PROVIDER" $provider ""
|
|
let eff_model = resolve_env "RUSTELO_IMAGE_MODEL" $model ""
|
|
let eff_quality = resolve_env "RUSTELO_IMAGE_QUALITY" $quality ""
|
|
|
|
# Validate the effective overrides (guards: fail-fast). The NCL contract only
|
|
# validates the file; these mirror it so a bad flag OR env var is rejected
|
|
# before any spend instead of failing mid-call.
|
|
if $eff_provider != "" and $eff_provider not-in ["openai" "gemini"] {
|
|
error make { msg: $"provider must be 'openai' or 'gemini', got '($eff_provider)' (check --provider / RUSTELO_IMAGE_PROVIDER)" }
|
|
}
|
|
if $eff_quality != "" and $eff_quality not-in ["standard" "hd" "low" "medium" "high"] {
|
|
error make { msg: $"quality must be one of standard|hd|low|medium|high, got '($eff_quality)' (check --quality / RUSTELO_IMAGE_QUALITY)" }
|
|
}
|
|
mut overrides = {}
|
|
if $eff_provider != "" { $overrides = ($overrides | merge { provider: $eff_provider }) }
|
|
if $eff_model != "" { $overrides = ($overrides | merge { model: $eff_model }) }
|
|
if $eff_quality != "" { $overrides = ($overrides | merge { quality: $eff_quality }) }
|
|
let cfg = $cfg_file | merge $overrides
|
|
|
|
# Provider-aware credential resolution (guard: fail-fast before any API call)
|
|
let provider = $cfg | get --optional provider | default "openai"
|
|
let api_key = if $provider == "gemini" {
|
|
resolve_env "GEMINI_API_KEY" "" ""
|
|
} else {
|
|
resolve_env "OPENAI_API_KEY" "" ""
|
|
}
|
|
if not $dry_run and ($api_key | is-empty) {
|
|
let key_var = if $provider == "gemini" { "GEMINI_API_KEY" } else { "OPENAI_API_KEY" }
|
|
error make { msg: $"($key_var) is not set for provider '($provider)'. Export it or add to .env." }
|
|
}
|
|
|
|
let image_types = match $image_type {
|
|
"thumbnail" => ["thumbnail"],
|
|
"feature" => ["feature"],
|
|
_ => ["thumbnail", "feature"],
|
|
}
|
|
|
|
let items = discover_items $content_root $type $lang $category $slug
|
|
|
|
if ($items | is-empty) {
|
|
print "No content items found matching the given filters."
|
|
return
|
|
}
|
|
|
|
let candidates = if $force {
|
|
$items
|
|
} else {
|
|
$items | where { |it| not $it.thumbnail_set }
|
|
}
|
|
|
|
if ($candidates | is-empty) {
|
|
print "All matching items already have images set. Use --force to regenerate."
|
|
return
|
|
}
|
|
|
|
print $" found : ($candidates | length) items needing images"
|
|
|
|
# --- Pre-flight cost estimate ---
|
|
let n_calls = ($candidates | length) * ($image_types | length)
|
|
let unit_cost = $image_types | each { |t|
|
|
let size = if $t == "thumbnail" { $cfg.sizes.thumbnail } else { $cfg.sizes.feature }
|
|
cost_per_image $cfg.model $cfg.quality $size
|
|
} | math sum
|
|
let est_total = ($candidates | length) * $unit_cost
|
|
|
|
let price_known = $unit_cost > 0.0
|
|
if $price_known {
|
|
print $" model : ($cfg.model) / ($cfg.quality)"
|
|
print $" calls : ($n_calls) est. cost: $($est_total | math round --precision 3) USD"
|
|
} else {
|
|
print $" model : ($cfg.model) / ($cfg.quality) [price unknown — not in table]"
|
|
}
|
|
|
|
if not $dry_run and $budget > 0.0 and $price_known and $est_total > $budget {
|
|
error make { msg: $"Estimated cost $($est_total | math round --precision 3) exceeds --budget $($budget). Aborting." }
|
|
}
|
|
|
|
if $dry_run {
|
|
print ""
|
|
print $"(ansi cyan_bold)Dry-run prompts:(ansi reset)"
|
|
for item in $candidates {
|
|
for img_type in $image_types {
|
|
let prompt = build_prompt $item $cfg $item.ct $img_type
|
|
print $"(ansi green)── ($item.ct)/($item.lang)/($item.cat)/($item.slug) [($img_type)](ansi reset)"
|
|
print $prompt
|
|
print ""
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
# --- Phase 1: Generate all images ---
|
|
|
|
print ""
|
|
print $"(ansi cyan_bold)Phase 1: Generating images...(ansi reset)"
|
|
|
|
mut pending = []
|
|
mut gen_errors = 0
|
|
|
|
for item in $candidates {
|
|
for img_type in $image_types {
|
|
let prompt = build_prompt $item $cfg $item.ct $img_type
|
|
let size = if $img_type == "thumbnail" { $cfg.sizes.thumbnail } else { $cfg.sizes.feature }
|
|
let ts = date now | format date "%Y%m%d%H%M%S"
|
|
let pending_dir = $"($item.images_dir)/pending"
|
|
let pending_name = $"($item.slug)_($img_type)_($ts).png"
|
|
let pending_path = $"($pending_dir)/($pending_name)"
|
|
|
|
if $verbose {
|
|
print $" generating ($item.ct)/($item.lang)/($item.cat)/($item.slug) [($img_type)]"
|
|
}
|
|
|
|
mkdir $pending_dir
|
|
|
|
let gen_result = generate_image $prompt $cfg $size $pending_path $api_key
|
|
if $gen_result.err != null {
|
|
print $" (ansi red)ERROR(ansi reset) ($item.slug) [($img_type)]: ($gen_result.err)"
|
|
$gen_errors = $gen_errors + 1
|
|
append_spend_log $log_path {
|
|
ts: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
|
|
model: $cfg.model,
|
|
quality: $cfg.quality,
|
|
size: $size,
|
|
ct: $item.ct,
|
|
lang: $item.lang,
|
|
slug: $item.slug,
|
|
img_type: $img_type,
|
|
cost_usd: 0.0,
|
|
cost_source: "none",
|
|
status: "error",
|
|
error: $gen_result.err,
|
|
}
|
|
continue
|
|
}
|
|
|
|
# Prefer actual cost from usage tokens (gpt-image-1); fall back to price table (dall-e-3)
|
|
let img_cost = $gen_result.actual_cost | default (cost_per_image $cfg.model $cfg.quality $size)
|
|
let cost_src = if ($gen_result.actual_cost | is-empty) { "table" } else { "usage" }
|
|
append_spend_log $log_path {
|
|
ts: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
|
|
model: $cfg.model,
|
|
quality: $cfg.quality,
|
|
size: $size,
|
|
ct: $item.ct,
|
|
lang: $item.lang,
|
|
slug: $item.slug,
|
|
img_type: $img_type,
|
|
cost_usd: $img_cost,
|
|
cost_source: $cost_src,
|
|
status: "generated",
|
|
error: null,
|
|
}
|
|
|
|
if $verbose { print $" (ansi green)saved(ansi reset) → ($pending_path) cost: $$($img_cost) [($cost_src)]" }
|
|
$pending = ($pending | append {
|
|
item: $item,
|
|
img_type: $img_type,
|
|
pending_path: $pending_path,
|
|
prompt: $prompt,
|
|
model: $cfg.model,
|
|
quality: $cfg.quality,
|
|
size: $size,
|
|
cost_usd: $img_cost,
|
|
cost_source: $cost_src,
|
|
})
|
|
}
|
|
}
|
|
|
|
if ($pending | is-empty) {
|
|
print $"(ansi yellow)No images generated — ($gen_errors) errors.(ansi reset)"
|
|
exit 1
|
|
}
|
|
|
|
print $" generated: ($pending | length) images errors: ($gen_errors)"
|
|
|
|
# --- Phase 2: Batch review loop ---
|
|
|
|
print ""
|
|
print $"(ansi cyan_bold)Phase 2: Review and approve...(ansi reset)"
|
|
|
|
mut approved = 0
|
|
mut skipped = 0
|
|
mut to_review = $pending
|
|
|
|
loop {
|
|
if ($to_review | is-empty) { break }
|
|
|
|
let decisions = run_review_batch $to_review
|
|
|
|
mut regen_batch = []
|
|
|
|
for d in $decisions {
|
|
match $d.decision {
|
|
"approve" => {
|
|
finalize_image $d.entry $content_root $no_nats $propagate_langs $insert_hero
|
|
$approved = $approved + 1
|
|
},
|
|
"skip" => {
|
|
rm --force $d.entry.pending_path
|
|
$skipped = $skipped + 1
|
|
if $verbose { print $" skip ($d.entry.item.slug) [($d.entry.img_type)]" }
|
|
},
|
|
"regenerate" => {
|
|
rm --force $d.entry.pending_path
|
|
|
|
let size = if $d.entry.img_type == "thumbnail" { $cfg.sizes.thumbnail } else { $cfg.sizes.feature }
|
|
let ts = date now | format date "%Y%m%d%H%M%S"
|
|
let new_pending_path = $"($d.entry.item.images_dir)/pending/($d.entry.item.slug)_($d.entry.img_type)_($ts).png"
|
|
|
|
let gen_result = generate_image $d.entry.prompt $cfg $size $new_pending_path $api_key
|
|
if $gen_result.err != null {
|
|
print $" (ansi red)REGEN ERROR(ansi reset) ($d.entry.item.slug): ($gen_result.err)"
|
|
$skipped = $skipped + 1
|
|
append_spend_log $log_path {
|
|
ts: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
|
|
model: $cfg.model,
|
|
quality: $cfg.quality,
|
|
size: $size,
|
|
ct: $d.entry.item.ct,
|
|
lang: $d.entry.item.lang,
|
|
slug: $d.entry.item.slug,
|
|
img_type: $d.entry.img_type,
|
|
cost_usd: 0.0,
|
|
cost_source: "none",
|
|
status: "regen-error",
|
|
error: $gen_result.err,
|
|
}
|
|
continue
|
|
}
|
|
|
|
let regen_cost = $gen_result.actual_cost | default (cost_per_image $cfg.model $cfg.quality $size)
|
|
let regen_src = if ($gen_result.actual_cost | is-empty) { "table" } else { "usage" }
|
|
append_spend_log $log_path {
|
|
ts: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
|
|
model: $cfg.model,
|
|
quality: $cfg.quality,
|
|
size: $size,
|
|
ct: $d.entry.item.ct,
|
|
lang: $d.entry.item.lang,
|
|
slug: $d.entry.item.slug,
|
|
img_type: $d.entry.img_type,
|
|
cost_usd: $regen_cost,
|
|
cost_source: $regen_src,
|
|
status: "regenerated",
|
|
error: null,
|
|
}
|
|
|
|
$regen_batch = ($regen_batch | append ($d.entry | merge { pending_path: $new_pending_path, cost_usd: $regen_cost, cost_source: $regen_src }))
|
|
},
|
|
_ => {
|
|
rm --force $d.entry.pending_path
|
|
$skipped = $skipped + 1
|
|
},
|
|
}
|
|
}
|
|
|
|
$to_review = $regen_batch
|
|
}
|
|
|
|
# --- Spend summary ---
|
|
let session_spend = if ($log_path | path exists) {
|
|
open $log_path
|
|
| lines
|
|
| where { |l| not ($l | is-empty) }
|
|
| each { |l| $l | from json }
|
|
| where status == "generated" or status == "regenerated"
|
|
| get cost_usd
|
|
| math sum
|
|
} else {
|
|
0.0
|
|
}
|
|
|
|
print ""
|
|
print $"(ansi green)Done(ansi reset) — approved: ($approved) skipped: ($skipped) gen-errors: ($gen_errors)"
|
|
if $price_known {
|
|
print $" session spend: $$($session_spend | math round --precision 4) USD"
|
|
print $" spend log: ($log_path)"
|
|
}
|
|
if $gen_errors > 0 { exit 1 }
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def load_config [config_path: string] {
|
|
# nickel-export requires an absolute path — plugins resolve relative to their own cwd
|
|
nickel-export ($config_path | path expand) | get image_generation
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def discover_items [
|
|
content_root: string,
|
|
type_filter: string,
|
|
lang_filter: string,
|
|
cat_filter: string,
|
|
slug_filter: string,
|
|
] {
|
|
let types = discover_dirs $content_root $type_filter
|
|
mut items = []
|
|
|
|
for ct in $types {
|
|
let langs = discover_dirs $"($content_root)/($ct)" $lang_filter
|
|
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)"
|
|
|
|
# Page-bundle flat layout (projects): lang_dir/slug/index.md
|
|
if ($"($sub_dir)/index.md" | path exists) {
|
|
if $cat_filter != "" { continue }
|
|
let found = collect_bundle_item $sub_dir $ct $language "" $sub $slug_filter $content_root
|
|
$items = ($items | append $found)
|
|
} else {
|
|
# Category directory: may contain page-bundles or flat .md files
|
|
if $cat_filter != "" and $sub != $cat_filter { continue }
|
|
|
|
# Page-bundle category layout: lang_dir/cat/slug/index.md
|
|
let slug_dirs = discover_dirs $sub_dir ""
|
|
for slug in $slug_dirs {
|
|
let slug_dir = $"($sub_dir)/($slug)"
|
|
let found = collect_bundle_item $slug_dir $ct $language $sub $slug $slug_filter $content_root
|
|
$items = ($items | append $found)
|
|
}
|
|
|
|
# Flat-file layout: lang_dir/cat/{slug}.md (blog posts)
|
|
let flat_items = collect_flat_items $sub_dir $ct $language $sub $slug_filter $content_root
|
|
$items = ($items | append $flat_items)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$items
|
|
}
|
|
|
|
# Collect one page-bundle item (requires slug/index.md); return [] if absent
|
|
def collect_bundle_item [
|
|
item_dir: string,
|
|
ct: string,
|
|
lang: string,
|
|
cat: string,
|
|
slug: string,
|
|
slug_filter: string,
|
|
content_root: string,
|
|
] {
|
|
if not ($"($item_dir)/index.md" | path exists) { return [] }
|
|
if $slug_filter != "" and $slug != $slug_filter { return [] }
|
|
|
|
let md_path = $"($item_dir)/index.md"
|
|
let ncl_path = $"($item_dir)/index.ncl"
|
|
# Images stored language-neutral: {ct}/_images/{cat}/{slug}/
|
|
let images_dir = if $cat == "" {
|
|
$"($content_root)/($ct)/_images/($slug)"
|
|
} else {
|
|
$"($content_root)/($ct)/_images/($cat)/($slug)"
|
|
}
|
|
|
|
let fm = extract_frontmatter $md_path
|
|
let title = extract_from_frontmatter $fm "title" "Untitled"
|
|
let excerpt = extract_from_frontmatter $fm "excerpt" ""
|
|
let subtitle = extract_from_frontmatter $fm "subtitle" ""
|
|
let category = extract_from_frontmatter $fm "category" $cat
|
|
let tags_raw = extract_from_frontmatter $fm "tags" ""
|
|
let tags = parse_tags $tags_raw
|
|
let event_type = extract_from_frontmatter $fm "event_type" "talk"
|
|
|
|
# Check thumbnail set in both .md and .ncl
|
|
let md_thumb = extract_from_frontmatter $fm "thumbnail" ""
|
|
let ncl_thumb = if ($ncl_path | path exists) {
|
|
extract_ncl_field $ncl_path "thumbnail"
|
|
} else { "" }
|
|
let thumbnail_set = (not ($md_thumb | is-empty)) and (not ($ncl_thumb | is-empty))
|
|
|
|
[{
|
|
ct: $ct,
|
|
lang: $lang,
|
|
cat: $cat,
|
|
slug: $slug,
|
|
md_path: $md_path,
|
|
ncl_path: $ncl_path,
|
|
images_dir: $images_dir,
|
|
title: $title,
|
|
excerpt: $excerpt,
|
|
subtitle: $subtitle,
|
|
category: $category,
|
|
tags: $tags,
|
|
event_type: $event_type,
|
|
thumbnail_set: $thumbnail_set,
|
|
}]
|
|
}
|
|
|
|
# Collect flat .md items from a category dir (blog pattern: cat/{slug}.md)
|
|
def collect_flat_items [
|
|
cat_dir: string,
|
|
ct: string,
|
|
lang: string,
|
|
cat: string,
|
|
slug_filter: string,
|
|
content_root: string,
|
|
] {
|
|
if not ($cat_dir | path exists) { return [] }
|
|
|
|
let md_files = (ls $cat_dir)
|
|
| where type == "file"
|
|
| get name
|
|
| each { |p| $p | path basename }
|
|
| where { |f| ($f | path parse | get extension) == "md" }
|
|
| where { |f| not ($f | str starts-with "_") }
|
|
|
|
mut results = []
|
|
for md_file in $md_files {
|
|
let slug = $md_file | path parse | get stem
|
|
if $slug_filter != "" and $slug != $slug_filter { continue }
|
|
|
|
let md_path = $"($cat_dir)/($md_file)"
|
|
let ncl_path = $"($cat_dir)/($slug).ncl"
|
|
# Images stored language-neutral: {ct}/_images/{cat}/{slug}/
|
|
let images_dir = $"($content_root)/($ct)/_images/($cat)/($slug)"
|
|
|
|
let fm = extract_frontmatter $md_path
|
|
let title = extract_from_frontmatter $fm "title" "Untitled"
|
|
let excerpt = extract_from_frontmatter $fm "excerpt" ""
|
|
let subtitle = extract_from_frontmatter $fm "subtitle" ""
|
|
let category = extract_from_frontmatter $fm "category" $cat
|
|
let tags_raw = extract_from_frontmatter $fm "tags" ""
|
|
let tags = parse_tags $tags_raw
|
|
let event_type = extract_from_frontmatter $fm "event_type" "talk"
|
|
|
|
let md_thumb = extract_from_frontmatter $fm "thumbnail" ""
|
|
let ncl_thumb = if ($ncl_path | path exists) { extract_ncl_field $ncl_path "thumbnail" } else { "" }
|
|
let thumbnail_set = (not ($md_thumb | is-empty)) and (not ($ncl_thumb | is-empty))
|
|
|
|
$results = ($results | append [{
|
|
ct: $ct,
|
|
lang: $lang,
|
|
cat: $cat,
|
|
slug: $slug,
|
|
md_path: $md_path,
|
|
ncl_path: $ncl_path,
|
|
images_dir: $images_dir,
|
|
title: $title,
|
|
excerpt: $excerpt,
|
|
subtitle: $subtitle,
|
|
category: $category,
|
|
tags: $tags,
|
|
event_type: $event_type,
|
|
thumbnail_set: $thumbnail_set,
|
|
}])
|
|
}
|
|
$results
|
|
}
|
|
|
|
# Parse YAML inline tag arrays: ["rust", "leptos"] or [rust, leptos]
|
|
def parse_tags [raw: string] {
|
|
if $raw == "" or $raw == "[]" { return [] }
|
|
$raw
|
|
| str replace --all '[' ''
|
|
| str replace --all ']' ''
|
|
| str replace --all '"' ''
|
|
| str replace --all "'" ''
|
|
| split row ','
|
|
| each { |t| $t | str trim }
|
|
| where { |t| not ($t | is-empty) }
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Prompt construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_prompt [item: record, cfg: record, ct: string, image_type: string] {
|
|
let template_path = $"($cfg.templates_dir)/($ct).j2" | path expand
|
|
if not ($template_path | path exists) {
|
|
error make { msg: $"Prompt template not found: ($template_path)" }
|
|
}
|
|
|
|
let style = $cfg.styles | get $ct
|
|
let context = {
|
|
title: $item.title,
|
|
category: $item.category,
|
|
tags: ($item.tags | str join ", "),
|
|
excerpt: $item.excerpt,
|
|
subtitle: $item.subtitle,
|
|
event_type: $item.event_type,
|
|
style: $style,
|
|
image_type: $image_type,
|
|
}
|
|
|
|
# tera-render is a Nu plugin: pipe the context record, pass template as positional
|
|
$context | tera-render $template_path
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Image provider backends — dispatch + per-provider REST calls
|
|
#
|
|
# Every backend returns the uniform Result shape { ok, actual_cost, err }:
|
|
# ok — destination path on success, else null
|
|
# actual_cost — real cost from API usage tokens when available, else null
|
|
# (caller falls back to the static price table)
|
|
# err — error string on failure, else null
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def generate_image [
|
|
prompt: string,
|
|
cfg: record,
|
|
size: string,
|
|
dest: string,
|
|
api_key: string,
|
|
] {
|
|
let provider = $cfg | get --optional provider | default "openai"
|
|
match $provider {
|
|
"gemini" => (call_gemini $prompt $cfg.model $size $dest $api_key),
|
|
"openai" => (call_openai $prompt $cfg.model $size $cfg.quality $dest $api_key),
|
|
_ => { ok: null, actual_cost: null, err: $"Unknown provider: ($provider)" },
|
|
}
|
|
}
|
|
|
|
# Map OpenAI-style dimensions to Gemini aspect ratios (Gemini has no size param).
|
|
def size_to_aspect [size: string] {
|
|
match $size {
|
|
"1792x1024" => "16:9",
|
|
"1024x1792" => "9:16",
|
|
_ => "1:1",
|
|
}
|
|
}
|
|
|
|
# --- OpenAI: dall-e-3 / gpt-image-1 (POST /v1/images/generations) ---
|
|
|
|
def call_openai [
|
|
prompt: string,
|
|
model: string,
|
|
size: string,
|
|
quality: string,
|
|
dest: string,
|
|
api_key: string,
|
|
] {
|
|
let body = {
|
|
model: $model,
|
|
prompt: $prompt,
|
|
n: 1,
|
|
size: $size,
|
|
quality: $quality,
|
|
response_format: "b64_json",
|
|
} | to json
|
|
|
|
# --fail-with-body: non-2xx exits non-zero AND returns the response body
|
|
# (unlike --fail which silences the body). Needed to surface rate-limit details.
|
|
let result = (^curl --silent --fail-with-body --show-error
|
|
-X POST "https://api.openai.com/v1/images/generations"
|
|
-H $"Authorization: Bearer ($api_key)"
|
|
-H "Content-Type: application/json"
|
|
-d $body) | complete
|
|
|
|
if $result.exit_code != 0 {
|
|
let api_err = if ($result.stdout | is-empty) { $result.stderr } else { $result.stdout }
|
|
return { ok: null, actual_cost: null, err: $"OpenAI request failed: ($api_err)" }
|
|
}
|
|
|
|
let parsed = $result.stdout | from json
|
|
let b64 = $parsed | get data | first | get b64_json
|
|
|
|
# Nu's `decode base64 | save` is unreliable for multi-MB payloads: it may
|
|
# write an internal temp-file path instead of the raw bytes. Use a shell
|
|
# redirect through bash so the binary lands directly in $dest.
|
|
let tmp_b64 = $"($nu.temp-dir)/dalle_b64.txt"
|
|
$b64 | save --force $tmp_b64
|
|
let decode_result = (^bash "-c" $"base64 -d < '($tmp_b64)' > '($dest)'") | complete
|
|
rm --force $tmp_b64
|
|
|
|
if $decode_result.exit_code != 0 {
|
|
rm --force $dest
|
|
return { ok: null, actual_cost: null, err: $"base64 decode failed: ($decode_result.stderr)" }
|
|
}
|
|
|
|
# gpt-image-1 returns a usage record; dall-e-3 does not.
|
|
# When present, compute actual cost from real token consumption.
|
|
let usage_field = $parsed | get --optional usage
|
|
let actual_cost = if ($usage_field | is-empty) {
|
|
null
|
|
} else {
|
|
cost_from_usage $usage_field
|
|
}
|
|
|
|
{ ok: $dest, actual_cost: $actual_cost, err: null }
|
|
}
|
|
|
|
# --- Gemini: gemini-2.5-flash-image / nano-banana (POST :generateContent) ---
|
|
#
|
|
# Gemini has no size/quality params: dimensions map to an aspectRatio hint, and
|
|
# the PNG arrives base64-encoded inside candidates[].content.parts[].inlineData.
|
|
def call_gemini [
|
|
prompt: string,
|
|
model: string,
|
|
size: string,
|
|
dest: string,
|
|
api_key: string,
|
|
] {
|
|
let body = {
|
|
contents: [{ parts: [{ text: $prompt }] }],
|
|
generationConfig: {
|
|
responseModalities: ["IMAGE"],
|
|
imageConfig: { aspectRatio: (size_to_aspect $size) },
|
|
},
|
|
} | to json
|
|
|
|
let url = $"https://generativelanguage.googleapis.com/v1beta/models/($model):generateContent"
|
|
let result = (^curl --silent --fail-with-body --show-error
|
|
-X POST $url
|
|
-H $"x-goog-api-key: ($api_key)"
|
|
-H "Content-Type: application/json"
|
|
-d $body) | complete
|
|
|
|
if $result.exit_code != 0 {
|
|
let api_err = if ($result.stdout | is-empty) { $result.stderr } else { $result.stdout }
|
|
return { ok: null, actual_cost: null, err: $"Gemini request failed: ($api_err)" }
|
|
}
|
|
|
|
let parsed = $result.stdout | from json
|
|
let candidates = $parsed | get --optional candidates | default []
|
|
if ($candidates | is-empty) {
|
|
return { ok: null, actual_cost: null, err: $"Gemini returned no candidates: ($result.stdout | str substring 0..500)" }
|
|
}
|
|
|
|
let parts = $candidates | first
|
|
| get --optional content | default {}
|
|
| get --optional parts | default []
|
|
let img_parts = $parts | where { |p| ($p | get --optional inlineData) != null }
|
|
if ($img_parts | is-empty) {
|
|
return { ok: null, actual_cost: null, err: $"Gemini returned no image part: ($result.stdout | str substring 0..500)" }
|
|
}
|
|
let b64 = $img_parts | first | get inlineData | get data
|
|
|
|
# Same multi-MB base64 caveat as OpenAI: decode via a shell redirect.
|
|
let tmp_b64 = $"($nu.temp-dir)/gemini_b64.txt"
|
|
$b64 | save --force $tmp_b64
|
|
let decode_result = (^bash "-c" $"base64 -d < '($tmp_b64)' > '($dest)'") | complete
|
|
rm --force $tmp_b64
|
|
|
|
if $decode_result.exit_code != 0 {
|
|
rm --force $dest
|
|
return { ok: null, actual_cost: null, err: $"base64 decode failed: ($decode_result.stderr)" }
|
|
}
|
|
|
|
let usage = $parsed | get --optional usageMetadata
|
|
let actual_cost = if ($usage | is-empty) {
|
|
null
|
|
} else {
|
|
cost_from_gemini_usage $usage
|
|
}
|
|
|
|
{ ok: $dest, actual_cost: $actual_cost, err: null }
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Approval: sequential per-item via typedialog select (Nu plugin)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def run_review_batch [pending: list] {
|
|
$pending | each { |e|
|
|
{ entry: $e, decision: (run_review_one $e) }
|
|
}
|
|
}
|
|
|
|
def run_review_one [entry: record] {
|
|
# Open image in system viewer (returns immediately on both macOS and Linux)
|
|
let viewer = if $nu.os-info.name == "macos" { "open" } else { "xdg-open" }
|
|
run-external $viewer $entry.pending_path
|
|
|
|
let label = $"($entry.item.ct)/($entry.item.lang)/($entry.item.cat)/($entry.item.slug) [($entry.img_type)]"
|
|
|
|
# typedialog select: Nu plugin — no ^ prefix, no | complete, takes list<string>
|
|
typedialog select $label ["approve" "skip" "regenerate"]
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Finalize approved image
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def finalize_image [
|
|
entry: record,
|
|
content_root: string,
|
|
no_nats: bool,
|
|
propagate_langs: bool,
|
|
insert_hero: bool,
|
|
] {
|
|
let item = $entry.item
|
|
let img_type = $entry.img_type
|
|
|
|
let final_name = $"($item.slug)_($img_type).png"
|
|
let final_path = $"($item.images_dir)/($final_name)"
|
|
mkdir $item.images_dir
|
|
mv --force $entry.pending_path $final_path
|
|
|
|
# Language-neutral public path: /content/{ct}/_images/{cat}/{slug}/{file}
|
|
let public_path = if $item.cat == "" {
|
|
$"/content/($item.ct)/_images/($item.slug)/($final_name)"
|
|
} else {
|
|
$"/content/($item.ct)/_images/($item.cat)/($item.slug)/($final_name)"
|
|
}
|
|
|
|
let field = if $img_type == "thumbnail" { "thumbnail" } else { "image_url" }
|
|
update_ncl_field $item.ncl_path $field $public_path
|
|
|
|
if $insert_hero and $img_type == "thumbnail" {
|
|
insert_hero_in_md $item.md_path $item.title $public_path
|
|
}
|
|
|
|
if $propagate_langs {
|
|
propagate_to_other_langs $item $content_root $field $public_path
|
|
}
|
|
|
|
# Per-post approved-spend log
|
|
let expenses_dir = $"($item.ncl_path | path dirname)/_expenses"
|
|
mkdir $expenses_dir
|
|
let expense_entry = {
|
|
ts: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
|
|
model: ($entry | get --optional model | default ""),
|
|
quality: ($entry | get --optional quality | default ""),
|
|
size: ($entry | get --optional size | default ""),
|
|
img_type: $img_type,
|
|
cost_usd: ($entry | get --optional cost_usd | default 0.0),
|
|
cost_source: ($entry | get --optional cost_source | default ""),
|
|
public_path: $public_path,
|
|
}
|
|
append_spend_log $"($expenses_dir)/image-spend.jsonl" $expense_entry
|
|
|
|
# Per-type aggregate
|
|
let type_expenses_dir = $"($content_root)/($item.ct)/_expenses"
|
|
mkdir $type_expenses_dir
|
|
append_spend_log $"($type_expenses_dir)/image-spend.jsonl" ($expense_entry | insert ct $item.ct | insert lang $item.lang | insert slug $item.slug)
|
|
|
|
publish_approved $item $img_type $no_nats
|
|
}
|
|
|
|
# Insert hero image as first paragraph after frontmatter (idempotent)
|
|
def insert_hero_in_md [md_path: string, title: string, public_path: string] {
|
|
if not ($md_path | path exists) { return }
|
|
|
|
let hero = $")"
|
|
let all_lines = open $md_path | lines
|
|
|
|
# Primary: replace <!-- hero --> marker wherever it appears in the body
|
|
let marker_rows = $all_lines | enumerate | where { |e| ($e.item | str trim) == "<!-- hero -->" }
|
|
if not ($marker_rows | is-empty) {
|
|
let marker_idx = $marker_rows | first | get index
|
|
let new_lines = $all_lines | enumerate | each { |e|
|
|
if $e.index == $marker_idx { $hero } else { $e.item }
|
|
}
|
|
$new_lines | str join "\n" | save --force $md_path
|
|
return
|
|
}
|
|
|
|
# Fallback: insert after frontmatter closing ---
|
|
mut fm_end = -1
|
|
mut idx = 0
|
|
mut in_fm = false
|
|
for line in $all_lines {
|
|
if $idx == 0 and ($line | str trim) == "---" {
|
|
$in_fm = true
|
|
} else if $in_fm and ($line | str trim) == "---" {
|
|
$fm_end = $idx
|
|
break
|
|
}
|
|
$idx = $idx + 1
|
|
}
|
|
if $fm_end < 0 { return }
|
|
|
|
# Skip if a hero image already exists in the body (idempotency)
|
|
let body_lines = $all_lines | skip ($fm_end + 1)
|
|
let already = $body_lines | any { |l| ($l | str trim) | str starts-with "![" }
|
|
if $already { return }
|
|
|
|
let new_lines = ($all_lines | first ($fm_end + 1)) ++ ["", $hero, ""] ++ $body_lines
|
|
$new_lines | str join "\n" | save --force $md_path
|
|
}
|
|
|
|
# Write the same image path to all other language variants of the same content item
|
|
def propagate_to_other_langs [
|
|
item: record,
|
|
content_root: string,
|
|
field: string,
|
|
public_path: string,
|
|
] {
|
|
let other_langs = discover_dirs $"($content_root)/($item.ct)" ""
|
|
| where { |l| $l != $item.lang }
|
|
|
|
for lang in $other_langs {
|
|
# Flat-file layout
|
|
let md_flat = $"($content_root)/($item.ct)/($lang)/($item.cat)/($item.slug).md"
|
|
let ncl_flat = $"($content_root)/($item.ct)/($lang)/($item.cat)/($item.slug).ncl"
|
|
# Page-bundle layout
|
|
let md_bundle = $"($content_root)/($item.ct)/($lang)/($item.cat)/($item.slug)/index.md"
|
|
let ncl_bundle = $"($content_root)/($item.ct)/($lang)/($item.cat)/($item.slug)/index.ncl"
|
|
|
|
let md = if ($md_flat | path exists) { $md_flat } else if ($md_bundle | path exists) { $md_bundle } else { "" }
|
|
let ncl = if ($ncl_flat | path exists) { $ncl_flat } else if ($ncl_bundle | path exists) { $ncl_bundle } else { "" }
|
|
|
|
if $ncl != "" { update_ncl_field $ncl $field $public_path }
|
|
print $" propagated ($field) → ($item.ct)/($lang)/($item.cat)/($item.slug)"
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Metadata injection — Markdown YAML frontmatter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def update_md_field [md_path: string, key: string, value: string] {
|
|
if not ($md_path | path exists) { return }
|
|
|
|
let content = open $md_path
|
|
let all_lines = $content | lines
|
|
|
|
# Locate frontmatter boundaries (first two `---` lines)
|
|
mut fm_start = -1
|
|
mut fm_end = -1
|
|
mut idx = 0
|
|
for line in $all_lines {
|
|
if $idx == 0 and ($line | str trim) == "---" {
|
|
$fm_start = $idx
|
|
} else if $fm_start >= 0 and $fm_end < 0 and ($line | str trim) == "---" {
|
|
$fm_end = $idx
|
|
break
|
|
}
|
|
$idx = $idx + 1
|
|
}
|
|
|
|
if $fm_start < 0 or $fm_end < 0 {
|
|
# No frontmatter — prepend one
|
|
let new_content = $"---\n($key): \"($value)\"\n---\n\n($content)"
|
|
$new_content | save --force $md_path
|
|
return
|
|
}
|
|
|
|
# Scan frontmatter for existing key
|
|
mut found_at = -1
|
|
mut scan_idx = $fm_start + 1
|
|
while $scan_idx < $fm_end {
|
|
let line = $all_lines | get $scan_idx
|
|
if ($line | str starts-with $"($key):") {
|
|
$found_at = $scan_idx
|
|
break
|
|
}
|
|
$scan_idx = $scan_idx + 1
|
|
}
|
|
|
|
let new_line = $"($key): \"($value)\""
|
|
let found_at_imm = $found_at
|
|
let fm_end_imm = $fm_end
|
|
let updated_lines = if $found_at_imm >= 0 {
|
|
$all_lines | enumerate | each { |e|
|
|
if $e.index == $found_at_imm { $new_line } else { $e.item }
|
|
}
|
|
} else {
|
|
# Insert before the closing `---`
|
|
$all_lines | enumerate | each { |e|
|
|
if $e.index == $fm_end_imm {
|
|
[$new_line, $e.item]
|
|
} else {
|
|
[$e.item]
|
|
}
|
|
} | flatten
|
|
}
|
|
|
|
$updated_lines | str join "\n" | save --force $md_path
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Metadata injection — Nickel NCL
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def update_ncl_field [ncl_path: string, key: string, value: string] {
|
|
if not ($ncl_path | path exists) { return }
|
|
|
|
let content = open $ncl_path
|
|
let all_lines = $content | lines
|
|
|
|
let field_prefix = $" ($key) ="
|
|
mut found_at = -1
|
|
mut idx = 0
|
|
for line in $all_lines {
|
|
if ($line | str starts-with $field_prefix) {
|
|
$found_at = $idx
|
|
break
|
|
}
|
|
$idx = $idx + 1
|
|
}
|
|
|
|
let new_line = $" ($key) = \"($value)\","
|
|
let found_at_imm = $found_at
|
|
|
|
let updated_lines = if $found_at_imm >= 0 {
|
|
$all_lines | enumerate | each { |e|
|
|
if $e.index == $found_at_imm { $new_line } else { $e.item }
|
|
}
|
|
} else {
|
|
# Find the last `}` line (closing brace of the make_* call)
|
|
let brace_rows = ($all_lines | enumerate | where { |e| ($e.item | str trim) == "}" })
|
|
let last_brace_idx = if ($brace_rows | is-empty) { -1 } else { $brace_rows | last | get index }
|
|
|
|
if $last_brace_idx < 0 {
|
|
# Unusual format — append before EOF
|
|
$all_lines | append $new_line
|
|
} else {
|
|
$all_lines | enumerate | each { |e|
|
|
if $e.index == $last_brace_idx {
|
|
[$new_line, $e.item]
|
|
} else {
|
|
[$e.item]
|
|
}
|
|
} | flatten
|
|
}
|
|
}
|
|
|
|
$updated_lines | str join "\n" | save --force $ncl_path
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# NCL field extraction (for thumbnail_set detection)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def extract_ncl_field [ncl_path: string, key: string] {
|
|
if not ($ncl_path | path exists) { return "" }
|
|
|
|
let field_prefix = $" ($key) ="
|
|
let matches = open $ncl_path
|
|
| lines
|
|
| where { |l| $l | str starts-with $field_prefix }
|
|
if ($matches | is-empty) { return "" }
|
|
$matches
|
|
| first
|
|
| str replace --regex $"^ ($key) = " ""
|
|
| str replace --all '"' ''
|
|
| str replace --all ',' ''
|
|
| str trim
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# NATS notification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def publish_approved [item: record, image_type: string, no_nats: bool] {
|
|
if $no_nats { return }
|
|
|
|
let ns = $env | get --optional NATS_NAMESPACE | default "rustelo"
|
|
let subject = $"($ns).content.image-approved"
|
|
|
|
# nats pub is a Nu plugin: pipe the record, pass subject as positional
|
|
{
|
|
content_type: $item.ct,
|
|
language: $item.lang,
|
|
id: $item.slug,
|
|
image_type: $image_type,
|
|
} | nats pub $subject | ignore
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared utilities (mirrors copy-content-images.nu)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def resolve_env [var_name: string, cli_arg: string, fallback: string] {
|
|
if $cli_arg != "" { return $cli_arg }
|
|
$env | get --optional $var_name | default $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 }
|
|
}
|