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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +172 -1
- data/README.md +10 -27
- data/_data/authors.yml +4 -3
- data/_data/backlog.yml +28 -0
- data/_data/features.yml +65 -18
- data/_data/i18n/languages.yml +36 -0
- data/_data/theme-manifest.yml +0 -2
- data/_data/ui-text.yml +36 -246
- data/_includes/README.md +4 -0
- data/_includes/components/author-avatar-url.html +4 -2
- data/_includes/components/env-switcher.html +3 -1
- data/_includes/components/language-toggle.html +81 -0
- data/_includes/components/search-modal.html +2 -2
- data/_includes/components/shortcuts-modal.html +1 -1
- data/_includes/components/translation-notice.html +27 -0
- data/_includes/content/intro.html +16 -15
- data/_includes/core/footer.html +9 -4
- data/_includes/core/head.html +9 -0
- data/_includes/core/header.html +9 -4
- data/_includes/core/hreflang.html +33 -0
- data/_includes/core/i18n.html +36 -0
- data/_includes/navigation/breadcrumbs.html +1 -1
- data/_includes/navigation/navbar.html +5 -4
- data/_includes/navigation/sidebar-right.html +3 -2
- data/_includes/navigation/unified-drawer.html +1 -1
- data/_layouts/article.html +7 -1
- data/_layouts/default.html +5 -3
- data/_layouts/news.html +4 -2
- data/_layouts/root.html +14 -8
- data/_layouts/section.html +4 -2
- data/_sass/core/_obsidian.scss +9 -1
- data/_sass/layouts/_navbar-extras.scss +6 -1
- data/assets/js/obsidian-graph.js +5 -1
- data/scripts/README.md +20 -26
- data/scripts/bin/audit-consumer +1 -1
- data/scripts/bin/manifest +0 -1
- data/scripts/bin/sync-plugins +0 -1
- data/scripts/dev/rasterize-svg.js +65 -0
- data/scripts/features/generate-preview-images +49 -1390
- data/scripts/features/install-preview-generator +55 -33
- data/scripts/install/README.md +9 -20
- data/scripts/install/ai/prompts/wizard.system.md +8 -17
- data/scripts/lib/README.md +1 -5
- data/scripts/lib/install/deploy/README.md +3 -9
- data/scripts/lib/preview_generator.py +2261 -1341
- data/scripts/translate.rb +1114 -0
- metadata +9 -3
- 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
|