jekyll-theme-zer0 1.26.0 → 1.27.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +172 -1
  3. data/README.md +10 -27
  4. data/_data/authors.yml +4 -3
  5. data/_data/backlog.yml +28 -0
  6. data/_data/features.yml +65 -18
  7. data/_data/i18n/languages.yml +36 -0
  8. data/_data/theme-manifest.yml +0 -2
  9. data/_data/ui-text.yml +36 -246
  10. data/_includes/README.md +4 -0
  11. data/_includes/components/author-avatar-url.html +4 -2
  12. data/_includes/components/env-switcher.html +3 -1
  13. data/_includes/components/language-toggle.html +81 -0
  14. data/_includes/components/search-modal.html +2 -2
  15. data/_includes/components/shortcuts-modal.html +1 -1
  16. data/_includes/components/translation-notice.html +27 -0
  17. data/_includes/content/intro.html +16 -15
  18. data/_includes/core/footer.html +9 -4
  19. data/_includes/core/head.html +9 -0
  20. data/_includes/core/header.html +9 -4
  21. data/_includes/core/hreflang.html +33 -0
  22. data/_includes/core/i18n.html +36 -0
  23. data/_includes/navigation/breadcrumbs.html +1 -1
  24. data/_includes/navigation/navbar.html +5 -4
  25. data/_includes/navigation/sidebar-right.html +3 -2
  26. data/_includes/navigation/unified-drawer.html +1 -1
  27. data/_layouts/article.html +7 -1
  28. data/_layouts/default.html +5 -3
  29. data/_layouts/news.html +4 -2
  30. data/_layouts/root.html +14 -8
  31. data/_layouts/section.html +4 -2
  32. data/_sass/core/_obsidian.scss +9 -1
  33. data/_sass/layouts/_navbar-extras.scss +6 -1
  34. data/assets/js/obsidian-graph.js +5 -1
  35. data/scripts/README.md +20 -26
  36. data/scripts/bin/audit-consumer +1 -1
  37. data/scripts/bin/manifest +0 -1
  38. data/scripts/bin/sync-plugins +0 -1
  39. data/scripts/dev/rasterize-svg.js +65 -0
  40. data/scripts/features/generate-preview-images +49 -1390
  41. data/scripts/features/install-preview-generator +55 -33
  42. data/scripts/install/README.md +9 -20
  43. data/scripts/install/ai/prompts/wizard.system.md +8 -17
  44. data/scripts/lib/README.md +1 -5
  45. data/scripts/lib/install/deploy/README.md +3 -9
  46. data/scripts/lib/preview_generator.py +2261 -1341
  47. data/scripts/translate.rb +1114 -0
  48. metadata +9 -3
  49. data/_plugins/preview_image_generator.rb +0 -351
@@ -0,0 +1,1114 @@
1
+ #!/usr/bin/env ruby
2
+ # Feature: ZER0-078
3
+ # frozen_string_literal: true
4
+
5
+ # ===================================================================
6
+ # translate.rb — AI translation pipeline for multilingual content
7
+ # ===================================================================
8
+ #
9
+ # Purpose: Generate alternate-language versions of the site's English
10
+ # content WITHOUT storing hand-written translations in source.
11
+ # English (pages/**, _data/ui-text.yml `en`) is the only
12
+ # human-maintained language; every other language is a build
13
+ # artifact produced by this utility and committed by the
14
+ # `translate.yml` workflow (never edited by hand).
15
+ #
16
+ # What it produces:
17
+ # fr/<area>/<file>.md Translated page files (plain Jekyll pages
18
+ # with explicit permalink /fr<en-url>, so the
19
+ # GitHub Pages safe-mode build needs no plugin)
20
+ # _data/i18n/<lang>.yml Translated UI strings (from ui-text.yml en)
21
+ # _data/i18n/manifest.yml Source-of-truth map: en URL -> per-language
22
+ # output URL + content SHA (drives the
23
+ # language toggle, hreflang tags, and
24
+ # incremental change detection)
25
+ #
26
+ # Modes:
27
+ # (default) Incremental — translate only new/changed sources
28
+ # --full Retranslate everything
29
+ # --check Report stale/missing translations; exit 1 if any (no API)
30
+ # --dry-run Plan only; no API calls, no writes
31
+ #
32
+ # Providers:
33
+ # claude (default) Anthropic Messages API. Credential precedence mirrors
34
+ # the chat proxy (templates/deploy/chat-proxy/worker.js):
35
+ # CLAUDE_CODE_OAUTH_TOKEN Bearer + oauth beta header
36
+ # ANTHROPIC_AUTH_TOKEN Bearer + oauth beta header
37
+ # ANTHROPIC_API_KEY x-api-key
38
+ # OAuth tokens require the first system block to carry the
39
+ # Claude Code identity (same rule as the chat proxy).
40
+ # For local runs, credentials are auto-loaded from the
41
+ # repo-root .env (gitignored; `claude setup-token` output
42
+ # goes there — same file the chat dev proxy reads).
43
+ # Real environment variables always win over .env.
44
+ # stub Deterministic offline pseudo-translation (tests/demo):
45
+ # appends " [<lang>]" to every segment. No network.
46
+ #
47
+ # Safety model (how markdown survives translation):
48
+ # - Fenced code blocks, {% highlight %}/{% raw %} regions and the YAML
49
+ # front matter are never sent to the model.
50
+ # - Inline code, Liquid tags/outputs, wiki-links, HTML tags and link
51
+ # destinations are masked as ⟦N⟧ placeholders before the request and
52
+ # restored after; a response that loses or invents placeholders, or
53
+ # changes the segment set/line shape, is rejected and retried once.
54
+ # - Translation is per-line ("one paragraph per line" house rule), sent
55
+ # as a JSON segment map — the file's structure is reassembled from the
56
+ # source, so code, blank lines and ordering are preserved by
57
+ # construction and the markdown-oneline CI check stays green.
58
+ #
59
+ # Usage:
60
+ # ruby scripts/translate.rb # incremental, configured langs
61
+ # ruby scripts/translate.rb --full --langs fr
62
+ # ruby scripts/translate.rb --dry-run --verbose
63
+ # ruby scripts/translate.rb --provider stub --root /tmp/sandbox # tests
64
+ #
65
+ # Configuration: `translation:` block in _config.yml (see there for keys).
66
+ # ===================================================================
67
+
68
+ require "date"
69
+ require "digest"
70
+ require "fileutils"
71
+ require "json"
72
+ require "net/http"
73
+ require "optparse"
74
+ require "time"
75
+ require "uri"
76
+ require "yaml"
77
+
78
+ module Zer0Translate
79
+ VERSION = "1.0.0"
80
+ PROMPT_VERSION = 1
81
+ MANIFEST_REL = File.join("_data", "i18n", "manifest.yml")
82
+ UI_TEXT_REL = File.join("_data", "ui-text.yml")
83
+ PLACEHOLDER_RE = /⟦\d+⟧/ # ⟦N⟧
84
+
85
+ FRONT_MATTER_FIELDS = %w[title sub-title subtitle description excerpt tagline].freeze
86
+ # Front-matter keys that must NOT be copied onto a generated translation
87
+ # (they would collide with the English page: duplicate redirects, wrong
88
+ # permalink, wiki aliases, stale translation metadata).
89
+ FRONT_MATTER_DROP = %w[
90
+ permalink redirect_from redirect_to aliases lang
91
+ translation_of translation_source_url machine_translated translated_from_sha
92
+ ].freeze
93
+
94
+ DEFAULT_CONFIG = {
95
+ "enabled" => false,
96
+ "source_lang" => "en",
97
+ "languages" => [],
98
+ "provider" => "claude",
99
+ "model" => "claude-opus-4-8",
100
+ "max_tokens" => 8192,
101
+ "max_chunk_chars" => 4000,
102
+ "max_chunk_segments" => 60,
103
+ "ui_text" => true,
104
+ "sources" => [
105
+ { "path" => "pages/_posts", "output" => "posts" },
106
+ { "path" => "pages/_docs", "output" => "docs" },
107
+ { "path" => "pages/_about", "output" => "about" },
108
+ { "path" => "pages/_quickstart", "output" => "quickstart" },
109
+ { "path" => "pages/_notes", "output" => "notes" },
110
+ ],
111
+ "exclude" => ["**/README.md", "**/_templates/**"],
112
+ }.freeze
113
+
114
+ # ----------------------------------------------------------------
115
+ # Small logging helpers (kept dependency-free; mirror scripts/lib tone)
116
+ # ----------------------------------------------------------------
117
+ module Log
118
+ class << self
119
+ attr_accessor :verbose
120
+
121
+ def info(msg) = puts(msg)
122
+ def debug(msg) = (puts(" [debug] #{msg}") if verbose)
123
+ def warn(msg) = Kernel.warn(" [warn] #{msg}")
124
+ def error(msg) = Kernel.warn("[error] #{msg}")
125
+ end
126
+ end
127
+
128
+ Segment = Struct.new(:key, :text, :placeholders, keyword_init: true)
129
+
130
+ # ----------------------------------------------------------------
131
+ # Masking: protect non-translatable spans inside a prose line
132
+ # ----------------------------------------------------------------
133
+ class Masker
134
+ # Order matters: coarser spans first so finer patterns never split them.
135
+ INLINE_PATTERNS = [
136
+ /\{%.*?%\}/m, # Liquid tags {% ... %}
137
+ /\{\{.*?\}\}/m, # Liquid output {{ ... }}
138
+ /!?\[\[[^\]]+\]\]/, # Obsidian wiki-links / embeds
139
+ /`[^`]*`/, # inline code spans
140
+ /\]\([^()\s]+\)/, # markdown link destinations "](url)"
141
+ /<https?:[^>\s]+>/, # autolinks
142
+ /<\/?[A-Za-z][^>]*>/, # inline HTML tags
143
+ ].freeze
144
+
145
+ def initialize
146
+ @map = {}
147
+ @counter = 0
148
+ end
149
+
150
+ attr_reader :map
151
+
152
+ def mask_line(line)
153
+ masked = line.dup
154
+ INLINE_PATTERNS.each do |pattern|
155
+ masked = masked.gsub(pattern) do |match|
156
+ @counter += 1
157
+ token = "⟦#{@counter}⟧"
158
+ @map[token] = match
159
+ token
160
+ end
161
+ end
162
+ masked
163
+ end
164
+
165
+ def unmask(text)
166
+ text.gsub(PLACEHOLDER_RE) { |token| @map.fetch(token, token) }
167
+ end
168
+ end
169
+
170
+ # ----------------------------------------------------------------
171
+ # Splits a markdown body into translatable segments + verbatim lines
172
+ # ----------------------------------------------------------------
173
+ class Segmenter
174
+ FENCE_RE = /\A(\s*)(`{3,}|~{3,})/
175
+ # A line that is a SINGLE Liquid construct only (e.g. `{% include x %}`,
176
+ # `{{ page.title }}`). The interior excludes braces so a line with prose
177
+ # BETWEEN two constructs (`{{ a }} text {{ b }}`) is NOT treated as pure
178
+ # Liquid — that text must still be translated.
179
+ PURE_LIQUID_RE = /\A\s*\{[%{][^{}]*[%}]\}\s*\z/
180
+ PURE_HTML_RE = %r{\A\s*</?[A-Za-z][^>]*/?>\s*\z}
181
+ HR_RE = /\A\s*(?:[-*_]\s*){3,}\z/
182
+ TABLE_RULE_RE = /\A\s*\|?[\s:|-]+\|?\s*\z/
183
+
184
+ attr_reader :lines, :segments, :masker
185
+
186
+ def initialize(body)
187
+ @lines = body.split("\n", -1)
188
+ @masker = Masker.new
189
+ @segments = []
190
+ scan
191
+ end
192
+
193
+ # Rebuild the body with translated segments swapped in.
194
+ def reassemble(translations)
195
+ out = @lines.dup
196
+ @segments.each do |seg|
197
+ translated = translations.fetch(seg.key)
198
+ out[seg.key.delete_prefix("s").to_i] = @masker.unmask(translated)
199
+ end
200
+ out.join("\n")
201
+ end
202
+
203
+ private
204
+
205
+ def scan
206
+ in_fence = false
207
+ fence_marker = nil
208
+ in_liquid_block = false
209
+
210
+ @lines.each_with_index do |line, idx|
211
+ if in_fence
212
+ in_fence = false if line.lstrip.start_with?(fence_marker)
213
+ next
214
+ end
215
+ if (m = line.match(FENCE_RE))
216
+ in_fence = true
217
+ fence_marker = m[2][0] * m[2].length
218
+ next
219
+ end
220
+ if in_liquid_block
221
+ in_liquid_block = false if line =~ /\{%-?\s*(endhighlight|endraw)\s*-?%\}/
222
+ next
223
+ end
224
+ if line =~ /\{%-?\s*(highlight|raw)\b/ && line !~ /\{%-?\s*end(highlight|raw)/
225
+ in_liquid_block = true
226
+ next
227
+ end
228
+
229
+ next if line.strip.empty?
230
+ next if line =~ PURE_LIQUID_RE || line =~ PURE_HTML_RE
231
+ next if line =~ HR_RE
232
+ next if line =~ TABLE_RULE_RE && line.include?("|")
233
+
234
+ masked = @masker.mask_line(line)
235
+ # Nothing human-readable left after masking → keep the line verbatim.
236
+ next unless masked =~ /\p{L}/
237
+
238
+ @segments << Segment.new(
239
+ key: "s#{idx}",
240
+ text: masked,
241
+ placeholders: masked.scan(PLACEHOLDER_RE).sort,
242
+ )
243
+ end
244
+ end
245
+ end
246
+
247
+ # ----------------------------------------------------------------
248
+ # Jekyll URL resolution for this repo's permalink patterns
249
+ # ----------------------------------------------------------------
250
+ class UrlBuilder
251
+ def initialize(site_config)
252
+ @config = site_config
253
+ end
254
+
255
+ # Returns the pretty URL ("/posts/2026/01/01/foo/") for a source file,
256
+ # or nil when the permalink template contains a placeholder we cannot
257
+ # resolve (the caller skips the file with a warning).
258
+ #
259
+ # Resolution matches the OBSERVED Jekyll 3.10 behavior for this repo's
260
+ # collection documents (verified against a real build): an explicit
261
+ # front-matter `permalink` wins, otherwise the collection's permalink
262
+ # template applies. Front-matter *defaults* permalinks do not affect
263
+ # collection documents on this Jekyll version.
264
+ def url_for(rel_path, front_matter, collection)
265
+ # An explicit front-matter permalink is served by Jekyll VERBATIM (it
266
+ # overrides the global `permalink: pretty`). Return it unchanged so the
267
+ # manifest key matches the page's real `page.url` — prettifying a
268
+ # non-trailing-slash permalink (`/faq`, `/x.html`) would key the
269
+ # manifest at `/faq/` and the toggle/hreflang lookup would miss.
270
+ if (explicit = front_matter["permalink"]) && !explicit.include?(":")
271
+ return explicit
272
+ end
273
+
274
+ template = front_matter["permalink"] || collection_permalink(collection)
275
+ return nil unless template
276
+ return prettify(template) unless template.include?(":")
277
+
278
+ fill_template(template, rel_path, front_matter, collection)
279
+ end
280
+
281
+ private
282
+
283
+ def collections_dir = @config["collections_dir"] || ""
284
+
285
+ def collection_permalink(collection)
286
+ cols = @config["collections"]
287
+ return nil unless cols.is_a?(Hash)
288
+
289
+ entry = cols[collection]
290
+ entry.is_a?(Hash) ? entry["permalink"] : nil
291
+ end
292
+
293
+ def fill_template(template, rel_path, front_matter, collection)
294
+ basename = File.basename(rel_path).sub(/\.[^.]+\z/, "")
295
+ date = resolve_date(front_matter, basename)
296
+ slug_base = basename
297
+ if (m = basename.match(/\A(\d{4})-(\d{2})-(\d{2})-(.+)\z/))
298
+ slug_base = m[4]
299
+ end
300
+ slug = front_matter["slug"] || slugify(slug_base)
301
+
302
+ categories = Array(front_matter["categories"] || front_matter["category"])
303
+ .flatten.compact.map { |c| slugify(c.to_s) }
304
+
305
+ in_collection = collection_relative(rel_path, collection)
306
+ subdir = File.dirname(in_collection)
307
+ subdir = "" if subdir == "."
308
+
309
+ url = template.dup
310
+ url = url.gsub(":collection", collection.to_s)
311
+ url = url.gsub(":categories", categories.join("/"))
312
+ url = url.gsub(":year", date ? format("%04d", date.year) : ":year")
313
+ url = url.gsub(":month", date ? format("%02d", date.month) : ":month")
314
+ url = url.gsub(":day", date ? format("%02d", date.day) : ":day")
315
+ url = url.gsub(":slug", slug)
316
+ url = url.gsub(":name", slugify(slug_base))
317
+ url = url.gsub(":title", slug)
318
+ url = url.gsub(":path", subdir)
319
+ url = url.gsub(":output_ext", "")
320
+
321
+ return nil if url.include?(":") # unresolved placeholder
322
+
323
+ prettify(url)
324
+ end
325
+
326
+ def collection_relative(rel_path, collection)
327
+ prefix = File.join(*[collections_dir, "_#{collection}"].reject(&:empty?))
328
+ rel_path.delete_prefix("#{prefix}/")
329
+ end
330
+
331
+ def resolve_date(front_matter, basename)
332
+ raw = front_matter["date"]
333
+ case raw
334
+ when Date, Time then return raw
335
+ when String
336
+ begin
337
+ return Time.parse(raw)
338
+ rescue ArgumentError
339
+ nil
340
+ end
341
+ end
342
+ m = basename.match(/\A(\d{4})-(\d{2})-(\d{2})-/)
343
+ m ? Date.new(m[1].to_i, m[2].to_i, m[3].to_i) : nil
344
+ end
345
+
346
+ def slugify(str)
347
+ str.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
348
+ end
349
+
350
+ # `permalink: pretty` — collapse duplicate slashes, ensure trailing slash.
351
+ def prettify(url)
352
+ url = "/#{url}".gsub(%r{/+}, "/")
353
+ url.end_with?("/") ? url : "#{url}/"
354
+ end
355
+ end
356
+
357
+ # ----------------------------------------------------------------
358
+ # Providers
359
+ # ----------------------------------------------------------------
360
+ class StubProvider
361
+ def name = "stub"
362
+
363
+ # Deterministic, structure-preserving pseudo-translation: appends a
364
+ # visible marker to every segment. Placeholders survive by construction.
365
+ def translate(segments, target_lang, _context)
366
+ segments.to_h { |key, text| [key, "#{text} [#{target_lang}]"] }
367
+ end
368
+ end
369
+
370
+ class ClaudeProvider
371
+ ENDPOINT = URI("https://api.anthropic.com/v1/messages")
372
+ API_VERSION = "2023-06-01"
373
+ OAUTH_BETA = "oauth-2025-04-20"
374
+ # Claude Code OAuth tokens are gated to Claude Code: the FIRST system
375
+ # block must carry this identity or the API rejects the request (same
376
+ # rule the chat proxy implements — see chat-proxy/worker.js).
377
+ CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."
378
+ MAX_ATTEMPTS = 4
379
+
380
+ def initialize(model:, max_tokens:)
381
+ @model = model
382
+ @max_tokens = max_tokens
383
+ @auth = resolve_auth
384
+ raise "No Anthropic credential found. Set CLAUDE_CODE_OAUTH_TOKEN " \
385
+ "(from `claude setup-token`), ANTHROPIC_AUTH_TOKEN, or ANTHROPIC_API_KEY." unless @auth
386
+ end
387
+
388
+ def name = "claude (#{@model})"
389
+
390
+ def translate(segments, target_lang, context)
391
+ payload = build_payload(segments, target_lang, context)
392
+ body = request_with_retries(payload)
393
+ text = (body["content"] || []).select { |b| b["type"] == "text" }
394
+ .map { |b| b["text"] }.join("\n")
395
+ parsed = extract_json(text)
396
+ raise ProviderError, "response was not a JSON object" unless parsed.is_a?(Hash)
397
+
398
+ parsed
399
+ end
400
+
401
+ class ProviderError < StandardError; end
402
+
403
+ private
404
+
405
+ def resolve_auth
406
+ if (token = ENV["CLAUDE_CODE_OAUTH_TOKEN"] || ENV["ANTHROPIC_AUTH_TOKEN"])
407
+ return { mode: :oauth, token: token } unless token.empty?
408
+ end
409
+ if (key = ENV["ANTHROPIC_API_KEY"])
410
+ return { mode: :api_key, token: key } unless key.empty?
411
+ end
412
+ nil
413
+ end
414
+
415
+ def system_blocks(target_lang)
416
+ instructions = <<~PROMPT
417
+ You are a professional technical translator for a software documentation website (Jekyll theme, Docker tooling, developer blog).
418
+ Translate the values of the JSON object the user provides from English into the language with IETF code "#{target_lang}".
419
+
420
+ Rules:
421
+ - Respond with ONLY a JSON object — no prose, no markdown fences — containing exactly the same keys; each value is the translation of the source value.
422
+ - Values are Markdown fragments. Preserve all Markdown/HTML syntax characters (#, *, -, >, |, [ ], ( ), :, emphasis markers) in their structural roles.
423
+ - Tokens like ⟦12⟧ are protected placeholders. Reproduce every placeholder EXACTLY as it appears, positioned where its content belongs in the translated sentence. Never translate, alter, merge, or drop a placeholder.
424
+ - Do not translate: code, shell commands, file paths, URLs, configuration keys, or product/proper names (Jekyll, Docker, Bootstrap, GitHub, Ruby, Obsidian, zer0-mistakes, ...).
425
+ - Every value must stay on a single line (no newline characters) because the site enforces one paragraph per line.
426
+ - Keys prefixed "fm:" are page metadata (title, description); keys prefixed "ui:" are short UI labels — translate them concisely. Keys prefixed "s" are body lines.
427
+ - Use natural, idiomatic phrasing in the target language with a professional technical register.
428
+ PROMPT
429
+
430
+ blocks = []
431
+ blocks << { type: "text", text: CLAUDE_CODE_IDENTITY } if @auth[:mode] == :oauth
432
+ blocks << { type: "text", text: instructions }
433
+ blocks
434
+ end
435
+
436
+ def build_payload(segments, target_lang, context)
437
+ user_text = +"Context: #{context}\n\nTranslate these segments:\n"
438
+ user_text << JSON.pretty_generate(segments)
439
+ {
440
+ model: @model,
441
+ max_tokens: @max_tokens,
442
+ system: system_blocks(target_lang),
443
+ messages: [{ role: "user", content: user_text }],
444
+ }
445
+ end
446
+
447
+ def headers
448
+ base = { "content-type" => "application/json", "anthropic-version" => API_VERSION }
449
+ if @auth[:mode] == :oauth
450
+ base["authorization"] = "Bearer #{@auth[:token]}"
451
+ base["anthropic-beta"] = OAUTH_BETA
452
+ else
453
+ base["x-api-key"] = @auth[:token]
454
+ end
455
+ base
456
+ end
457
+
458
+ def request_with_retries(payload)
459
+ attempt = 0
460
+ begin
461
+ attempt += 1
462
+ response = post(payload)
463
+ code = response.code.to_i
464
+ if [429, 500, 502, 503, 529].include?(code) && attempt < MAX_ATTEMPTS
465
+ delay = (response["retry-after"]&.to_i&.positive? ? response["retry-after"].to_i : 2**attempt)
466
+ Log.warn "API #{code}; retrying in #{delay}s (attempt #{attempt}/#{MAX_ATTEMPTS})"
467
+ sleep delay
468
+ raise RetryableError
469
+ end
470
+ body = JSON.parse(response.body)
471
+ unless code == 200
472
+ message = body.dig("error", "message") || response.body[0, 300]
473
+ raise ProviderError, "Anthropic API #{code}: #{message}"
474
+ end
475
+ body
476
+ rescue RetryableError
477
+ retry
478
+ rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
479
+ raise ProviderError, "network error: #{e.message}" unless attempt < MAX_ATTEMPTS
480
+
481
+ sleep 2**attempt
482
+ retry
483
+ end
484
+ end
485
+
486
+ class RetryableError < StandardError; end
487
+
488
+ def post(payload)
489
+ http = Net::HTTP.new(ENDPOINT.host, ENDPOINT.port)
490
+ http.use_ssl = true
491
+ http.open_timeout = 30
492
+ http.read_timeout = 300
493
+ request = Net::HTTP::Post.new(ENDPOINT.request_uri, headers)
494
+ request.body = JSON.generate(payload)
495
+ http.request(request)
496
+ end
497
+
498
+ # Tolerate fences / stray prose around the JSON object: slicing from the
499
+ # first "{" to the last "}" covers fenced responses too, without any
500
+ # backtracking-prone regex over model-controlled text.
501
+ def extract_json(text)
502
+ clean = text.to_s
503
+ first = clean.index("{")
504
+ last = clean.rindex("}")
505
+ return nil if first.nil? || last.nil? || last < first
506
+
507
+ JSON.parse(clean[first..last])
508
+ rescue JSON::ParserError
509
+ nil
510
+ end
511
+ end
512
+
513
+ # ----------------------------------------------------------------
514
+ # Chunked, validated translation of a segment map
515
+ # ----------------------------------------------------------------
516
+ class Translator
517
+ def initialize(provider, chunk_chars:, chunk_segments:)
518
+ @provider = provider
519
+ @chunk_chars = chunk_chars
520
+ @chunk_segments = chunk_segments
521
+ end
522
+
523
+ # segments: { key => masked text }; returns { key => translated text }.
524
+ # Raises TranslationError when a chunk cannot be validated after a retry.
525
+ def translate_map(segments, target_lang, context)
526
+ result = {}
527
+ each_chunk(segments) do |chunk|
528
+ result.merge!(translate_chunk(chunk, target_lang, context))
529
+ end
530
+ result
531
+ end
532
+
533
+ class TranslationError < StandardError; end
534
+
535
+ private
536
+
537
+ def each_chunk(segments)
538
+ chunk = {}
539
+ size = 0
540
+ segments.each do |key, text|
541
+ if !chunk.empty? && (size + text.length > @chunk_chars || chunk.size >= @chunk_segments)
542
+ yield chunk
543
+ chunk = {}
544
+ size = 0
545
+ end
546
+ chunk[key] = text
547
+ size += text.length
548
+ end
549
+ yield chunk unless chunk.empty?
550
+ end
551
+
552
+ def translate_chunk(chunk, target_lang, context, retried: false)
553
+ output = @provider.translate(chunk, target_lang, context)
554
+ validate!(chunk, output)
555
+ output
556
+ rescue TranslationError, ClaudeProvider::ProviderError => e
557
+ raise TranslationError, e.message if retried
558
+
559
+ Log.warn "chunk rejected (#{e.message}); retrying once"
560
+ translate_chunk(chunk, target_lang, "#{context} | RETRY — previous attempt failed validation: #{e.message}. Follow the placeholder and single-line rules exactly.", retried: true)
561
+ end
562
+
563
+ def validate!(input, output)
564
+ raise TranslationError, "provider returned #{output.class}" unless output.is_a?(Hash)
565
+
566
+ missing = input.keys - output.keys
567
+ raise TranslationError, "missing keys: #{missing.first(5).join(', ')}" unless missing.empty?
568
+
569
+ input.each do |key, source|
570
+ value = output[key]
571
+ raise TranslationError, "#{key}: non-string value" unless value.is_a?(String)
572
+ raise TranslationError, "#{key}: empty translation" if value.strip.empty? && !source.strip.empty?
573
+ raise TranslationError, "#{key}: line break introduced" if value.include?("\n")
574
+
575
+ expected = source.scan(PLACEHOLDER_RE).sort
576
+ actual = value.scan(PLACEHOLDER_RE).sort
577
+ raise TranslationError, "#{key}: placeholder mismatch" unless expected == actual
578
+
579
+ assert_link_brackets_balanced!(key, source, value)
580
+ end
581
+ end
582
+
583
+ # A masked markdown link destination — `](url)` — carries the `]` that
584
+ # closes a preceding `[text]`. The multiset check above still passes if the
585
+ # model relocates that placeholder away from its bracket, but unmask would
586
+ # then sever the link (literal brackets on the page). Guard it structurally
587
+ # without needing the masker's map: any placeholder that closes a link in
588
+ # the SOURCE must, in the OUTPUT, sit where an unclosed `[` still awaits it
589
+ # (more literal `[` than `]` in the text before it — the placeholder's own
590
+ # `]` is still masked, so it isn't counted). A false alarm is impossible:
591
+ # a correctly-placed link always has its opening `[` earlier in the line.
592
+ def assert_link_brackets_balanced!(key, source, value)
593
+ link_tokens = link_closing_placeholders(source)
594
+ return if link_tokens.empty?
595
+
596
+ scan_placeholder_positions(value).each do |token, before|
597
+ next unless link_tokens.include?(token)
598
+ next if before.count("[") > before.count("]")
599
+
600
+ raise TranslationError, "#{key}: link placeholder #{token} detached from its [text]"
601
+ end
602
+ end
603
+
604
+ # Placeholders in `source` that sit immediately after link text with no
605
+ # intervening bracket — i.e. the token that provides a link's closing `]`.
606
+ # Identified structurally: in the source the token is preceded by an
607
+ # unclosed `[`.
608
+ def link_closing_placeholders(source)
609
+ scan_placeholder_positions(source).filter_map do |token, before|
610
+ token if before.count("[") > before.count("]")
611
+ end
612
+ end
613
+
614
+ def scan_placeholder_positions(text)
615
+ positions = []
616
+ text.scan(PLACEHOLDER_RE) do
617
+ positions << [Regexp.last_match(0), text[0...Regexp.last_match.begin(0)]]
618
+ end
619
+ positions
620
+ end
621
+ end
622
+
623
+ # ----------------------------------------------------------------
624
+ # Manifest — generated map read by Liquid (toggle/hreflang) and by
625
+ # this script for incremental change detection.
626
+ # ----------------------------------------------------------------
627
+ class Manifest
628
+ attr_reader :data
629
+
630
+ def initialize(root)
631
+ @path = File.join(root, MANIFEST_REL)
632
+ @data = load_data
633
+ end
634
+
635
+ def load_data
636
+ if File.file?(@path)
637
+ raw = File.read(@path, encoding: "bom|utf-8")
638
+ loaded = YAML.safe_load(raw, permitted_classes: [Date, Time], aliases: true)
639
+ return loaded if loaded.is_a?(Hash) && loaded["pages"].is_a?(Hash)
640
+ end
641
+ { "version" => 1, "pages" => {}, "ui_text" => {} }
642
+ end
643
+
644
+ def pages = @data["pages"]
645
+ def ui_text = @data["ui_text"] ||= {}
646
+
647
+ def entry_for(url)
648
+ pages[url] ||= {}
649
+ end
650
+
651
+ def save(source_lang, languages)
652
+ @data["version"] = 1
653
+ @data["source_lang"] = source_lang
654
+ @data["languages"] = languages
655
+ @data["updated_at"] = Time.now.utc.iso8601
656
+ @data["pages"] = pages.sort.to_h
657
+ FileUtils.mkdir_p(File.dirname(@path))
658
+ header = <<~HEADER
659
+ # ================================================================
660
+ # GENERATED FILE — do not edit by hand.
661
+ # Maintained by scripts/translate.rb (see .github/workflows/translate.yml).
662
+ # Maps each English page URL to its generated translations; read by
663
+ # _includes/components/language-toggle.html and _includes/core/hreflang.html.
664
+ # ================================================================
665
+ HEADER
666
+ File.write(@path, header + @data.to_yaml.sub(/\A---\n/, ""))
667
+ end
668
+ end
669
+
670
+ # ----------------------------------------------------------------
671
+ # A single translatable source page
672
+ # ----------------------------------------------------------------
673
+ class SourceFile
674
+ FRONT_MATTER_RE = /\A---\s*\n(.*?)\n---\s*\n?/m
675
+
676
+ attr_reader :rel_path, :collection, :output_area, :error
677
+
678
+ def initialize(root, rel_path, collection:, output_area:)
679
+ @root = root
680
+ @rel_path = rel_path
681
+ @collection = collection
682
+ @output_area = output_area
683
+ @raw = File.read(File.join(root, rel_path), encoding: "bom|utf-8")
684
+ if (m = @raw.match(FRONT_MATTER_RE))
685
+ @front_matter = YAML.safe_load(m[1], permitted_classes: [Date, Time], aliases: true) || {}
686
+ @body = m.post_match
687
+ else
688
+ @front_matter = nil
689
+ @body = @raw
690
+ end
691
+ rescue Psych::Exception => e
692
+ @front_matter = nil
693
+ @error = "front matter parse error: #{e.message}"
694
+ end
695
+
696
+ def front_matter = @front_matter || {}
697
+ def body = @body.to_s
698
+
699
+ def translatable?
700
+ return false unless @front_matter.is_a?(Hash)
701
+ return false if front_matter["published"] == false
702
+ return false if front_matter["translate"] == false
703
+ return false if front_matter["lang"] && front_matter["lang"].to_s[0, 2] != "en"
704
+
705
+ true
706
+ end
707
+
708
+ def sha
709
+ @sha ||= Digest::SHA256.hexdigest(@raw)
710
+ end
711
+
712
+ # Path of the generated translation, mirroring subfolders within the
713
+ # collection: pages/_posts/a.md -> <lang>/posts/a.md
714
+ def output_rel_path(lang, collections_dir)
715
+ prefix = File.join(*[collections_dir, "_#{collection}"].reject(&:empty?))
716
+ inner = rel_path.delete_prefix("#{prefix}/")
717
+ File.join(lang, output_area, inner)
718
+ end
719
+ end
720
+
721
+ # ----------------------------------------------------------------
722
+ # Local credential wiring: load KEY=VALUE pairs from the repo-root
723
+ # .env (gitignored) so `claude setup-token` credentials work for
724
+ # local runs — the same file the chat dev proxy reads. Real
725
+ # environment variables always win; values are never logged.
726
+ # ----------------------------------------------------------------
727
+ module DotEnv
728
+ def self.load(root)
729
+ path = File.join(root, ".env")
730
+ return unless File.file?(path)
731
+
732
+ File.foreach(path, encoding: "bom|utf-8") do |line|
733
+ line = line.strip
734
+ next if line.empty? || line.start_with?("#")
735
+
736
+ key, _, value = line.partition("=")
737
+ key = key.sub(/\Aexport\s+/, "").strip
738
+ next unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
739
+ next if ENV.key?(key) # real environment always wins
740
+
741
+ ENV[key] = value.strip.gsub(/\A["']|["']\z/, "")
742
+ end
743
+ end
744
+ end
745
+
746
+ # ----------------------------------------------------------------
747
+ # CLI / orchestration
748
+ # ----------------------------------------------------------------
749
+ class CLI
750
+ def initialize(argv)
751
+ @options = {
752
+ root: Dir.pwd, mode: :incremental, dry_run: false, check: false,
753
+ provider: nil, model: nil, langs: nil, limit: nil, verbose: false
754
+ }
755
+ parse(argv)
756
+ Log.verbose = @options[:verbose]
757
+ @root = File.expand_path(@options[:root])
758
+ DotEnv.load(@root)
759
+ @site_config = load_yaml(File.join(@root, "_config.yml")) || {}
760
+ @config = DEFAULT_CONFIG.merge(@site_config["translation"] || {})
761
+ @config["languages"] = @options[:langs] if @options[:langs]
762
+ @url_builder = UrlBuilder.new(@site_config)
763
+ @manifest = Manifest.new(@root)
764
+ @stats = Hash.new(0)
765
+ end
766
+
767
+ def run
768
+ languages = Array(@config["languages"]).map(&:to_s)
769
+ source_lang = @config["source_lang"] || "en"
770
+ if languages.empty?
771
+ Log.info "No target languages configured (translation.languages) — nothing to do."
772
+ return 0
773
+ end
774
+ bad = languages.reject { |l| l.match?(/\A[a-z]{2}(-[A-Za-z]{2})?\z/) }
775
+ raise "invalid language code(s): #{bad.join(', ')}" unless bad.empty?
776
+
777
+ sources = discover_sources
778
+ plan = build_plan(sources, languages)
779
+ report_plan(plan, sources.size, languages)
780
+
781
+ return check_result(plan) if @options[:check]
782
+ if plan.empty? && !prune_needed?(sources, languages)
783
+ Log.info "Everything is up to date."
784
+ return 0
785
+ end
786
+ if @options[:dry_run]
787
+ Log.info "Dry run — no API calls made, no files written."
788
+ return 0
789
+ end
790
+
791
+ translator = build_translator
792
+ execute(plan, translator)
793
+ prune(sources, languages)
794
+ @manifest.save(source_lang, languages)
795
+ summary
796
+ @stats[:failed].positive? ? 1 : 0
797
+ end
798
+
799
+ private
800
+
801
+ # -- planning ---------------------------------------------------
802
+
803
+ Job = Struct.new(:type, :source, :lang, :url, keyword_init: true)
804
+
805
+ def discover_sources
806
+ collections_dir = @site_config["collections_dir"].to_s
807
+ list = []
808
+ Array(@config["sources"]).each do |src|
809
+ dir = src["path"].to_s
810
+ area = src["output"] || File.basename(dir).delete_prefix("_")
811
+ collection = File.basename(dir).delete_prefix("_")
812
+ Dir.glob(File.join(@root, dir, "**", "*.{md,markdown}")).sort.each do |abs|
813
+ rel = abs.delete_prefix("#{@root}/")
814
+ next if excluded?(rel)
815
+
816
+ next unless matches_only_filter?(rel)
817
+
818
+ file = SourceFile.new(@root, rel, collection: collection, output_area: area)
819
+ if file.error
820
+ Log.warn "#{rel}: #{file.error} — skipped"
821
+ next
822
+ end
823
+ next unless file.translatable?
824
+
825
+ list << file
826
+ end
827
+ end
828
+ @collections_dir = collections_dir
829
+ list
830
+ end
831
+
832
+ def excluded?(rel)
833
+ Array(@config["exclude"]).any? do |pattern|
834
+ File.fnmatch?(pattern, rel, File::FNM_PATHNAME | File::FNM_DOTMATCH) ||
835
+ File.fnmatch?(pattern, rel)
836
+ end
837
+ end
838
+
839
+ # --only accepts a glob or plain substring of the source path.
840
+ def matches_only_filter?(rel)
841
+ pattern = @options[:only]
842
+ return true unless pattern
843
+
844
+ File.fnmatch?(pattern, rel) || rel.include?(pattern)
845
+ end
846
+
847
+ def build_plan(sources, languages)
848
+ plan = []
849
+ sources.each do |file|
850
+ url = @url_builder.url_for(file.rel_path, file.front_matter, file.collection)
851
+ unless url
852
+ Log.warn "#{file.rel_path}: could not resolve permalink template — skipped"
853
+ next
854
+ end
855
+ entry = @manifest.pages[url]
856
+ languages.each do |lang|
857
+ state = entry && entry[lang]
858
+ out_path = file.output_rel_path(lang, @collections_dir)
859
+ stale = @options[:mode] == :full ||
860
+ state.nil? ||
861
+ state["sha"] != file.sha ||
862
+ !File.file?(File.join(@root, out_path))
863
+ plan << Job.new(type: :page, source: file, lang: lang, url: url) if stale
864
+ end
865
+ end
866
+ plan.concat(ui_text_jobs(languages))
867
+ plan = plan.first(@options[:limit]) if @options[:limit]
868
+ plan
869
+ end
870
+
871
+ def ui_text_jobs(languages)
872
+ return [] unless @config["ui_text"]
873
+
874
+ strings = ui_text_strings
875
+ return [] if strings.empty?
876
+
877
+ sha = Digest::SHA256.hexdigest(JSON.generate(strings.sort.to_h))
878
+ languages.filter_map do |lang|
879
+ state = @manifest.ui_text[lang]
880
+ out = File.join(@root, "_data", "i18n", "#{lang}.yml")
881
+ next if @options[:mode] != :full && state && state["sha"] == sha && File.file?(out)
882
+
883
+ Job.new(type: :ui_text, source: sha, lang: lang, url: nil)
884
+ end
885
+ end
886
+
887
+ def ui_text_strings
888
+ @ui_text_strings ||= begin
889
+ path = File.join(@root, UI_TEXT_REL)
890
+ data = File.file?(path) ? load_yaml(path) : nil
891
+ en = data && (data[@config["source_lang"]] || data["en"])
892
+ en.is_a?(Hash) ? en.select { |_k, v| v.is_a?(String) && !v.strip.empty? } : {}
893
+ end
894
+ end
895
+
896
+ def report_plan(plan, source_count, languages)
897
+ pages = plan.count { |j| j.type == :page }
898
+ ui = plan.count { |j| j.type == :ui_text }
899
+ Log.info "Translation plan: #{source_count} source page(s) × #{languages.join(', ')} → " \
900
+ "#{pages} page translation(s) + #{ui} UI-string set(s) needed " \
901
+ "(mode: #{@options[:mode]}#{@options[:limit] ? ", limit: #{@options[:limit]}" : ''})"
902
+ plan.first(20).each do |job|
903
+ label = job.type == :page ? job.source.rel_path : "_data/ui-text.yml"
904
+ Log.debug "→ [#{job.lang}] #{label}"
905
+ end
906
+ end
907
+
908
+ def check_result(plan)
909
+ if plan.empty?
910
+ Log.info "--check: translations are up to date."
911
+ 0
912
+ else
913
+ Log.info "--check: #{plan.size} translation job(s) pending. Run the translate workflow."
914
+ 1
915
+ end
916
+ end
917
+
918
+ # -- execution --------------------------------------------------
919
+
920
+ def build_translator
921
+ provider_name = @options[:provider] || @config["provider"] || "claude"
922
+ provider =
923
+ case provider_name
924
+ when "stub" then StubProvider.new
925
+ when "claude"
926
+ ClaudeProvider.new(
927
+ model: @options[:model] || ENV["TRANSLATE_MODEL"] || @config["model"],
928
+ max_tokens: @config["max_tokens"].to_i,
929
+ )
930
+ else
931
+ raise "unknown provider: #{provider_name}"
932
+ end
933
+ Log.info "Provider: #{provider.name}"
934
+ Translator.new(provider,
935
+ chunk_chars: @config["max_chunk_chars"].to_i,
936
+ chunk_segments: @config["max_chunk_segments"].to_i)
937
+ end
938
+
939
+ def execute(plan, translator)
940
+ plan.each do |job|
941
+ case job.type
942
+ when :page then translate_page(job, translator)
943
+ when :ui_text then translate_ui_text(job, translator)
944
+ end
945
+ rescue Translator::TranslationError, ClaudeProvider::ProviderError => e
946
+ label = job.type == :page ? job.source.rel_path : "ui-text"
947
+ Log.error "[#{job.lang}] #{label}: #{e.message}"
948
+ @stats[:failed] += 1
949
+ end
950
+ end
951
+
952
+ def translate_page(job, translator)
953
+ file = job.source
954
+ segmenter = Segmenter.new(file.body)
955
+ segments = segmenter.segments.to_h { |s| [s.key, s.text] }
956
+
957
+ fm_masker = Masker.new
958
+ FRONT_MATTER_FIELDS.each do |field|
959
+ value = file.front_matter[field]
960
+ next unless value.is_a?(String) && !value.strip.empty?
961
+
962
+ # Front-matter fields render as single-line HTML attributes (title,
963
+ # description, ...). Collapse any embedded newlines from a YAML block
964
+ # scalar (`description: |` / `>`) BEFORE masking, so the single-line
965
+ # validator can't reject an otherwise-faithful translation and leave
966
+ # the whole page permanently untranslated.
967
+ segments["fm:#{field}"] = fm_masker.mask_line(value.gsub(/\s*\n\s*/, " ").strip)
968
+ end
969
+
970
+ context = "Page \"#{file.front_matter['title']}\" (#{file.rel_path}) from the zer0-mistakes Jekyll theme site."
971
+ translated = segments.empty? ? {} : translator.translate_map(segments, job.lang, context)
972
+
973
+ out_rel = file.output_rel_path(job.lang, @collections_dir)
974
+ write_page(file, job, translated, segmenter, fm_masker, out_rel)
975
+
976
+ entry = @manifest.entry_for(job.url)
977
+ entry["source"] = file.rel_path
978
+ entry["sha"] = file.sha
979
+ entry[job.lang] = {
980
+ "url" => "/#{job.lang}#{job.url}",
981
+ "path" => out_rel,
982
+ "sha" => file.sha,
983
+ "translated_at" => Time.now.utc.iso8601,
984
+ "prompt_version" => PROMPT_VERSION,
985
+ }
986
+ @stats[:pages] += 1
987
+ Log.info " ✓ [#{job.lang}] #{file.rel_path} → #{out_rel}"
988
+ end
989
+
990
+ def write_page(file, job, translated, segmenter, fm_masker, out_rel)
991
+ fm = file.front_matter.reject { |k, _| FRONT_MATTER_DROP.include?(k) }
992
+ FRONT_MATTER_FIELDS.each do |field|
993
+ key = "fm:#{field}"
994
+ fm[field] = fm_masker.unmask(translated[key]) if translated.key?(key)
995
+ end
996
+ fm["lang"] = job.lang
997
+ fm["permalink"] = "/#{job.lang}#{job.url}"
998
+ fm["translation_of"] = file.rel_path
999
+ fm["translation_source_url"] = job.url
1000
+ fm["machine_translated"] = true
1001
+ fm["translated_from_sha"] = file.sha[0, 12]
1002
+
1003
+ body_out = segmenter.reassemble(
1004
+ segmenter.segments.to_h { |s| [s.key, translated.fetch(s.key, s.text)] },
1005
+ )
1006
+
1007
+ abs = File.join(@root, out_rel)
1008
+ FileUtils.mkdir_p(File.dirname(abs))
1009
+ File.write(abs, "#{fm.to_yaml}---\n\n#{body_out.sub(/\A\n+/, '')}")
1010
+ end
1011
+
1012
+ def translate_ui_text(job, translator)
1013
+ strings = ui_text_strings
1014
+ masker = Masker.new
1015
+ segments = strings.transform_values { |v| masker.mask_line(v) }
1016
+ .transform_keys { |k| "ui:#{k}" }
1017
+ context = "Short UI labels for the zer0-mistakes Jekyll theme (navigation, search, footer)."
1018
+ translated = translator.translate_map(segments, job.lang, context)
1019
+
1020
+ out = strings.keys.to_h { |k| [k, masker.unmask(translated.fetch("ui:#{k}"))] }
1021
+ path = File.join(@root, "_data", "i18n", "#{job.lang}.yml")
1022
+ FileUtils.mkdir_p(File.dirname(path))
1023
+ header = <<~HEADER
1024
+ # ================================================================
1025
+ # GENERATED FILE — do not edit by hand.
1026
+ # Machine-translated UI strings (#{job.lang}) produced by
1027
+ # scripts/translate.rb from _data/ui-text.yml (en). Regenerate via
1028
+ # the Translate workflow or: ruby scripts/translate.rb --langs #{job.lang}
1029
+ # ================================================================
1030
+ HEADER
1031
+ File.write(path, header + out.to_yaml.sub(/\A---\n/, ""))
1032
+
1033
+ @manifest.ui_text[job.lang] = {
1034
+ "sha" => job.source,
1035
+ "translated_at" => Time.now.utc.iso8601,
1036
+ "prompt_version" => PROMPT_VERSION,
1037
+ }
1038
+ @stats[:ui] += 1
1039
+ Log.info " ✓ [#{job.lang}] UI strings → _data/i18n/#{job.lang}.yml"
1040
+ end
1041
+
1042
+ # -- pruning ----------------------------------------------------
1043
+
1044
+ def orphaned_urls(sources, _languages)
1045
+ live = sources.filter_map { |f| @url_builder.url_for(f.rel_path, f.front_matter, f.collection) }
1046
+ @manifest.pages.keys - live
1047
+ end
1048
+
1049
+ def prune_needed?(sources, languages)
1050
+ orphaned_urls(sources, languages).any?
1051
+ end
1052
+
1053
+ def prune(sources, languages)
1054
+ orphaned_urls(sources, languages).each do |url|
1055
+ entry = @manifest.pages.delete(url)
1056
+ languages.each do |lang|
1057
+ path = entry.dig(lang, "path")
1058
+ next unless path
1059
+
1060
+ abs = File.join(@root, path)
1061
+ # Only ever delete inside a language output root.
1062
+ if abs.start_with?(File.join(@root, lang, "")) && File.file?(abs)
1063
+ File.delete(abs)
1064
+ Log.info " ✗ pruned #{path} (source removed)"
1065
+ @stats[:pruned] += 1
1066
+ end
1067
+ end
1068
+ end
1069
+ end
1070
+
1071
+ def summary
1072
+ Log.info "Done: #{@stats[:pages]} page(s), #{@stats[:ui]} UI set(s) translated; " \
1073
+ "#{@stats[:pruned]} pruned; #{@stats[:failed]} failed."
1074
+ end
1075
+
1076
+ # -- plumbing ---------------------------------------------------
1077
+
1078
+ def load_yaml(path)
1079
+ YAML.safe_load(File.read(path, encoding: "bom|utf-8"),
1080
+ permitted_classes: [Date, Time], aliases: true)
1081
+ rescue Psych::Exception => e
1082
+ raise "failed to parse #{path}: #{e.message}"
1083
+ end
1084
+
1085
+ def parse(argv)
1086
+ OptionParser.new do |o|
1087
+ o.banner = "Usage: ruby scripts/translate.rb [options]"
1088
+ o.on("--full", "Retranslate everything (ignore manifest state)") { @options[:mode] = :full }
1089
+ o.on("--incremental", "Translate only new/changed sources (default)") { @options[:mode] = :incremental }
1090
+ o.on("--check", "Report pending translations; exit 1 if any (no API calls)") { @options[:check] = true }
1091
+ o.on("-n", "--dry-run", "Plan only; no API calls, no writes") { @options[:dry_run] = true }
1092
+ o.on("--langs LANGS", "Comma-separated target languages (overrides config)") { |v| @options[:langs] = v.split(",").map(&:strip) }
1093
+ o.on("--only PATTERN", "Only sources matching this glob/substring") { |v| @options[:only] = v }
1094
+ o.on("--limit N", Integer, "Cap the number of translation jobs this run") { |v| @options[:limit] = v }
1095
+ o.on("--provider NAME", "claude | stub (overrides config)") { |v| @options[:provider] = v }
1096
+ o.on("--model MODEL", "Override the Claude model") { |v| @options[:model] = v }
1097
+ o.on("--root PATH", "Repo root (default: cwd; used by tests)") { |v| @options[:root] = v }
1098
+ o.on("-V", "--verbose", "Extra logging") { @options[:verbose] = true }
1099
+ o.on("-v", "--version", "Print version") { puts VERSION; exit 0 }
1100
+ o.on("-h", "--help", "Show this help") { puts o; exit 0 }
1101
+ end.parse!(argv)
1102
+ end
1103
+ end
1104
+ end
1105
+
1106
+ if $PROGRAM_NAME == __FILE__
1107
+ begin
1108
+ exit Zer0Translate::CLI.new(ARGV).run
1109
+ rescue StandardError => e
1110
+ Zer0Translate::Log.error e.message
1111
+ Zer0Translate::Log.debug e.backtrace.join("\n") if Zer0Translate::Log.verbose
1112
+ exit 1
1113
+ end
1114
+ end