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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +106 -2
  4. data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
  5. data/lib/zer0_image_generator/abc.rb +75 -0
  6. data/lib/zer0_image_generator/all.rb +57 -0
  7. data/lib/zer0_image_generator/claude/client.rb +291 -0
  8. data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
  9. data/lib/zer0_image_generator/cli.rb +318 -0
  10. data/lib/zer0_image_generator/config.rb +192 -0
  11. data/lib/zer0_image_generator/constants.rb +177 -0
  12. data/lib/zer0_image_generator/content.rb +379 -0
  13. data/lib/zer0_image_generator/engine.rb +21 -0
  14. data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
  15. data/lib/zer0_image_generator/freesvg/client.rb +467 -0
  16. data/lib/zer0_image_generator/http.rb +264 -0
  17. data/lib/zer0_image_generator/library.rb +201 -0
  18. data/lib/zer0_image_generator/logging.rb +236 -0
  19. data/lib/zer0_image_generator/preview_generator.py +1072 -100
  20. data/lib/zer0_image_generator/prompt.rb +48 -0
  21. data/lib/zer0_image_generator/providers/base.rb +152 -0
  22. data/lib/zer0_image_generator/providers/gemini.rb +60 -0
  23. data/lib/zer0_image_generator/providers/local.rb +90 -0
  24. data/lib/zer0_image_generator/providers/openai.rb +102 -0
  25. data/lib/zer0_image_generator/providers/stability.rb +64 -0
  26. data/lib/zer0_image_generator/providers/xai.rb +81 -0
  27. data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
  28. data/lib/zer0_image_generator/providers.rb +22 -0
  29. data/lib/zer0_image_generator/runner.rb +573 -0
  30. data/lib/zer0_image_generator/settings.rb +386 -0
  31. data/lib/zer0_image_generator/stats.rb +61 -0
  32. data/lib/zer0_image_generator/support/py_random.rb +189 -0
  33. data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
  34. data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
  35. data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
  36. data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
  37. data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
  38. data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
  39. data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
  40. data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
  41. data/lib/zer0_image_generator/svg/lint.rb +400 -0
  42. data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
  43. data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
  44. data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
  45. data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
  46. data/lib/zer0_image_generator/version.rb +1 -1
  47. 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