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,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Port of the constants block of lib/zer0_image_generator/preview_generator.py
|
|
4
|
+
# (the Python engine remains the oracle). Values here are reproduced BYTE FOR
|
|
5
|
+
# BYTE: the palettes, composition variants and geometry feed the deterministic
|
|
6
|
+
# `local` provider, whose output is committed under docs/samples and
|
|
7
|
+
# docs/showcase — a changed literal silently re-skins every committed piece.
|
|
8
|
+
# test/ruby/test_constants.rb diffs the whole table against the Python module.
|
|
9
|
+
|
|
10
|
+
module Zer0ImageGenerator
|
|
11
|
+
# Built-in fallbacks — used only when a key is absent from _config.yml, env,
|
|
12
|
+
# and CLI. Claude orchestration (analysis + review) is on by default and
|
|
13
|
+
# degrades gracefully without a Claude credential.
|
|
14
|
+
#
|
|
15
|
+
# String keys, because the same table is indexed by keys read straight out of
|
|
16
|
+
# YAML; `resolve_settings` looks up DEFAULTS[cfg_key] with the config key.
|
|
17
|
+
DEFAULTS = {
|
|
18
|
+
"enabled" => true,
|
|
19
|
+
"provider" => "openai",
|
|
20
|
+
"model" => "", # empty → the active provider's default_model
|
|
21
|
+
"size" => "1536x1024",
|
|
22
|
+
"quality" => "auto",
|
|
23
|
+
"style" => "retro pixel art, 8-bit video game aesthetic, vibrant colors, " \
|
|
24
|
+
"nostalgic, clean pixel graphics",
|
|
25
|
+
"style_modifiers" => "pixelated, retro gaming style, CRT screen glow effect, " \
|
|
26
|
+
"limited color palette",
|
|
27
|
+
"output_dir" => "assets/images/previews",
|
|
28
|
+
"assets_prefix" => "/assets",
|
|
29
|
+
"auto_prefix" => true,
|
|
30
|
+
"collections" => ["posts"].freeze, # or "auto": discover from Jekyll `collections:`
|
|
31
|
+
"collections_dir" => "", # Jekyll's collections_dir (zer0-mistakes: "pages")
|
|
32
|
+
"front_matter_key" => "preview", # jekyll-seo-tag users typically want "image"
|
|
33
|
+
"authors_file" => "_data/authors.yml", # "" disables author overrides
|
|
34
|
+
"prompt_engine" => "claude", # claude analyzes the article; falls back to template
|
|
35
|
+
"review_engine" => "claude", # claude vision-reviews the render; `none` disables
|
|
36
|
+
"claude_model" => "", # empty → DEFAULT_CLAUDE_MODEL
|
|
37
|
+
"claude_effort" => "low", # output_config.effort for brief/review calls
|
|
38
|
+
# (short creative tasks; "" sends no effort)
|
|
39
|
+
"rasterizer" => "auto" # SVG→PNG tool for the SVG providers (local,
|
|
40
|
+
# claude): auto|rsvg|inkscape|magick|playwright
|
|
41
|
+
# |none. "none" is SVG-only mode — the vector
|
|
42
|
+
# banner IS the deliverable and no PNG is made.
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# Enhance mode (OpenAI /v1/images/edits — see the OpenAI provider's `edit`).
|
|
46
|
+
ENHANCE_DEFAULTS = {
|
|
47
|
+
"model" => "gpt-image-2",
|
|
48
|
+
"quality" => "auto",
|
|
49
|
+
"fidelity" => "high",
|
|
50
|
+
"format" => "png"
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
DEFAULT_ENHANCE_PROMPT = "Improve this preview banner image: fix any misspelled, garbled, or " \
|
|
54
|
+
"incorrect text so it reads clearly and accurately. Sharpen visual details " \
|
|
55
|
+
"and improve composition while preserving the original art style, color " \
|
|
56
|
+
"palette, and theme. Ensure the image is clean and professional."
|
|
57
|
+
|
|
58
|
+
# Anthropic wire constants — keep in sync with templates/deploy/chat-proxy/worker.js.
|
|
59
|
+
ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"
|
|
60
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
61
|
+
OAUTH_BETA = "oauth-2025-04-20"
|
|
62
|
+
# Claude Code OAuth tokens require the FIRST system block to identify as Claude
|
|
63
|
+
# Code, otherwise the API rejects the call with a misleading `rate_limit_error`.
|
|
64
|
+
CLAUDE_CODE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."
|
|
65
|
+
DEFAULT_CLAUDE_MODEL = "claude-opus-4-8"
|
|
66
|
+
CLAUDE_MAX_TOKENS = 16_000 # non-streaming ceiling; SVG banners fit comfortably
|
|
67
|
+
|
|
68
|
+
# Model-name prefix → provider family. Used to ignore a configured/author model
|
|
69
|
+
# that belongs to a different vendor than the active provider (never send a
|
|
70
|
+
# request that is guaranteed to 400).
|
|
71
|
+
#
|
|
72
|
+
# Insertion order is part of the contract: `model_family` returns the FIRST
|
|
73
|
+
# matching prefix, exactly as the Python dict iteration does.
|
|
74
|
+
MODEL_FAMILIES = {
|
|
75
|
+
"claude-" => "claude",
|
|
76
|
+
"gpt-image-" => "openai",
|
|
77
|
+
"dall-e-" => "openai",
|
|
78
|
+
"grok-" => "xai",
|
|
79
|
+
"stable-" => "stability",
|
|
80
|
+
"sd3" => "stability",
|
|
81
|
+
"gemini-" => "gemini",
|
|
82
|
+
"imagen-" => "gemini"
|
|
83
|
+
}.freeze
|
|
84
|
+
|
|
85
|
+
# Banner geometry for SVG-producing providers (claude, local).
|
|
86
|
+
SVG_WIDTH = 1536
|
|
87
|
+
SVG_HEIGHT = 1024
|
|
88
|
+
|
|
89
|
+
# Curated retro palettes: (sky/background, far, mid, near, accent, glow).
|
|
90
|
+
# Selected deterministically per slug so re-runs stay visually stable.
|
|
91
|
+
RETRO_PALETTES = [
|
|
92
|
+
["#1a1a2e", "#16213e", "#0f3460", "#533483", "#e94560", "#f9ed69"].freeze,
|
|
93
|
+
["#0d1b2a", "#1b263b", "#415a77", "#778da9", "#e0e1dd", "#ffb703"].freeze,
|
|
94
|
+
["#2d132c", "#801336", "#c72c41", "#ee4540", "#f9d276", "#f4f4f4"].freeze,
|
|
95
|
+
["#10002b", "#3c096c", "#7b2cbf", "#c77dff", "#e0aaff", "#72efdd"].freeze,
|
|
96
|
+
["#001219", "#005f73", "#0a9396", "#94d2bd", "#e9d8a6", "#ee9b00"].freeze,
|
|
97
|
+
["#03071e", "#370617", "#9d0208", "#dc2f02", "#f48c06", "#ffba08"].freeze,
|
|
98
|
+
["#0b132b", "#1c2541", "#3a506b", "#5bc0be", "#6fffe9", "#ff6b6b"].freeze,
|
|
99
|
+
["#232931", "#393e46", "#4ecca3", "#a5ecd7", "#eeeeee", "#f95959"].freeze,
|
|
100
|
+
["#1f0a24", "#571089", "#ab51e3", "#f7aef8", "#b388eb", "#8093f1"].freeze,
|
|
101
|
+
["#141e30", "#243b55", "#3c6382", "#82ccdd", "#f8c291", "#e55039"].freeze
|
|
102
|
+
].freeze
|
|
103
|
+
|
|
104
|
+
COMPOSITION_VARIANTS = [
|
|
105
|
+
"a low horizon with a huge rising sun disk banded by scanlines",
|
|
106
|
+
"layered diagonal mountain silhouettes receding into haze",
|
|
107
|
+
"a vaporwave perspective grid floor vanishing toward the horizon",
|
|
108
|
+
"floating terraced islands with cascading pixel waterfalls",
|
|
109
|
+
"a night starfield with a large ringed planet arcing across the frame",
|
|
110
|
+
"a stepped city skyline of blocky towers with lit windows",
|
|
111
|
+
"rolling desert dunes with a lone monolith and long shadows",
|
|
112
|
+
"an ocean of chunky pixel waves under drifting square clouds"
|
|
113
|
+
].freeze
|
|
114
|
+
|
|
115
|
+
# System prompt for Claude's ART-DIRECTOR role (prompt_engine: claude): read
|
|
116
|
+
# the article, then write the brief a raster image model will render. NOTE:
|
|
117
|
+
# "no text" matches the long-standing prompt rule of this feature — image
|
|
118
|
+
# models garble lettering.
|
|
119
|
+
#
|
|
120
|
+
# `.chomp` because Python's triple-quoted literal ends right after "kind." —
|
|
121
|
+
# a heredoc would otherwise append a newline the oracle never sends.
|
|
122
|
+
ART_DIRECTOR_SYSTEM = <<~PROMPT.chomp
|
|
123
|
+
You are an art director for a technical blog. You will be given an article
|
|
124
|
+
(title, description, tags, an excerpt) plus mandatory style directions. Your
|
|
125
|
+
job is to design ONE preview banner image and describe it to an AI image
|
|
126
|
+
model.
|
|
127
|
+
|
|
128
|
+
Respond with ONLY the image-generation prompt — no preamble, no quotes, no
|
|
129
|
+
markdown. One vivid paragraph of at most 130 words that:
|
|
130
|
+
- captures the article's actual SUBJECT as a concrete visual metaphor or
|
|
131
|
+
scene (specific objects, actions and spatial arrangement — never a generic
|
|
132
|
+
'technology background');
|
|
133
|
+
- specifies composition for a wide banner (what sits left/center/right,
|
|
134
|
+
foreground/background, focal point);
|
|
135
|
+
- weaves in the given art style and palette directions verbatim in spirit;
|
|
136
|
+
- states that the image must contain NO text, letters, words, numbers, logos
|
|
137
|
+
or UI copy of any kind.
|
|
138
|
+
PROMPT
|
|
139
|
+
|
|
140
|
+
# System prompt for Claude's REVIEWER role (review_engine: claude): look at
|
|
141
|
+
# the rendered image and decide whether it represents the article.
|
|
142
|
+
REVIEWER_SYSTEM = <<~PROMPT.chomp
|
|
143
|
+
You are reviewing an AI-generated blog preview banner against the article it
|
|
144
|
+
illustrates. Judge three things: (1) does the image clearly evoke the
|
|
145
|
+
article's actual subject, (2) does it follow the requested art style, and
|
|
146
|
+
(3) is it free of text/lettering artifacts and visual glitches.
|
|
147
|
+
|
|
148
|
+
Respond with ONLY a JSON object, no markdown fences:
|
|
149
|
+
{"verdict": "approve" | "revise",
|
|
150
|
+
"critique": "<one or two sentences on what is wrong or right>",
|
|
151
|
+
"revised_prompt": "<empty when approving; otherwise a complete replacement
|
|
152
|
+
image-generation prompt (max 130 words) that fixes the problems while keeping
|
|
153
|
+
the required style and the no-text rule>"}
|
|
154
|
+
|
|
155
|
+
Approve unless the image genuinely misrepresents the subject, breaks the
|
|
156
|
+
style, or contains text/glitches — minor taste differences are not grounds
|
|
157
|
+
for revision.
|
|
158
|
+
PROMPT
|
|
159
|
+
|
|
160
|
+
PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
|
|
161
|
+
|
|
162
|
+
# Pause after each successful non-dry generation (Bash parity: polite pacing
|
|
163
|
+
# between paid API calls).
|
|
164
|
+
POST_GENERATION_SLEEP = 2.0
|
|
165
|
+
|
|
166
|
+
# Settings fields an override block (author preview: / collection_styles:)
|
|
167
|
+
# may replace per file.
|
|
168
|
+
OVERRIDE_KEYS = %w[style style_modifiers size quality model].freeze
|
|
169
|
+
|
|
170
|
+
class << self
|
|
171
|
+
# The Python module-global POST_GENERATION_SLEEP is rebindable and the test
|
|
172
|
+
# suite sets it to 0; a Ruby constant is not, so the *runtime* value lives
|
|
173
|
+
# here and the constant stays the immutable published default.
|
|
174
|
+
attr_accessor :post_generation_sleep
|
|
175
|
+
end
|
|
176
|
+
self.post_generation_sleep = POST_GENERATION_SLEEP
|
|
177
|
+
end
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module Zer0ImageGenerator
|
|
8
|
+
# A parsed content file. `categories` is the already-joined "a, b" string the
|
|
9
|
+
# prompt layer wants, not a list — the Python dataclass shipped it that way and
|
|
10
|
+
# callers depend on it.
|
|
11
|
+
ContentFile = Struct.new(:path, :title, :description, :categories, :preview,
|
|
12
|
+
:author, :content, :front_matter)
|
|
13
|
+
|
|
14
|
+
# Front-matter layer: read a Jekyll content file, and — the dangerous half —
|
|
15
|
+
# write one key back into a file a human owns without disturbing a byte of
|
|
16
|
+
# anything else.
|
|
17
|
+
#
|
|
18
|
+
# Ported from lib/zer0_image_generator/preview_generator.py (the oracle).
|
|
19
|
+
# Several helpers below reimplement CPython string semantics rather than using
|
|
20
|
+
# the nearest Ruby method, because the nearest Ruby method is subtly different
|
|
21
|
+
# and this layer rewrites user source files.
|
|
22
|
+
module Content
|
|
23
|
+
# Front matter opens on the very first line; trailing spaces/tabs are
|
|
24
|
+
# tolerated because editors leave them and Jekyll accepts them.
|
|
25
|
+
FM_OPEN = /\A---[ \t]*\r?\n/.freeze
|
|
26
|
+
FM_CLOSE = /^---[ \t]*\r?$/.freeze
|
|
27
|
+
|
|
28
|
+
# CPython's `str.isspace()` set — verified identical to what `re`'s `\s`
|
|
29
|
+
# matches for str patterns, and to what `str.strip()` removes. Ruby's `\s`
|
|
30
|
+
# is ASCII-only and `[[:space:]]` follows Unicode White_Space (which excludes
|
|
31
|
+
# U+001C..U+001F), so neither is a drop-in substitute. Built from codepoints
|
|
32
|
+
# so the source stays ASCII; none of these characters are class-special.
|
|
33
|
+
PY_SPACE_CODEPOINTS = [
|
|
34
|
+
0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x85, 0xA0,
|
|
35
|
+
0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007,
|
|
36
|
+
0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000
|
|
37
|
+
].freeze
|
|
38
|
+
PY_SPACE_CLASS = PY_SPACE_CODEPOINTS.map { |cp| cp.chr(Encoding::UTF_8) }.join.freeze
|
|
39
|
+
PY_SPACE = /[#{PY_SPACE_CLASS}]/.freeze
|
|
40
|
+
|
|
41
|
+
# `str.splitlines(keepends=True)` breaks on far more than `\n`: VT, FF, the
|
|
42
|
+
# C0 separators (but NOT U+001F, which isspace() counts yet splitlines does
|
|
43
|
+
# not), NEL, and the Unicode line/paragraph separators all end a line.
|
|
44
|
+
# `String#lines` only knows `\n`, which would merge two front-matter lines
|
|
45
|
+
# into one and put the new key in the wrong place.
|
|
46
|
+
PY_LINE_BREAK_CODEPOINTS = [
|
|
47
|
+
0x0A, 0x0B, 0x0C, 0x0D, 0x1C, 0x1D, 0x1E, 0x85, 0x2028, 0x2029
|
|
48
|
+
].freeze
|
|
49
|
+
PY_LINE_BREAK = PY_LINE_BREAK_CODEPOINTS.map { |cp| cp.chr(Encoding::UTF_8) }.join.freeze
|
|
50
|
+
PY_SPLITLINES = /[^#{PY_LINE_BREAK}]*(?:\r\n|[#{PY_LINE_BREAK}])?/.freeze
|
|
51
|
+
|
|
52
|
+
# Offsets of the raw front-matter body between the opening and closing `---`
|
|
53
|
+
# fences: [fm_start, fm_end, fm_text], or nil when the file has none.
|
|
54
|
+
# Offsets are character indices, as in the oracle.
|
|
55
|
+
def self.split_front_matter(text)
|
|
56
|
+
open_match = FM_OPEN.match(text)
|
|
57
|
+
return nil unless open_match
|
|
58
|
+
|
|
59
|
+
fm_start = open_match.end(0)
|
|
60
|
+
close = FM_CLOSE.match(text, fm_start)
|
|
61
|
+
return nil unless close
|
|
62
|
+
|
|
63
|
+
[fm_start, close.begin(0), text[fm_start...close.begin(0)]]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.parse_front_matter(path, fm_key = "preview")
|
|
67
|
+
text = read_text(path)
|
|
68
|
+
return nil unless text
|
|
69
|
+
|
|
70
|
+
parts = split_front_matter(text)
|
|
71
|
+
unless parts
|
|
72
|
+
log(:debug, "No front matter found in: #{path}")
|
|
73
|
+
return nil
|
|
74
|
+
end
|
|
75
|
+
fm_end = parts[1]
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
data = YAML.safe_load(parts[2], permitted_classes: [Date, Time], aliases: true)
|
|
79
|
+
rescue Psych::Exception => e
|
|
80
|
+
log(:warn, "Failed to parse YAML in #{path}: #{e.message}")
|
|
81
|
+
return nil
|
|
82
|
+
end
|
|
83
|
+
return nil unless data.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
categories = data.fetch("categories", [])
|
|
86
|
+
categories = [categories] if categories.is_a?(String)
|
|
87
|
+
categories = [] unless categories.is_a?(Array)
|
|
88
|
+
|
|
89
|
+
body_start = text.index("\n", fm_end)
|
|
90
|
+
body = body_start ? text[(body_start + 1)..] : ""
|
|
91
|
+
|
|
92
|
+
preview = data[fm_key]
|
|
93
|
+
ContentFile.new(
|
|
94
|
+
path.to_s,
|
|
95
|
+
py_str(truthy?(data["title"]) ? data["title"] : ""),
|
|
96
|
+
py_str(truthy?(data["description"]) ? data["description"] : ""),
|
|
97
|
+
categories.map { |c| py_str(c) }.join(", "),
|
|
98
|
+
preview.is_a?(String) ? preview : nil,
|
|
99
|
+
data["author"],
|
|
100
|
+
body,
|
|
101
|
+
data
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Set `<key>:` inside the front-matter block ONLY (first match).
|
|
106
|
+
#
|
|
107
|
+
# Replaces an existing `<key>:` line, else inserts after the first
|
|
108
|
+
# `description:` line, else after the first `title:` line. Everything outside
|
|
109
|
+
# the front-matter block is preserved byte-for-byte (the former sed/regex
|
|
110
|
+
# implementations operated file-wide and could corrupt body lines that start
|
|
111
|
+
# with the key). Writes a transient `.bak`, replaces atomically, removes the
|
|
112
|
+
# `.bak` on success — CI asserts no `.bak` survives a successful run.
|
|
113
|
+
def self.update_front_matter(path, preview_path, dry_run: false, key: "preview")
|
|
114
|
+
if dry_run
|
|
115
|
+
log(:info, "[DRY RUN] Would update #{key} in #{path} to: #{preview_path}")
|
|
116
|
+
return true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Bytes round-trip: the newline-translating read used by parse_front_matter
|
|
120
|
+
# would silently rewrite a CRLF file as LF.
|
|
121
|
+
text = read_text(path, universal_newlines: false)
|
|
122
|
+
return false unless text
|
|
123
|
+
|
|
124
|
+
parts = split_front_matter(text)
|
|
125
|
+
unless parts
|
|
126
|
+
log(:warn, "No front matter block in #{path}; not updating")
|
|
127
|
+
return false
|
|
128
|
+
end
|
|
129
|
+
fm_start, fm_end, fm_text = parts
|
|
130
|
+
|
|
131
|
+
eol = fm_text.include?("\r\n") ? "\r\n" : "\n"
|
|
132
|
+
lines = splitlines(fm_text)
|
|
133
|
+
|
|
134
|
+
preview_idx = find_key(lines, "#{key}:")
|
|
135
|
+
if preview_idx != -1
|
|
136
|
+
line = lines[preview_idx]
|
|
137
|
+
line_eol = if line.end_with?("\r\n") then "\r\n"
|
|
138
|
+
elsif line.end_with?("\n") then "\n"
|
|
139
|
+
else ""
|
|
140
|
+
end
|
|
141
|
+
lines[preview_idx] = "#{key}: #{preview_path}#{line_eol}"
|
|
142
|
+
else
|
|
143
|
+
anchor = find_key(lines, "description:")
|
|
144
|
+
anchor = find_key(lines, "title:") if anchor == -1
|
|
145
|
+
if anchor == -1
|
|
146
|
+
log(:warn, "No #{key}:/description:/title: anchor in #{path} front matter")
|
|
147
|
+
return false
|
|
148
|
+
end
|
|
149
|
+
insert_at = end_of_block(lines, anchor)
|
|
150
|
+
if !lines[anchor].end_with?("\n") && insert_at == anchor + 1
|
|
151
|
+
lines[anchor] += eol # anchor was the final unterminated line
|
|
152
|
+
end
|
|
153
|
+
lines.insert(insert_at, "#{key}: #{preview_path}#{eol}")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
new_text = text[0, fm_start] + lines.join + text[fm_end..]
|
|
157
|
+
write_atomically(path, new_text) or return false
|
|
158
|
+
|
|
159
|
+
log(:success, "Updated front matter with #{key}: #{preview_path}")
|
|
160
|
+
true
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Slug identical to the historical Bash chain (trim before the 50-cut).
|
|
164
|
+
def self.generate_filename(title)
|
|
165
|
+
slug = title.downcase.gsub(/[^a-z0-9]/, "-")
|
|
166
|
+
slug = slug.gsub(/-+/, "-").sub(/\A-+/, "").sub(/-+\z/, "")
|
|
167
|
+
slug[0, 50]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Front-matter path -> site-absolute path (/assets/... form) for existence
|
|
171
|
+
# checks. External URLs pass through unchanged.
|
|
172
|
+
def self.normalize_preview_path(preview, settings)
|
|
173
|
+
return preview if preview.nil? || preview.empty?
|
|
174
|
+
return preview if preview.start_with?("http://", "https://")
|
|
175
|
+
|
|
176
|
+
# Substring (not prefix) check is deliberate: it mirrors the Liquid
|
|
177
|
+
# `contains` logic in components/preview-image.html and content/seo.html —
|
|
178
|
+
# the engine's idea of "already prefixed" must match what the site renders.
|
|
179
|
+
if settings.auto_prefix && !preview.include?(settings.assets_prefix)
|
|
180
|
+
return "#{settings.assets_prefix}#{preview}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
preview
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def self.check_preview_exists(preview, settings, root)
|
|
187
|
+
return false if preview.nil? || preview.empty?
|
|
188
|
+
# External URLs count as present (matches the Liquid component + SEO tags;
|
|
189
|
+
# the old Bash engine would silently regenerate over them).
|
|
190
|
+
return true if preview.start_with?("http://", "https://")
|
|
191
|
+
|
|
192
|
+
normalized = normalize_preview_path(preview, settings) || ""
|
|
193
|
+
clean = normalized.sub(%r{\A/+}, "")
|
|
194
|
+
[join_path(root, clean), join_path(root, "assets", clean)].any? { |p| File.file?(p) }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The value written to front matter — WITHOUT the assets prefix (Liquid adds
|
|
198
|
+
# it): assets/images/previews -> /images/previews/<filename>.
|
|
199
|
+
def self.preview_front_matter_path(settings, filename)
|
|
200
|
+
out = strip_slashes(settings.output_dir)
|
|
201
|
+
prefix = strip_slashes(settings.assets_prefix)
|
|
202
|
+
if !prefix.empty? && (out == prefix || out.start_with?("#{prefix}/"))
|
|
203
|
+
out = strip_slashes(out[prefix.length..])
|
|
204
|
+
end
|
|
205
|
+
out.empty? ? "/#{filename}" : "/#{out}/#{filename}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Locate an existing preview image on disk (for --enhance).
|
|
209
|
+
def self.find_preview_image(preview, settings, root)
|
|
210
|
+
return nil if preview.nil? || preview.empty?
|
|
211
|
+
return nil if preview.start_with?("http://", "https://")
|
|
212
|
+
|
|
213
|
+
clean = preview.sub(%r{\A/+}, "")
|
|
214
|
+
candidates = [
|
|
215
|
+
join_path(root, clean),
|
|
216
|
+
join_path(root, "assets", clean),
|
|
217
|
+
join_path(root, settings.output_dir, File.basename(clean)),
|
|
218
|
+
]
|
|
219
|
+
candidates.find { |candidate| File.file?(candidate) }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# pathlib's `/` operator, which is what the oracle joins with: empty and "."
|
|
223
|
+
# components are dropped, runs of slashes collapse, and an absolute
|
|
224
|
+
# component resets the path. `File.join` does none of that, and
|
|
225
|
+
# find_preview_image hands the joined string back to its caller.
|
|
226
|
+
def self.join_path(*parts)
|
|
227
|
+
absolute = false
|
|
228
|
+
segments = []
|
|
229
|
+
parts.each do |part|
|
|
230
|
+
str = part.to_s
|
|
231
|
+
if str.start_with?("/")
|
|
232
|
+
absolute = true
|
|
233
|
+
segments.clear
|
|
234
|
+
end
|
|
235
|
+
segments.concat(str.split("/").reject { |seg| seg.empty? || seg == "." })
|
|
236
|
+
end
|
|
237
|
+
joined = segments.join("/")
|
|
238
|
+
return "/#{joined}" if absolute
|
|
239
|
+
|
|
240
|
+
joined.empty? ? "." : joined
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# --- CPython string semantics -------------------------------------------
|
|
244
|
+
|
|
245
|
+
# `str.splitlines(keepends=True)`.
|
|
246
|
+
def self.splitlines(text)
|
|
247
|
+
parts = text.scan(PY_SPLITLINES)
|
|
248
|
+
parts.pop if parts.last == "" # the scan always closes with an empty match
|
|
249
|
+
parts
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# `re.sub(r"\s+", " ", text).strip()` — the excerpt normalizer shared with
|
|
253
|
+
# the prompt layer. Exposed because `String#strip` also eats NUL, which
|
|
254
|
+
# Python's does not, and because both `\s+` and the final strip must use the
|
|
255
|
+
# CPython whitespace set.
|
|
256
|
+
def self.collapse_whitespace(text)
|
|
257
|
+
collapsed = text.gsub(/[#{PY_SPACE_CLASS}]+/, " ")
|
|
258
|
+
# After collapsing, every whitespace run is a single ASCII space, so
|
|
259
|
+
# trimming leading/trailing spaces reproduces str.strip() exactly.
|
|
260
|
+
collapsed.sub(/\A +/, "").sub(/ +\z/, "")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Python truthiness, which is what `data.get("title") or ""` tests.
|
|
264
|
+
def self.truthy?(value)
|
|
265
|
+
case value
|
|
266
|
+
when nil, false then false
|
|
267
|
+
when String, Array, Hash then !value.empty?
|
|
268
|
+
when Numeric then !value.zero?
|
|
269
|
+
else true
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Python's `str()` over the scalar shapes a YAML front-matter value can take.
|
|
274
|
+
# Containers and floats fall through to Ruby's #to_s, which formats
|
|
275
|
+
# differently from CPython's repr() — see DEVIATIONS in the port notes.
|
|
276
|
+
def self.py_str(value)
|
|
277
|
+
case value
|
|
278
|
+
when String then value
|
|
279
|
+
when true then "True"
|
|
280
|
+
when false then "False"
|
|
281
|
+
when nil then "None"
|
|
282
|
+
else value.to_s
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# --- internals ------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def self.strip_slashes(text)
|
|
289
|
+
text.sub(%r{\A/+}, "").sub(%r{/+\z}, "")
|
|
290
|
+
end
|
|
291
|
+
private_class_method :strip_slashes
|
|
292
|
+
|
|
293
|
+
def self.find_key(lines, prefix)
|
|
294
|
+
lines.index { |line| line.start_with?(prefix) } || -1
|
|
295
|
+
end
|
|
296
|
+
private_class_method :find_key
|
|
297
|
+
|
|
298
|
+
# Index just past a key line and any indented continuation lines
|
|
299
|
+
# (folded/literal scalars, nested maps).
|
|
300
|
+
def self.end_of_block(lines, idx)
|
|
301
|
+
j = idx + 1
|
|
302
|
+
while j < lines.length && (lines[j].start_with?(" ", "\t") || blank?(lines[j]))
|
|
303
|
+
# A blank line only continues the block if an indented line follows.
|
|
304
|
+
break if blank?(lines[j]) &&
|
|
305
|
+
!(j + 1 < lines.length && lines[j + 1].start_with?(" ", "\t"))
|
|
306
|
+
|
|
307
|
+
j += 1
|
|
308
|
+
end
|
|
309
|
+
j
|
|
310
|
+
end
|
|
311
|
+
private_class_method :end_of_block
|
|
312
|
+
|
|
313
|
+
# `line.strip() == ""`: True when the line is empty or all-whitespace.
|
|
314
|
+
def self.blank?(line)
|
|
315
|
+
line.match?(/\A[#{PY_SPACE_CLASS}]*\z/)
|
|
316
|
+
end
|
|
317
|
+
private_class_method :blank?
|
|
318
|
+
|
|
319
|
+
# `Path.read_text(encoding="utf-8")` (universal newlines) or `read_bytes()
|
|
320
|
+
# .decode("utf-8")`. Both raise on unreadable/undecodable input; the oracle
|
|
321
|
+
# warns and returns None for either, so this returns nil.
|
|
322
|
+
def self.read_text(path, universal_newlines: true)
|
|
323
|
+
raw = begin
|
|
324
|
+
File.binread(path.to_s)
|
|
325
|
+
rescue SystemCallError, IOError => e
|
|
326
|
+
log(:warn, "Failed to read #{path}: #{e.message}")
|
|
327
|
+
return nil
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
text = raw.force_encoding(Encoding::UTF_8)
|
|
331
|
+
unless text.valid_encoding?
|
|
332
|
+
# Python raises UnicodeDecodeError here and the oracle handles it
|
|
333
|
+
# identically to an OSError.
|
|
334
|
+
log(:warn, "Failed to read #{path}: invalid UTF-8")
|
|
335
|
+
return nil
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
universal_newlines ? text.gsub(/\r\n?/, "\n") : text
|
|
339
|
+
end
|
|
340
|
+
private_class_method :read_text
|
|
341
|
+
|
|
342
|
+
# `.bak` then atomic rename. On failure the `.bak` is deliberately left
|
|
343
|
+
# behind (the oracle only unlinks the temp file) — it is the user's only
|
|
344
|
+
# copy if the rename tore.
|
|
345
|
+
def self.write_atomically(path, new_text)
|
|
346
|
+
target = path.to_s
|
|
347
|
+
backup = "#{target}.bak"
|
|
348
|
+
tmp = "#{target}.tmp~"
|
|
349
|
+
begin
|
|
350
|
+
FileUtils.cp(target, backup, preserve: true)
|
|
351
|
+
File.binwrite(tmp, new_text)
|
|
352
|
+
File.rename(tmp, target)
|
|
353
|
+
File.unlink(backup) if File.exist?(backup)
|
|
354
|
+
rescue SystemCallError, IOError => e
|
|
355
|
+
log(:warn, "Failed to update front matter in #{path}: #{e.message}")
|
|
356
|
+
File.unlink(tmp) if File.exist?(tmp)
|
|
357
|
+
return false
|
|
358
|
+
end
|
|
359
|
+
true
|
|
360
|
+
end
|
|
361
|
+
private_class_method :write_atomically
|
|
362
|
+
|
|
363
|
+
# The engine's shared logger lives in the `Zer0ImageGenerator::Logging`
|
|
364
|
+
# slice (info/warn/success/debug/step class methods, --verbose-gated debug).
|
|
365
|
+
# Route through it when present. Until it is loaded, warnings still have to
|
|
366
|
+
# reach a human — a silently-skipped front-matter write is the worst failure
|
|
367
|
+
# mode this file has — so fall back to stderr. `:debug` is dropped by the
|
|
368
|
+
# fallback because the shared logger gates it on --verbose (off by default).
|
|
369
|
+
def self.log(level, message)
|
|
370
|
+
logger = Zer0ImageGenerator.const_defined?(:Logging, false) ? Zer0ImageGenerator::Logging : nil
|
|
371
|
+
if logger.respond_to?(level)
|
|
372
|
+
logger.public_send(level, message)
|
|
373
|
+
elsif level != :debug
|
|
374
|
+
$stderr.puts("[#{level.to_s.upcase}] #{message}")
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
private_class_method :log
|
|
378
|
+
end
|
|
379
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Pure-Ruby entry point for the image engine.
|
|
4
|
+
#
|
|
5
|
+
# Deliberately separate from `lib/zer0-image-generator.rb`: that file is the
|
|
6
|
+
# gem's Bundler entry point and conditionally pulls in Jekyll to register the
|
|
7
|
+
# `jekyll preview-images` command. This file loads the engine and nothing else,
|
|
8
|
+
# so the Rails app in `web/` (and any plain script) can drive the same code
|
|
9
|
+
# without dragging Jekyll in.
|
|
10
|
+
#
|
|
11
|
+
# Stdlib only, by policy — the gem stays dependency-light, so anything required
|
|
12
|
+
# here must ship with Ruby.
|
|
13
|
+
|
|
14
|
+
require_relative "version"
|
|
15
|
+
require_relative "support/py_random"
|
|
16
|
+
|
|
17
|
+
module Zer0ImageGenerator
|
|
18
|
+
# Raised for engine-level failures that callers are expected to surface to a
|
|
19
|
+
# human — a missing credential, an unreadable site, a provider refusal.
|
|
20
|
+
class Error < StandardError; end
|
|
21
|
+
end
|