zer0-image-generator 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +106 -2
- data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
- data/lib/zer0_image_generator/abc.rb +75 -0
- data/lib/zer0_image_generator/all.rb +57 -0
- data/lib/zer0_image_generator/claude/client.rb +291 -0
- data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
- data/lib/zer0_image_generator/cli.rb +318 -0
- data/lib/zer0_image_generator/config.rb +192 -0
- data/lib/zer0_image_generator/constants.rb +177 -0
- data/lib/zer0_image_generator/content.rb +379 -0
- data/lib/zer0_image_generator/engine.rb +21 -0
- data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
- data/lib/zer0_image_generator/freesvg/client.rb +467 -0
- data/lib/zer0_image_generator/http.rb +264 -0
- data/lib/zer0_image_generator/library.rb +201 -0
- data/lib/zer0_image_generator/logging.rb +236 -0
- data/lib/zer0_image_generator/preview_generator.py +1072 -100
- data/lib/zer0_image_generator/prompt.rb +48 -0
- data/lib/zer0_image_generator/providers/base.rb +152 -0
- data/lib/zer0_image_generator/providers/gemini.rb +60 -0
- data/lib/zer0_image_generator/providers/local.rb +90 -0
- data/lib/zer0_image_generator/providers/openai.rb +102 -0
- data/lib/zer0_image_generator/providers/stability.rb +64 -0
- data/lib/zer0_image_generator/providers/xai.rb +81 -0
- data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
- data/lib/zer0_image_generator/providers.rb +22 -0
- data/lib/zer0_image_generator/runner.rb +573 -0
- data/lib/zer0_image_generator/settings.rb +386 -0
- data/lib/zer0_image_generator/stats.rb +61 -0
- data/lib/zer0_image_generator/support/py_random.rb +189 -0
- data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
- data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
- data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
- data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
- data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
- data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
- data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
- data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
- data/lib/zer0_image_generator/svg/lint.rb +400 -0
- data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
- data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
- data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
- data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
- data/lib/zer0_image_generator/version.rb +1 -1
- metadata +43 -2
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
require_relative "logging"
|
|
8
|
+
require_relative "stats"
|
|
9
|
+
require_relative "runner"
|
|
10
|
+
|
|
11
|
+
module Zer0ImageGenerator
|
|
12
|
+
# Argument parsing + `main`. Ported from the Python engine's argparse CLI so
|
|
13
|
+
# the flag surface, defaults, and exit codes match exactly:
|
|
14
|
+
# 0 = success, 1 = validation failure / per-file errors, 2 = usage error.
|
|
15
|
+
# OptionParser stands in for argparse; parse errors are normalized to the
|
|
16
|
+
# argparse exit code (2) and --help to 0, both raised as ExitError so a Rails
|
|
17
|
+
# host is never taken down by a bad request — CLI.start turns it back into a
|
|
18
|
+
# process status for command-line use.
|
|
19
|
+
module CLI
|
|
20
|
+
PROG = "zer0-image-generator"
|
|
21
|
+
|
|
22
|
+
# Must equal sorted(PROVIDERS.keys()); see INTEGRATION_NEEDS. Argparse
|
|
23
|
+
# derived this from the registry — kept as a literal so parse_args stays
|
|
24
|
+
# runnable without the providers slice loaded.
|
|
25
|
+
PROVIDER_CHOICES = %w[gemini local openai stability xai].freeze
|
|
26
|
+
ENHANCE_QUALITY_CHOICES = %w[low medium high auto].freeze
|
|
27
|
+
ENHANCE_FIDELITY_CHOICES = %w[high low].freeze
|
|
28
|
+
ENHANCE_FORMAT_CHOICES = %w[png jpeg webp].freeze
|
|
29
|
+
PROMPT_ENGINE_CHOICES = %w[template claude].freeze
|
|
30
|
+
REVIEW_CHOICES = %w[claude none].freeze
|
|
31
|
+
RASTERIZER_CHOICES = %w[auto rsvg inkscape magick playwright none].freeze
|
|
32
|
+
|
|
33
|
+
# The argparse Namespace, field-for-field. Booleans default false, parallel
|
|
34
|
+
# defaults nil (resolve_settings then falls back to MAX_PARALLEL/4), batch
|
|
35
|
+
# defaults 0, everything else nil — the exact shape resolve_settings reads.
|
|
36
|
+
Args = Struct.new(
|
|
37
|
+
:dry_run, :verbose, :file, :collection, :provider, :model, :output_dir,
|
|
38
|
+
:force, :list_missing, :parallel, :enhance, :enhance_prompt,
|
|
39
|
+
:enhance_model, :enhance_quality, :enhance_fidelity, :enhance_format,
|
|
40
|
+
:prompt_engine, :review, :rasterizer, :style, :assets_prefix,
|
|
41
|
+
:no_auto_prefix, :collections_dir, :front_matter_key, :authors_file,
|
|
42
|
+
:batch, :log_file, :rate_limit,
|
|
43
|
+
keyword_init: true
|
|
44
|
+
) do
|
|
45
|
+
def to_h_sorted
|
|
46
|
+
to_h.transform_keys(&:to_s).sort.to_h
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
def default_args
|
|
53
|
+
Args.new(
|
|
54
|
+
dry_run: false, verbose: false, file: nil, collection: nil,
|
|
55
|
+
provider: nil, model: nil, output_dir: nil, force: false,
|
|
56
|
+
list_missing: false, parallel: nil, enhance: false, enhance_prompt: nil,
|
|
57
|
+
enhance_model: nil, enhance_quality: nil, enhance_fidelity: nil,
|
|
58
|
+
enhance_format: nil, prompt_engine: nil, review: nil, rasterizer: nil,
|
|
59
|
+
style: nil, assets_prefix: nil, no_auto_prefix: false,
|
|
60
|
+
collections_dir: nil, front_matter_key: nil, authors_file: nil,
|
|
61
|
+
batch: 0, log_file: nil, rate_limit: nil
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Builds the OptionParser bound to `args`. Kept separate (as the Python's
|
|
66
|
+
# build_arg_parser) so callers can render --help without parsing.
|
|
67
|
+
# OptionParser token for base-10 integer flags. Python argparse's
|
|
68
|
+
# `type=int` calls int(), which is base-10 and rejects 0x/0o/0b prefixes;
|
|
69
|
+
# OptionParser's built-in Integer accepts those prefixes, so `--parallel 010`
|
|
70
|
+
# would be octal 8 in Ruby but decimal 10 in Python. Parse base-10 to match.
|
|
71
|
+
Base10Int = Object.new
|
|
72
|
+
|
|
73
|
+
def build_arg_parser(args)
|
|
74
|
+
OptionParser.new do |o|
|
|
75
|
+
o.program_name = PROG
|
|
76
|
+
o.banner = "usage: #{PROG} [options]"
|
|
77
|
+
o.accept(Base10Int, /\A\s*[-+]?\d+\s*\z/) { |s| Integer(s.strip, 10) }
|
|
78
|
+
o.separator ""
|
|
79
|
+
o.separator "AI preview/social-image generator for Jekyll sites — " \
|
|
80
|
+
"Claude analyzes & reviews, a renderer produces " \
|
|
81
|
+
"(openai [default], xai, stability, gemini, local)"
|
|
82
|
+
|
|
83
|
+
o.on("-d", "--dry-run", "Preview what would be generated (no changes)") { args.dry_run = true }
|
|
84
|
+
o.on("-v", "--verbose", "Enable verbose output") { args.verbose = true }
|
|
85
|
+
o.on("-f", "--file FILE", "Process a specific file only") { |v| args.file = v }
|
|
86
|
+
o.on("-c", "--collection NAME",
|
|
87
|
+
"Process one collection by name, or 'all' for every configured collection") { |v| args.collection = v }
|
|
88
|
+
o.on("-p", "--provider PROVIDER", "AI provider (default: claude, via _config.yml)") do |v|
|
|
89
|
+
args.provider = choice!("-p/--provider", v, PROVIDER_CHOICES)
|
|
90
|
+
end
|
|
91
|
+
o.on("--model MODEL", "Override the image/SVG model for the provider") { |v| args.model = v }
|
|
92
|
+
o.on("--output-dir DIR", "Output directory for images (default: assets/images/previews)") { |v| args.output_dir = v }
|
|
93
|
+
o.on("--force", "Regenerate images even if preview exists") { args.force = true }
|
|
94
|
+
o.on("--list-missing", "Only list files with missing previews") { args.list_missing = true }
|
|
95
|
+
o.on("-j", "-w", "--parallel N", "--workers N", Base10Int,
|
|
96
|
+
"Concurrent workers (default 4; serial for dry-run/list)") { |v| args.parallel = v }
|
|
97
|
+
o.on("-e", "--enhance", "Enhance existing preview images (OpenAI images/edits)") { args.enhance = true }
|
|
98
|
+
o.on("--enhance-prompt PROMPT", "Custom enhancement prompt (implies --enhance)") { |v| args.enhance_prompt = v }
|
|
99
|
+
o.on("--enhance-model MODEL", "Model for enhancement (default: gpt-image-2)") { |v| args.enhance_model = v }
|
|
100
|
+
o.on("--enhance-quality Q", "Enhancement quality (default: auto)") do |v|
|
|
101
|
+
args.enhance_quality = choice!("--enhance-quality", v, ENHANCE_QUALITY_CHOICES)
|
|
102
|
+
end
|
|
103
|
+
o.on("--enhance-fidelity F", "Input fidelity (implies --enhance)") do |v|
|
|
104
|
+
args.enhance_fidelity = choice!("--enhance-fidelity", v, ENHANCE_FIDELITY_CHOICES)
|
|
105
|
+
end
|
|
106
|
+
o.on("--enhance-format FMT", "Enhanced output format (implies --enhance)") do |v|
|
|
107
|
+
args.enhance_format = choice!("--enhance-format", v, ENHANCE_FORMAT_CHOICES)
|
|
108
|
+
end
|
|
109
|
+
o.on("--prompt-engine ENGINE",
|
|
110
|
+
"Art-direction brief: claude analyzes the article (default) or template") do |v|
|
|
111
|
+
args.prompt_engine = choice!("--prompt-engine", v, PROMPT_ENGINE_CHOICES)
|
|
112
|
+
end
|
|
113
|
+
o.on("--review ENGINE",
|
|
114
|
+
"Post-render review: claude inspects the image and may request one regeneration (default: claude)") do |v|
|
|
115
|
+
args.review = choice!("--review", v, REVIEW_CHOICES)
|
|
116
|
+
end
|
|
117
|
+
o.on("--rasterizer TOOL",
|
|
118
|
+
"SVG→PNG tool for claude/local providers (default: auto; `none` keeps the .svg and writes no PNG)") do |v|
|
|
119
|
+
args.rasterizer = choice!("--rasterizer", v, RASTERIZER_CHOICES)
|
|
120
|
+
end
|
|
121
|
+
o.on("--style STYLE", "Override image style prompt") { |v| args.style = v }
|
|
122
|
+
o.on("--assets-prefix PREFIX", "Assets prefix for path normalization") { |v| args.assets_prefix = v }
|
|
123
|
+
# store_true; the yielded value is irrelevant (OptionParser passes false
|
|
124
|
+
# for a bare --no-… switch), the flag's mere presence sets it.
|
|
125
|
+
o.on("--no-auto-prefix", "Disable automatic assets prefix prepending") { args.no_auto_prefix = true }
|
|
126
|
+
o.on("--collections-dir DIR",
|
|
127
|
+
"Directory holding _<collection> dirs (default: Jekyll's top-level collections_dir, else the site root)") { |v| args.collections_dir = v }
|
|
128
|
+
o.on("--front-matter-key KEY",
|
|
129
|
+
"Front-matter key to read/write (default: preview; jekyll-seo-tag sites typically use image)") { |v| args.front_matter_key = v }
|
|
130
|
+
o.on("--authors-file FILE",
|
|
131
|
+
"Author-overrides YAML relative to the site root (default: _data/authors.yml; pass '' to disable)") { |v| args.authors_file = v }
|
|
132
|
+
o.on("--batch N", Base10Int, "Limit number of files processed (0 = no limit)") { |v| args.batch = v }
|
|
133
|
+
o.on("--log-file FILE", "Also write log output to a file") { |v| args.log_file = v }
|
|
134
|
+
# Back-compat no-op (argparse.SUPPRESS in the oracle): accepted, ignored.
|
|
135
|
+
o.on("--rate-limit N", Base10Int) { |v| args.rate_limit = v }
|
|
136
|
+
o.on("-h", "--help", "Show this help message and exit") do
|
|
137
|
+
# argparse prints help to stdout and exits 0.
|
|
138
|
+
$stdout.puts o
|
|
139
|
+
raise ExitError.new("", 0)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Parses argv into an Args. Mirrors the Python parse_args, including the
|
|
145
|
+
# historical rule that --enhance-prompt/-fidelity/-format imply --enhance
|
|
146
|
+
# (but --enhance-model does NOT). Raises ExitError(2) on any usage error,
|
|
147
|
+
# ExitError(0) on --help.
|
|
148
|
+
def parse_args(argv = ARGV)
|
|
149
|
+
args = default_args
|
|
150
|
+
parser = build_arg_parser(args)
|
|
151
|
+
begin
|
|
152
|
+
rest = parser.parse(argv.dup)
|
|
153
|
+
rescue OptionParser::ParseError => e
|
|
154
|
+
# argparse: usage error → stderr + exit 2.
|
|
155
|
+
$stderr.puts "#{parser.banner}"
|
|
156
|
+
$stderr.puts "#{PROG}: error: #{e.message}"
|
|
157
|
+
raise ExitError.new(e.message, 2)
|
|
158
|
+
end
|
|
159
|
+
unless rest.empty?
|
|
160
|
+
$stderr.puts "#{parser.banner}"
|
|
161
|
+
$stderr.puts "#{PROG}: error: unrecognized arguments: #{rest.join(' ')}"
|
|
162
|
+
raise ExitError.new("unrecognized arguments: #{rest.join(' ')}", 2)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
if args.enhance_prompt || args.enhance_fidelity || args.enhance_format
|
|
166
|
+
args.enhance = true
|
|
167
|
+
end
|
|
168
|
+
args
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# argparse rejects an out-of-set choice with exit 2; OptionParser would
|
|
172
|
+
# otherwise silently prefix-match a value, so validate exactly.
|
|
173
|
+
def choice!(flag, value, choices)
|
|
174
|
+
return value if choices.include?(value)
|
|
175
|
+
|
|
176
|
+
raise OptionParser::InvalidArgument, value
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Credential checks are skipped for --list-missing/--dry-run (historical
|
|
180
|
+
# behavior), but an unknown provider name (from AI_PROVIDER / _config.yml —
|
|
181
|
+
# argparse already constrains -p) errors in every mode.
|
|
182
|
+
def validate_credentials(settings, ctx, deps: Collaborators.new, logger: Logging.logger)
|
|
183
|
+
provider = deps.providers[settings.provider]
|
|
184
|
+
if provider.nil?
|
|
185
|
+
logger.error_exit("Unknown AI provider: #{settings.provider}. " \
|
|
186
|
+
"Available: #{deps.provider_names_sorted.join(', ')}")
|
|
187
|
+
end
|
|
188
|
+
return if settings.list_only || settings.dry_run
|
|
189
|
+
|
|
190
|
+
if provider.name == "local"
|
|
191
|
+
logger.info("Using local provider - no API key required")
|
|
192
|
+
return
|
|
193
|
+
end
|
|
194
|
+
logger.error_exit(provider.missing_hint(ctx.env)) unless provider.is_configured(ctx.env)
|
|
195
|
+
# Providers with more than one way in (xAI: OAuth token, then API key) say
|
|
196
|
+
# which one this run picked, so a silent fallback is never a surprise.
|
|
197
|
+
if provider.respond_to?(:auth_description)
|
|
198
|
+
description = provider.auth_description(ctx.env)
|
|
199
|
+
logger.info("#{provider.name} auth: #{description}") if description
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Full run. Returns the process exit code (0/1). Usage errors and
|
|
204
|
+
# error_exit escape as ExitError(status); CLI.start converts those for a
|
|
205
|
+
# command-line binary.
|
|
206
|
+
def main(argv = ARGV, deps: Collaborators.new, logger: Logging.logger,
|
|
207
|
+
interrupt: InterruptFlag.new, install_signals: true)
|
|
208
|
+
# The whole engine (including the settings/content slices) logs through
|
|
209
|
+
# the module-level Logging helpers; point them at this run's sink so a
|
|
210
|
+
# web UI streams config resolution too, not just the Runner.
|
|
211
|
+
Logging.logger = logger
|
|
212
|
+
|
|
213
|
+
install_signal_handlers(interrupt) if install_signals
|
|
214
|
+
|
|
215
|
+
args = parse_args(argv)
|
|
216
|
+
deps.ensure_yaml
|
|
217
|
+
deps.load_dotenv
|
|
218
|
+
|
|
219
|
+
project_root = deps.find_project_root
|
|
220
|
+
site_config = deps.read_config(project_root)
|
|
221
|
+
project_root = deps.apply_source_root(project_root, site_config)
|
|
222
|
+
settings = deps.resolve_settings(args, site_config)
|
|
223
|
+
logger.verbose = settings.verbose
|
|
224
|
+
|
|
225
|
+
if args.log_file && logger.open_log_file(args.log_file)
|
|
226
|
+
logger.info("Logging to: #{args.log_file}")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
explicitly_targeted =
|
|
230
|
+
!settings.file.to_s.empty? || !settings.collection.to_s.empty? ||
|
|
231
|
+
settings.enhance || settings.provider_explicit
|
|
232
|
+
if !settings.enabled && !explicitly_targeted
|
|
233
|
+
logger.info("preview_images.enabled is false in _config.yml — nothing to do " \
|
|
234
|
+
"(pass --provider, --file or --collection to override).")
|
|
235
|
+
return 0
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
logger.print_header("🎨 Preview Image Generator")
|
|
239
|
+
ctx = deps.new_run_context(project_root, ENV.to_h)
|
|
240
|
+
validate_credentials(settings, ctx, deps: deps, logger: logger)
|
|
241
|
+
|
|
242
|
+
# Claude orchestration (analyze/review) degrades gracefully: without a
|
|
243
|
+
# Claude credential the run continues on template prompts, unreviewed.
|
|
244
|
+
wants_claude =
|
|
245
|
+
settings.provider != "local" &&
|
|
246
|
+
(settings.prompt_engine == "claude" || settings.review_engine == "claude") &&
|
|
247
|
+
!(settings.dry_run || settings.list_only)
|
|
248
|
+
if wants_claude
|
|
249
|
+
if ctx.claude.available?
|
|
250
|
+
logger.info("Claude orchestration: #{ctx.claude.describe}")
|
|
251
|
+
else
|
|
252
|
+
logger.warn(deps.claude_credential_hint)
|
|
253
|
+
settings = deps.degrade_settings(settings)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
output_dir = Pathname.new(project_root.to_s) / settings.output_dir
|
|
258
|
+
unless settings.dry_run || settings.list_only
|
|
259
|
+
FileUtils.mkdir_p(output_dir.to_s)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
logger.info("Configuration:")
|
|
263
|
+
model = settings.model.to_s.empty? ? deps.default_model(settings.provider) : settings.model
|
|
264
|
+
logger.say(" AI Provider: #{settings.provider}")
|
|
265
|
+
logger.say(" Image Model: #{model}")
|
|
266
|
+
logger.say(" Output Dir: #{settings.output_dir}")
|
|
267
|
+
logger.say(" Image Size: #{settings.size}")
|
|
268
|
+
logger.say(" Parallel Workers: #{settings.parallel}")
|
|
269
|
+
logger.say(" Dry Run: #{settings.dry_run}")
|
|
270
|
+
logger.say(" Force: #{settings.force}")
|
|
271
|
+
logger.say(" List Only: #{settings.list_only}")
|
|
272
|
+
logger.say(" Prompt Engine: #{settings.prompt_engine}")
|
|
273
|
+
logger.say(" Review: #{settings.review_engine}")
|
|
274
|
+
if settings.enhance
|
|
275
|
+
logger.say(" Mode: ENHANCE (improve existing images)")
|
|
276
|
+
logger.say(" Enhance Model: #{settings.enhance_model}")
|
|
277
|
+
logger.say(" Enhance Quality: #{settings.enhance_quality}")
|
|
278
|
+
logger.say(" Input Fidelity: #{settings.enhance_fidelity}")
|
|
279
|
+
logger.say(" Output Format: #{settings.enhance_format}")
|
|
280
|
+
if settings.enhance_prompt && !settings.enhance_prompt.empty?
|
|
281
|
+
logger.say(" Custom Prompt: #{settings.enhance_prompt[0, 80]}...")
|
|
282
|
+
else
|
|
283
|
+
logger.say(" Prompt: (default improvement prompt)")
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
logger.say
|
|
287
|
+
|
|
288
|
+
runner = Runner.new(settings, project_root, ctx: ctx, deps: deps,
|
|
289
|
+
logger: logger, interrupt: interrupt)
|
|
290
|
+
exit_code = runner.run
|
|
291
|
+
|
|
292
|
+
logger.close_log_file
|
|
293
|
+
exit_code
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Convenience wrapper for a command-line binary: run main and turn an
|
|
297
|
+
# ExitError (usage error, --help, error_exit) into a plain exit status.
|
|
298
|
+
def start(argv = ARGV, **kwargs)
|
|
299
|
+
main(argv, **kwargs)
|
|
300
|
+
rescue ExitError => e
|
|
301
|
+
e.status
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def install_signal_handlers(interrupt)
|
|
305
|
+
# Graceful SIGINT/SIGTERM: flip the flag so the run stops SCHEDULING new
|
|
306
|
+
# work and let in-flight files finish. Trap context forbids mutex use, so
|
|
307
|
+
# this writes to stdout directly (as the Python handler's bare print did)
|
|
308
|
+
# and Interrupt is deliberately lock-free.
|
|
309
|
+
handler = proc do
|
|
310
|
+
interrupt.trip
|
|
311
|
+
$stdout.write("\n#{Colors::YELLOW}⚠️ Interrupt received. " \
|
|
312
|
+
"Finishing current tasks...#{Colors::NC}\n")
|
|
313
|
+
end
|
|
314
|
+
Signal.trap("INT", &handler)
|
|
315
|
+
Signal.trap("TERM", &handler)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Port of the config layer of lib/zer0_image_generator/preview_generator.py
|
|
4
|
+
# (find_project_root, load_yaml_file, apply_source_root, read_config,
|
|
5
|
+
# read_authors, _load_dotenv, _env_flag). The Python file stays the oracle;
|
|
6
|
+
# test/ruby/test_config.rb differential-tests these against it where runnable.
|
|
7
|
+
|
|
8
|
+
require "yaml"
|
|
9
|
+
require "date" # so YAML.safe_load can parse the date/timestamp scalars a
|
|
10
|
+
# real _config.yml may carry — Python's yaml.safe_load accepts them and we must
|
|
11
|
+
# not diverge by rejecting an otherwise-valid config.
|
|
12
|
+
require "pathname"
|
|
13
|
+
|
|
14
|
+
require_relative "constants"
|
|
15
|
+
|
|
16
|
+
module Zer0ImageGenerator
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# --- Diagnostic seam --------------------------------------------------------
|
|
20
|
+
# The Python engine funnels warn()/info() through one colorized log(). Here
|
|
21
|
+
# that shared logger lives in the `Zer0ImageGenerator::Logging` slice; route
|
|
22
|
+
# through it when the full engine is loaded (so config/settings log with the
|
|
23
|
+
# same colors, stream split, and --log-file mirror as everyone else). When
|
|
24
|
+
# only this slice is present — a bare `ruby -Ilib` unit run — fall back to the
|
|
25
|
+
# oracle's own stream split: info to stdout, warnings to stderr. Detected at
|
|
26
|
+
# call time (like content.rb) so the slice never hard-depends on logging.rb.
|
|
27
|
+
def info(msg)
|
|
28
|
+
_route_log(:info, "INFO", $stdout, msg)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def warn(msg)
|
|
32
|
+
_route_log(:warn, "WARNING", $stderr, msg)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def _route_log(logging_method, label, fallback_stream, msg)
|
|
36
|
+
if const_defined?(:Logging, false) && Logging.respond_to?(logging_method)
|
|
37
|
+
Logging.public_send(logging_method, msg)
|
|
38
|
+
else
|
|
39
|
+
fallback_stream.puts("[#{label}] #{msg}")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# --- CPython-truthiness helpers ---------------------------------------------
|
|
44
|
+
# The precedence chain in resolve_settings leans on Python's `x or y` and
|
|
45
|
+
# `bool(x)` semantics, where "" / 0 / [] / {} / None are all falsy. Ruby only
|
|
46
|
+
# treats nil and false as falsy, so a naive `||` would let an empty string or
|
|
47
|
+
# a `false`-from-YAML slip through where CPython would fall past it. These two
|
|
48
|
+
# helpers make the port match the oracle exactly.
|
|
49
|
+
|
|
50
|
+
# Mirrors CPython's builtin bool(): only nil/false, empty string, zero, and
|
|
51
|
+
# empty collections are falsy.
|
|
52
|
+
def python_bool(value)
|
|
53
|
+
case value
|
|
54
|
+
when nil, false then false
|
|
55
|
+
when true then true
|
|
56
|
+
when String then !value.empty?
|
|
57
|
+
when Numeric then value != 0
|
|
58
|
+
when Array, Hash then !value.empty?
|
|
59
|
+
else true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Mirrors CPython's str() for the scalar types a YAML config yields. The only
|
|
64
|
+
# values that diverge from Ruby's to_s in practice are booleans and nil
|
|
65
|
+
# (Python str(True)=="True"/str(None)=="None" vs Ruby "true"/""), which a
|
|
66
|
+
# config like `quality: true` or `collections: [true]` would otherwise flip.
|
|
67
|
+
# Numeric str() agrees between the two runtimes for the integers/plain floats
|
|
68
|
+
# a config carries (see DEVIATIONS for exotic-float exponents).
|
|
69
|
+
def python_str(value)
|
|
70
|
+
case value
|
|
71
|
+
when true then "True"
|
|
72
|
+
when false then "False"
|
|
73
|
+
when nil then "None"
|
|
74
|
+
else value.to_s
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Mirrors CPython's `a or b or c ...`: the first python-truthy value, else the
|
|
79
|
+
# last value regardless of truthiness.
|
|
80
|
+
def python_first_truthy(*values)
|
|
81
|
+
values.each_with_index do |value, index|
|
|
82
|
+
return value if index == values.length - 1
|
|
83
|
+
return value if python_bool(value)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# --- .env loading -----------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
# Load .env from cwd (or `start`) up to 4 parents.
|
|
90
|
+
#
|
|
91
|
+
# Non-empty exported env vars win over .env; an EMPTY env var is treated as
|
|
92
|
+
# unset (docker/VS Code tasks forward `-e KEY=${env:KEY}` which materializes
|
|
93
|
+
# empty strings that must not shadow a real value in .env).
|
|
94
|
+
def _load_dotenv(start = nil)
|
|
95
|
+
search_dir = Pathname.new(start ? start.to_s : Dir.pwd)
|
|
96
|
+
5.times do
|
|
97
|
+
env_file = search_dir + ".env"
|
|
98
|
+
if env_file.file?
|
|
99
|
+
begin
|
|
100
|
+
env_file.read(encoding: "UTF-8").each_line do |line|
|
|
101
|
+
line = line.strip
|
|
102
|
+
next if line.empty? || line.start_with?("#") || !line.include?("=")
|
|
103
|
+
|
|
104
|
+
key, _, value = line.partition("=")
|
|
105
|
+
key = key.strip
|
|
106
|
+
value = value.strip
|
|
107
|
+
if %w[' "].include?(value[0, 1]) && value[-1, 1] == value[0, 1]
|
|
108
|
+
value = value[1..-2] # matched surrounding quotes
|
|
109
|
+
else
|
|
110
|
+
value = value.split(" #", 2).first.rstrip # inline comment
|
|
111
|
+
end
|
|
112
|
+
# An empty exported var is treated as unset, so it does not shadow.
|
|
113
|
+
ENV[key] = value if !key.empty? && (ENV[key].nil? || ENV[key].empty?)
|
|
114
|
+
end
|
|
115
|
+
rescue SystemCallError
|
|
116
|
+
# Oracle swallows OSError here (a partially-readable .env must not
|
|
117
|
+
# abort the run); mirror that, but only for I/O errors.
|
|
118
|
+
end
|
|
119
|
+
return
|
|
120
|
+
end
|
|
121
|
+
break if search_dir.parent == search_dir
|
|
122
|
+
|
|
123
|
+
search_dir = search_dir.parent
|
|
124
|
+
end
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- Project root / config files -------------------------------------------
|
|
129
|
+
|
|
130
|
+
# Site root: two parents above this file when vendored at <site>/scripts/lib/
|
|
131
|
+
# (curl layout); else walk up from cwd — the gem layout, where the engine
|
|
132
|
+
# lives far from the site and `jekyll` runs at the root. Returns a Pathname.
|
|
133
|
+
def find_project_root
|
|
134
|
+
script_root = Pathname.new(File.expand_path("../../..", __FILE__))
|
|
135
|
+
return script_root if (script_root + "_config.yml").file?
|
|
136
|
+
|
|
137
|
+
probe = Pathname.new(Dir.pwd)
|
|
138
|
+
6.times do
|
|
139
|
+
return probe if (probe + "_config.yml").file?
|
|
140
|
+
break if probe.parent == probe
|
|
141
|
+
|
|
142
|
+
probe = probe.parent
|
|
143
|
+
end
|
|
144
|
+
script_root
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def load_yaml_file(path)
|
|
148
|
+
text = File.read(path.to_s, encoding: "UTF-8")
|
|
149
|
+
data = YAML.safe_load(text, permitted_classes: [Date, Time], aliases: true)
|
|
150
|
+
data.is_a?(Hash) ? data : {}
|
|
151
|
+
rescue Errno::ENOENT
|
|
152
|
+
{} # missing file: silent, exactly like the oracle's FileNotFoundError arm
|
|
153
|
+
rescue StandardError => e
|
|
154
|
+
# Malformed YAML (or any other read/parse error) must not kill the run.
|
|
155
|
+
warn("Could not parse #{File.basename(path.to_s)}: #{e}")
|
|
156
|
+
{}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Honor Jekyll's top-level `source:` key: content (collections, assets,
|
|
160
|
+
# _data) lives under <root>/<source> while _config.yml stays at the root —
|
|
161
|
+
# e.g. zer0-pages sets `source: pages`. Disk-side only: URL-space values
|
|
162
|
+
# (front-matter paths, assets_prefix) are unaffected. Returns a Pathname.
|
|
163
|
+
def apply_source_root(project_root, config)
|
|
164
|
+
root = Pathname.new(project_root.to_s)
|
|
165
|
+
# python_str, not to_s: mirror CPython str() so a non-string Jekyll `source:`
|
|
166
|
+
# (a bool or number) reroots the same way the oracle does.
|
|
167
|
+
source = python_str(python_first_truthy(config["source"], "")).strip.gsub(%r{\A/+|/+\z}, "")
|
|
168
|
+
return root + source if !source.empty? && source != "."
|
|
169
|
+
|
|
170
|
+
root
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The site's full _config.yml — resolve_settings extracts the `preview_images:`
|
|
174
|
+
# block plus the top-level Jekyll keys it honors (collections_dir, collections).
|
|
175
|
+
def read_config(project_root)
|
|
176
|
+
load_yaml_file(Pathname.new(project_root.to_s) + "_config.yml")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Author-override map. `authors_file` is project-root-relative; an empty value
|
|
180
|
+
# disables the feature (universal sites rarely have authors.yml).
|
|
181
|
+
def read_authors(project_root, authors_file)
|
|
182
|
+
rel = (authors_file || "").to_s
|
|
183
|
+
return {} if rel.strip.empty?
|
|
184
|
+
|
|
185
|
+
load_yaml_file(Pathname.new(project_root.to_s) + rel.strip.sub(%r{\A/+}, ""))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# `os.environ.get(name, "").strip().lower() == "true"`
|
|
189
|
+
def _env_flag(name)
|
|
190
|
+
ENV.fetch(name, "").strip.downcase == "true"
|
|
191
|
+
end
|
|
192
|
+
end
|